i18n和夜晚模式
This commit is contained in:
parent
1f941ca65f
commit
9a6462f22a
2169
package-lock.json
generated
Normal file
2169
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -31,6 +31,7 @@
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vue": "^3.5.25",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
37
src/App.vue
37
src/App.vue
@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
import HomeEntryView from '@/features/workbench/components/HomeEntryView.vue'
|
||||
import Tab from '@/layout/tab.vue'
|
||||
@ -8,6 +9,7 @@ import localforage from 'localforage'
|
||||
import {
|
||||
buildProjectUrl,
|
||||
ensureProjectIdInUrl,
|
||||
FORCE_HOME_QUERY_KEY,
|
||||
getProjectDbName,
|
||||
NEW_PROJECT_QUERY_KEY,
|
||||
PROJECT_TAB_ID,
|
||||
@ -17,6 +19,7 @@ import { collectActiveProjectSessionLocks, initProjectSessionLock } from '@/lib/
|
||||
import { createProject, listProjects, type ProjectMeta } from '@/lib/projectRegistry'
|
||||
|
||||
const tabStore = useTabStore()
|
||||
const { t } = useI18n()
|
||||
const isReady = ref(false)
|
||||
const lockConflict = ref(false)
|
||||
const currentProjectId = ref('')
|
||||
@ -109,15 +112,25 @@ const formatProjectEditedTime = (value: string) => {
|
||||
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(() => {
|
||||
currentProjectId.value = ensureProjectIdInUrl()
|
||||
refreshConflictProjectList()
|
||||
let isNewProjectRequest = false
|
||||
let forceHomeRequest = false
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
isNewProjectRequest = url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1'
|
||||
forceHomeRequest = url.searchParams.get(FORCE_HOME_QUERY_KEY) === '1'
|
||||
} catch {
|
||||
isNewProjectRequest = false
|
||||
forceHomeRequest = false
|
||||
}
|
||||
if (currentProjectId.value !== QUICK_PROJECT_ID) {
|
||||
const lock = initProjectSessionLock({
|
||||
@ -138,8 +151,13 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
window.addEventListener('home-import-selected', handleImportComplete)
|
||||
window.addEventListener('jgjs-release-project-lock', handleReleaseProjectLock)
|
||||
waitForHydration('tabs').then(() => {
|
||||
if (!tabStore.hasCompletedSetup && !isNewProjectRequest) {
|
||||
if (forceHomeRequest) {
|
||||
tabStore.resetTabs()
|
||||
tabStore.hasCompletedSetup = false
|
||||
}
|
||||
if (!tabStore.hasCompletedSetup && !isNewProjectRequest && !forceHomeRequest) {
|
||||
const hasProjects = listProjects().length > 0
|
||||
if (hasProjects) {
|
||||
if (Array.isArray(tabStore.tabs) && tabStore.tabs.length > 0) {
|
||||
@ -147,7 +165,7 @@ onMounted(() => {
|
||||
} else {
|
||||
tabStore.enterWorkspace({
|
||||
id: PROJECT_TAB_ID,
|
||||
title: '项目计算',
|
||||
title: t('home.cards.projectBudget'),
|
||||
componentName: 'ProjectCalcView'
|
||||
})
|
||||
tabStore.hasCompletedSetup = true
|
||||
@ -168,6 +186,7 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
clearCloseCountdown()
|
||||
window.removeEventListener('home-import-selected', handleImportComplete)
|
||||
window.removeEventListener('jgjs-release-project-lock', handleReleaseProjectLock)
|
||||
if (releaseLock) {
|
||||
releaseLock()
|
||||
releaseLock = null
|
||||
@ -182,12 +201,12 @@ onBeforeUnmount(() => {
|
||||
class="flex min-h-screen items-center justify-center bg-slate-50 px-4"
|
||||
>
|
||||
<div class="w-full max-w-lg rounded-xl border border-red-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-lg font-semibold text-slate-900">检测到项目重复打开</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">
|
||||
项目「{{ currentProjectName }}」已在其他页面处于活跃状态。为避免 IndexedDB 数据冲突,本页面已阻断编辑。
|
||||
{{ t('app.projectConflict.desc', { name: currentProjectName }) }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-slate-500">
|
||||
本页将在 {{ closeCountdown }} 秒后自动尝试关闭。你也可以先在新标签页打开其他项目。
|
||||
{{ t('app.projectConflict.countdown', { seconds: closeCountdown }) }}
|
||||
</p>
|
||||
<div class="mt-4 max-h-52 space-y-2 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-2">
|
||||
<button
|
||||
@ -201,9 +220,9 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<span class="font-medium text-slate-700">
|
||||
{{ 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 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>
|
||||
</div>
|
||||
<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"
|
||||
@click="createProjectAndOpen"
|
||||
>
|
||||
新建项目并打开
|
||||
{{ t('app.projectConflict.createAndOpen') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -221,7 +240,7 @@ onBeforeUnmount(() => {
|
||||
:disabled="isConflictProjectOpen('default')"
|
||||
@click="openProjectInNewTab('default')"
|
||||
>
|
||||
打开默认项目
|
||||
{{ t('app.projectConflict.openDefault') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -99,6 +99,10 @@ const toastTitle = ref('操作成功')
|
||||
const toastText = ref('')
|
||||
const deleteConfirmOpen = ref(false)
|
||||
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 })
|
||||
let dragStartX = 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(() => {
|
||||
if (!pendingDeleteContractId.value) return ''
|
||||
const target = contracts.value.find(item => item.id === pendingDeleteContractId.value)
|
||||
@ -519,7 +529,7 @@ const initializeContractScaleData = async (contractId: string) => {
|
||||
|
||||
const exportSelectedContracts = async () => {
|
||||
if (selectedContractIds.value.length === 0) {
|
||||
window.alert('请先勾选至少一个合同段。')
|
||||
showMessageDialog('提示', '请先勾选至少一个合同段。')
|
||||
return
|
||||
}
|
||||
|
||||
@ -544,7 +554,7 @@ const exportSelectedContracts = async () => {
|
||||
|
||||
const projectIndustry = await getCurrentProjectIndustry()
|
||||
if (!projectIndustry) {
|
||||
window.alert('导出失败:未读取到当前项目工程行业,请先在“基础信息”里新建项目。')
|
||||
showMessageDialog('导出失败', '未读取到当前项目工程行业,请先在“基础信息”里新建项目。')
|
||||
return
|
||||
}
|
||||
|
||||
@ -583,7 +593,7 @@ const exportSelectedContracts = async () => {
|
||||
exitContractSelectionMode()
|
||||
} catch (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 : ''
|
||||
if (message.startsWith('PROJECT_INDUSTRY_MISMATCH:')) {
|
||||
const [, importIndustry = '', currentIndustry = ''] = message.split(':')
|
||||
window.alert(
|
||||
`导入失败:工程行业不一致(导入包:${formatIndustryLabel(importIndustry)},当前项目:${formatIndustryLabel(currentIndustry)})。`
|
||||
showMessageDialog(
|
||||
'导入失败',
|
||||
`工程行业不一致(导入包:${formatIndustryLabel(importIndustry)},当前项目:${formatIndustryLabel(currentIndustry)})。`
|
||||
)
|
||||
} else if (message === 'CURRENT_PROJECT_INDUSTRY_MISSING') {
|
||||
window.alert('导入失败:当前项目未设置工程行业,请先在“基础信息”里新建项目。')
|
||||
showMessageDialog('导入失败', '当前项目未设置工程行业,请先在“基础信息”里新建项目。')
|
||||
} else if (message === 'IMPORT_PACKAGE_INDUSTRY_MISSING') {
|
||||
window.alert('导入失败:导入包缺少工程行业信息,请使用最新版本重新导出后再导入。')
|
||||
showMessageDialog('导入失败', '导入包缺少工程行业信息,请使用最新版本重新导出后再导入。')
|
||||
} else {
|
||||
window.alert('导入失败:文件无效、已损坏或不是合同段导出文件。')
|
||||
showMessageDialog('导入失败', '文件无效、已损坏或不是合同段导出文件。')
|
||||
}
|
||||
} finally {
|
||||
input.value = ''
|
||||
@ -844,21 +855,27 @@ const deleteContract = async (id: string) => {
|
||||
|
||||
const deleteSelectedContracts = async () => {
|
||||
if (selectedContractIds.value.length === 0) {
|
||||
window.alert('请先勾选至少一个合同段。')
|
||||
showMessageDialog('提示', '请先勾选至少一个合同段。')
|
||||
return
|
||||
}
|
||||
|
||||
const selectedSet = new Set(selectedContractIds.value)
|
||||
const targets = contracts.value.filter(item => selectedSet.has(item.id))
|
||||
if (targets.length === 0) {
|
||||
window.alert('未找到可删除的合同段。')
|
||||
showMessageDialog('提示', '未找到可删除的合同段。')
|
||||
return
|
||||
}
|
||||
batchDeleteConfirmOpen.value = true
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`即将删除 ${targets.length} 个合同段及其关联咨询服务和计价数据,是否继续?`
|
||||
)
|
||||
if (!confirmed) return
|
||||
const confirmDeleteSelectedContracts = async () => {
|
||||
const selectedSet = new Set(selectedContractIds.value)
|
||||
const targets = contracts.value.filter(item => selectedSet.has(item.id))
|
||||
if (targets.length === 0) {
|
||||
batchDeleteConfirmOpen.value = false
|
||||
showMessageDialog('提示', '未找到可删除的合同段。')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const targetIds = targets.map(item => item.id)
|
||||
@ -882,7 +899,9 @@ const deleteSelectedContracts = async () => {
|
||||
exitContractSelectionMode()
|
||||
} catch (error) {
|
||||
console.error('delete selected contracts failed:', error)
|
||||
window.alert('批量删除失败,请重试。')
|
||||
showMessageDialog('批量删除失败', '请重试。')
|
||||
} finally {
|
||||
batchDeleteConfirmOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -592,12 +592,12 @@ const mydiyTheme = myTheme.withParams({
|
||||
rowBorder: {
|
||||
style: "solid",
|
||||
width: 0.8,
|
||||
color: "#d8d8dd"
|
||||
color: "var(--border)"
|
||||
},
|
||||
columnBorder: {
|
||||
style: "solid",
|
||||
width: 0.8,
|
||||
color: "#d8d8dd"
|
||||
color: "var(--border)"
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -765,7 +765,7 @@ const confirmDeleteRow = () => {
|
||||
width: 100%;
|
||||
}
|
||||
:deep(.work-content-placeholder) {
|
||||
color: #94a3b8;
|
||||
color: var(--muted-foreground);
|
||||
font-style: italic;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
|
||||
@ -32,3 +32,15 @@
|
||||
.tab-strip-scroll-area :deep([data-slot='scroll-area-corner']) {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<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 { Button } from '@/components/ui/button'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
@ -8,12 +9,9 @@ import {
|
||||
Calculator,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Download,
|
||||
FolderKanban,
|
||||
X,
|
||||
Zap
|
||||
X
|
||||
} from 'lucide-vue-next'
|
||||
import { industryTypeList } from '@/sql'
|
||||
import { getIndustryDisplayName, industryTypeList } from '@/sql'
|
||||
import { initializeProjectFactorStates } from '@/lib/projectWorkspace'
|
||||
import {
|
||||
SelectContent,
|
||||
@ -24,12 +22,13 @@ import {
|
||||
SelectPortal,
|
||||
SelectRoot,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectViewport
|
||||
} from 'reka-ui'
|
||||
import {
|
||||
DEFAULT_PROJECT_ID,
|
||||
FORCE_HOME_QUERY_KEY,
|
||||
NEW_PROJECT_QUERY_KEY,
|
||||
OPEN_PROJECT_DIALOG_QUERY_KEY,
|
||||
PROJECT_TAB_ID,
|
||||
QUICK_PROJECT_ID,
|
||||
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||
@ -38,6 +37,7 @@ import {
|
||||
QUICK_CONTRACT_META_KEY,
|
||||
QUICK_MAJOR_FACTOR_KEY,
|
||||
QUICK_PROJECT_INFO_KEY,
|
||||
readCurrentProjectId,
|
||||
writeProjectIdToUrl,
|
||||
setPendingHomeImportFile,
|
||||
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 DEFAULT_PROJECT_NAME = 'xxx 造价咨询服务'
|
||||
const PROJECT_INIT_CHANGED_EVENT = 'xm-project-init-changed'
|
||||
const getActiveProjectId = () => readCurrentProjectId()
|
||||
|
||||
const tabStore = useTabStore()
|
||||
const kvStore = useKvStore()
|
||||
const { t, locale } = useI18n()
|
||||
const projectDialogOpen = ref(false)
|
||||
const projectIndustry = ref(String(industryTypeList[0]?.id || ''))
|
||||
const projectSubmitting = ref(false)
|
||||
@ -83,9 +85,14 @@ const quickIndustry = ref(String(industryTypeList[0]?.id || ''))
|
||||
const quickContractName = ref(QUICK_CONTRACT_FALLBACK_NAME)
|
||||
const quickSubmitting = ref(false)
|
||||
const homeImportInputRef = ref<HTMLInputElement | null>(null)
|
||||
const projectIconAvailable = ref(false)
|
||||
const quickIconAvailable = ref(false)
|
||||
const importIconAvailable = ref(false)
|
||||
const homeImportConfirmOpen = ref(false)
|
||||
const pendingHomeImportFile = ref<File | null>(null)
|
||||
const pendingHomeImportFileName = ref('')
|
||||
const projectIndustryLabel = computed(() => {
|
||||
const target = String(projectIndustry.value || '').trim()
|
||||
if (!target) return ''
|
||||
return getIndustryDisplayName(target, locale.value) || ''
|
||||
})
|
||||
|
||||
const getTodayDateString = () => {
|
||||
const now = new Date()
|
||||
@ -96,8 +103,9 @@ const getTodayDateString = () => {
|
||||
}
|
||||
|
||||
const enterProjectCalc = () => {
|
||||
upsertProject(DEFAULT_PROJECT_ID, '默认项目')
|
||||
writeProjectIdToUrl(DEFAULT_PROJECT_ID)
|
||||
const projectId = getActiveProjectId()
|
||||
upsertProject(projectId, projectId === DEFAULT_PROJECT_ID ? '默认项目' : undefined)
|
||||
writeProjectIdToUrl(projectId)
|
||||
writeWorkspaceMode('project')
|
||||
tabStore.enterWorkspace({
|
||||
id: PROJECT_TAB_ID,
|
||||
@ -223,12 +231,9 @@ const handleHomeImportChange = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
setPendingHomeImportFile(file)
|
||||
window.dispatchEvent(new CustomEvent('home-import-selected', {
|
||||
detail: {
|
||||
file
|
||||
}
|
||||
}))
|
||||
pendingHomeImportFile.value = file
|
||||
pendingHomeImportFileName.value = file.name
|
||||
homeImportConfirmOpen.value = true
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
@ -236,14 +241,45 @@ const openHomeImport = () => {
|
||||
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(() => {
|
||||
void loadProjectDefaults()
|
||||
void loadQuickDefaults()
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
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(OPEN_PROJECT_DIALOG_QUERY_KEY)
|
||||
url.searchParams.delete(FORCE_HOME_QUERY_KEY)
|
||||
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`)
|
||||
}
|
||||
} 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="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="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
|
||||
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 -left-10 -bottom-10 h-40 w-40 rounded-full bg-white/8 blur-3xl" />
|
||||
<div
|
||||
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--1" />
|
||||
<div class="home-hero-meteor home-hero-meteor--2" />
|
||||
<div class="home-hero-meteor home-hero-meteor--3" />
|
||||
<div class="home-hero-meteor home-hero-meteor--4" />
|
||||
<div class="home-hero-meteor home-hero-meteor--5" />
|
||||
<div class="home-hero-meteor home-hero-meteor--6" />
|
||||
<div class="home-hero-meteor home-hero-meteor--7" />
|
||||
<div class="home-hero-meteor home-hero-meteor--8" />
|
||||
<div class="home-hero-meteor home-hero-meteor--9" />
|
||||
<div class="home-hero-meteor home-hero-meteor--10" />
|
||||
<div class="relative inline-flex h-11 w-11 items-center justify-center rounded-xl bg-white/15 ring-1 ring-white/35">
|
||||
<Calculator class="h-5 w-5" />
|
||||
</div>
|
||||
<h2 class="relative mt-8 text-2xl font-semibold leading-tight tracking-tight lg:text-3xl">智能预算一键生成</h2>
|
||||
<p class="relative mt-2 text-sm text-red-200/90">助力《规范》高效落地</p>
|
||||
<div class="relative mt-6 h-px bg-gradient-to-r from-white/20 via-white/10 to-transparent" />
|
||||
<p class="relative mt-4 text-xs leading-5 text-red-200/60">交通建设项目工程造价咨询服务费计算</p>
|
||||
<h2 class="relative mt-8 text-2xl font-semibold leading-tight tracking-tight lg:text-3xl">{{ t('home.cards.heroTitle') }}</h2>
|
||||
<p class="relative mt-2 text-sm text-red-200/90">{{ t('home.cards.heroSubTitle') }}</p>
|
||||
<div class="relative mt-6 h-px bg-white/20" />
|
||||
<p class="relative mt-4 text-xs leading-5 text-red-200/60">{{ t('home.cards.heroDesc') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="home-title text-center">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-slate-900 lg:text-3xl">计算入口</h1>
|
||||
<p class="mt-1.5 text-sm text-slate-500">项目计算 · 单项速算 · 导入数据</p>
|
||||
<Card
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="home-card home-card-base home-entry-item home-entry-item--2 group flex cursor-pointer flex-col justify-between rounded-xl border border-slate-200/80 bg-white/95 px-5 py-5 shadow-[0_4px_20px_rgba(15,23,42,0.06)] backdrop-blur-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_32px_rgba(15,23,42,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
|
||||
@click="openProjectCalc"
|
||||
@keydown.enter.prevent="openProjectCalc"
|
||||
@keydown.space.prevent="openProjectCalc"
|
||||
>
|
||||
<CardHeader class="p-0">
|
||||
<div
|
||||
class="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-blue-100 bg-blue-50/80 text-blue-600 shadow-sm transition-transform duration-200 group-hover:scale-105"
|
||||
>
|
||||
<svg viewBox="0 0 1024 1024" class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M938.666667 874.666667c0 11.733333-9.6 21.333333-21.333334 21.333333H106.666667c-11.733333 0-21.333333-9.6-21.333334-21.333333s9.6-21.333333 21.333334-21.333334h42.666666V490.666667c0-11.733333 9.6-21.333333 21.333334-21.333334h170.666666c11.733333 0 21.333333 9.6 21.333334 21.333334v362.666666h42.666666V320c0-11.733333 9.6-21.333333 21.333334-21.333333h170.666666c11.733333 0 21.333333 9.6 21.333334 21.333333v533.333333h42.666666V149.333333c0-11.733333 9.6-21.333333 21.333334-21.333333h170.666666c11.733333 0 21.333333 9.6 21.333334 21.333333v704h42.666666c11.733333 0 21.333333 9.6 21.333334 21.333334z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle class="mt-4 text-base font-semibold text-slate-900">{{ t('home.cards.projectBudget') }}</CardTitle>
|
||||
<CardDescription class="mt-1.5 text-xs leading-5 text-slate-500">
|
||||
{{ t('home.cards.projectBudgetDesc') }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<div class="mt-4 flex items-center 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 class="mt-5 grid flex-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<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"
|
||||
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="openQuickCalc"
|
||||
@keydown.enter.prevent="openQuickCalc"
|
||||
@keydown.space.prevent="openQuickCalc"
|
||||
<Card
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="home-card home-card-base home-entry-item home-entry-item--3 group flex cursor-pointer flex-col justify-between rounded-xl border border-slate-200/80 bg-white/95 px-5 py-5 shadow-[0_4px_20px_rgba(15,23,42,0.06)] backdrop-blur-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_32px_rgba(15,23,42,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
|
||||
@click="openQuickCalc"
|
||||
@keydown.enter.prevent="openQuickCalc"
|
||||
@keydown.space.prevent="openQuickCalc"
|
||||
>
|
||||
<CardHeader class="p-0">
|
||||
<div
|
||||
class="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-amber-100 bg-amber-50/80 text-amber-600 shadow-sm transition-transform duration-200 group-hover:scale-105"
|
||||
>
|
||||
<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 shadow-sm"
|
||||
>
|
||||
<img
|
||||
v-if="quickIconAvailable"
|
||||
:src="'/image_1.png'"
|
||||
alt="单项速算"
|
||||
class="h-5 w-5 object-contain"
|
||||
@error="quickIconAvailable = false"
|
||||
>
|
||||
<Zap v-else class="h-5 w-5 text-amber-500" />
|
||||
</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
|
||||
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="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>
|
||||
<svg viewBox="0 0 800 800" class="h-10 w-10" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5245 5891c-11-5-47-38-80-73-33-36-269-281-525-544-256-263-514-530-575-592-90-93-113-123-130-170-20-54-79-170-200-392-70-129-81-164-61-194 22-35 59-40 114-15 26 11 119 47 207 79 88 33 185 69 215 81 30 12 83 31 118 44 58 20 82 40 300 246 130 123 291 276 357 339 66 63 165 158 219 210 55 52 105 100 111 106 57 58 205 194 212 194 4 0 7-520 5-1155l-2-1155-553 0c-343 0-576-4-613-11-86-15-175-46-227-79-25-15-48-26-51-23-3 4-6 154-6 335l0 328-80 0-80 0 0-336 0-336-32 22c-44 29-106 56-176 77-50 15-130 17-647 22l-590 6 0 1165 0 1165 440 0c467 0 528-4 702-50 105-28 200-69 261-114 41-31 42-32 42-91l0-60 80 0 80 0 1 33c3 69 8 82 40 110 19 16 63 43 99 59 36 16 71 33 79 37 13 7 56 169 47 178-6 6-170-49-221-74-27-14-66-38-86-54l-35-28-32 25c-51 40-181 101-274 128-188 54-249 59-840 63l-548 4 0-1330 0-1331 619 0c669 0 715-3 826-54 63-29 138-94 155-136 12-30 13-30 91-30l78 0 17 35c20 44 70 87 137 122 114 58 98 56 807 62l655 6 3 1313c2 1296 2 1314 22 1339 34 43 21 153-29 252-35 67-147 173-224 211-70 34-179 50-222 31z m171-190c72-42 154-148 154-200 0-12-47-64-125-138-69-65-129-119-133-121-4-1-60 51-125 116l-117 118 121 127c136 144 141 146 225 98z m-341-468l110-114-65-62c-36-33-110-104-165-157-385-367-609-575-618-575-7 0-54 44-106 97l-94 97 335 343c184 189 366 376 403 416 38 39 73 71 79 71 6-1 61-53 121-116z m-905-994c0-7-193-79-211-79-5 0 14 46 42 101l51 102 59-59c32-32 59-61 59-65z"
|
||||
transform="translate(0,800) scale(0.1,-0.1)"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M1897 4983c-9-8-9-2599-1-2630l6-23 787 0c432 0 791-4 797-8 6-4 18-21 27-38 24-46 102-113 170-144 169-79 482-78 640 1 96 49 141 90 193 177 6 9 97 11 822 11l782 1 0 1330 0 1330-85 0-85 0 0-1250 0-1250-773 0-772 0-18-51c-24-66-84-131-146-158-131-56-369-51-491 11-69 35-121 96-138 162l-8 31-775 5-774 5-2 1150c-2 633-3 1194-3 1248l0 97-73 0c-41 0-77-3-80-7z"
|
||||
transform="translate(0,800) scale(0.1,-0.1)"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3391 3872c-261-2-347-5-353-15-4-6-8-38-8-69 0-47 4-59 19-68 13-6 214-10 584-10 474 0 566 2 576 14 7 9 11 40 9 78l-3 63-240 5c-132 3-395 4-584 2z"
|
||||
transform="translate(0,800) scale(0.1,-0.1)"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle class="mt-4 text-base font-semibold text-slate-900">{{ t('home.cards.quickCalc') }}</CardTitle>
|
||||
<CardDescription class="mt-1.5 text-xs leading-5 text-slate-500">
|
||||
{{ t('home.cards.quickCalcDesc') }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<div class="mt-4 flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
|
||||
<span>{{ t('home.cards.enter') }}</span>
|
||||
<svg class="ml-1 h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||
</div>
|
||||
</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>
|
||||
@ -390,8 +434,8 @@ onMounted(() => {
|
||||
<div class="w-full max-w-md rounded-xl border bg-background shadow-2xl">
|
||||
<div class="flex items-center justify-between border-b px-5 py-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-foreground">新建项目</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">选择工程行业后,直接进入项目计算页面。</p>
|
||||
<h3 class="text-base font-semibold text-foreground">{{ t('home.dialog.newProject') }}</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">{{ t('home.dialog.chooseIndustryDesc') }}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeProjectCalcDialog">
|
||||
<X class="h-4 w-4" />
|
||||
@ -400,12 +444,14 @@ onMounted(() => {
|
||||
|
||||
<div class="space-y-4 px-5 py-4">
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
<SelectValue placeholder="请选择工程行业" />
|
||||
<span :class="projectIndustryLabel ? 'text-foreground' : 'text-muted-foreground'">
|
||||
{{ projectIndustryLabel || t('home.dialog.selectIndustry') }}
|
||||
</span>
|
||||
<SelectIcon as-child>
|
||||
<ChevronDown class="h-4 w-4 text-muted-foreground" />
|
||||
</SelectIcon>
|
||||
@ -423,7 +469,7 @@ onMounted(() => {
|
||||
:value="String(item.id)"
|
||||
class="relative flex h-9 w-full cursor-default select-none items-center rounded-md pl-3 pr-8 text-sm outline-none data-[highlighted]:bg-muted data-[highlighted]:text-foreground data-[state=checked]:bg-slate-100"
|
||||
>
|
||||
<SelectItemText>{{ item.name }}</SelectItemText>
|
||||
<SelectItemText>{{ getIndustryDisplayName(item.id, locale) }}</SelectItemText>
|
||||
<SelectItemIndicator class="absolute right-2 inline-flex items-center text-slate-700">
|
||||
<Check class="h-4 w-4" />
|
||||
</SelectItemIndicator>
|
||||
@ -436,31 +482,125 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{{ projectSubmitting ? '进入中...' : '进入项目计算' }}
|
||||
{{ projectSubmitting ? t('home.dialog.entering') : t('home.dialog.enterProjectCalc') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="homeImportConfirmOpen"
|
||||
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
|
||||
@click.self="cancelHomeImportConfirm"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-xl border bg-background shadow-2xl">
|
||||
<div class=" px-5 py-4">
|
||||
<h3 class="text-base font-semibold text-foreground">{{ t('home.dialog.confirmImport') }}</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ t('home.dialog.confirmImportDesc', { file: pendingHomeImportFileName || t('home.cards.pickFile') }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 px-5 py-4">
|
||||
<Button variant="outline" @click="cancelHomeImportConfirm">{{ t('common.cancel') }}</Button>
|
||||
<Button variant="destructive" @click="confirmHomeImport">{{ t('home.dialog.confirmImportAction') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-hero {
|
||||
animation: hero-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation: none;
|
||||
}
|
||||
.home-card-base {
|
||||
min-height: 248px;
|
||||
}
|
||||
.home-title {
|
||||
animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.15s both;
|
||||
}
|
||||
.home-card:nth-child(1) { animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.25s both; }
|
||||
.home-card:nth-child(2) { animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.35s 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 {
|
||||
animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.home-entry-item--1 { animation-delay: 0.2s; }
|
||||
.home-entry-item--2 { animation-delay: 0.3s; }
|
||||
.home-entry-item--3 { animation-delay: 0.4s; }
|
||||
.home-entry-item--4 { animation-delay: 0.5s; }
|
||||
|
||||
@keyframes hero-in {
|
||||
from { opacity: 0; transform: translateX(-20px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||
}
|
||||
.home-hero-meteor {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.65), rgba(255, 255, 255, 0));
|
||||
transform: rotate(-28deg);
|
||||
opacity: 0;
|
||||
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.45));
|
||||
animation: hero-meteor 3.8s linear infinite;
|
||||
}
|
||||
.home-hero-meteor--1 {
|
||||
top: 16%;
|
||||
right: -30%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.home-hero-meteor--2 {
|
||||
top: 38%;
|
||||
right: -40%;
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
.home-hero-meteor--3 {
|
||||
top: 62%;
|
||||
right: -35%;
|
||||
animation-delay: 2.2s;
|
||||
}
|
||||
.home-hero-meteor--4 {
|
||||
top: 24%;
|
||||
right: -45%;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
.home-hero-meteor--5 {
|
||||
top: 50%;
|
||||
right: -28%;
|
||||
animation-delay: 1.7s;
|
||||
}
|
||||
.home-hero-meteor--6 {
|
||||
top: 74%;
|
||||
right: -42%;
|
||||
animation-delay: 2.8s;
|
||||
}
|
||||
.home-hero-meteor--7 {
|
||||
top: 10%;
|
||||
right: -48%;
|
||||
animation-delay: 0.35s;
|
||||
}
|
||||
.home-hero-meteor--8 {
|
||||
top: 31%;
|
||||
right: -26%;
|
||||
animation-delay: 1.05s;
|
||||
}
|
||||
.home-hero-meteor--9 {
|
||||
top: 56%;
|
||||
right: -50%;
|
||||
animation-delay: 2.45s;
|
||||
}
|
||||
.home-hero-meteor--10 {
|
||||
top: 82%;
|
||||
right: -30%;
|
||||
animation-delay: 3.15s;
|
||||
}
|
||||
@keyframes hero-meteor {
|
||||
0% { transform: translate3d(0, 0, 0) rotate(-28deg); opacity: 0; }
|
||||
8% { opacity: 0.9; }
|
||||
34% { opacity: 0.9; }
|
||||
42% { opacity: 0; }
|
||||
100% { transform: translate3d(-340px, 220px, 0) rotate(-28deg); opacity: 0; }
|
||||
}
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
|
||||
130
src/i18n/dictionary-en.ts
Normal file
130
src/i18n/dictionary-en.ts
Normal 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
36
src/i18n/index.ts
Normal 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
104
src/i18n/locales/en-US.ts
Normal 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
104
src/i18n/locales/zh-CN.ts
Normal 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
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, markRaw, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import draggable from 'vuedraggable'
|
||||
import { useDark, useToggle } from '@vueuse/core'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import { useZxFwPricingKeysStore } from '@/pinia/zxFwPricingKeys'
|
||||
@ -10,7 +12,7 @@ import { useKvStore } from '@/pinia/kv'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
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 {
|
||||
AlertDialogAction,
|
||||
@ -34,7 +36,6 @@ import {
|
||||
SelectPortal,
|
||||
SelectRoot,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectViewport
|
||||
} from 'reka-ui'
|
||||
import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive'
|
||||
@ -99,6 +100,7 @@ import {
|
||||
PROJECT_TAB_ID,
|
||||
QUICK_TAB_ID,
|
||||
consumePendingHomeImportFile,
|
||||
consumePendingHomeImportSkipConfirm,
|
||||
readWorkspaceMode,
|
||||
writeProjectIdToUrl,
|
||||
writeWorkspaceMode
|
||||
@ -138,10 +140,12 @@ import {
|
||||
toMoney
|
||||
} from '@/lib/reportExportBuilders'
|
||||
import { exportFile } from '@/sql'
|
||||
import { industryTypeList } from '@/sql'
|
||||
import { getIndustryDisplayName, industryTypeList } from '@/sql'
|
||||
import { initializeProjectFactorStates } from '@/lib/projectWorkspace'
|
||||
import { setAppLocale, type AppLocale } from '@/i18n'
|
||||
|
||||
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 LEGACY_PROJECT_DB_KEY = 'xm-info-v3'
|
||||
const CONSULT_CATEGORY_FACTOR_DB_KEY = 'xm-consult-category-factor-v1'
|
||||
@ -240,6 +244,15 @@ const zxFwPricingStore = useZxFwPricingStore()
|
||||
const zxFwPricingKeysStore = useZxFwPricingKeysStore()
|
||||
const zxFwPricingHtFeeStore = useZxFwPricingHtFeeStore()
|
||||
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 newProjectSubmitting = 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 openedProjectIds = ref<string[]>([])
|
||||
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 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 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)
|
||||
})
|
||||
@ -360,7 +385,19 @@ const canCloseRight = computed(() => {
|
||||
const canCloseOther = computed(() =>
|
||||
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 = () => {
|
||||
tabContextOpen.value = false
|
||||
@ -513,13 +550,21 @@ const formatProjectEditedTime = (value: string) => {
|
||||
|
||||
const removeProjectItem = async (project: ProjectMeta) => {
|
||||
const isCurrentProject = project.id === currentProjectId.value
|
||||
const ok = window.confirm(
|
||||
isCurrentProject
|
||||
? `确认删除当前项目「${project.name}」吗?将先清空该项目全部本地数据,并跳转到新项目选择页。`
|
||||
: `确认删除项目「${project.name}」吗?这会移除该项目本地数据。`
|
||||
)
|
||||
if (!ok) return
|
||||
|
||||
const deleteIndexedDBByName = (dbName: string) =>
|
||||
new Promise<void>((resolve) => {
|
||||
try {
|
||||
const request = window.indexedDB?.deleteDatabase(dbName)
|
||||
if (!request) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
request.onsuccess = () => resolve()
|
||||
request.onerror = () => resolve()
|
||||
request.onblocked = () => resolve()
|
||||
} catch (_error) {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
const clearProjectPersistence = async () => {
|
||||
await projectDefaultForage.clear()
|
||||
await Promise.all(
|
||||
@ -537,34 +582,83 @@ const removeProjectItem = async (project: ProjectMeta) => {
|
||||
}
|
||||
|
||||
if (isCurrentProject) {
|
||||
window.dispatchEvent(new CustomEvent('jgjs-release-project-lock'))
|
||||
await clearProjectPersistence()
|
||||
await deleteIndexedDBByName(getProjectDbName(project.id))
|
||||
const removed = deleteProject(project.id)
|
||||
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')
|
||||
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
|
||||
}
|
||||
|
||||
const removed = deleteProject(project.id)
|
||||
if (!removed) return
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
const request = window.indexedDB?.deleteDatabase(getProjectDbName(project.id))
|
||||
if (!request) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
request.onsuccess = () => resolve()
|
||||
request.onerror = () => resolve()
|
||||
request.onblocked = () => resolve()
|
||||
})
|
||||
await deleteIndexedDBByName(getProjectDbName(project.id))
|
||||
} catch (error) {
|
||||
console.error('delete project database failed:', error)
|
||||
}
|
||||
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 = () => {
|
||||
try {
|
||||
localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1')
|
||||
@ -1251,7 +1345,7 @@ const exportData = async () => {
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('export failed:', error)
|
||||
window.alert('导出失败,请重试。')
|
||||
showMessageDialog('导出失败', '请重试。')
|
||||
} finally {
|
||||
dataMenuOpen.value = false
|
||||
}
|
||||
@ -1279,7 +1373,10 @@ const triggerImport = () => {
|
||||
importFileRef.value?.click()
|
||||
}
|
||||
|
||||
const prepareImportPayloadFromFile = async (file: File) => {
|
||||
const prepareImportPayloadFromFile = async (
|
||||
file: File,
|
||||
options?: { skipConfirm?: boolean }
|
||||
) => {
|
||||
if (!file.name.toLowerCase().endsWith(ZW_FILE_EXTENSION)) {
|
||||
throw new Error('INVALID_FILE_EXT')
|
||||
}
|
||||
@ -1298,6 +1395,10 @@ const prepareImportPayloadFromFile = async (file: File) => {
|
||||
}
|
||||
pendingImportPayload.value = payload
|
||||
pendingImportFileName.value = file.name
|
||||
if (options?.skipConfirm) {
|
||||
await confirmImportOverride()
|
||||
return
|
||||
}
|
||||
importConfirmOpen.value = true
|
||||
}
|
||||
|
||||
@ -1311,14 +1412,14 @@ const importData = async (event: Event) => {
|
||||
} catch (error) {
|
||||
console.error('import failed:', error)
|
||||
if (error instanceof Error && error.message === 'PROJECT_ID_MISSING') {
|
||||
window.alert('导入失败:该数据包不包含项目标识(旧版本导出包),为避免串项目已禁止导入。')
|
||||
showMessageDialog('导入失败', '该数据包不包含项目标识(旧版本导出包),为避免串项目已禁止导入。')
|
||||
return
|
||||
}
|
||||
if (error instanceof Error && error.message.startsWith('PROJECT_ID_MISMATCH:')) {
|
||||
window.alert('导入失败:该数据包属于其他项目,不能覆盖当前项目。')
|
||||
showMessageDialog('导入失败', '该数据包属于其他项目,不能覆盖当前项目。')
|
||||
return
|
||||
}
|
||||
window.alert('导入失败:文件无效、已损坏或被修改。')
|
||||
showMessageDialog('导入失败', '文件无效、已损坏或被修改。')
|
||||
} finally {
|
||||
input.value = ''
|
||||
}
|
||||
@ -1389,7 +1490,7 @@ const confirmImportOverride = async () => {
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('import apply failed:', error)
|
||||
window.alert('导入失败:写入本地数据时发生错误。')
|
||||
showMessageDialog('导入失败', '写入本地数据时发生错误。')
|
||||
} finally {
|
||||
cancelImportConfirm()
|
||||
}
|
||||
@ -1397,6 +1498,7 @@ const confirmImportOverride = async () => {
|
||||
|
||||
const handleReset = async () => {
|
||||
if (isResetting.value) return
|
||||
resetUiWorkspaceMode.value = readWorkspaceMode()
|
||||
const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||
const resetStartedAt = Date.now()
|
||||
const allProjectIds = Array.from(new Set(['default', 'quick', ...listProjects().map(item => item.id)]))
|
||||
@ -1427,9 +1529,15 @@ const handleReset = async () => {
|
||||
isResetting.value = true
|
||||
dataMenuOpen.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) 仅清持久化层,不先改当前页面内存态,避免“先切首页再刷新”的二次闪烁。
|
||||
localStorage.clear()
|
||||
if (themePreference != null) {
|
||||
localStorage.setItem(THEME_PREFERENCE_KEY, themePreference)
|
||||
}
|
||||
sessionStorage.clear()
|
||||
await projectDefaultForage.clear()
|
||||
|
||||
@ -1466,6 +1574,7 @@ const handleReset = async () => {
|
||||
} catch (error) {
|
||||
console.error('reset failed:', error)
|
||||
isResetting.value = false
|
||||
resetUiWorkspaceMode.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@ -1482,6 +1591,7 @@ onMounted(() => {
|
||||
window.addEventListener('keydown', handleGlobalKeyDown)
|
||||
window.addEventListener('resize', scheduleUpdateTabTitleOverflow)
|
||||
const pendingHomeImportFile = consumePendingHomeImportFile()
|
||||
const skipWorkspaceImportConfirm = consumePendingHomeImportSkipConfirm()
|
||||
void nextTick(() => {
|
||||
bindTabStripScroll()
|
||||
ensureActiveTabVisible()
|
||||
@ -1490,9 +1600,9 @@ onMounted(() => {
|
||||
scheduleRestoreTabInnerScrollTop(tabStore.activeTabId)
|
||||
})
|
||||
if (pendingHomeImportFile) {
|
||||
void prepareImportPayloadFromFile(pendingHomeImportFile).catch(error => {
|
||||
void prepareImportPayloadFromFile(pendingHomeImportFile, { skipConfirm: skipWorkspaceImportConfirm }).catch(error => {
|
||||
console.error('home import failed:', error)
|
||||
window.alert('导入失败:文件无效、已损坏或被修改。')
|
||||
showMessageDialog('导入失败', '文件无效、已损坏或被修改。')
|
||||
})
|
||||
}
|
||||
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(
|
||||
() => tabStore.activeTabId,
|
||||
(nextId, prevId) => {
|
||||
@ -1608,97 +1744,149 @@ watch(
|
||||
</button>
|
||||
</div>
|
||||
<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"
|
||||
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"
|
||||
@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>
|
||||
<div v-if="dataMenuOpen"
|
||||
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"
|
||||
<Transition name="toolbar-dropdown">
|
||||
<div v-if="dataMenuOpen"
|
||||
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"
|
||||
@click="triggerImport">
|
||||
导入
|
||||
</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">
|
||||
导出
|
||||
</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>
|
||||
v-if="workspaceModeForUi !== 'quick'"
|
||||
@click="exportReport">
|
||||
{{ t('tab.toolbar.exportReport') }}
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
<input ref="importFileRef" type="file" accept=".zw" class="hidden" @change="importData" />
|
||||
</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"
|
||||
@click="openUserGuide(0)">
|
||||
<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>
|
||||
|
||||
<div v-if="readWorkspaceMode() !== 'quick'" ref="projectMenuRef" class="relative shrink-0">
|
||||
<div v-if="workspaceModeForUi !== 'quick'" ref="projectMenuRef" class="relative shrink-0">
|
||||
<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="projectMenuOpen ? 'border-primary/40 bg-primary/5 text-foreground' : ''"
|
||||
:disabled="isResetting"
|
||||
@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>
|
||||
<div
|
||||
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
|
||||
v-for="project in projectList"
|
||||
:key="project.id"
|
||||
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)"
|
||||
<Transition name="toolbar-dropdown">
|
||||
<div
|
||||
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
|
||||
v-for="project in projectList"
|
||||
:key="project.id"
|
||||
class="flex items-center gap-2 rounded px-2 py-1.5"
|
||||
:class="isProjectOpen(project.id) ? 'opacity-60' : 'hover:bg-muted'"
|
||||
>
|
||||
<div class="font-medium leading-5">
|
||||
{{ project.name }}
|
||||
<span v-if="isProjectOpen(project.id)" class="ml-1 text-xs text-muted-foreground">(已打开)</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">最后编辑:{{ formatProjectEditedTime(project.updatedAt) }}</div>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
@click="removeProjectItem(project)"
|
||||
>
|
||||
删除
|
||||
<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">
|
||||
{{ project.name }}
|
||||
<span v-if="isProjectOpen(project.id)" class="ml-1 text-xs text-muted-foreground">{{ t('tab.toolbar.opened') }}</span>
|
||||
</div>
|
||||
<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>
|
||||
</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">
|
||||
新建项目
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" class="h-8 px-3 text-xs" @click="resetConfirmOpen = true">
|
||||
重置全部项目
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<AlertDialogRoot :open="resetConfirmOpen" @update:open="handleResetConfirmOpenChange">
|
||||
@ -1706,15 +1894,15 @@ watch(
|
||||
<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">确认重置</AlertDialogTitle>
|
||||
<AlertDialogTitle class="text-base font-semibold">{{ t('tab.dialog.resetTitle') }}</AlertDialogTitle>
|
||||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||
将清空全部项目数据,并恢复默认页面,确认继续吗?
|
||||
{{ t('tab.dialog.resetDesc') }}
|
||||
</AlertDialogDescription>
|
||||
<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">
|
||||
<Loader2 v-if="isResetting" class="mr-1 h-4 w-4 animate-spin" />
|
||||
{{ isResetting ? '重置中...' : '确认重置' }}
|
||||
{{ isResetting ? t('tab.toolbar.resetting') : t('tab.dialog.confirmReset') }}
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
@ -1726,16 +1914,16 @@ watch(
|
||||
<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">确认导入覆盖</AlertDialogTitle>
|
||||
<AlertDialogTitle class="text-base font-semibold">{{ t('tab.dialog.importOverrideTitle') }}</AlertDialogTitle>
|
||||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||
将使用“{{ pendingImportFileName || '所选文件' }}”覆盖当前本地全部数据,是否继续?
|
||||
{{ t('tab.dialog.importOverrideDesc', { file: pendingImportFileName || t('home.cards.pickFile') }) }}
|
||||
</AlertDialogDescription>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<AlertDialogCancel as-child>
|
||||
<Button variant="outline" @click="cancelImportConfirm">取消</Button>
|
||||
<Button variant="outline" @click="cancelImportConfirm">{{ t('common.cancel') }}</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<Button variant="destructive" @click="confirmImportOverride">确认覆盖</Button>
|
||||
<Button variant="destructive" @click="confirmImportOverride">{{ t('tab.dialog.confirmOverride') }}</Button>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
@ -1747,17 +1935,19 @@ watch(
|
||||
<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">新建项目</AlertDialogTitle>
|
||||
<AlertDialogTitle class="text-base font-semibold">{{ t('tab.dialog.newProjectTitle') }}</AlertDialogTitle>
|
||||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||
选择工程行业后,将在新标签页直接打开新项目计算页面。
|
||||
{{ t('tab.dialog.newProjectDesc') }}
|
||||
</AlertDialogDescription>
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
<SelectValue placeholder="请选择工程行业" />
|
||||
<span :class="newProjectIndustryLabel ? 'text-foreground' : 'text-muted-foreground'">
|
||||
{{ newProjectIndustryLabel || t('home.dialog.selectIndustry') }}
|
||||
</span>
|
||||
<SelectIcon as-child>
|
||||
<ChevronDown class="h-4 w-4 text-muted-foreground" />
|
||||
</SelectIcon>
|
||||
@ -1775,7 +1965,7 @@ watch(
|
||||
:value="String(item.id)"
|
||||
class="relative flex h-9 w-full cursor-default select-none items-center rounded-md pl-3 pr-8 text-sm outline-none data-[highlighted]:bg-muted data-[highlighted]:text-foreground data-[state=checked]:bg-slate-100"
|
||||
>
|
||||
<SelectItemText>{{ item.name }}</SelectItemText>
|
||||
<SelectItemText>{{ getIndustryDisplayName(item.id, locale) }}</SelectItemText>
|
||||
<SelectItemIndicator class="absolute right-2 inline-flex items-center text-slate-700">
|
||||
<Check class="h-4 w-4" />
|
||||
</SelectItemIndicator>
|
||||
@ -1787,12 +1977,12 @@ watch(
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<AlertDialogCancel as-child>
|
||||
<Button variant="outline" :disabled="newProjectSubmitting" @click="closeCreateProjectDialog">取消</Button>
|
||||
<Button variant="outline" :disabled="newProjectSubmitting" @click="closeCreateProjectDialog">{{ t('common.cancel') }}</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<Button :disabled="newProjectSubmitting || !newProjectIndustry" @click="handleCreateProjectConfirm">
|
||||
<Loader2 v-if="newProjectSubmitting" class="mr-1 h-4 w-4 animate-spin" />
|
||||
{{ newProjectSubmitting ? '创建中...' : '新建并打开' }}
|
||||
{{ newProjectSubmitting ? t('tab.dialog.creating') : t('tab.dialog.createAndOpen') }}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
@ -1805,13 +1995,50 @@ watch(
|
||||
<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">项目数量已达上限</AlertDialogTitle>
|
||||
<AlertDialogTitle class="text-base font-semibold">{{ t('tab.dialog.projectLimitTitle') }}</AlertDialogTitle>
|
||||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||
当前项目数量已达到 {{ MAX_PROJECT_COUNT }} 个,请先删除一个项目后再添加。
|
||||
{{ t('tab.dialog.projectLimitDesc', { max: MAX_PROJECT_COUNT }) }}
|
||||
</AlertDialogDescription>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
@ -1839,22 +2066,22 @@ watch(
|
||||
<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"
|
||||
:disabled="!hasClosableTabs" @click="runTabMenuAction('all')">
|
||||
删除所有
|
||||
{{ t('tab.menu.closeAll') }}
|
||||
</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"
|
||||
:disabled="!canCloseLeft" @click="runTabMenuAction('left')">
|
||||
删除左侧
|
||||
{{ t('tab.menu.closeLeft') }}
|
||||
</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"
|
||||
:disabled="!canCloseRight" @click="runTabMenuAction('right')">
|
||||
删除右侧
|
||||
{{ t('tab.menu.closeRight') }}
|
||||
</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"
|
||||
:disabled="!canCloseOther" @click="runTabMenuAction('other')">
|
||||
删除其他
|
||||
{{ t('tab.menu.closeOther') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -1863,7 +2090,7 @@ watch(
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeUserGuide(false)">
|
||||
@ -1888,9 +2115,9 @@ watch(
|
||||
@click="jumpToGuideStep(index)" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" @click="closeUserGuide(false)">稍后再看</Button>
|
||||
<Button variant="outline" :disabled="isFirstGuideStep" @click="prevUserGuideStep">上一步</Button>
|
||||
<Button @click="nextUserGuideStep">{{ isLastGuideStep ? '完成并不再自动弹出' : '下一步' }}</Button>
|
||||
<Button variant="ghost" @click="closeUserGuide(false)">{{ t('tab.guide.later') }}</Button>
|
||||
<Button variant="outline" :disabled="isFirstGuideStep" @click="prevUserGuideStep">{{ t('tab.guide.prev') }}</Button>
|
||||
<Button @click="nextUserGuideStep">{{ isLastGuideStep ? t('tab.guide.finish') : t('tab.guide.next') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1904,7 +2131,7 @@ watch(
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@ -9,20 +9,20 @@ import { themeQuartz } from 'ag-grid-community'
|
||||
const borderConfig = {
|
||||
style: 'solid',
|
||||
width: 0.3,
|
||||
color: '#d3d3d3'
|
||||
color: 'var(--border)'
|
||||
}
|
||||
|
||||
export const myTheme = themeQuartz.withParams({
|
||||
wrapperBorder: false,
|
||||
wrapperBorderRadius: 0,
|
||||
headerBackgroundColor: '#f0f2f3',
|
||||
headerTextColor: '#374151',
|
||||
headerBackgroundColor: 'var(--muted)',
|
||||
headerTextColor: 'var(--foreground)',
|
||||
headerFontSize: 15,
|
||||
headerFontWeight: 'normal',
|
||||
rowBorder: borderConfig,
|
||||
columnBorder: borderConfig,
|
||||
headerRowBorder: borderConfig,
|
||||
dataBackgroundColor: '#fefefe'
|
||||
dataBackgroundColor: 'var(--card)'
|
||||
})
|
||||
|
||||
// AG Grid 容器通用 class(占满父容器,配合父元素为 flex/grid 且有明确高度使用)
|
||||
|
||||
@ -2,6 +2,7 @@ const LOCK_TTL_MS = 12_000
|
||||
const HEARTBEAT_MS = 4_000
|
||||
export const PROJECT_LOCK_KEY_PREFIX = 'jgjs-project-lock:'
|
||||
const CHANNEL_NAME = 'jgjs-project-lock-channel'
|
||||
const TAB_SESSION_ID_KEY = 'jgjs-project-tab-session-id'
|
||||
|
||||
type LockPayload = {
|
||||
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 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 = {
|
||||
projectId: string
|
||||
onConflict: (conflicted: boolean) => void
|
||||
@ -41,7 +54,7 @@ type InitProjectLockParams = {
|
||||
export const initProjectSessionLock = (params: InitProjectLockParams) => {
|
||||
const projectId = String(params.projectId || '').trim()
|
||||
const onConflict = params.onConflict
|
||||
const sessionId = randomSessionId()
|
||||
const sessionId = getOrCreateTabSessionId()
|
||||
const key = lockKeyOf(projectId)
|
||||
let conflicted = false
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@ -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 PROJECT_ID_QUERY_KEY = 'projectId'
|
||||
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 QUICK_PROJECT_ID = 'quick'
|
||||
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'
|
||||
|
||||
let pendingHomeImportFile: File | null = null
|
||||
let pendingHomeImportSkipWorkspaceConfirm = false
|
||||
|
||||
export interface QuickContractMeta {
|
||||
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
|
||||
pendingHomeImportSkipWorkspaceConfirm = Boolean(options?.skipWorkspaceConfirm)
|
||||
}
|
||||
|
||||
export const consumePendingHomeImportFile = () => {
|
||||
@ -65,6 +72,12 @@ export const consumePendingHomeImportFile = () => {
|
||||
return file
|
||||
}
|
||||
|
||||
export const consumePendingHomeImportSkipConfirm = () => {
|
||||
const skip = pendingHomeImportSkipWorkspaceConfirm
|
||||
pendingHomeImportSkipWorkspaceConfirm = false
|
||||
return skip
|
||||
}
|
||||
|
||||
export const normalizeProjectId = (value: unknown) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return DEFAULT_PROJECT_ID
|
||||
@ -97,14 +110,28 @@ export const ensureProjectIdInUrl = () => {
|
||||
return id
|
||||
}
|
||||
|
||||
export const buildProjectUrl = (projectIdRaw: string, options?: { newProject?: boolean }) => {
|
||||
export const buildProjectUrl = (
|
||||
projectIdRaw: string,
|
||||
options?: { newProject?: boolean; openProjectDialog?: boolean; forceHome?: boolean }
|
||||
) => {
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set(PROJECT_ID_QUERY_KEY, normalizeProjectId(projectIdRaw))
|
||||
if (options?.newProject) {
|
||||
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 {
|
||||
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}`
|
||||
} catch {
|
||||
|
||||
@ -23,6 +23,7 @@ import piniaPersistedstate from '@/pinia/Plugin/indexdb'
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
import { i18n } from '@/i18n'
|
||||
import { ensureProjectIdInUrl, getProjectDbName } from '@/lib/workspace'
|
||||
import { listProjects } from '@/lib/projectRegistry'
|
||||
|
||||
@ -79,4 +80,4 @@ pinia.use(
|
||||
// 在应用启动时一次性注册 AG Grid 运行所需模块。
|
||||
ModuleRegistry.registerModules(AG_GRID_MODULES)
|
||||
|
||||
createApp(App).use(pinia).mount('#app')
|
||||
createApp(App).use(pinia).use(i18n).mount('#app')
|
||||
|
||||
92
src/sql.ts
92
src/sql.ts
@ -1,6 +1,12 @@
|
||||
// @ts-nocheck
|
||||
import { addNumbers, roundTo, toDecimal, toFiniteNumberOrZero } from '@/lib/decimal'
|
||||
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";
|
||||
// 统一数字千分位格式化,默认保留 2 位小数。
|
||||
const numberFormatter = (value: unknown, fractionDigits = 2) =>
|
||||
@ -66,10 +72,79 @@ export const TYPE_LABEL_MAP: Record<number, WorkType> = {
|
||||
5: '自定义'
|
||||
}
|
||||
export const industryTypeList = [
|
||||
{ id: '0', name: '公路工程', type: 'isRoad' },
|
||||
{ id: '1', name: '铁路工程', type: 'isRailway' },
|
||||
{ id: '2', name: '水运工程', type: 'isWaterway' }
|
||||
{ id: '0', name: '公路工程', nameEn: 'Highway Engineering', type: 'isRoad' },
|
||||
{ id: '1', name: '铁路工程', nameEn: 'Railway Engineering', type: 'isRailway' },
|
||||
{ id: '2', name: '水运工程', nameEn: 'Waterway Engineering', type: 'isWaterway' }
|
||||
] 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 = {
|
||||
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 },
|
||||
@ -204,6 +279,15 @@ export const expertList = {
|
||||
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 = [
|
||||
{
|
||||
id: '1',
|
||||
@ -605,7 +689,7 @@ const buildDictEntries = (source: Record<string, any>) =>
|
||||
return {
|
||||
id: rawId,
|
||||
rawId,
|
||||
item
|
||||
item: localizeDictItem(item)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@ -191,6 +191,15 @@ input[inputmode='numeric'] {
|
||||
.ag-theme-quartz {
|
||||
--ag-font-size: var(--app-grid-font-size);
|
||||
--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. */
|
||||
@ -327,7 +336,7 @@ input[inputmode='numeric'] {
|
||||
display: inline-block;
|
||||
min-width: 84%;
|
||||
padding: 2px 4px;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.xmMx .remark-wrap-cell .ag-cell-value {
|
||||
@ -349,7 +358,7 @@ input[inputmode='numeric'] {
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-empty .ag-cell-value {
|
||||
color: #94a3b8 !important;
|
||||
color: var(--muted-foreground) !important;
|
||||
font-style: italic;
|
||||
opacity: 1 !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-value,
|
||||
.xmMx .ag-cell.editable-cell-empty .ag-cell-value * {
|
||||
--ag-data-color: #94a3b8;
|
||||
color: #94a3b8 !important;
|
||||
--ag-data-color: var(--muted-foreground);
|
||||
color: var(--muted-foreground) !important;
|
||||
height:100%;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@ -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"}
|
||||
Loading…
x
Reference in New Issue
Block a user