i18n和夜晚模式

This commit is contained in:
wintsa 2026-03-25 14:31:01 +08:00
parent 1f941ca65f
commit 9a6462f22a
20 changed files with 3403 additions and 308 deletions

2169
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -31,6 +31,7 @@
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-i18n": "^11.3.0",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTabStore } from '@/pinia/tab' import { useTabStore } from '@/pinia/tab'
import HomeEntryView from '@/features/workbench/components/HomeEntryView.vue' import HomeEntryView from '@/features/workbench/components/HomeEntryView.vue'
import Tab from '@/layout/tab.vue' import Tab from '@/layout/tab.vue'
@ -8,6 +9,7 @@ import localforage from 'localforage'
import { import {
buildProjectUrl, buildProjectUrl,
ensureProjectIdInUrl, ensureProjectIdInUrl,
FORCE_HOME_QUERY_KEY,
getProjectDbName, getProjectDbName,
NEW_PROJECT_QUERY_KEY, NEW_PROJECT_QUERY_KEY,
PROJECT_TAB_ID, PROJECT_TAB_ID,
@ -17,6 +19,7 @@ import { collectActiveProjectSessionLocks, initProjectSessionLock } from '@/lib/
import { createProject, listProjects, type ProjectMeta } from '@/lib/projectRegistry' import { createProject, listProjects, type ProjectMeta } from '@/lib/projectRegistry'
const tabStore = useTabStore() const tabStore = useTabStore()
const { t } = useI18n()
const isReady = ref(false) const isReady = ref(false)
const lockConflict = ref(false) const lockConflict = ref(false)
const currentProjectId = ref('') const currentProjectId = ref('')
@ -109,15 +112,25 @@ const formatProjectEditedTime = (value: string) => {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` 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
}
onMounted(() => { onMounted(() => {
currentProjectId.value = ensureProjectIdInUrl() currentProjectId.value = ensureProjectIdInUrl()
refreshConflictProjectList() refreshConflictProjectList()
let isNewProjectRequest = false let isNewProjectRequest = false
let forceHomeRequest = false
try { try {
const url = new URL(window.location.href) const url = new URL(window.location.href)
isNewProjectRequest = url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1' isNewProjectRequest = url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1'
forceHomeRequest = url.searchParams.get(FORCE_HOME_QUERY_KEY) === '1'
} catch { } catch {
isNewProjectRequest = false isNewProjectRequest = false
forceHomeRequest = false
} }
if (currentProjectId.value !== QUICK_PROJECT_ID) { if (currentProjectId.value !== QUICK_PROJECT_ID) {
const lock = initProjectSessionLock({ const lock = initProjectSessionLock({
@ -138,8 +151,13 @@ onMounted(() => {
} }
window.addEventListener('home-import-selected', handleImportComplete) window.addEventListener('home-import-selected', handleImportComplete)
window.addEventListener('jgjs-release-project-lock', handleReleaseProjectLock)
waitForHydration('tabs').then(() => { waitForHydration('tabs').then(() => {
if (!tabStore.hasCompletedSetup && !isNewProjectRequest) { if (forceHomeRequest) {
tabStore.resetTabs()
tabStore.hasCompletedSetup = false
}
if (!tabStore.hasCompletedSetup && !isNewProjectRequest && !forceHomeRequest) {
const hasProjects = listProjects().length > 0 const hasProjects = listProjects().length > 0
if (hasProjects) { if (hasProjects) {
if (Array.isArray(tabStore.tabs) && tabStore.tabs.length > 0) { if (Array.isArray(tabStore.tabs) && tabStore.tabs.length > 0) {
@ -147,7 +165,7 @@ onMounted(() => {
} else { } else {
tabStore.enterWorkspace({ tabStore.enterWorkspace({
id: PROJECT_TAB_ID, id: PROJECT_TAB_ID,
title: '项目计算', title: t('home.cards.projectBudget'),
componentName: 'ProjectCalcView' componentName: 'ProjectCalcView'
}) })
tabStore.hasCompletedSetup = true tabStore.hasCompletedSetup = true
@ -168,6 +186,7 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearCloseCountdown() clearCloseCountdown()
window.removeEventListener('home-import-selected', handleImportComplete) window.removeEventListener('home-import-selected', handleImportComplete)
window.removeEventListener('jgjs-release-project-lock', handleReleaseProjectLock)
if (releaseLock) { if (releaseLock) {
releaseLock() releaseLock()
releaseLock = null releaseLock = null
@ -182,12 +201,12 @@ onBeforeUnmount(() => {
class="flex min-h-screen items-center justify-center bg-slate-50 px-4" 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"> <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">检测到项目重复打开</h2> <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"> <p class="mt-2 text-sm leading-6 text-slate-600">
项目{{ currentProjectName }}已在其他页面处于活跃状态为避免 IndexedDB 数据冲突本页面已阻断编辑 {{ t('app.projectConflict.desc', { name: currentProjectName }) }}
</p> </p>
<p class="mt-2 text-xs text-slate-500"> <p class="mt-2 text-xs text-slate-500">
本页将在 {{ closeCountdown }} 秒后自动尝试关闭你也可以先在新标签页打开其他项目 {{ t('app.projectConflict.countdown', { seconds: closeCountdown }) }}
</p> </p>
<div class="mt-4 max-h-52 space-y-2 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-2"> <div class="mt-4 max-h-52 space-y-2 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-2">
<button <button
@ -201,9 +220,9 @@ onBeforeUnmount(() => {
> >
<span class="font-medium text-slate-700"> <span class="font-medium text-slate-700">
{{ project.name }} {{ project.name }}
<span v-if="isConflictProjectOpen(project.id)" class="ml-1 text-xs text-slate-500">已打开</span> <span v-if="isConflictProjectOpen(project.id)" class="ml-1 text-xs text-slate-500">{{ t('app.projectConflict.opened') }}</span>
</span> </span>
<span class="text-xs text-slate-500">最后编辑{{ formatProjectEditedTime(project.updatedAt) }}</span> <span class="text-xs text-slate-500">{{ t('app.projectConflict.lastEdited', { time: formatProjectEditedTime(project.updatedAt) }) }}</span>
</button> </button>
</div> </div>
<div class="mt-4 flex items-center gap-2"> <div class="mt-4 flex items-center gap-2">
@ -212,7 +231,7 @@ onBeforeUnmount(() => {
class="cursor-pointer rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 hover:bg-slate-100" 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" @click="createProjectAndOpen"
> >
新建项目并打开 {{ t('app.projectConflict.createAndOpen') }}
</button> </button>
<button <button
type="button" type="button"
@ -221,7 +240,7 @@ onBeforeUnmount(() => {
:disabled="isConflictProjectOpen('default')" :disabled="isConflictProjectOpen('default')"
@click="openProjectInNewTab('default')" @click="openProjectInNewTab('default')"
> >
打开默认项目 {{ t('app.projectConflict.openDefault') }}
</button> </button>
</div> </div>
</div> </div>

View File

@ -99,6 +99,10 @@ const toastTitle = ref('操作成功')
const toastText = ref('') const toastText = ref('')
const deleteConfirmOpen = ref(false) const deleteConfirmOpen = ref(false)
const pendingDeleteContractId = ref<string | null>(null) const pendingDeleteContractId = ref<string | null>(null)
const messageDialogOpen = ref(false)
const messageDialogTitle = ref('')
const messageDialogDesc = ref('')
const batchDeleteConfirmOpen = ref(false)
const modalOffset = ref({ x: 0, y: 0 }) const modalOffset = ref({ x: 0, y: 0 })
let dragStartX = 0 let dragStartX = 0
let dragStartY = 0 let dragStartY = 0
@ -186,6 +190,12 @@ const notify = (text: string) => {
}) })
} }
const showMessageDialog = (title: string, description: string) => {
messageDialogTitle.value = title
messageDialogDesc.value = description
messageDialogOpen.value = true
}
const pendingDeleteContractName = computed(() => { const pendingDeleteContractName = computed(() => {
if (!pendingDeleteContractId.value) return '' if (!pendingDeleteContractId.value) return ''
const target = contracts.value.find(item => item.id === pendingDeleteContractId.value) const target = contracts.value.find(item => item.id === pendingDeleteContractId.value)
@ -519,7 +529,7 @@ const initializeContractScaleData = async (contractId: string) => {
const exportSelectedContracts = async () => { const exportSelectedContracts = async () => {
if (selectedContractIds.value.length === 0) { if (selectedContractIds.value.length === 0) {
window.alert('请先勾选至少一个合同段。') showMessageDialog('提示', '请先勾选至少一个合同段。')
return return
} }
@ -544,7 +554,7 @@ const exportSelectedContracts = async () => {
const projectIndustry = await getCurrentProjectIndustry() const projectIndustry = await getCurrentProjectIndustry()
if (!projectIndustry) { if (!projectIndustry) {
window.alert('导出失败:未读取到当前项目工程行业,请先在“基础信息”里新建项目。') showMessageDialog('导出失败', '未读取到当前项目工程行业,请先在“基础信息”里新建项目。')
return return
} }
@ -583,7 +593,7 @@ const exportSelectedContracts = async () => {
exitContractSelectionMode() exitContractSelectionMode()
} catch (error) { } catch (error) {
console.error('export selected contracts failed:', error) console.error('export selected contracts failed:', error)
window.alert('导出失败,请重试。') showMessageDialog('导出失败', '请重试。')
} }
} }
@ -689,15 +699,16 @@ const importContractSegments = async (event: Event) => {
const message = error instanceof Error ? error.message : '' const message = error instanceof Error ? error.message : ''
if (message.startsWith('PROJECT_INDUSTRY_MISMATCH:')) { if (message.startsWith('PROJECT_INDUSTRY_MISMATCH:')) {
const [, importIndustry = '', currentIndustry = ''] = message.split(':') const [, importIndustry = '', currentIndustry = ''] = message.split(':')
window.alert( showMessageDialog(
`导入失败:工程行业不一致(导入包:${formatIndustryLabel(importIndustry)},当前项目:${formatIndustryLabel(currentIndustry)})。` '导入失败',
`工程行业不一致(导入包:${formatIndustryLabel(importIndustry)},当前项目:${formatIndustryLabel(currentIndustry)})。`
) )
} else if (message === 'CURRENT_PROJECT_INDUSTRY_MISSING') { } else if (message === 'CURRENT_PROJECT_INDUSTRY_MISSING') {
window.alert('导入失败:当前项目未设置工程行业,请先在“基础信息”里新建项目。') showMessageDialog('导入失败', '当前项目未设置工程行业,请先在“基础信息”里新建项目。')
} else if (message === 'IMPORT_PACKAGE_INDUSTRY_MISSING') { } else if (message === 'IMPORT_PACKAGE_INDUSTRY_MISSING') {
window.alert('导入失败:导入包缺少工程行业信息,请使用最新版本重新导出后再导入。') showMessageDialog('导入失败', '导入包缺少工程行业信息,请使用最新版本重新导出后再导入。')
} else { } else {
window.alert('导入失败:文件无效、已损坏或不是合同段导出文件。') showMessageDialog('导入失败', '文件无效、已损坏或不是合同段导出文件。')
} }
} finally { } finally {
input.value = '' input.value = ''
@ -844,21 +855,27 @@ const deleteContract = async (id: string) => {
const deleteSelectedContracts = async () => { const deleteSelectedContracts = async () => {
if (selectedContractIds.value.length === 0) { if (selectedContractIds.value.length === 0) {
window.alert('请先勾选至少一个合同段。') showMessageDialog('提示', '请先勾选至少一个合同段。')
return return
} }
const selectedSet = new Set(selectedContractIds.value) const selectedSet = new Set(selectedContractIds.value)
const targets = contracts.value.filter(item => selectedSet.has(item.id)) const targets = contracts.value.filter(item => selectedSet.has(item.id))
if (targets.length === 0) { if (targets.length === 0) {
window.alert('未找到可删除的合同段。') showMessageDialog('提示', '未找到可删除的合同段。')
return return
} }
batchDeleteConfirmOpen.value = true
}
const confirmed = window.confirm( const confirmDeleteSelectedContracts = async () => {
`即将删除 ${targets.length} 个合同段及其关联咨询服务和计价数据,是否继续?` const selectedSet = new Set(selectedContractIds.value)
) const targets = contracts.value.filter(item => selectedSet.has(item.id))
if (!confirmed) return if (targets.length === 0) {
batchDeleteConfirmOpen.value = false
showMessageDialog('提示', '未找到可删除的合同段。')
return
}
try { try {
const targetIds = targets.map(item => item.id) const targetIds = targets.map(item => item.id)
@ -882,7 +899,9 @@ const deleteSelectedContracts = async () => {
exitContractSelectionMode() exitContractSelectionMode()
} catch (error) { } catch (error) {
console.error('delete selected contracts failed:', error) console.error('delete selected contracts failed:', error)
window.alert('批量删除失败,请重试。') showMessageDialog('批量删除失败', '请重试。')
} finally {
batchDeleteConfirmOpen.value = false
} }
} }

View File

@ -592,12 +592,12 @@ const mydiyTheme = myTheme.withParams({
rowBorder: { rowBorder: {
style: "solid", style: "solid",
width: 0.8, width: 0.8,
color: "#d8d8dd" color: "var(--border)"
}, },
columnBorder: { columnBorder: {
style: "solid", style: "solid",
width: 0.8, width: 0.8,
color: "#d8d8dd" color: "var(--border)"
} }
}) })
</script> </script>

View File

@ -765,7 +765,7 @@ const confirmDeleteRow = () => {
width: 100%; width: 100%;
} }
:deep(.work-content-placeholder) { :deep(.work-content-placeholder) {
color: #94a3b8; color: var(--muted-foreground);
font-style: italic; font-style: italic;
min-width: 0; min-width: 0;
flex: 1; flex: 1;

View File

@ -32,3 +32,15 @@
.tab-strip-scroll-area :deep([data-slot='scroll-area-corner']) { .tab-strip-scroll-area :deep([data-slot='scroll-area-corner']) {
display: none !important; display: none !important;
} }
.toolbar-dropdown-enter-active,
.toolbar-dropdown-leave-active {
transition: opacity 180ms ease, transform 180ms ease;
transform-origin: top right;
}
.toolbar-dropdown-enter-from,
.toolbar-dropdown-leave-to {
opacity: 0;
transform: translateY(-6px) scale(0.98);
}

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useTabStore } from '@/pinia/tab' import { useTabStore } from '@/pinia/tab'
@ -8,12 +9,9 @@ import {
Calculator, Calculator,
Check, Check,
ChevronDown, ChevronDown,
Download, X
FolderKanban,
X,
Zap
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { industryTypeList } from '@/sql' import { getIndustryDisplayName, industryTypeList } from '@/sql'
import { initializeProjectFactorStates } from '@/lib/projectWorkspace' import { initializeProjectFactorStates } from '@/lib/projectWorkspace'
import { import {
SelectContent, SelectContent,
@ -24,12 +22,13 @@ import {
SelectPortal, SelectPortal,
SelectRoot, SelectRoot,
SelectTrigger, SelectTrigger,
SelectValue,
SelectViewport SelectViewport
} from 'reka-ui' } from 'reka-ui'
import { import {
DEFAULT_PROJECT_ID, DEFAULT_PROJECT_ID,
FORCE_HOME_QUERY_KEY,
NEW_PROJECT_QUERY_KEY, NEW_PROJECT_QUERY_KEY,
OPEN_PROJECT_DIALOG_QUERY_KEY,
PROJECT_TAB_ID, PROJECT_TAB_ID,
QUICK_PROJECT_ID, QUICK_PROJECT_ID,
QUICK_CONSULT_CATEGORY_FACTOR_KEY, QUICK_CONSULT_CATEGORY_FACTOR_KEY,
@ -38,6 +37,7 @@ import {
QUICK_CONTRACT_META_KEY, QUICK_CONTRACT_META_KEY,
QUICK_MAJOR_FACTOR_KEY, QUICK_MAJOR_FACTOR_KEY,
QUICK_PROJECT_INFO_KEY, QUICK_PROJECT_INFO_KEY,
readCurrentProjectId,
writeProjectIdToUrl, writeProjectIdToUrl,
setPendingHomeImportFile, setPendingHomeImportFile,
writeWorkspaceMode writeWorkspaceMode
@ -73,9 +73,11 @@ const PROJECT_CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
const PROJECT_MAJOR_FACTOR_KEY = 'xm-major-factor-v1' const PROJECT_MAJOR_FACTOR_KEY = 'xm-major-factor-v1'
const DEFAULT_PROJECT_NAME = 'xxx 造价咨询服务' const DEFAULT_PROJECT_NAME = 'xxx 造价咨询服务'
const PROJECT_INIT_CHANGED_EVENT = 'xm-project-init-changed' const PROJECT_INIT_CHANGED_EVENT = 'xm-project-init-changed'
const getActiveProjectId = () => readCurrentProjectId()
const tabStore = useTabStore() const tabStore = useTabStore()
const kvStore = useKvStore() const kvStore = useKvStore()
const { t, locale } = useI18n()
const projectDialogOpen = ref(false) const projectDialogOpen = ref(false)
const projectIndustry = ref(String(industryTypeList[0]?.id || '')) const projectIndustry = ref(String(industryTypeList[0]?.id || ''))
const projectSubmitting = ref(false) const projectSubmitting = ref(false)
@ -83,9 +85,14 @@ const quickIndustry = ref(String(industryTypeList[0]?.id || ''))
const quickContractName = ref(QUICK_CONTRACT_FALLBACK_NAME) const quickContractName = ref(QUICK_CONTRACT_FALLBACK_NAME)
const quickSubmitting = ref(false) const quickSubmitting = ref(false)
const homeImportInputRef = ref<HTMLInputElement | null>(null) const homeImportInputRef = ref<HTMLInputElement | null>(null)
const projectIconAvailable = ref(false) const homeImportConfirmOpen = ref(false)
const quickIconAvailable = ref(false) const pendingHomeImportFile = ref<File | null>(null)
const importIconAvailable = ref(false) const pendingHomeImportFileName = ref('')
const projectIndustryLabel = computed(() => {
const target = String(projectIndustry.value || '').trim()
if (!target) return ''
return getIndustryDisplayName(target, locale.value) || ''
})
const getTodayDateString = () => { const getTodayDateString = () => {
const now = new Date() const now = new Date()
@ -96,8 +103,9 @@ const getTodayDateString = () => {
} }
const enterProjectCalc = () => { const enterProjectCalc = () => {
upsertProject(DEFAULT_PROJECT_ID, '默认项目') const projectId = getActiveProjectId()
writeProjectIdToUrl(DEFAULT_PROJECT_ID) upsertProject(projectId, projectId === DEFAULT_PROJECT_ID ? '默认项目' : undefined)
writeProjectIdToUrl(projectId)
writeWorkspaceMode('project') writeWorkspaceMode('project')
tabStore.enterWorkspace({ tabStore.enterWorkspace({
id: PROJECT_TAB_ID, id: PROJECT_TAB_ID,
@ -223,12 +231,9 @@ const handleHomeImportChange = (event: Event) => {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
const file = input.files?.[0] const file = input.files?.[0]
if (!file) return if (!file) return
setPendingHomeImportFile(file) pendingHomeImportFile.value = file
window.dispatchEvent(new CustomEvent('home-import-selected', { pendingHomeImportFileName.value = file.name
detail: { homeImportConfirmOpen.value = true
file
}
}))
input.value = '' input.value = ''
} }
@ -236,14 +241,45 @@ const openHomeImport = () => {
homeImportInputRef.value?.click() homeImportInputRef.value?.click()
} }
const cancelHomeImportConfirm = () => {
homeImportConfirmOpen.value = false
pendingHomeImportFile.value = null
pendingHomeImportFileName.value = ''
}
const confirmHomeImport = () => {
const file = pendingHomeImportFile.value
if (!file) return
setPendingHomeImportFile(file, { skipWorkspaceConfirm: true })
window.dispatchEvent(new CustomEvent('home-import-selected', {
detail: { file }
}))
const projectId = getActiveProjectId()
upsertProject(projectId, projectId === DEFAULT_PROJECT_ID ? '默认项目' : undefined)
writeProjectIdToUrl(projectId)
writeWorkspaceMode('project')
tabStore.enterWorkspace({
id: PROJECT_TAB_ID,
title: '项目计算',
componentName: 'ProjectCalcView'
})
tabStore.hasCompletedSetup = true
cancelHomeImportConfirm()
}
onMounted(() => { onMounted(() => {
void loadProjectDefaults() void loadProjectDefaults()
void loadQuickDefaults() void loadQuickDefaults()
try { try {
const url = new URL(window.location.href) const url = new URL(window.location.href)
if (url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1') { if (url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1') {
void openProjectCalc() 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(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}`) window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`)
} }
} catch { } catch {
@ -257,127 +293,135 @@ onMounted(() => {
<div class="home-entry relative flex min-h-full items-center justify-center px-4 py-8 lg:py-10"> <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="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="relative w-full max-w-[1240px]">
<div class="grid items-stretch gap-6 lg:grid-cols-[300px_1fr]"> <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 <div
class="home-hero relative overflow-hidden rounded-2xl bg-[linear-gradient(145deg,#5c0a0a_0%,#991b1b_50%,#dc2626_100%)] p-7 text-white shadow-[0_24px_60px_rgba(153,27,27,0.45)]" 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 -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="pointer-events-none absolute -left-10 -bottom-10 h-40 w-40 rounded-full bg-white/8 blur-3xl" />
<div <div class="home-hero-meteor home-hero-meteor--1" />
class="pointer-events-none absolute inset-0 bg-[repeating-linear-gradient(140deg,rgba(255,255,255,0.06)_0,rgba(255,255,255,0.06)_1px,transparent_1px,transparent_20px)]" <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"> <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" /> <Calculator class="h-5 w-5" />
</div> </div>
<h2 class="relative mt-8 text-2xl font-semibold leading-tight tracking-tight lg:text-3xl">智能预算一键生成</h2> <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">助力规范高效落地</p> <p class="relative mt-2 text-sm text-red-200/90">{{ t('home.cards.heroSubTitle') }}</p>
<div class="relative mt-6 h-px bg-gradient-to-r from-white/20 via-white/10 to-transparent" /> <div class="relative mt-6 h-px bg-white/20" />
<p class="relative mt-4 text-xs leading-5 text-red-200/60">交通建设项目工程造价咨询服务费计算</p> <p class="relative mt-4 text-xs leading-5 text-red-200/60">{{ t('home.cards.heroDesc') }}</p>
</div> </div>
<div class="flex flex-col"> <Card
<div class="home-title text-center"> role="button"
<h1 class="text-2xl font-semibold tracking-tight text-slate-900 lg:text-3xl">计算入口</h1> tabindex="0"
<p class="mt-1.5 text-sm text-slate-500">项目计算 · 单项速算 · 导入数据</p> 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 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>
<div class="mt-5 grid flex-1 gap-3 md:grid-cols-2 xl:grid-cols-3"> </Card>
<Card
role="button"
tabindex="0"
class="home-card 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 shadow-sm"
>
<img
v-if="projectIconAvailable"
:src="'/image_0.png'"
alt="项目预算"
class="h-5 w-5 object-contain"
@error="projectIconAvailable = false"
>
<FolderKanban v-else class="h-5 w-5 text-blue-600" />
</div>
<CardTitle class="mt-4 text-base font-semibold text-slate-900">项目预算</CardTitle>
<CardDescription class="mt-1.5 text-xs leading-5 text-slate-500">
适用于多合同段项目级整体计算支持导出/导入完整项目数据
</CardDescription>
</CardHeader>
<div class="mt-4 flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
<span>进入计算</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 <Card
role="button" role="button"
tabindex="0" tabindex="0"
class="home-card 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" 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" @click="openQuickCalc"
@keydown.enter.prevent="openQuickCalc" @keydown.enter.prevent="openQuickCalc"
@keydown.space.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"
> >
<CardHeader class="p-0"> <svg viewBox="0 0 800 800" class="h-10 w-10" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<div <path
class="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-amber-100 bg-amber-50/80 shadow-sm" 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"
<img transform="translate(0,800) scale(0.1,-0.1)"
v-if="quickIconAvailable" />
:src="'/image_1.png'" <path
alt="单项速算" fill="currentColor"
class="h-5 w-5 object-contain" 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"
@error="quickIconAvailable = false" transform="translate(0,800) scale(0.1,-0.1)"
> />
<Zap v-else class="h-5 w-5 text-amber-500" /> <path
</div> fill="currentColor"
<CardTitle class="mt-4 text-base font-semibold text-slate-900">单项速算</CardTitle> 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"
<CardDescription class="mt-1.5 text-xs leading-5 text-slate-500"> transform="translate(0,800) scale(0.1,-0.1)"
单项速算选择行业与咨询类型输入基数秒出结果 />
</CardDescription> </svg>
</CardHeader> </div>
<div class="mt-4 flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600"> <CardTitle class="mt-4 text-base font-semibold text-slate-900">{{ t('home.cards.quickCalc') }}</CardTitle>
<span>进入计算</span> <CardDescription class="mt-1.5 text-xs leading-5 text-slate-500">
<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> {{ t('home.cards.quickCalcDesc') }}
</div> </CardDescription>
</Card> </CardHeader>
<div class="mt-4 flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
<Card <span>{{ t('home.cards.enter') }}</span>
role="button" <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>
tabindex="0"
class="home-card 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-cyan-100 bg-cyan-50/80 shadow-sm"
>
<img
v-if="importIconAvailable"
:src="'/image_2.png'"
alt="导入数据"
class="h-5 w-5 object-contain"
@error="importIconAvailable = false"
>
<Download v-else class="h-5 w-5 text-cyan-600" />
</div>
<CardTitle class="mt-4 text-base font-semibold text-slate-900">导入数据</CardTitle>
<CardDescription class="mt-1.5 text-xs leading-5 text-slate-500">
导入".zw"数据包快速恢复项目计算状态续未完成工作
</CardDescription>
</CardHeader>
<div class="mt-4 flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
<span>选择文件</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>
</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>
</div> </div>
</div> </div>
@ -390,8 +434,8 @@ onMounted(() => {
<div class="w-full max-w-md rounded-xl border bg-background shadow-2xl"> <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 class="flex items-center justify-between border-b px-5 py-4">
<div> <div>
<h3 class="text-base font-semibold text-foreground">新建项目</h3> <h3 class="text-base font-semibold text-foreground">{{ t('home.dialog.newProject') }}</h3>
<p class="mt-1 text-sm text-muted-foreground">选择工程行业后直接进入项目计算页面</p> <p class="mt-1 text-sm text-muted-foreground">{{ t('home.dialog.chooseIndustryDesc') }}</p>
</div> </div>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeProjectCalcDialog"> <Button variant="ghost" size="icon" class="h-8 w-8" @click="closeProjectCalcDialog">
<X class="h-4 w-4" /> <X class="h-4 w-4" />
@ -400,12 +444,14 @@ onMounted(() => {
<div class="space-y-4 px-5 py-4"> <div class="space-y-4 px-5 py-4">
<label class="block space-y-2"> <label class="block space-y-2">
<span class="text-sm font-medium text-foreground">工程行业</span> <span class="text-sm font-medium text-foreground">{{ t('home.dialog.industry') }}</span>
<SelectRoot v-model="projectIndustry"> <SelectRoot v-model="projectIndustry">
<SelectTrigger <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" 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"
> >
<SelectValue placeholder="请选择工程行业" /> <span :class="projectIndustryLabel ? 'text-foreground' : 'text-muted-foreground'">
{{ projectIndustryLabel || t('home.dialog.selectIndustry') }}
</span>
<SelectIcon as-child> <SelectIcon as-child>
<ChevronDown class="h-4 w-4 text-muted-foreground" /> <ChevronDown class="h-4 w-4 text-muted-foreground" />
</SelectIcon> </SelectIcon>
@ -423,7 +469,7 @@ onMounted(() => {
:value="String(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" 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>{{ item.name }}</SelectItemText> <SelectItemText>{{ getIndustryDisplayName(item.id, locale) }}</SelectItemText>
<SelectItemIndicator class="absolute right-2 inline-flex items-center text-slate-700"> <SelectItemIndicator class="absolute right-2 inline-flex items-center text-slate-700">
<Check class="h-4 w-4" /> <Check class="h-4 w-4" />
</SelectItemIndicator> </SelectItemIndicator>
@ -436,31 +482,125 @@ onMounted(() => {
</div> </div>
<div class="flex items-center justify-end gap-2 border-t px-5 py-4"> <div class="flex items-center justify-end gap-2 border-t px-5 py-4">
<Button variant="outline" @click="closeProjectCalcDialog">取消</Button> <Button variant="outline" @click="closeProjectCalcDialog">{{ t('common.cancel') }}</Button>
<Button :disabled="projectSubmitting || !projectIndustry" @click="confirmProjectCalc"> <Button :disabled="projectSubmitting || !projectIndustry" @click="confirmProjectCalc">
{{ projectSubmitting ? '进入中...' : '进入项目计算' }} {{ projectSubmitting ? t('home.dialog.entering') : t('home.dialog.enterProjectCalc') }}
</Button> </Button>
</div> </div>
</div> </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> </template>
<style scoped> <style scoped>
.home-hero { .home-hero {
animation: hero-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; animation: none;
}
.home-card-base {
min-height: 248px;
} }
.home-title { .home-title {
animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.15s both; animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.15s both;
} }
.home-card:nth-child(1) { animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.25s both; } .home-entry-item {
.home-card:nth-child(2) { animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.35s both; } animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
.home-card:nth-child(3) { animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.45s 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 { @keyframes hero-in {
from { opacity: 0; transform: translateX(-20px) scale(0.97); } from { opacity: 0; transform: translateX(-20px) scale(0.97); }
to { opacity: 1; transform: translateX(0) scale(1); } 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 { @keyframes fade-up {
from { opacity: 0; transform: translateY(16px); } from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }

130
src/i18n/dictionary-en.ts Normal file
View File

@ -0,0 +1,130 @@
export const MAJOR_NAME_EN_BY_CODE: Record<string, string> = {
E1: 'General Transportation Engineering',
'E1-1': 'Land (Sea) Acquisition Compensation',
'E1-2': 'Relocation Compensation',
'E1-3': 'Relocation/Utility Diversion Works',
'E1-4': 'Other Construction Expenses',
'E1-5': 'Contingency',
'E1-6': 'Construction Loan Interest',
E2: 'Highway Engineering',
'E2-1': 'Temporary Works',
'E2-2': 'Subgrade Works',
'E2-3': 'Pavement Works',
'E2-4': 'Bridge and Culvert Works',
'E2-5': 'Tunnel Works',
'E2-6': 'Interchange Works',
'E2-7': 'MEP Works',
'E2-8': 'Traffic Safety Facilities',
'E2-9': 'Landscaping and Environmental Works',
'E2-10': 'Building Works',
E3: 'Railway Engineering',
'E3-1': 'Large Temporary Facilities and Transitional Works',
'E3-2': 'Subgrade Works',
'E3-3': 'Bridge and Culvert Works',
'E3-4': 'Tunnel and Cut-and-Cover Works',
'E3-5': 'Track Works',
'E3-6': 'Communication, Signaling, Information and Disaster Monitoring',
'E3-7': 'Power and Traction Power Supply Works',
'E3-8': 'Building Works (Buildings and Ancillary Works)',
'E3-9': 'Interior Decoration Works',
E4: 'Waterway Engineering',
'E4-1': 'Temporary Works',
'E4-2': 'Civil Works',
'E4-3': 'Mechanical, Electrical and Steel Structure Works',
'E4-4': 'Equipment Works',
'E4-5': 'Ancillary Building Works (Buildings and Ancillary Works)'
}
export const SERVICE_NAME_EN_BY_CODE: Record<string, string> = {
D1: 'Whole-Process Cost Consulting',
D2: 'Stage-Based Cost Consulting',
'D2-1': 'Early-Stage Cost Consulting',
'D2-2-1': 'Implementation-Stage Cost Consulting (Highway/Waterway)',
'D2-2-2': 'Implementation-Stage Cost Consulting (Railway)',
D3: 'Basic Cost Consulting',
'D3-1': 'Investment Estimate',
'D3-2': 'Design Estimate',
'D3-3': 'Construction Drawing Budget',
'D3-4': 'BOQ and BOQ Budget (or Max Bid Price)',
'D3-5': 'Estimate Review/Reconciliation (Railway Only)',
'D3-6-1': 'Contract (Project) Settlement',
'D3-6-2': 'Contract (Project) Settlement',
'D3-7': 'Final Account',
D4: 'Specialized Cost Consulting',
'D4-1': 'Cost Advisory Service',
'D4-2': 'Cost Policy Formulation/Revision',
'D4-3': 'Cost Science and Technology Research',
'D4-4': 'Quota Determination',
'D4-5': 'Cost Information Consulting',
'D4-6': 'Cost Appraisal',
'D4-7': 'Cost Estimation',
'D4-8': 'Cost Accounting',
'D4-9': 'Quantity Takeoff',
'D4-10': 'Variation Cost Consulting',
'D4-11': 'Adjusted Estimate',
'D4-12': 'Adjusted Budget Estimate',
'D4-13': 'Cost Inspection',
'D4-14': 'Other Specialized Consulting',
'D4-15-1': 'Cost Data Validation (Estimate)',
'D4-15-2': 'Cost Data Validation (Budget Estimate)',
'D4-15-3': 'Cost Data Validation (Construction Drawing Budget)',
'D4-15-4': 'Cost Data Validation (BOQ and BOQ Budget)',
'D4-15-5': 'Cost Data Validation (Estimate Review, Railway Only)',
'D4-15-6': 'Cost Data Validation (Contract Settlement)',
'D4-15-7': 'Cost Data Validation (Contract Settlement)',
'D4-15-8': 'Cost Data Validation (Final Account)'
}
export const TASK_NAME_EN_BY_CODE: Record<string, string> = {
'C4-1': 'Daily Cost Advisory',
'C4-2': 'Special Cost Advisory',
'C5-1': 'Organization and Research',
'C5-2-1': 'Document Drafting',
'C5-2-2': 'Document Drafting',
'C5-3-1': 'Review',
'C5-3-2': 'Review',
'C5-3-3': 'Review',
'C6-1': 'Organization and Research',
'C6-2-1': 'Research and Report Writing',
'C6-2-2': 'Research and Report Writing',
'C6-2-3': 'Research and Report Writing',
'C6-3-1': 'Standards/Guideline Drafting',
'C6-3-2': 'Standards/Guideline Drafting',
'C6-3-3': 'Standards/Guideline Drafting',
'C6-3-4': 'Standards/Guideline Drafting',
'C6-4-1': 'Review and Acceptance',
'C6-4-2': 'Review and Acceptance',
'C6-4-3': 'Review and Acceptance',
'C6-5-1': 'Training and Communication',
'C6-5-2': 'Training and Communication',
'C7-1': 'Organization and Research',
'C7-2': 'Outline Preparation',
'C7-3': 'Data Collection and Measurement',
'C7-4-1': 'Data Processing and Analysis',
'C7-4-2': 'Data Processing and Analysis',
'C7-5': 'Quota Determination Report',
'C7-6-1': 'Quota Text and Notes Drafting',
'C7-6-2': 'Quota Text and Notes Drafting',
'C7-7-1': 'Review and Acceptance',
'C7-7-2': 'Review and Acceptance',
'C7-7-3': 'Review and Acceptance',
'C7-8-1': 'Training and Communication',
'C7-8-2': 'Training and Communication',
'C8-1': 'Q ≤ 10',
'C8-2': '10 < Q ≤ 30',
'C8-3': '30 < Q ≤ 50',
'C8-4': '50 < Q ≤ 100',
'C8-5': 'Q > 100'
}
export const EXPERT_NAME_EN_BY_CODE: Record<string, string> = {
'C9-1-1': 'Technician and Others',
'C9-1-2': 'Assistant Engineer',
'C9-1-3': 'Intermediate Engineer or Level-2 Cost Engineer',
'C9-1-4': 'Senior Engineer or Level-1 Cost Engineer',
'C9-1-5': 'Professor-Level Senior Engineer or Senior Expert',
'C9-2-1': 'Level-2 Cost Engineer + Intermediate Engineer',
'C9-3-1': 'Level-1 Cost Engineer + Intermediate Engineer',
'C9-3-2': 'Level-1 Cost Engineer + Senior Engineer'
}

36
src/i18n/index.ts Normal file
View File

@ -0,0 +1,36 @@
import { createI18n } from 'vue-i18n'
import { enUS } from './locales/en-US'
import { zhCN } from './locales/zh-CN'
export const I18N_LOCALE_KEY = 'jgjs-locale-v1'
export const DEFAULT_LOCALE = 'zh-CN'
const messages = {
'zh-CN': zhCN,
'en-US': enUS
} as const
export type AppLocale = keyof typeof messages
const getInitialLocale = (): AppLocale => {
if (typeof window === 'undefined') return DEFAULT_LOCALE
const saved = String(localStorage.getItem(I18N_LOCALE_KEY) || '').trim() as AppLocale
if (saved in messages) return saved
const language = String(navigator.language || '').toLowerCase()
return language.startsWith('en') ? 'en-US' : DEFAULT_LOCALE
}
export const i18n = createI18n({
legacy: false,
locale: getInitialLocale(),
fallbackLocale: DEFAULT_LOCALE,
messages
})
export const setAppLocale = (locale: AppLocale) => {
i18n.global.locale.value = locale
if (typeof window !== 'undefined') {
localStorage.setItem(I18N_LOCALE_KEY, locale)
}
}

104
src/i18n/locales/en-US.ts Normal file
View File

@ -0,0 +1,104 @@
export const enUS = {
common: {
cancel: 'Cancel',
confirm: 'Confirm',
delete: 'Delete',
close: 'Close'
},
app: {
projectConflict: {
title: 'Project Already Open',
desc: 'Project "{name}" is already active in another tab. Editing is blocked here to avoid IndexedDB conflicts.',
countdown: 'This page will try to close automatically in {seconds} seconds. You can open another project in a new tab first.',
opened: '(Opened)',
lastEdited: 'Last edited: {time}',
openDefault: 'Open Default Project',
createAndOpen: 'Create and Open'
}
},
home: {
title: 'Calculation Entry',
subtitle: 'Project Budget · Quick Calc · Import Data',
cards: {
heroTitle: 'One-Click Smart Budget',
heroSubTitle: 'Accelerate standards adoption',
heroDesc: 'Cost consulting fee calculator for transport construction projects',
projectBudget: 'Project Budget',
projectBudgetDesc: 'For full project-level calculation across multiple contracts with import/export support',
quickCalc: 'Quick Calc',
quickCalcDesc: 'Pick industry and consulting type, input scale values, and get results instantly',
importData: 'Import Data',
importDataDesc: 'Import ".zw" package to restore project state and continue work quickly',
enter: 'Enter',
pickFile: 'Choose File'
},
dialog: {
newProject: 'New Project',
chooseIndustryDesc: 'Choose an industry and enter project calculation directly.',
industry: 'Industry',
selectIndustry: 'Select industry',
entering: 'Entering...',
enterProjectCalc: 'Enter Project Calculation',
confirmImport: 'Confirm Import',
confirmImportDesc: 'Import "{file}" and enter workspace immediately, overriding current project data.',
confirmImportAction: 'Import'
}
},
tab: {
toolbar: {
light: 'Light',
dark: 'Dark',
language: 'Lang',
importExport: 'Import/Export',
importData: 'Import',
exportData: 'Export',
exportReport: 'Export Report',
userGuide: 'Guide',
reset: 'Reset',
resetting: 'Resetting...',
projectList: 'Projects',
projectCount: 'Projects: {count}',
createProject: 'New Project',
resetAll: 'Reset All',
opened: '(Opened)',
lastEdited: 'Last edited: {time}'
},
menu: {
closeAll: 'Close All',
closeLeft: 'Close Left',
closeRight: 'Close Right',
closeOther: 'Close Others'
},
dialog: {
resetTitle: 'Confirm Reset',
resetDesc: 'All project data will be cleared and the default page will be restored. Continue?',
confirmReset: 'Confirm Reset',
importOverrideTitle: 'Confirm Override Import',
importOverrideDesc: 'Use "{file}" to override all local data for current project. Continue?',
confirmOverride: 'Confirm Override',
newProjectTitle: 'New Project',
newProjectDesc: 'Choose an industry, then open the new project calculation page in a new tab.',
createAndOpen: 'Create & Open',
creating: 'Creating...',
projectLimitTitle: 'Project Limit Reached',
projectLimitDesc: 'Project count has reached {max}. Delete one project before adding a new one.',
iKnow: 'OK',
deleteProjectTitle: 'Confirm Delete Project',
deleteCurrentProjectDesc: 'Delete current project "{name}"? Data will be cleared and you will return to home.',
deleteProjectDesc: 'Delete project "{name}"? This will remove local data for that project.'
},
guide: {
title: 'User Guide',
later: 'Later',
prev: 'Prev',
next: 'Next',
finish: 'Finish and Disable Auto Popup'
},
toast: {
export: 'Export Report',
success: 'Export Success',
failed: 'Export Failed'
}
}
} as const

104
src/i18n/locales/zh-CN.ts Normal file
View File

@ -0,0 +1,104 @@
export const zhCN = {
common: {
cancel: '取消',
confirm: '确认',
delete: '删除',
close: '关闭'
},
app: {
projectConflict: {
title: '检测到项目重复打开',
desc: '项目「{name}」已在其他页面处于活跃状态。为避免 IndexedDB 数据冲突,本页面已阻断编辑。',
countdown: '本页将在 {seconds} 秒后自动尝试关闭。你也可以先在新标签页打开其他项目。',
opened: '(已打开)',
lastEdited: '最后编辑:{time}',
openDefault: '打开默认项目',
createAndOpen: '新建项目并打开'
}
},
home: {
title: '计算入口',
subtitle: '项目计算 · 单项速算 · 导入数据',
cards: {
heroTitle: '智能预算一键生成',
heroSubTitle: '助力《规范》高效落地',
heroDesc: '交通建设项目工程造价咨询服务费计算',
projectBudget: '项目预算',
projectBudgetDesc: '适用于多合同段、项目级整体计算,支持导出/导入完整项目数据',
quickCalc: '单项速算',
quickCalcDesc: '单项速算,选择行业与咨询类型,输入基数秒出结果',
importData: '导入数据',
importDataDesc: '导入".zw"数据包,快速恢复项目计算状态,续未完成工作',
enter: '进入计算',
pickFile: '选择文件'
},
dialog: {
newProject: '新建项目',
chooseIndustryDesc: '选择工程行业后,直接进入项目计算页面。',
industry: '工程行业',
selectIndustry: '请选择工程行业',
entering: '进入中...',
enterProjectCalc: '进入项目计算',
confirmImport: '确认导入数据',
confirmImportDesc: '将导入“{file}”,并立即进入工作台覆盖当前项目数据。',
confirmImportAction: '确认导入'
}
},
tab: {
toolbar: {
light: '浅色',
dark: '深色',
language: '语言',
importExport: '导入/导出',
importData: '导入数据',
exportData: '导出数据',
exportReport: '导出报表',
userGuide: '使用引导',
reset: '重置',
resetting: '重置中...',
projectList: '项目列表',
projectCount: '项目数量:{count}',
createProject: '新建项目',
resetAll: '重置全部项目',
opened: '(已打开)',
lastEdited: '最后编辑:{time}'
},
menu: {
closeAll: '删除所有',
closeLeft: '删除左侧',
closeRight: '删除右侧',
closeOther: '删除其他'
},
dialog: {
resetTitle: '确认重置',
resetDesc: '将清空全部项目数据,并恢复默认页面,确认继续吗?',
confirmReset: '确认重置',
importOverrideTitle: '确认导入覆盖',
importOverrideDesc: '将使用“{file}”覆盖当前本地全部数据,是否继续?',
confirmOverride: '确认覆盖',
newProjectTitle: '新建项目',
newProjectDesc: '选择工程行业后,将在新标签页直接打开新项目计算页面。',
createAndOpen: '新建并打开',
creating: '创建中...',
projectLimitTitle: '项目数量已达上限',
projectLimitDesc: '当前项目数量已达到 {max} 个,请先删除一个项目后再添加。',
iKnow: '我知道了',
deleteProjectTitle: '确认删除项目',
deleteCurrentProjectDesc: '确认删除当前项目「{name}」吗?将先清空该项目全部本地数据并返回首页。',
deleteProjectDesc: '确认删除项目「{name}」吗?这会移除该项目本地数据。'
},
guide: {
title: '新用户引导',
later: '稍后再看',
prev: '上一步',
next: '下一步',
finish: '完成并不再自动弹出'
},
toast: {
export: '导出报表',
success: '导出成功',
failed: '导出失败'
}
}
} as const

View File

@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineAsyncComponent, markRaw, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue' import { computed, defineAsyncComponent, markRaw, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue' import type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import { useDark, useToggle } from '@vueuse/core'
import { useTabStore } from '@/pinia/tab' import { useTabStore } from '@/pinia/tab'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing' import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useZxFwPricingKeysStore } from '@/pinia/zxFwPricingKeys' import { useZxFwPricingKeysStore } from '@/pinia/zxFwPricingKeys'
@ -10,7 +12,7 @@ import { useKvStore } from '@/pinia/kv'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip' import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
import { Check, ChevronDown, CircleHelp, Loader2, X } from 'lucide-vue-next' import { Check, ChevronDown, CircleHelp, Loader2, Moon, Sun, X } from 'lucide-vue-next'
import localforage from 'localforage' import localforage from 'localforage'
import { import {
AlertDialogAction, AlertDialogAction,
@ -34,7 +36,6 @@ import {
SelectPortal, SelectPortal,
SelectRoot, SelectRoot,
SelectTrigger, SelectTrigger,
SelectValue,
SelectViewport SelectViewport
} from 'reka-ui' } from 'reka-ui'
import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive' import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive'
@ -99,6 +100,7 @@ import {
PROJECT_TAB_ID, PROJECT_TAB_ID,
QUICK_TAB_ID, QUICK_TAB_ID,
consumePendingHomeImportFile, consumePendingHomeImportFile,
consumePendingHomeImportSkipConfirm,
readWorkspaceMode, readWorkspaceMode,
writeProjectIdToUrl, writeProjectIdToUrl,
writeWorkspaceMode writeWorkspaceMode
@ -138,10 +140,12 @@ import {
toMoney toMoney
} from '@/lib/reportExportBuilders' } from '@/lib/reportExportBuilders'
import { exportFile } from '@/sql' import { exportFile } from '@/sql'
import { industryTypeList } from '@/sql' import { getIndustryDisplayName, industryTypeList } from '@/sql'
import { initializeProjectFactorStates } from '@/lib/projectWorkspace' import { initializeProjectFactorStates } from '@/lib/projectWorkspace'
import { setAppLocale, type AppLocale } from '@/i18n'
const USER_GUIDE_COMPLETED_KEY = 'jgjs-user-guide-completed-v1' const USER_GUIDE_COMPLETED_KEY = 'jgjs-user-guide-completed-v1'
const THEME_PREFERENCE_KEY = 'jgjs-theme-dark-v1'
const PROJECT_INFO_DB_KEY = 'xm-base-info-v1' const PROJECT_INFO_DB_KEY = 'xm-base-info-v1'
const LEGACY_PROJECT_DB_KEY = 'xm-info-v3' const LEGACY_PROJECT_DB_KEY = 'xm-info-v3'
const CONSULT_CATEGORY_FACTOR_DB_KEY = 'xm-consult-category-factor-v1' const CONSULT_CATEGORY_FACTOR_DB_KEY = 'xm-consult-category-factor-v1'
@ -240,6 +244,15 @@ const zxFwPricingStore = useZxFwPricingStore()
const zxFwPricingKeysStore = useZxFwPricingKeysStore() const zxFwPricingKeysStore = useZxFwPricingKeysStore()
const zxFwPricingHtFeeStore = useZxFwPricingHtFeeStore() const zxFwPricingHtFeeStore = useZxFwPricingHtFeeStore()
const kvStore = useKvStore() const kvStore = useKvStore()
const { t, locale } = useI18n()
const isDark = useDark({
selector: 'html',
attribute: 'class',
valueDark: 'dark',
valueLight: '',
storageKey: 'jgjs-theme-dark-v1'
})
const toggleDark = useToggle(isDark)
@ -257,6 +270,11 @@ const newProjectDialogOpen = ref(false)
const newProjectIndustry = ref(String(industryTypeList[0]?.id || '')) const newProjectIndustry = ref(String(industryTypeList[0]?.id || ''))
const newProjectSubmitting = ref(false) const newProjectSubmitting = ref(false)
const projectLimitDialogOpen = ref(false) const projectLimitDialogOpen = ref(false)
const projectDeleteConfirmOpen = ref(false)
const pendingDeleteProject = shallowRef<ProjectMeta | null>(null)
const messageDialogOpen = ref(false)
const messageDialogTitle = ref('')
const messageDialogDesc = ref('')
const projectList = ref<ProjectMeta[]>([]) const projectList = ref<ProjectMeta[]>([])
const openedProjectIds = ref<string[]>([]) const openedProjectIds = ref<string[]>([])
const currentProjectId = ref(readCurrentProjectId()) const currentProjectId = ref(readCurrentProjectId())
@ -335,11 +353,18 @@ const tabsModel = computed({
}) })
const activeTab = computed(() => tabStore.tabs.find((t: any) => t.id === tabStore.activeTabId) || null) const activeTab = computed(() => tabStore.tabs.find((t: any) => t.id === tabStore.activeTabId) || null)
const activeTabId = computed(() => (activeTab.value ? String(activeTab.value.id || '') : '')) const activeTabId = computed(() => (activeTab.value ? String(activeTab.value.id || '') : ''))
const resetUiWorkspaceMode = ref<string>('')
const quickModeThemePreferenceSnapshot = ref<string | null | undefined>(undefined)
const workspaceModeForUi = computed(() =>
isResetting.value && resetUiWorkspaceMode.value
? resetUiWorkspaceMode.value
: readWorkspaceMode()
)
const contextTabIndex = computed(() => tabStore.tabs.findIndex((t:any) => t.id === contextTabId.value)) const contextTabIndex = computed(() => tabStore.tabs.findIndex((t:any) => t.id === contextTabId.value))
const hasClosableTabs = computed(() => { const hasClosableTabs = computed(() => {
const fixedId = readWorkspaceMode() === 'quick' ? QUICK_TAB_ID : PROJECT_TAB_ID const fixedId = workspaceModeForUi.value === 'quick' ? QUICK_TAB_ID : PROJECT_TAB_ID
return tabStore.tabs.some((t: any) => t.componentName !== fixedId) return tabStore.tabs.some((t: any) => t.componentName !== fixedId)
}) })
@ -360,7 +385,19 @@ const canCloseRight = computed(() => {
const canCloseOther = computed(() => const canCloseOther = computed(() =>
tabStore.tabs.slice(1, contextTabIndex.value).length > 1 && contextTabIndex.value !== 0 tabStore.tabs.slice(1, contextTabIndex.value).length > 1 && contextTabIndex.value !== 0
) )
const projectCountText = computed(() => `${projectList.value.length}/${MAX_PROJECT_COUNT}`) const localeLabel = computed(() => (locale.value === 'en-US' ? 'EN' : '中'))
const projectCountText = computed(() => t('tab.toolbar.projectCount', { count: `${projectList.value.length}/${MAX_PROJECT_COUNT}` }))
const newProjectIndustryLabel = computed(() => {
const target = String(newProjectIndustry.value || '').trim()
if (!target) return ''
return getIndustryDisplayName(target, locale.value) || ''
})
const toggleLocale = () => {
const nextLocale: AppLocale = locale.value === 'en-US' ? 'zh-CN' : 'en-US'
setAppLocale(nextLocale)
window.location.reload()
}
const closeMenus = () => { const closeMenus = () => {
tabContextOpen.value = false tabContextOpen.value = false
@ -513,13 +550,21 @@ const formatProjectEditedTime = (value: string) => {
const removeProjectItem = async (project: ProjectMeta) => { const removeProjectItem = async (project: ProjectMeta) => {
const isCurrentProject = project.id === currentProjectId.value const isCurrentProject = project.id === currentProjectId.value
const ok = window.confirm( const deleteIndexedDBByName = (dbName: string) =>
isCurrentProject new Promise<void>((resolve) => {
? `确认删除当前项目「${project.name}」吗?将先清空该项目全部本地数据,并跳转到新项目选择页。` try {
: `确认删除项目「${project.name}」吗?这会移除该项目本地数据。` const request = window.indexedDB?.deleteDatabase(dbName)
) if (!request) {
if (!ok) return resolve()
return
}
request.onsuccess = () => resolve()
request.onerror = () => resolve()
request.onblocked = () => resolve()
} catch (_error) {
resolve()
}
})
const clearProjectPersistence = async () => { const clearProjectPersistence = async () => {
await projectDefaultForage.clear() await projectDefaultForage.clear()
await Promise.all( await Promise.all(
@ -537,34 +582,83 @@ const removeProjectItem = async (project: ProjectMeta) => {
} }
if (isCurrentProject) { if (isCurrentProject) {
window.dispatchEvent(new CustomEvent('jgjs-release-project-lock'))
await clearProjectPersistence() await clearProjectPersistence()
await deleteIndexedDBByName(getProjectDbName(project.id))
const removed = deleteProject(project.id) const removed = deleteProject(project.id)
if (!removed) return if (!removed) return
const nextProject = createProject() const nextProject = createProject(DEFAULT_PROJECT_NAME)
const defaultIndustry = String(industryTypeList[0]?.id || '').trim()
if (defaultIndustry) {
const kvAdapter = createProjectKvAdapter(nextProject.id)
await kvAdapter.setItem(PROJECT_INFO_DB_KEY, {
projectIndustry: defaultIndustry,
projectName: DEFAULT_PROJECT_NAME,
preparedBy: '',
reviewedBy: '',
preparedCompany: '',
preparedDate: getTodayDateString()
})
await initializeProjectFactorStates(
kvAdapter,
defaultIndustry,
CONSULT_CATEGORY_FACTOR_DB_KEY,
MAJOR_FACTOR_DB_KEY
)
}
writeWorkspaceMode('project') writeWorkspaceMode('project')
window.location.href = buildProjectUrl(nextProject.id, { newProject: true }) tabStore.resetTabs()
tabStore.hasCompletedSetup = false
window.location.href = buildProjectUrl(nextProject.id, {
newProject: true,
openProjectDialog: false,
forceHome: true
})
return return
} }
const removed = deleteProject(project.id) const removed = deleteProject(project.id)
if (!removed) return if (!removed) return
try { try {
await new Promise<void>((resolve) => { await deleteIndexedDBByName(getProjectDbName(project.id))
const request = window.indexedDB?.deleteDatabase(getProjectDbName(project.id))
if (!request) {
resolve()
return
}
request.onsuccess = () => resolve()
request.onerror = () => resolve()
request.onblocked = () => resolve()
})
} catch (error) { } catch (error) {
console.error('delete project database failed:', error) console.error('delete project database failed:', error)
} }
await refreshProjectList() await refreshProjectList()
} }
const requestRemoveProjectItem = (project: ProjectMeta) => {
pendingDeleteProject.value = project
projectDeleteConfirmOpen.value = true
}
const handleProjectDeleteDialogOpenChange = (open: boolean) => {
projectDeleteConfirmOpen.value = open
}
const showMessageDialog = (title: string, description: string) => {
messageDialogTitle.value = title
messageDialogDesc.value = description
messageDialogOpen.value = true
}
const handleToggleTheme = () => {
toggleDark()
}
const cancelRemoveProjectItem = () => {
projectDeleteConfirmOpen.value = false
pendingDeleteProject.value = null
}
const confirmRemoveProjectItem = async () => {
const project = pendingDeleteProject.value
if (!project) return
projectDeleteConfirmOpen.value = false
pendingDeleteProject.value = null
await removeProjectItem(project)
}
const markGuideCompleted = () => { const markGuideCompleted = () => {
try { try {
localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1') localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1')
@ -1251,7 +1345,7 @@ const exportData = async () => {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} catch (error) { } catch (error) {
console.error('export failed:', error) console.error('export failed:', error)
window.alert('导出失败,请重试。') showMessageDialog('导出失败', '请重试。')
} finally { } finally {
dataMenuOpen.value = false dataMenuOpen.value = false
} }
@ -1279,7 +1373,10 @@ const triggerImport = () => {
importFileRef.value?.click() importFileRef.value?.click()
} }
const prepareImportPayloadFromFile = async (file: File) => { const prepareImportPayloadFromFile = async (
file: File,
options?: { skipConfirm?: boolean }
) => {
if (!file.name.toLowerCase().endsWith(ZW_FILE_EXTENSION)) { if (!file.name.toLowerCase().endsWith(ZW_FILE_EXTENSION)) {
throw new Error('INVALID_FILE_EXT') throw new Error('INVALID_FILE_EXT')
} }
@ -1298,6 +1395,10 @@ const prepareImportPayloadFromFile = async (file: File) => {
} }
pendingImportPayload.value = payload pendingImportPayload.value = payload
pendingImportFileName.value = file.name pendingImportFileName.value = file.name
if (options?.skipConfirm) {
await confirmImportOverride()
return
}
importConfirmOpen.value = true importConfirmOpen.value = true
} }
@ -1311,14 +1412,14 @@ const importData = async (event: Event) => {
} catch (error) { } catch (error) {
console.error('import failed:', error) console.error('import failed:', error)
if (error instanceof Error && error.message === 'PROJECT_ID_MISSING') { if (error instanceof Error && error.message === 'PROJECT_ID_MISSING') {
window.alert('导入失败:该数据包不包含项目标识(旧版本导出包),为避免串项目已禁止导入。') showMessageDialog('导入失败', '该数据包不包含项目标识(旧版本导出包),为避免串项目已禁止导入。')
return return
} }
if (error instanceof Error && error.message.startsWith('PROJECT_ID_MISMATCH:')) { if (error instanceof Error && error.message.startsWith('PROJECT_ID_MISMATCH:')) {
window.alert('导入失败:该数据包属于其他项目,不能覆盖当前项目。') showMessageDialog('导入失败', '该数据包属于其他项目,不能覆盖当前项目。')
return return
} }
window.alert('导入失败:文件无效、已损坏或被修改。') showMessageDialog('导入失败', '文件无效、已损坏或被修改。')
} finally { } finally {
input.value = '' input.value = ''
} }
@ -1389,7 +1490,7 @@ const confirmImportOverride = async () => {
window.location.reload() window.location.reload()
} catch (error) { } catch (error) {
console.error('import apply failed:', error) console.error('import apply failed:', error)
window.alert('导入失败:写入本地数据时发生错误。') showMessageDialog('导入失败', '写入本地数据时发生错误。')
} finally { } finally {
cancelImportConfirm() cancelImportConfirm()
} }
@ -1397,6 +1498,7 @@ const confirmImportOverride = async () => {
const handleReset = async () => { const handleReset = async () => {
if (isResetting.value) return if (isResetting.value) return
resetUiWorkspaceMode.value = readWorkspaceMode()
const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)) const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
const resetStartedAt = Date.now() const resetStartedAt = Date.now()
const allProjectIds = Array.from(new Set(['default', 'quick', ...listProjects().map(item => item.id)])) const allProjectIds = Array.from(new Set(['default', 'quick', ...listProjects().map(item => item.id)]))
@ -1427,9 +1529,15 @@ const handleReset = async () => {
isResetting.value = true isResetting.value = true
dataMenuOpen.value = false dataMenuOpen.value = false
projectMenuOpen.value = false projectMenuOpen.value = false
window.dispatchEvent(new CustomEvent('jgjs-release-project-lock'))
const themePreference =
typeof localStorage !== 'undefined' ? localStorage.getItem(THEME_PREFERENCE_KEY) : null
// 1) // 1)
localStorage.clear() localStorage.clear()
if (themePreference != null) {
localStorage.setItem(THEME_PREFERENCE_KEY, themePreference)
}
sessionStorage.clear() sessionStorage.clear()
await projectDefaultForage.clear() await projectDefaultForage.clear()
@ -1466,6 +1574,7 @@ const handleReset = async () => {
} catch (error) { } catch (error) {
console.error('reset failed:', error) console.error('reset failed:', error)
isResetting.value = false isResetting.value = false
resetUiWorkspaceMode.value = ''
} }
} }
@ -1482,6 +1591,7 @@ onMounted(() => {
window.addEventListener('keydown', handleGlobalKeyDown) window.addEventListener('keydown', handleGlobalKeyDown)
window.addEventListener('resize', scheduleUpdateTabTitleOverflow) window.addEventListener('resize', scheduleUpdateTabTitleOverflow)
const pendingHomeImportFile = consumePendingHomeImportFile() const pendingHomeImportFile = consumePendingHomeImportFile()
const skipWorkspaceImportConfirm = consumePendingHomeImportSkipConfirm()
void nextTick(() => { void nextTick(() => {
bindTabStripScroll() bindTabStripScroll()
ensureActiveTabVisible() ensureActiveTabVisible()
@ -1490,9 +1600,9 @@ onMounted(() => {
scheduleRestoreTabInnerScrollTop(tabStore.activeTabId) scheduleRestoreTabInnerScrollTop(tabStore.activeTabId)
}) })
if (pendingHomeImportFile) { if (pendingHomeImportFile) {
void prepareImportPayloadFromFile(pendingHomeImportFile).catch(error => { void prepareImportPayloadFromFile(pendingHomeImportFile, { skipConfirm: skipWorkspaceImportConfirm }).catch(error => {
console.error('home import failed:', error) console.error('home import failed:', error)
window.alert('导入失败:文件无效、已损坏或被修改。') showMessageDialog('导入失败', '文件无效、已损坏或被修改。')
}) })
} }
void (async () => { void (async () => {
@ -1516,6 +1626,32 @@ onBeforeUnmount(() => {
} }
}) })
watch(
() => workspaceModeForUi.value,
(mode, prevMode) => {
if (typeof window === 'undefined') return
if (mode === 'quick') {
if (quickModeThemePreferenceSnapshot.value === undefined) {
quickModeThemePreferenceSnapshot.value = localStorage.getItem(THEME_PREFERENCE_KEY)
}
isDark.value = false
document.documentElement.classList.remove('dark')
return
}
if (prevMode !== 'quick' || quickModeThemePreferenceSnapshot.value === undefined) return
const snapshot = quickModeThemePreferenceSnapshot.value
quickModeThemePreferenceSnapshot.value = undefined
if (snapshot == null) {
localStorage.removeItem(THEME_PREFERENCE_KEY)
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
return
}
localStorage.setItem(THEME_PREFERENCE_KEY, snapshot)
isDark.value = snapshot === 'true'
},
{ immediate: true }
)
watch( watch(
() => tabStore.activeTabId, () => tabStore.activeTabId,
(nextId, prevId) => { (nextId, prevId) => {
@ -1608,97 +1744,149 @@ watch(
</button> </button>
</div> </div>
<div class="flex shrink-0 self-center items-center gap-1"> <div class="flex shrink-0 self-center items-center gap-1">
<div v-if="readWorkspaceMode() !== 'quick'" ref="dataMenuRef" class="relative shrink-0"> <Button
v-if="workspaceModeForUi !== 'quick'"
variant="ghost"
size="icon"
class="h-9 w-9 shrink-0 cursor-pointer text-muted-foreground transition-all duration-200 hover:text-foreground"
:disabled="isResetting"
:title="isDark ? t('tab.toolbar.dark') : t('tab.toolbar.light')"
:aria-label="isDark ? t('tab.toolbar.dark') : t('tab.toolbar.light')"
@click="handleToggleTheme"
>
<component
:is="isDark ? Moon : Sun"
class="h-4 w-4 transition-transform duration-200"
:class="isDark ? 'rotate-0' : 'rotate-180'"
/>
</Button>
<Button
v-if="workspaceModeForUi !== 'quick'"
variant="ghost"
size="sm"
class="h-9 min-w-9 px-2 shrink-0 cursor-pointer text-muted-foreground transition-all duration-200 hover:text-foreground"
:disabled="isResetting"
:title="t('tab.toolbar.language')"
:aria-label="t('tab.toolbar.language')"
@click="toggleLocale"
>
{{ localeLabel }}
</Button>
<div v-if="workspaceModeForUi !== 'quick'" ref="dataMenuRef" class="relative shrink-0">
<Button variant="outline" size="sm" <Button variant="outline" size="sm"
class="app-toolbar-btn shrink-0 cursor-pointer" class="app-toolbar-btn shrink-0 cursor-pointer transition-all duration-200"
:class="dataMenuOpen ? 'border-primary/40 bg-primary/5 text-foreground' : ''"
:disabled="isResetting" :disabled="isResetting"
@click="dataMenuOpen = !dataMenuOpen"> @click="dataMenuOpen = !dataMenuOpen">
<ChevronDown class="h-4 w-4 mr-1" /> <ChevronDown
导入/导出 class="mr-1 h-4 w-4 transition-transform duration-200"
:class="dataMenuOpen ? 'rotate-180' : 'rotate-0'"
/>
{{ t('tab.toolbar.importExport') }}
</Button> </Button>
<div v-if="dataMenuOpen" <Transition name="toolbar-dropdown">
class="absolute right-0 top-full mt-1 z-50 rounded-md border bg-background p-1 shadow-md"> <div v-if="dataMenuOpen"
<button class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50" class="absolute right-0 top-full mt-1 z-50 rounded-md border bg-background p-1 shadow-md">
<button class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isResetting"
@click="triggerImport">
{{ t('tab.toolbar.importData') }}
</button>
<button class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isResetting"
@click="exportData">
{{ t('tab.toolbar.exportData') }}
</button>
<button class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isResetting" :disabled="isResetting"
@click="triggerImport"> v-if="workspaceModeForUi !== 'quick'"
导入 @click="exportReport">
</button> {{ t('tab.toolbar.exportReport') }}
<button class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50" </button>
:disabled="isResetting" </div>
@click="exportData"> </Transition>
导出
</button>
<button class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isResetting"
v-if="readWorkspaceMode() !== 'quick'"
@click="exportReport">
导出报表
</button>
</div>
<input ref="importFileRef" type="file" accept=".zw" class="hidden" @change="importData" /> <input ref="importFileRef" type="file" accept=".zw" class="hidden" @change="importData" />
</div> </div>
<Button v-if="readWorkspaceMode() !== 'quick'" variant="outline" size="sm" class="app-toolbar-btn shrink-0 cursor-pointer" <Button v-if="workspaceModeForUi !== 'quick'" variant="outline" size="sm" class="app-toolbar-btn shrink-0 cursor-pointer"
:disabled="isResetting" :disabled="isResetting"
@click="openUserGuide(0)"> @click="openUserGuide(0)">
<CircleHelp class="h-4 w-4 mr-1" /> <CircleHelp class="h-4 w-4 mr-1" />
使用引导 {{ t('tab.toolbar.userGuide') }}
</Button>
<Button
v-if="workspaceModeForUi === 'quick'"
variant="destructive"
size="sm"
class="app-toolbar-btn shrink-0 cursor-pointer"
:disabled="isResetting"
@click="resetConfirmOpen = true"
>
<Loader2 v-if="isResetting" class="mr-1 h-4 w-4 animate-spin" />
{{ isResetting ? t('tab.toolbar.resetting') : t('tab.toolbar.reset') }}
</Button> </Button>
<div v-if="readWorkspaceMode() !== 'quick'" ref="projectMenuRef" class="relative shrink-0"> <div v-if="workspaceModeForUi !== 'quick'" ref="projectMenuRef" class="relative shrink-0">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
class="app-toolbar-btn shrink-0 cursor-pointer" class="app-toolbar-btn shrink-0 cursor-pointer transition-all duration-200"
:class="projectMenuOpen ? 'border-primary/40 bg-primary/5 text-foreground' : ''"
:disabled="isResetting" :disabled="isResetting"
@click="projectMenuOpen = !projectMenuOpen; if (projectMenuOpen) void refreshProjectList()" @click="projectMenuOpen = !projectMenuOpen; if (projectMenuOpen) void refreshProjectList()"
> >
<ChevronDown class="mr-1 h-4 w-4" /> <ChevronDown
项目列表 class="mr-1 h-4 w-4 transition-transform duration-200"
:class="projectMenuOpen ? 'rotate-180' : 'rotate-0'"
/>
{{ t('tab.toolbar.projectList') }}
</Button> </Button>
<div <Transition name="toolbar-dropdown">
v-if="projectMenuOpen" <div
class="absolute right-0 top-full z-50 mt-1 w-[420px] rounded-md border bg-background p-2 shadow-md" v-if="projectMenuOpen"
> class="absolute right-0 top-full z-50 mt-1 w-[420px] rounded-md border bg-background p-2 shadow-md"
<div class="max-h-56 space-y-1 overflow-auto"> >
<div <div class="max-h-56 space-y-1 overflow-auto">
v-for="project in projectList" <div
:key="project.id" v-for="project in projectList"
class="flex items-center gap-2 rounded px-2 py-1.5" :key="project.id"
:class="isProjectOpen(project.id) ? 'opacity-60' : 'hover:bg-muted'" class="flex items-center gap-2 rounded px-2 py-1.5"
> :class="isProjectOpen(project.id) ? 'opacity-60' : 'hover:bg-muted'"
<button
class="flex-1 text-left text-sm"
:class="isProjectOpen(project.id) ? 'cursor-not-allowed' : 'cursor-pointer'"
:disabled="isProjectOpen(project.id)"
@click="openProjectInNewTab(project.id)"
> >
<div class="font-medium leading-5"> <button
{{ project.name }} class="flex-1 text-left text-sm"
<span v-if="isProjectOpen(project.id)" class="ml-1 text-xs text-muted-foreground">已打开</span> :class="isProjectOpen(project.id) ? 'cursor-not-allowed' : 'cursor-pointer'"
</div> :disabled="isProjectOpen(project.id)"
<div class="text-xs text-muted-foreground">最后编辑{{ formatProjectEditedTime(project.updatedAt) }}</div> @click="openProjectInNewTab(project.id)"
</button> >
<Button <div class="font-medium leading-5">
variant="ghost" {{ project.name }}
size="sm" <span v-if="isProjectOpen(project.id)" class="ml-1 text-xs text-muted-foreground">{{ t('tab.toolbar.opened') }}</span>
class="h-7 px-2 text-xs" </div>
@click="removeProjectItem(project)" <div class="text-xs text-muted-foreground">{{ t('tab.toolbar.lastEdited', { time: formatProjectEditedTime(project.updatedAt) }) }}</div>
> </button>
删除 <Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
@click="requestRemoveProjectItem(project)"
>
{{ t('common.delete') }}
</Button>
</div>
</div>
<div class="mt-2 flex items-center justify-end gap-2 border-t pt-2">
<span class="mr-auto text-xs text-muted-foreground">{{ projectCountText }}</span>
<Button size="sm" class="h-8 px-3 text-xs" @click="openCreateProjectDialog">
{{ t('tab.toolbar.createProject') }}
</Button>
<Button variant="destructive" size="sm" class="h-8 px-3 text-xs" @click="resetConfirmOpen = true">
{{ t('tab.toolbar.resetAll') }}
</Button> </Button>
</div> </div>
</div> </div>
<div class="mt-2 flex items-center justify-end gap-2 border-t pt-2"> </Transition>
<span class="mr-auto text-xs text-muted-foreground">项目数量{{ projectCountText }}</span>
<Button size="sm" class="h-8 px-3 text-xs" @click="openCreateProjectDialog">
新建项目
</Button>
<Button variant="destructive" size="sm" class="h-8 px-3 text-xs" @click="resetConfirmOpen = true">
重置全部项目
</Button>
</div>
</div>
</div> </div>
<AlertDialogRoot :open="resetConfirmOpen" @update:open="handleResetConfirmOpenChange"> <AlertDialogRoot :open="resetConfirmOpen" @update:open="handleResetConfirmOpenChange">
@ -1706,15 +1894,15 @@ watch(
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent <AlertDialogContent
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl"> class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认重置</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">{{ t('tab.dialog.resetTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将清空全部项目数据并恢复默认页面确认继续吗 {{ t('tab.dialog.resetDesc') }}
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<Button variant="outline" :disabled="isResetting" @click="resetConfirmOpen = false">取消</Button> <Button variant="outline" :disabled="isResetting" @click="resetConfirmOpen = false">{{ t('common.cancel') }}</Button>
<Button variant="destructive" :disabled="isResetting" @click="handleReset"> <Button variant="destructive" :disabled="isResetting" @click="handleReset">
<Loader2 v-if="isResetting" class="mr-1 h-4 w-4 animate-spin" /> <Loader2 v-if="isResetting" class="mr-1 h-4 w-4 animate-spin" />
{{ isResetting ? '重置中...' : '确认重置' }} {{ isResetting ? t('tab.toolbar.resetting') : t('tab.dialog.confirmReset') }}
</Button> </Button>
</div> </div>
</AlertDialogContent> </AlertDialogContent>
@ -1726,16 +1914,16 @@ watch(
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent <AlertDialogContent
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl"> class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认导入覆盖</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">{{ t('tab.dialog.importOverrideTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将使用{{ pendingImportFileName || '所选文件' }}覆盖当前本地全部数据是否继续 {{ t('tab.dialog.importOverrideDesc', { file: pendingImportFileName || t('home.cards.pickFile') }) }}
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child> <AlertDialogCancel as-child>
<Button variant="outline" @click="cancelImportConfirm">取消</Button> <Button variant="outline" @click="cancelImportConfirm">{{ t('common.cancel') }}</Button>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction as-child> <AlertDialogAction as-child>
<Button variant="destructive" @click="confirmImportOverride">确认覆盖</Button> <Button variant="destructive" @click="confirmImportOverride">{{ t('tab.dialog.confirmOverride') }}</Button>
</AlertDialogAction> </AlertDialogAction>
</div> </div>
</AlertDialogContent> </AlertDialogContent>
@ -1747,17 +1935,19 @@ watch(
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent <AlertDialogContent
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl"> class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">新建项目</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">{{ t('tab.dialog.newProjectTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
选择工程行业后将在新标签页直接打开新项目计算页面 {{ t('tab.dialog.newProjectDesc') }}
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 space-y-2"> <div class="mt-4 space-y-2">
<label class="text-sm font-medium text-foreground">工程行业</label> <label class="text-sm font-medium text-foreground">{{ t('home.dialog.industry') }}</label>
<SelectRoot v-model="newProjectIndustry"> <SelectRoot v-model="newProjectIndustry">
<SelectTrigger <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" 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"
> >
<SelectValue placeholder="请选择工程行业" /> <span :class="newProjectIndustryLabel ? 'text-foreground' : 'text-muted-foreground'">
{{ newProjectIndustryLabel || t('home.dialog.selectIndustry') }}
</span>
<SelectIcon as-child> <SelectIcon as-child>
<ChevronDown class="h-4 w-4 text-muted-foreground" /> <ChevronDown class="h-4 w-4 text-muted-foreground" />
</SelectIcon> </SelectIcon>
@ -1775,7 +1965,7 @@ watch(
:value="String(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" 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>{{ item.name }}</SelectItemText> <SelectItemText>{{ getIndustryDisplayName(item.id, locale) }}</SelectItemText>
<SelectItemIndicator class="absolute right-2 inline-flex items-center text-slate-700"> <SelectItemIndicator class="absolute right-2 inline-flex items-center text-slate-700">
<Check class="h-4 w-4" /> <Check class="h-4 w-4" />
</SelectItemIndicator> </SelectItemIndicator>
@ -1787,12 +1977,12 @@ watch(
</div> </div>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child> <AlertDialogCancel as-child>
<Button variant="outline" :disabled="newProjectSubmitting" @click="closeCreateProjectDialog">取消</Button> <Button variant="outline" :disabled="newProjectSubmitting" @click="closeCreateProjectDialog">{{ t('common.cancel') }}</Button>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction as-child> <AlertDialogAction as-child>
<Button :disabled="newProjectSubmitting || !newProjectIndustry" @click="handleCreateProjectConfirm"> <Button :disabled="newProjectSubmitting || !newProjectIndustry" @click="handleCreateProjectConfirm">
<Loader2 v-if="newProjectSubmitting" class="mr-1 h-4 w-4 animate-spin" /> <Loader2 v-if="newProjectSubmitting" class="mr-1 h-4 w-4 animate-spin" />
{{ newProjectSubmitting ? '创建中...' : '新建并打开' }} {{ newProjectSubmitting ? t('tab.dialog.creating') : t('tab.dialog.createAndOpen') }}
</Button> </Button>
</AlertDialogAction> </AlertDialogAction>
</div> </div>
@ -1805,13 +1995,50 @@ watch(
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent <AlertDialogContent
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl"> class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">项目数量已达上限</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">{{ t('tab.dialog.projectLimitTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
当前项目数量已达到 {{ MAX_PROJECT_COUNT }} 请先删除一个项目后再添加 {{ t('tab.dialog.projectLimitDesc', { max: MAX_PROJECT_COUNT }) }}
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogAction as-child> <AlertDialogAction as-child>
<Button @click="projectLimitDialogOpen = false">我知道了</Button> <Button @click="projectLimitDialogOpen = false">{{ t('tab.dialog.iKnow') }}</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<AlertDialogRoot :open="projectDeleteConfirmOpen" @update:open="handleProjectDeleteDialogOpenChange">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">{{ t('tab.dialog.deleteProjectTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
{{ pendingDeleteProject?.id === currentProjectId
? t('tab.dialog.deleteCurrentProjectDesc', { name: pendingDeleteProject?.name || '' })
: t('tab.dialog.deleteProjectDesc', { name: pendingDeleteProject?.name || '' }) }}
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<Button variant="outline" @click="cancelRemoveProjectItem">{{ t('common.cancel') }}</Button>
<Button variant="destructive" @click="confirmRemoveProjectItem">{{ t('common.confirm') }}</Button>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<AlertDialogRoot :open="messageDialogOpen" @update:open="messageDialogOpen = $event">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">{{ messageDialogTitle }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
{{ messageDialogDesc }}
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogAction as-child>
<Button @click="messageDialogOpen = false">{{ t('tab.dialog.iKnow') }}</Button>
</AlertDialogAction> </AlertDialogAction>
</div> </div>
</AlertDialogContent> </AlertDialogContent>
@ -1839,22 +2066,22 @@ watch(
<button <button
class="flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50" class="flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!hasClosableTabs" @click="runTabMenuAction('all')"> :disabled="!hasClosableTabs" @click="runTabMenuAction('all')">
删除所有 {{ t('tab.menu.closeAll') }}
</button> </button>
<button <button
class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50" class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!canCloseLeft" @click="runTabMenuAction('left')"> :disabled="!canCloseLeft" @click="runTabMenuAction('left')">
删除左侧 {{ t('tab.menu.closeLeft') }}
</button> </button>
<button <button
class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50" class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!canCloseRight" @click="runTabMenuAction('right')"> :disabled="!canCloseRight" @click="runTabMenuAction('right')">
删除右侧 {{ t('tab.menu.closeRight') }}
</button> </button>
<button <button
class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50" class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!canCloseOther" @click="runTabMenuAction('other')"> :disabled="!canCloseOther" @click="runTabMenuAction('other')">
删除其他 {{ t('tab.menu.closeOther') }}
</button> </button>
</div> </div>
@ -1863,7 +2090,7 @@ watch(
<div class="w-full max-w-3xl rounded-xl border bg-background shadow-2xl"> <div class="w-full max-w-3xl rounded-xl border bg-background shadow-2xl">
<div class="flex items-start justify-between border-b px-6 py-5"> <div class="flex items-start justify-between border-b px-6 py-5">
<div> <div>
<p class="text-xs text-muted-foreground">新用户引导 · {{ guideProgressText }}</p> <p class="text-xs text-muted-foreground">{{ t('tab.guide.title') }} · {{ guideProgressText }}</p>
<h3 class="mt-1 text-lg font-semibold">{{ activeGuideStep.title }}</h3> <h3 class="mt-1 text-lg font-semibold">{{ activeGuideStep.title }}</h3>
</div> </div>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeUserGuide(false)"> <Button variant="ghost" size="icon" class="h-8 w-8" @click="closeUserGuide(false)">
@ -1888,9 +2115,9 @@ watch(
@click="jumpToGuideStep(index)" /> @click="jumpToGuideStep(index)" />
</div> </div>
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<Button variant="ghost" @click="closeUserGuide(false)">稍后再看</Button> <Button variant="ghost" @click="closeUserGuide(false)">{{ t('tab.guide.later') }}</Button>
<Button variant="outline" :disabled="isFirstGuideStep" @click="prevUserGuideStep">上一步</Button> <Button variant="outline" :disabled="isFirstGuideStep" @click="prevUserGuideStep">{{ t('tab.guide.prev') }}</Button>
<Button @click="nextUserGuideStep">{{ isLastGuideStep ? '完成并不再自动弹出' : '下一步' }}</Button> <Button @click="nextUserGuideStep">{{ isLastGuideStep ? t('tab.guide.finish') : t('tab.guide.next') }}</Button>
</div> </div>
</div> </div>
</div> </div>
@ -1904,7 +2131,7 @@ watch(
> >
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<ToastTitle class="text-sm font-semibold text-foreground"> <ToastTitle class="text-sm font-semibold text-foreground">
{{ reportExportStatus === 'running' ? '导出报表' : (reportExportStatus === 'success' ? '导出成功' : '导出失败') }} {{ reportExportStatus === 'running' ? t('tab.toast.export') : (reportExportStatus === 'success' ? t('tab.toast.success') : t('tab.toast.failed')) }}
</ToastTitle> </ToastTitle>
<Button <Button
variant="ghost" variant="ghost"

View File

@ -9,20 +9,20 @@ import { themeQuartz } from 'ag-grid-community'
const borderConfig = { const borderConfig = {
style: 'solid', style: 'solid',
width: 0.3, width: 0.3,
color: '#d3d3d3' color: 'var(--border)'
} }
export const myTheme = themeQuartz.withParams({ export const myTheme = themeQuartz.withParams({
wrapperBorder: false, wrapperBorder: false,
wrapperBorderRadius: 0, wrapperBorderRadius: 0,
headerBackgroundColor: '#f0f2f3', headerBackgroundColor: 'var(--muted)',
headerTextColor: '#374151', headerTextColor: 'var(--foreground)',
headerFontSize: 15, headerFontSize: 15,
headerFontWeight: 'normal', headerFontWeight: 'normal',
rowBorder: borderConfig, rowBorder: borderConfig,
columnBorder: borderConfig, columnBorder: borderConfig,
headerRowBorder: borderConfig, headerRowBorder: borderConfig,
dataBackgroundColor: '#fefefe' dataBackgroundColor: 'var(--card)'
}) })
// AG Grid 容器通用 class占满父容器配合父元素为 flex/grid 且有明确高度使用) // AG Grid 容器通用 class占满父容器配合父元素为 flex/grid 且有明确高度使用)

View File

@ -2,6 +2,7 @@ const LOCK_TTL_MS = 12_000
const HEARTBEAT_MS = 4_000 const HEARTBEAT_MS = 4_000
export const PROJECT_LOCK_KEY_PREFIX = 'jgjs-project-lock:' export const PROJECT_LOCK_KEY_PREFIX = 'jgjs-project-lock:'
const CHANNEL_NAME = 'jgjs-project-lock-channel' const CHANNEL_NAME = 'jgjs-project-lock-channel'
const TAB_SESSION_ID_KEY = 'jgjs-project-tab-session-id'
type LockPayload = { type LockPayload = {
sessionId: string sessionId: string
@ -33,6 +34,18 @@ const isExpired = (payload: LockPayload) => now() - payload.updatedAt > LOCK_TTL
const randomSessionId = () => `${now()}-${Math.random().toString(36).slice(2, 10)}` const randomSessionId = () => `${now()}-${Math.random().toString(36).slice(2, 10)}`
const getOrCreateTabSessionId = () => {
try {
const existing = String(window.sessionStorage.getItem(TAB_SESSION_ID_KEY) || '').trim()
if (existing) return existing
const created = randomSessionId()
window.sessionStorage.setItem(TAB_SESSION_ID_KEY, created)
return created
} catch {
return randomSessionId()
}
}
type InitProjectLockParams = { type InitProjectLockParams = {
projectId: string projectId: string
onConflict: (conflicted: boolean) => void onConflict: (conflicted: boolean) => void
@ -41,7 +54,7 @@ type InitProjectLockParams = {
export const initProjectSessionLock = (params: InitProjectLockParams) => { export const initProjectSessionLock = (params: InitProjectLockParams) => {
const projectId = String(params.projectId || '').trim() const projectId = String(params.projectId || '').trim()
const onConflict = params.onConflict const onConflict = params.onConflict
const sessionId = randomSessionId() const sessionId = getOrCreateTabSessionId()
const key = lockKeyOf(projectId) const key = lockKeyOf(projectId)
let conflicted = false let conflicted = false
let heartbeatTimer: ReturnType<typeof setInterval> | null = null let heartbeatTimer: ReturnType<typeof setInterval> | null = null

View File

@ -8,6 +8,8 @@ export const FIXED_WORKSPACE_TAB_IDS = [PROJECT_TAB_ID, QUICK_TAB_ID] as const
export const WORKSPACE_MODE_STORAGE_KEY = 'jgjs-workspace-mode-v1' export const WORKSPACE_MODE_STORAGE_KEY = 'jgjs-workspace-mode-v1'
export const PROJECT_ID_QUERY_KEY = 'projectId' export const PROJECT_ID_QUERY_KEY = 'projectId'
export const NEW_PROJECT_QUERY_KEY = 'newProject' export const NEW_PROJECT_QUERY_KEY = 'newProject'
export const OPEN_PROJECT_DIALOG_QUERY_KEY = 'openProjectDialog'
export const FORCE_HOME_QUERY_KEY = 'forceHome'
export const DEFAULT_PROJECT_ID = 'default' export const DEFAULT_PROJECT_ID = 'default'
export const QUICK_PROJECT_ID = 'quick' export const QUICK_PROJECT_ID = 'quick'
export const PROJECT_DB_NAME_PREFIX = 'DB' export const PROJECT_DB_NAME_PREFIX = 'DB'
@ -23,6 +25,7 @@ export const QUICK_CONSULT_CATEGORY_FACTOR_KEY = 'quick-xm-consult-category-fact
export const QUICK_MAJOR_FACTOR_KEY = 'quick-xm-major-factor-v1' export const QUICK_MAJOR_FACTOR_KEY = 'quick-xm-major-factor-v1'
let pendingHomeImportFile: File | null = null let pendingHomeImportFile: File | null = null
let pendingHomeImportSkipWorkspaceConfirm = false
export interface QuickContractMeta { export interface QuickContractMeta {
id: string id: string
@ -55,8 +58,12 @@ export const writeWorkspaceMode = (mode: WorkspaceMode) => {
} }
} }
export const setPendingHomeImportFile = (file: File | null) => { export const setPendingHomeImportFile = (
file: File | null,
options?: { skipWorkspaceConfirm?: boolean }
) => {
pendingHomeImportFile = file pendingHomeImportFile = file
pendingHomeImportSkipWorkspaceConfirm = Boolean(options?.skipWorkspaceConfirm)
} }
export const consumePendingHomeImportFile = () => { export const consumePendingHomeImportFile = () => {
@ -65,6 +72,12 @@ export const consumePendingHomeImportFile = () => {
return file return file
} }
export const consumePendingHomeImportSkipConfirm = () => {
const skip = pendingHomeImportSkipWorkspaceConfirm
pendingHomeImportSkipWorkspaceConfirm = false
return skip
}
export const normalizeProjectId = (value: unknown) => { export const normalizeProjectId = (value: unknown) => {
const raw = String(value || '').trim() const raw = String(value || '').trim()
if (!raw) return DEFAULT_PROJECT_ID if (!raw) return DEFAULT_PROJECT_ID
@ -97,14 +110,28 @@ export const ensureProjectIdInUrl = () => {
return id return id
} }
export const buildProjectUrl = (projectIdRaw: string, options?: { newProject?: boolean }) => { export const buildProjectUrl = (
projectIdRaw: string,
options?: { newProject?: boolean; openProjectDialog?: boolean; forceHome?: boolean }
) => {
try { try {
const url = new URL(window.location.href) const url = new URL(window.location.href)
url.searchParams.set(PROJECT_ID_QUERY_KEY, normalizeProjectId(projectIdRaw)) url.searchParams.set(PROJECT_ID_QUERY_KEY, normalizeProjectId(projectIdRaw))
if (options?.newProject) { if (options?.newProject) {
url.searchParams.set(NEW_PROJECT_QUERY_KEY, '1') url.searchParams.set(NEW_PROJECT_QUERY_KEY, '1')
if (options?.openProjectDialog === false) {
url.searchParams.set(OPEN_PROJECT_DIALOG_QUERY_KEY, '0')
} else {
url.searchParams.delete(OPEN_PROJECT_DIALOG_QUERY_KEY)
}
} else { } else {
url.searchParams.delete(NEW_PROJECT_QUERY_KEY) url.searchParams.delete(NEW_PROJECT_QUERY_KEY)
url.searchParams.delete(OPEN_PROJECT_DIALOG_QUERY_KEY)
}
if (options?.forceHome) {
url.searchParams.set(FORCE_HOME_QUERY_KEY, '1')
} else {
url.searchParams.delete(FORCE_HOME_QUERY_KEY)
} }
return `${url.pathname}${url.search}${url.hash}` return `${url.pathname}${url.search}${url.hash}`
} catch { } catch {

View File

@ -23,6 +23,7 @@ import piniaPersistedstate from '@/pinia/Plugin/indexdb'
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import './style.css' import './style.css'
import { i18n } from '@/i18n'
import { ensureProjectIdInUrl, getProjectDbName } from '@/lib/workspace' import { ensureProjectIdInUrl, getProjectDbName } from '@/lib/workspace'
import { listProjects } from '@/lib/projectRegistry' import { listProjects } from '@/lib/projectRegistry'
@ -79,4 +80,4 @@ pinia.use(
// 在应用启动时一次性注册 AG Grid 运行所需模块。 // 在应用启动时一次性注册 AG Grid 运行所需模块。
ModuleRegistry.registerModules(AG_GRID_MODULES) ModuleRegistry.registerModules(AG_GRID_MODULES)
createApp(App).use(pinia).mount('#app') createApp(App).use(pinia).use(i18n).mount('#app')

View File

@ -1,6 +1,12 @@
// @ts-nocheck // @ts-nocheck
import { addNumbers, roundTo, toDecimal, toFiniteNumberOrZero } from '@/lib/decimal' import { addNumbers, roundTo, toDecimal, toFiniteNumberOrZero } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat' import { formatThousands } from '@/lib/numberFormat'
import {
EXPERT_NAME_EN_BY_CODE,
MAJOR_NAME_EN_BY_CODE,
SERVICE_NAME_EN_BY_CODE,
TASK_NAME_EN_BY_CODE
} from '@/i18n/dictionary-en'
import ExcelJS from "ExcelJS"; import ExcelJS from "ExcelJS";
// 统一数字千分位格式化,默认保留 2 位小数。 // 统一数字千分位格式化,默认保留 2 位小数。
const numberFormatter = (value: unknown, fractionDigits = 2) => const numberFormatter = (value: unknown, fractionDigits = 2) =>
@ -66,10 +72,79 @@ export const TYPE_LABEL_MAP: Record<number, WorkType> = {
5: '自定义' 5: '自定义'
} }
export const industryTypeList = [ export const industryTypeList = [
{ id: '0', name: '公路工程', type: 'isRoad' }, { id: '0', name: '公路工程', nameEn: 'Highway Engineering', type: 'isRoad' },
{ id: '1', name: '铁路工程', type: 'isRailway' }, { id: '1', name: '铁路工程', nameEn: 'Railway Engineering', type: 'isRailway' },
{ id: '2', name: '水运工程', type: 'isWaterway' } { id: '2', name: '水运工程', nameEn: 'Waterway Engineering', type: 'isWaterway' }
] as const ] as const
const runtimeLocale = typeof window === 'undefined'
? 'zh-cn'
: String(localStorage.getItem('jgjs-locale-v1') || 'zh-CN').toLowerCase()
if (runtimeLocale.startsWith('en')) {
;(industryTypeList as unknown as Array<Record<string, any>>).forEach((item) => {
if (typeof item?.nameEn === 'string' && item.nameEn.trim()) {
item.name = item.nameEn
}
})
}
export const getIndustryDisplayName = (
industryId: string | number,
locale: string = 'zh-CN'
) => {
const key = String(industryId || '').trim()
const item = industryTypeList.find(entry => String(entry.id) === key)
if (!item) return ''
if (String(locale || '').toLowerCase().startsWith('en')) {
return item.nameEn || item.name
}
return item.name
}
const attachNameEnByCode = (source: Record<string, any>, nameMap: Record<string, string>) => {
Object.values(source).forEach((item) => {
if (!item || typeof item !== 'object') return
const code = String((item as Record<string, any>).code || '').trim()
if (!code) return
const nameEn = nameMap[code]
if (nameEn && typeof nameEn === 'string') {
;(item as Record<string, any>).nameEn = nameEn
}
})
}
const getCurrentLocale = () => {
if (typeof window === 'undefined') return 'zh-CN'
return String(localStorage.getItem('jgjs-locale-v1') || 'zh-CN')
}
const isEnglishLocale = (locale: string) => String(locale || '').toLowerCase().startsWith('en')
const localizeDictItem = (item: Record<string, any>) => {
const locale = getCurrentLocale()
if (!isEnglishLocale(locale)) return item
return {
...item,
name: item.nameEn || item.name,
quickLabel: item.quickLabelEn || item.quickLabel || item.nameEn || item.name,
basicParam: item.basicParamEn || item.basicParam,
unit: item.unitEn || item.unit,
desc: item.descEn || item.desc
}
}
const applyLocaleToDictionary = (source: Record<string, any>) => {
const locale = getCurrentLocale()
if (!isEnglishLocale(locale)) return
Object.values(source).forEach((item) => {
if (!item || typeof item !== 'object') return
const target = item as Record<string, any>
if (typeof target.nameEn === 'string' && target.nameEn.trim()) target.name = target.nameEn
if (typeof target.quickLabelEn === 'string' && target.quickLabelEn.trim()) target.quickLabel = target.quickLabelEn
if (typeof target.basicParamEn === 'string' && target.basicParamEn.trim()) target.basicParam = target.basicParamEn
if (typeof target.unitEn === 'string' && target.unitEn.trim()) target.unit = target.unitEn
if (typeof target.descEn === 'string' && target.descEn.trim()) target.desc = target.descEn
})
}
export const majorList = { export const majorList = {
0: { code: 'E1', name: '交通运输工程通用专业', hideInIndustrySelector: true, maxCoe: null, minCoe: null, defCoe: null, desc: '', isRoad: true, isRailway: true, isWaterway: true, order: 1, hasCost: false, hasArea: false }, 0: { code: 'E1', name: '交通运输工程通用专业', hideInIndustrySelector: true, maxCoe: null, minCoe: null, defCoe: null, desc: '', isRoad: true, isRailway: true, isWaterway: true, order: 1, hasCost: false, hasArea: false },
1: { code: 'E1-1', name: '征地(用海)补偿', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于交通建设项目征地(用海)补偿的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: true, isWaterway: true, order: 2, hasCost: true, hasArea: true }, 1: { code: 'E1-1', name: '征地(用海)补偿', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于交通建设项目征地(用海)补偿的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: true, isWaterway: true, order: 2, hasCost: true, hasArea: true },
@ -204,6 +279,15 @@ export const expertList = {
7: { code: 'C9-3-2', name: '一级造价工程师且具备高级工程师资格', maxPrice: 2000, minPrice: 1800, defPrice: 1900, manageCoe: 2.05 }, 7: { code: 'C9-3-2', name: '一级造价工程师且具备高级工程师资格', maxPrice: 2000, minPrice: 1800, defPrice: 1900, manageCoe: 2.05 },
}; };
attachNameEnByCode(majorList as Record<string, any>, MAJOR_NAME_EN_BY_CODE)
attachNameEnByCode(serviceList as Record<string, any>, SERVICE_NAME_EN_BY_CODE)
attachNameEnByCode(taskList as Record<string, any>, TASK_NAME_EN_BY_CODE)
attachNameEnByCode(expertList as Record<string, any>, EXPERT_NAME_EN_BY_CODE)
applyLocaleToDictionary(majorList as Record<string, any>)
applyLocaleToDictionary(serviceList as Record<string, any>)
applyLocaleToDictionary(taskList as Record<string, any>)
applyLocaleToDictionary(expertList as Record<string, any>)
export const additionalWorkList = [ export const additionalWorkList = [
{ {
id: '1', id: '1',
@ -605,7 +689,7 @@ const buildDictEntries = (source: Record<string, any>) =>
return { return {
id: rawId, id: rawId,
rawId, rawId,
item item: localizeDictItem(item)
} }
}) })
) )

View File

@ -191,6 +191,15 @@ input[inputmode='numeric'] {
.ag-theme-quartz { .ag-theme-quartz {
--ag-font-size: var(--app-grid-font-size); --ag-font-size: var(--app-grid-font-size);
--ag-row-height: var(--app-grid-row-h); --ag-row-height: var(--app-grid-row-h);
--ag-background-color: var(--card);
--ag-foreground-color: var(--foreground);
--ag-data-color: var(--foreground);
--ag-header-background-color: var(--muted);
--ag-header-foreground-color: var(--foreground);
--ag-border-color: var(--border);
--ag-row-border-color: var(--border);
--ag-secondary-border-color: var(--border);
--ag-input-text-color: var(--foreground);
} }
/* When one column uses auto-height rows, keep other columns vertically centered. */ /* When one column uses auto-height rows, keep other columns vertically centered. */
@ -327,7 +336,7 @@ input[inputmode='numeric'] {
display: inline-block; display: inline-block;
min-width: 84%; min-width: 84%;
padding: 2px 4px; padding: 2px 4px;
border-bottom: 1px solid #cbd5e1; border-bottom: 1px solid var(--border);
} }
.xmMx .remark-wrap-cell .ag-cell-value { .xmMx .remark-wrap-cell .ag-cell-value {
@ -349,7 +358,7 @@ input[inputmode='numeric'] {
} }
.xmMx .editable-cell-empty .ag-cell-value { .xmMx .editable-cell-empty .ag-cell-value {
color: #94a3b8 !important; color: var(--muted-foreground) !important;
font-style: italic; font-style: italic;
opacity: 1 !important; opacity: 1 !important;
border-bottom: none !important; border-bottom: none !important;
@ -359,8 +368,8 @@ input[inputmode='numeric'] {
.xmMx .ag-cell.editable-cell-empty .ag-cell-wrapper, .xmMx .ag-cell.editable-cell-empty .ag-cell-wrapper,
.xmMx .ag-cell.editable-cell-empty .ag-cell-value, .xmMx .ag-cell.editable-cell-empty .ag-cell-value,
.xmMx .ag-cell.editable-cell-empty .ag-cell-value * { .xmMx .ag-cell.editable-cell-empty .ag-cell-value * {
--ag-data-color: #94a3b8; --ag-data-color: var(--muted-foreground);
color: #94a3b8 !important; color: var(--muted-foreground) !important;
height:100%; height:100%;
font-style: italic; font-style: italic;
} }

View File

@ -1 +1 @@
{"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/features/ht/contracts.ts","./src/features/ht/importexport.ts","./src/features/ht/types.ts","./src/features/tab/importexport.ts","./src/features/tab/types.ts","./src/lib/aggridresetheader.ts","./src/lib/contractsegment.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricinghourlycalc.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingpersistcontrol.ts","./src/lib/pricingpinnedrows.ts","./src/lib/pricingscalecalc.ts","./src/lib/pricingscalecolumns.ts","./src/lib/pricingscaledetail.ts","./src/lib/pricingscaledict.ts","./src/lib/pricingscalefee.ts","./src/lib/pricingscalegrid.ts","./src/lib/pricingscalelink.ts","./src/lib/pricingscalepanedata.ts","./src/lib/pricingscalepanelifecycle.ts","./src/lib/pricingscaleproject.ts","./src/lib/pricingscalerowmap.ts","./src/lib/pricingworkloadcalc.ts","./src/lib/projectregistry.ts","./src/lib/projectsessionlock.ts","./src/lib/projectworkspace.ts","./src/lib/reportexportbuilders.ts","./src/lib/utils.ts","./src/lib/workspace.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/kv.ts","./src/pinia/tab.ts","./src/pinia/zxfwpricing.ts","./src/pinia/zxfwpricinghtfee.ts","./src/pinia/zxfwpricingkeys.ts","./src/pinia/plugin/indexdb.ts","./src/pinia/plugin/types.d.ts","./src/types/pricing.ts","./src/app.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/features/ht/components/ht.vue","./src/features/ht/components/htadditionalworkfee.vue","./src/features/ht/components/htbaseinfo.vue","./src/features/ht/components/htconsultcategoryfactor.vue","./src/features/ht/components/htcontractsummary.vue","./src/features/ht/components/htfeeratemethodform.vue","./src/features/ht/components/htmajorfactor.vue","./src/features/ht/components/htreservefee.vue","./src/features/ht/components/htcard.vue","./src/features/ht/components/htinfo.vue","./src/features/ht/components/zxfw.vue","./src/features/pricing/components/hourlypricingpane.vue","./src/features/pricing/components/investmentscalepricingpane.vue","./src/features/pricing/components/landscalepricingpane.vue","./src/features/pricing/components/workloadpricingpane.vue","./src/features/shared/components/hourlyfeegrid.vue","./src/features/shared/components/htfeegrid.vue","./src/features/shared/components/htfeemethodgrid.vue","./src/features/shared/components/methodunavailablenotice.vue","./src/features/shared/components/servicecheckboxselector.vue","./src/features/shared/components/workcontentgrid.vue","./src/features/shared/components/xmfactorgrid.vue","./src/features/shared/components/xmcommonaggrid.vue","./src/features/workbench/components/homeentryview.vue","./src/features/workbench/components/htfeemethodtypelineview.vue","./src/features/workbench/components/quickcalcworkbenchview.vue","./src/features/workbench/components/zxfwview.vue","./src/features/xm/components/xmconsultcategoryfactor.vue","./src/features/xm/components/xmmajorfactor.vue","./src/features/xm/components/info.vue","./src/features/xm/components/xmcard.vue","./src/features/xm/components/xminfo.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"} {"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/features/ht/contracts.ts","./src/features/ht/importexport.ts","./src/features/ht/types.ts","./src/features/tab/importexport.ts","./src/features/tab/types.ts","./src/i18n/dictionary-en.ts","./src/i18n/index.ts","./src/i18n/locales/en-us.ts","./src/i18n/locales/zh-cn.ts","./src/lib/aggridresetheader.ts","./src/lib/contractsegment.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricinghourlycalc.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingpersistcontrol.ts","./src/lib/pricingpinnedrows.ts","./src/lib/pricingscalecalc.ts","./src/lib/pricingscalecolumns.ts","./src/lib/pricingscaledetail.ts","./src/lib/pricingscaledict.ts","./src/lib/pricingscalefee.ts","./src/lib/pricingscalegrid.ts","./src/lib/pricingscalelink.ts","./src/lib/pricingscalepanedata.ts","./src/lib/pricingscalepanelifecycle.ts","./src/lib/pricingscaleproject.ts","./src/lib/pricingscalerowmap.ts","./src/lib/pricingworkloadcalc.ts","./src/lib/projectregistry.ts","./src/lib/projectsessionlock.ts","./src/lib/projectworkspace.ts","./src/lib/reportexportbuilders.ts","./src/lib/utils.ts","./src/lib/workspace.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/kv.ts","./src/pinia/tab.ts","./src/pinia/zxfwpricing.ts","./src/pinia/zxfwpricinghtfee.ts","./src/pinia/zxfwpricingkeys.ts","./src/pinia/plugin/indexdb.ts","./src/pinia/plugin/types.d.ts","./src/types/pricing.ts","./src/app.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/features/ht/components/ht.vue","./src/features/ht/components/htadditionalworkfee.vue","./src/features/ht/components/htbaseinfo.vue","./src/features/ht/components/htconsultcategoryfactor.vue","./src/features/ht/components/htcontractsummary.vue","./src/features/ht/components/htfeeratemethodform.vue","./src/features/ht/components/htmajorfactor.vue","./src/features/ht/components/htreservefee.vue","./src/features/ht/components/htcard.vue","./src/features/ht/components/htinfo.vue","./src/features/ht/components/zxfw.vue","./src/features/pricing/components/hourlypricingpane.vue","./src/features/pricing/components/investmentscalepricingpane.vue","./src/features/pricing/components/landscalepricingpane.vue","./src/features/pricing/components/workloadpricingpane.vue","./src/features/shared/components/hourlyfeegrid.vue","./src/features/shared/components/htfeegrid.vue","./src/features/shared/components/htfeemethodgrid.vue","./src/features/shared/components/methodunavailablenotice.vue","./src/features/shared/components/servicecheckboxselector.vue","./src/features/shared/components/workcontentgrid.vue","./src/features/shared/components/xmfactorgrid.vue","./src/features/shared/components/xmcommonaggrid.vue","./src/features/workbench/components/homeentryview.vue","./src/features/workbench/components/htfeemethodtypelineview.vue","./src/features/workbench/components/quickcalcworkbenchview.vue","./src/features/workbench/components/zxfwview.vue","./src/features/xm/components/xmconsultcategoryfactor.vue","./src/features/xm/components/xmmajorfactor.vue","./src/features/xm/components/info.vue","./src/features/xm/components/xmcard.vue","./src/features/xm/components/xminfo.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}