翻译问题

This commit is contained in:
wintsa 2026-04-22 09:40:08 +08:00
parent 32f7469e84
commit 9508f1390e
17 changed files with 790 additions and 967 deletions

View File

@ -1,92 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
const brandName = '慧众易'
const messages = ['真容易', '算费真容易', '算费不熬夜', '算费不费力', '不熬夜,不费力']
const messageIndex = ref(0)
let rotationTimer: ReturnType<typeof setInterval> | null = null
const activeMessage = computed(() => {
if (!messages.length) return ''
return messages[((messageIndex.value % messages.length) + messages.length) % messages.length]
})
const stopRotation = () => {
if (rotationTimer == null) return
clearInterval(rotationTimer)
rotationTimer = null
}
const startRotation = () => {
stopRotation()
if (messages.length <= 1) return
rotationTimer = setInterval(() => {
messageIndex.value = (messageIndex.value + 1) % messages.length
}, 2400)
}
onMounted(() => {
startRotation()
})
onBeforeUnmount(() => {
stopRotation()
})
</script>
<template>
<div class="hero-brand-ticker">
<span class="hero-brand-ticker__brand">{{ brandName }}</span>
<span class="hero-brand-ticker__divider" />
<Transition name="hero-brand-message" mode="out-in">
<span :key="messageIndex" class="hero-brand-ticker__message">{{ activeMessage }}</span>
</Transition>
</div>
</template>
<style scoped>
.hero-brand-ticker {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 12px;
padding: 8px 16px;
color: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 999px;
backdrop-filter: blur(8px);
background: rgba(255, 255, 255, 0.1);
}
.hero-brand-ticker__brand {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.28em;
color: #fff;
}
.hero-brand-ticker__divider {
width: 1px;
height: 14px;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.25);
}
.hero-brand-ticker__message {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hero-brand-message-enter-active,
.hero-brand-message-leave-active {
transition: opacity 0.22s ease, transform 0.22s ease;
}
.hero-brand-message-enter-from,
.hero-brand-message-leave-to {
opacity: 0;
transform: translateY(6px);
}
</style>

View File

@ -1,75 +0,0 @@
<template>
<section class="hwz-promo-banner" aria-label="慧众易宣传文案">
<div class="hwz-promo-banner__inner">
<p class="hwz-promo-banner__eyebrow">智算费用 即点即出</p>
<h2 class="hwz-promo-banner__title">您的时间留给创造</h2>
<p class="hwz-promo-banner__brand">和慧众易</p>
</div>
</section>
</template>
<style scoped>
.hwz-promo-banner {
display: flex;
align-items: center;
justify-content: center;
min-height: 320px;
padding: 32px;
background:
radial-gradient(circle at top left, rgba(254, 240, 138, 0.95), transparent 38%),
linear-gradient(135deg, #0f172a 0%, #1d4ed8 48%, #0f766e 100%);
border-radius: 28px;
overflow: hidden;
}
.hwz-promo-banner__inner {
width: min(100%, 720px);
padding: 40px;
color: #f8fafc;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 24px;
backdrop-filter: blur(14px);
background: rgba(15, 23, 42, 0.26);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.28);
}
.hwz-promo-banner__eyebrow {
margin: 0 0 16px;
font-size: 18px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #dbeafe;
}
.hwz-promo-banner__title {
margin: 0;
font-size: clamp(34px, 7vw, 64px);
line-height: 1.08;
font-weight: 700;
}
.hwz-promo-banner__brand {
margin: 20px 0 0;
font-size: clamp(20px, 3vw, 28px);
font-weight: 500;
color: #fde68a;
}
@media (max-width: 640px) {
.hwz-promo-banner {
min-height: 260px;
padding: 20px;
border-radius: 22px;
}
.hwz-promo-banner__inner {
padding: 28px 22px;
border-radius: 18px;
}
.hwz-promo-banner__eyebrow {
font-size: 14px;
letter-spacing: 0.16em;
}
}
</style>

117
data.js Normal file
View File

@ -0,0 +1,117 @@
const DEFAULT_FILE_NAME = 'data'
const DEFAULT_MIME_TYPE = 'application/octet-stream'
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const normalizeSuffix = (suffix) => {
const value = String(suffix || '').trim()
if (!value) throw new Error('INVALID_SUFFIX')
return value.startsWith('.') ? value : `.${value}`
}
const sanitizeFileNamePart = (value) => {
const cleaned = String(value || '')
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim()
return cleaned || DEFAULT_FILE_NAME
}
const formatTimestamp = (date = new Date()) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
const second = String(date.getSeconds()).padStart(2, '0')
return `${year}${month}${day}-${hour}${minute}${second}`
}
const encodeData = (data) => {
const json = JSON.stringify(data)
return encoder.encode(json)
}
const decodeData = (bytes) => {
const text = decoder.decode(bytes)
return JSON.parse(text)
}
const downloadBlob = (blob, fileName) => {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
const pickFile = (accept) => {
return new Promise((resolve, reject) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = accept
input.style.display = 'none'
const cleanup = () => {
input.removeEventListener('change', handleChange)
input.remove()
}
const handleChange = () => {
const file = input.files?.[0] || null
cleanup()
if (!file) {
reject(new Error('FILE_NOT_SELECTED'))
return
}
resolve(file)
}
input.addEventListener('change', handleChange, { once: true })
document.body.appendChild(input)
input.click()
})
}
export const exportData = async (data, suffix, options = {}) => {
const normalizedSuffix = normalizeSuffix(suffix)
const bytes = encodeData(data)
const blob = new Blob([bytes], {
type: options.mimeType || DEFAULT_MIME_TYPE
})
const baseName = sanitizeFileNamePart(options.fileName)
const fileName = `${baseName}-${formatTimestamp()}${normalizedSuffix}`
const shouldDownload = options.download !== false
if (shouldDownload) {
downloadBlob(blob, fileName)
}
return {
blob,
fileName,
bytes
}
}
export const importData = async (suffix) => {
const normalizedSuffix = normalizeSuffix(suffix)
const file = await pickFile(normalizedSuffix)
const fileName = String(file.name || '')
if (!fileName.toLowerCase().endsWith(normalizedSuffix.toLowerCase())) {
throw new Error('INVALID_FILE_SUFFIX')
}
const bytes = new Uint8Array(await file.arrayBuffer())
return decodeData(bytes)
}

View File

@ -4,7 +4,19 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>联众咨询</title>
<title>交通运输工程造价咨询服务预算编制规范</title>
<!-- 👇 企微 / 微信分享专用 meta 👇 -->
<meta name="description" content="交通运输工程造价咨询服务预算编制规范工具,依据 T/GDHS 017-2026 标准,提供专业预算编制、计算、导出功能。" />
<!-- 微信开放平台标签(解决不显示封面/标题问题) -->
<meta property="og:title" content="交通运输工程造价咨询服务预算编制工具" />
<meta property="og:description" content="依据 T/GDHS 017-2026 标准,专业工程造价预算编制工具。" />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://jtzjfw.lianzhong.com.cn/logo.jpg" /> <!-- 必须替换成你自己的在线图片 -->
<!-- 企微专用优化 -->
<meta name="wx:cover" content="https://jtzjfw.lianzhong.com.cn/logo.jpg" />
</head>
<body>
<div id="app"></div>

22
package-lock.json generated
View File

@ -32,6 +32,7 @@
"tailwindcss": "^4.1.18",
"vue": "^3.5.25",
"vue-i18n": "^11.3.0",
"vue-router": "^4.6.4",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
@ -2127,6 +2128,27 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/vue-router/node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vue-tsc": {
"version": "3.2.5",
"dev": true,

View File

@ -1,7 +1,7 @@
{
"name": "my-vue-app",
"name": "ZWJJ2026",
"private": true,
"version": "0.0.0",
"version": "1.0",
"type": "module",
"scripts": {
"dev": "bunx --bun vite",
@ -35,6 +35,7 @@
"tailwindcss": "^4.1.18",
"vue": "^3.5.25",
"vue-i18n": "^11.3.0",
"vue-router": "^4.6.4",
"vuedraggable": "^4.1.0"
},
"devDependencies": {

View File

@ -1,470 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>预算编制工具免责声明</title>
<style>
:root {
color-scheme: light;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans SC", sans-serif;
color: #0f172a;
background:
radial-gradient(circle at top, rgba(14, 116, 144, 0.14), transparent 30%),
linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%);
}
.page {
width: min(100%, 960px);
margin: 0 auto;
padding: 28px 16px 40px;
}
.card {
padding: 24px 22px;
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 24px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 24px 56px rgba(15, 23, 42, 0.1);
backdrop-filter: blur(12px);
}
.eyebrow {
margin: 0 0 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.16em;
color: #0f766e;
}
h1 {
margin: 0;
font-size: clamp(22px, 3.6vw, 30px);
line-height: 1.25;
}
.date {
margin-top: 14px;
font-size: 14px;
color: #475569;
}
.lead {
margin-top: 18px;
font-size: 15px;
line-height: 1.95;
color: #334155;
}
.divider {
height: 1px;
margin: 24px 0 28px;
background: linear-gradient(90deg, rgba(148, 163, 184, 0), rgba(148, 163, 184, 0.8), rgba(148, 163, 184, 0));
}
.section {
margin-top: 24px;
}
.section h2 {
margin: 0 0 12px;
font-size: 20px;
line-height: 1.5;
}
.section p {
margin: 0 0 10px;
font-size: 15px;
line-height: 1.95;
color: #334155;
}
.confirm {
margin-top: 30px;
padding: 20px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(15, 118, 110, 0.08), rgba(14, 165, 233, 0.08));
border: 1px solid rgba(15, 118, 110, 0.16);
}
.confirm-title {
margin: 0 0 10px;
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.confirm p {
margin: 0;
font-size: 15px;
line-height: 1.9;
color: #334155;
}
.checkbox-row {
display: flex;
align-items: flex-start;
gap: 12px;
margin-top: 16px;
padding: 14px 16px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(148, 163, 184, 0.22);
}
.checkbox-row input {
width: 16px;
height: 16px;
margin-top: 4px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 18px;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 42px;
padding: 0 18px;
border-radius: 999px;
border: 1px solid transparent;
font-size: 14px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: 0.2s ease;
}
.button-primary {
color: #fff;
background: #0f766e;
box-shadow: 0 12px 24px rgba(15, 118, 110, 0.18);
}
.button-primary:hover {
background: #115e59;
}
.button-primary:disabled {
cursor: not-allowed;
opacity: 0.55;
box-shadow: none;
}
.button-secondary {
color: #334155;
background: #fff;
border-color: rgba(148, 163, 184, 0.45);
}
.page-actions {
margin-top: 28px;
}
.hint {
margin-top: 12px;
font-size: 13px;
color: #64748b;
}
@media (max-width: 640px) {
.page {
padding: 16px 10px 24px;
}
.card {
padding: 18px 14px;
border-radius: 18px;
}
.section h2 {
font-size: 18px;
}
.actions {
flex-direction: column;
}
.button {
width: 100%;
}
}
</style>
</head>
<body>
<main class="page">
<section class="card">
<p id="eyebrow" class="eyebrow">DISCLAIMER</p>
<h1 id="page-title">《交通运输工程造价咨询服务预算编制规范》T/GDHS 017-2026预算编制工具免责声明</h1>
<p class="date"><span id="date-label">最后更新日期:</span><span id="current-date"></span></p>
<p id="lead-text" class="lead">
感谢您使用本网站提供的《交通运输工程造价咨询服务预算编制规范》T/GDHS 017-2026造价咨询服务预算编制工具以下简称编制工具以下简称本工具。在您使用本工具前请仔细阅读以下免责声明条款。您继续使用本工具即视为您已阅读、理解并同意接受本声明的全部内容。
</p>
<div class="divider"></div>
<section class="section">
<h2 id="section1-title">1. 标准依据说明</h2>
<p id="section1-p1">1.1 本工具依据广东省公路学会发布的团体标准《交通运输工程造价咨询服务预算编制规范》T/GDHS 017-2026以下简称本规范设定的编制方法。使用者应自行判断该标准是否适用于其具体项目及所在地区主管部门的要求。</p>
<p id="section1-p2">1.2 本工具所依据的规范版本已在工具界面中标注T/GDHS 017-2026。如该规范后续发布修订内容、补充规定或被新版本替代本工具可能无法及时同步更新。使用者有责任在使用前确认所依据的规范版本是否为最新有效版本。</p>
<p id="section1-p3">1.3 本工具的计算结果基于本规范中的预算编制方法、费用组成及编制规则,但不同地区、不同项目法人对造价咨询服务预算编制的具体要求和计算方法可能存在差异。本工具不保证其计算结果符合任何特定项目或特定主管部门的审核要求。</p>
</section>
<section class="section">
<h2 id="section2-title">2. 计算结果仅供参考</h2>
<p id="section2-p1">本工具所提供的所有计算结果(包括但不限于数值、明细表、汇总报表、编制说明等)均基于您输入的参数(如工程行业、项目规模、咨询类别、工程专业、工作内容、调整系数等)以及本规范中的数学模型与公式自动生成,仅供您参考使用。这些结果不构成任何形式的专业建议,也不代表任何官方或强制性的预算审批依据。</p>
</section>
<section class="section">
<h2 id="section3-title">3. 不保证准确性与完整性</h2>
<p id="section3-p1">尽管我们尽力确保工具的可用性,但本工具按现状和现有基础提供,不附带任何明示或暗示的保证。我们无法保证计算结果在任何情况下均准确、无误或完整。由于数据输入错误、公式取舍、四舍五入或系统延迟等原因,结果可能与实际情况存在偏差。</p>
</section>
<section class="section">
<h2 id="section4-title">4. 用户自行承担风险</h2>
<p id="section4-p1">您应当独立判断计算结果的可信性,并承担将其用于任何决策所产生的全部风险与责任。您不应依赖本工具的编制结果替代专业人士的具体计算或复核。在作出重大决定前,建议您咨询持有交通运输工程造价工程师注册证书的专业人员,或结合项目具体情况进行人工验证与复核。</p>
</section>
<section class="section">
<h2 id="section5-title">5. 责任限制</h2>
<p id="section5-p1">在适用法律允许的最大范围内,本工具的开发方、管理方、发布方及其关联方不对因使用或无法使用本工具而导致的任何直接、间接、偶然、特殊或后果性损失承担法律责任,即使已被告知可能发生此类损失。</p>
<p id="section5-p2">特别声明:任何造价咨询企业或人员依据本工具计算结果出具的造价咨询成果文件,其质量责任由出具方自行承担。本工具不对任何第三方造价咨询成果的准确性、合规性或引发的任何纠纷承担任何责任。</p>
</section>
<section class="section">
<h2 id="section6-title">6. 服务中断与修改</h2>
<p id="section6-p1">我们保留随时修改、暂停或终止本工具部分或全部功能的权利,且可能不另行通知。对于因技术维护、网络故障、第三方服务中断、规范版本变更等原因导致的工具不可用、数据丢失或计算结果变化,我们不承担责任。</p>
</section>
<section class="section">
<h2 id="section7-title">7. 外部链接与第三方内容</h2>
<p id="section7-p1">如果本工具引用或链接至全国团体标准信息平台、广东省公路学会官网或其他第三方网站,该等链接仅为方便用户查阅规范原文而提供,不代表我们认可其内容的准确性、时效性或完整性。对于任何第三方网站或工具的信息、服务或内容,我们不承担任何责任。</p>
</section>
<section class="section">
<h2 id="section8-title">8. 适用法律</h2>
<p id="section8-p1">本声明的解释、效力及争议解决均适用中华人民共和国法律。若本声明任何条款被认定为无效或不可执行,不影响其余条款的效力。</p>
</section>
<section id="confirm-section" class="confirm">
<p id="confirm-title" class="confirm-title">用户确认</p>
<p id="confirm-desc-1">我已阅读、理解并同意本免责声明的全部内容。</p>
<p id="confirm-desc-2">(勾选后方可继续使用本工具)</p>
<label class="checkbox-row">
<input id="accept-checkbox" type="checkbox" />
<span id="checkbox-label">我已阅读、理解并同意本免责声明的全部内容。</span>
</label>
<div class="actions">
<button id="continue-button" class="button button-primary" type="button" disabled>同意并继续</button>
<a id="back-button" class="button button-secondary" href="/">返回入口</a>
</div>
<p id="confirm-hint" class="hint">勾选后将记录当前浏览器的同意状态,后续从同一受限入口访问时不再重复提示。</p>
</section>
<div id="page-actions" class="actions page-actions" style="display: none;">
<a id="fallback-back-button" class="button button-secondary" href="/">返回入口</a>
</div>
</section>
</main>
<script>
const DISCLAIMER_ACCEPTANCE_STORAGE_KEY = 'jgjs-disclaimer-accepted-v1'
const DISCLAIMER_RETURN_URL_QUERY_KEY = 'returnUrl'
const DISCLAIMER_ENTRY_QUERY_KEY = 'from'
const I18N_LOCALE_KEY = 'jgjs-locale-v1'
const DEFAULT_LOCALE = 'zh-CN'
const translations = {
'zh-CN': {
documentTitle: '预算编制工具免责声明',
eyebrow: 'DISCLAIMER',
pageTitle: '《交通运输工程造价咨询服务预算编制规范》T/GDHS 017-2026预算编制工具免责声明',
dateLabel: '最后更新日期:',
leadText: '感谢您使用本网站提供的《交通运输工程造价咨询服务预算编制规范》T/GDHS 017-2026造价咨询服务预算编制工具以下简称编制工具以下简称本工具。在您使用本工具前请仔细阅读以下免责声明条款。您继续使用本工具即视为您已阅读、理解并同意接受本声明的全部内容。',
section1Title: '1. 标准依据说明',
section1P1: '1.1 本工具依据广东省公路学会发布的团体标准《交通运输工程造价咨询服务预算编制规范》T/GDHS 017-2026以下简称本规范设定的编制方法。使用者应自行判断该标准是否适用于其具体项目及所在地区主管部门的要求。',
section1P2: '1.2 本工具所依据的规范版本已在工具界面中标注T/GDHS 017-2026。如该规范后续发布修订内容、补充规定或被新版本替代本工具可能无法及时同步更新。使用者有责任在使用前确认所依据的规范版本是否为最新有效版本。',
section1P3: '1.3 本工具的计算结果基于本规范中的预算编制方法、费用组成及编制规则,但不同地区、不同项目法人对造价咨询服务预算编制的具体要求和计算方法可能存在差异。本工具不保证其计算结果符合任何特定项目或特定主管部门的审核要求。',
section2Title: '2. 计算结果仅供参考',
section2P1: '本工具所提供的所有计算结果(包括但不限于数值、明细表、汇总报表、编制说明等)均基于您输入的参数(如工程行业、项目规模、咨询类别、工程专业、工作内容、调整系数等)以及本规范中的数学模型与公式自动生成,仅供您参考使用。这些结果不构成任何形式的专业建议,也不代表任何官方或强制性的预算审批依据。',
section3Title: '3. 不保证准确性与完整性',
section3P1: '尽管我们尽力确保工具的可用性,但本工具按现状和现有基础提供,不附带任何明示或暗示的保证。我们无法保证计算结果在任何情况下均准确、无误或完整。由于数据输入错误、公式取舍、四舍五入或系统延迟等原因,结果可能与实际情况存在偏差。',
section4Title: '4. 用户自行承担风险',
section4P1: '您应当独立判断计算结果的可信性,并承担将其用于任何决策所产生的全部风险与责任。您不应依赖本工具的编制结果替代专业人士的具体计算或复核。在作出重大决定前,建议您咨询持有交通运输工程造价工程师注册证书的专业人员,或结合项目具体情况进行人工验证与复核。',
section5Title: '5. 责任限制',
section5P1: '在适用法律允许的最大范围内,本工具的开发方、管理方、发布方及其关联方不对因使用或无法使用本工具而导致的任何直接、间接、偶然、特殊或后果性损失承担法律责任,即使已被告知可能发生此类损失。',
section5P2: '特别声明:任何造价咨询企业或人员依据本工具计算结果出具的造价咨询成果文件,其质量责任由出具方自行承担。本工具不对任何第三方造价咨询成果的准确性、合规性或引发的任何纠纷承担任何责任。',
section6Title: '6. 服务中断与修改',
section6P1: '我们保留随时修改、暂停或终止本工具部分或全部功能的权利,且可能不另行通知。对于因技术维护、网络故障、第三方服务中断、规范版本变更等原因导致的工具不可用、数据丢失或计算结果变化,我们不承担责任。',
section7Title: '7. 外部链接与第三方内容',
section7P1: '如果本工具引用或链接至全国团体标准信息平台、广东省公路学会官网或其他第三方网站,该等链接仅为方便用户查阅规范原文而提供,不代表我们认可其内容的准确性、时效性或完整性。对于任何第三方网站或工具的信息、服务或内容,我们不承担任何责任。',
section8Title: '8. 适用法律',
section8P1: '本声明的解释、效力及争议解决均适用中华人民共和国法律。若本声明任何条款被认定为无效或不可执行,不影响其余条款的效力。',
confirmTitle: '用户确认',
confirmDesc1: '我已阅读、理解并同意本免责声明的全部内容。',
confirmDesc2: '(勾选后方可继续使用本工具)',
checkboxLabel: '我已阅读、理解并同意本免责声明的全部内容。',
continueButton: '同意并继续',
backButton: '返回入口',
confirmHint: '勾选后将记录当前浏览器的同意状态,后续从同一受限入口访问时不再重复提示。'
},
'en-US': {
documentTitle: 'Budget Tool Disclaimer',
eyebrow: 'DISCLAIMER',
pageTitle: 'Disclaimer for the Budget Preparation Tool under T/GDHS 017-2026',
dateLabel: 'Last updated: ',
leadText: 'Thank you for using the cost consulting budget preparation tool for the Specification for Budget Preparation of Cost Consulting Services for Transportation Engineering (T/GDHS 017-2026) provided on this website. Before using this tool, please read the following disclaimer carefully. By continuing to use this tool, you acknowledge that you have read, understood, and agreed to all terms of this disclaimer.',
section1Title: '1. Standard Basis',
section1P1: '1.1 This tool is configured according to the methodology set out in the group standard Specification for Budget Preparation of Cost Consulting Services for Transportation Engineering (T/GDHS 017-2026) issued by the Guangdong Highway Society. Users are responsible for determining whether the standard applies to their specific projects and local regulatory requirements.',
section1P2: '1.2 The standard version used by this tool is identified in the interface as T/GDHS 017-2026. If the standard is later revised, supplemented, or replaced, this tool may not be updated in time. Users are responsible for confirming that the referenced version remains the latest valid version before use.',
section1P3: '1.3 The calculation results generated by this tool are based on the budgeting methods, cost structure, and preparation rules in the standard. Requirements and calculation approaches may still differ across regions and project owners. This tool does not guarantee that its results meet the review requirements of any specific project or authority.',
section2Title: '2. Results Are for Reference Only',
section2P1: 'All results generated by this tool, including but not limited to figures, detail tables, summary reports, and preparation notes, are produced automatically based on the parameters you provide and the mathematical models and formulas in the standard. They are for reference only and do not constitute professional advice or any official or mandatory basis for budget approval.',
section3Title: '3. No Guarantee of Accuracy or Completeness',
section3P1: 'Although we make reasonable efforts to keep the tool available, it is provided on an as-is basis without any express or implied warranty. We do not guarantee that the results will always be accurate, error-free, or complete. Differences may arise due to input errors, formula selection, rounding, or system delays.',
section4Title: '4. Users Bear Their Own Risks',
section4P1: 'You should independently assess the reliability of the calculation results and bear all risks and responsibilities arising from any decisions made based on them. The output of this tool should not replace professional calculation or review. Before making important decisions, you should consult qualified professionals or perform manual verification based on the specific project context.',
section5Title: '5. Limitation of Liability',
section5P1: 'To the fullest extent permitted by applicable law, the developers, operators, publishers, and their affiliates shall not be liable for any direct, indirect, incidental, special, or consequential losses arising from the use of or inability to use this tool, even if advised of the possibility of such losses.',
section5P2: 'In particular, if any cost consulting enterprise or individual issues deliverables based on the results of this tool, the issuing party bears sole responsibility for their quality. This tool assumes no responsibility for the accuracy, compliance, or disputes arising from any third-party deliverables.',
section6Title: '6. Service Interruption and Changes',
section6P1: 'We reserve the right to modify, suspend, or terminate part or all of this tool at any time, with or without notice. We are not responsible for unavailability, data loss, or changes in calculation results caused by maintenance, network failures, third-party service interruptions, or updates to the standard.',
section7Title: '7. External Links and Third-Party Content',
section7P1: 'If this tool references or links to the National Group Standards Information Platform, the Guangdong Highway Society website, or other third-party websites, such links are provided only for convenience and do not imply endorsement of their accuracy, timeliness, or completeness. We assume no responsibility for any information, services, or content provided by third parties.',
section8Title: '8. Governing Law',
section8P1: 'This disclaimer shall be governed by the laws of the People\'s Republic of China. If any provision of this disclaimer is held invalid or unenforceable, the remaining provisions shall remain in effect.',
confirmTitle: 'User Confirmation',
confirmDesc1: 'I have read, understood, and agree to the full contents of this disclaimer.',
confirmDesc2: '(You must check the box before continuing to use this tool.)',
checkboxLabel: 'I have read, understood, and agree to the full contents of this disclaimer.',
continueButton: 'Agree and Continue',
backButton: 'Back to Home',
confirmHint: 'Once checked, your acceptance will be recorded in this browser so the same restricted entry will not prompt you again.'
}
}
const readLocale = () => {
try {
const saved = String(window.localStorage.getItem(I18N_LOCALE_KEY) || '').trim()
if (saved === 'en-US' || saved === 'zh-CN') return saved
} catch {
// ignore local storage errors
}
const language = String(navigator.language || '').toLowerCase()
return language.startsWith('en') ? 'en-US' : DEFAULT_LOCALE
}
const locale = readLocale()
const text = translations[locale] || translations[DEFAULT_LOCALE]
document.documentElement.lang = locale
document.title = text.documentTitle
document.getElementById('eyebrow').textContent = text.eyebrow
document.getElementById('page-title').textContent = text.pageTitle
document.getElementById('date-label').textContent = text.dateLabel
document.getElementById('lead-text').textContent = text.leadText
document.getElementById('section1-title').textContent = text.section1Title
document.getElementById('section1-p1').textContent = text.section1P1
document.getElementById('section1-p2').textContent = text.section1P2
document.getElementById('section1-p3').textContent = text.section1P3
document.getElementById('section2-title').textContent = text.section2Title
document.getElementById('section2-p1').textContent = text.section2P1
document.getElementById('section3-title').textContent = text.section3Title
document.getElementById('section3-p1').textContent = text.section3P1
document.getElementById('section4-title').textContent = text.section4Title
document.getElementById('section4-p1').textContent = text.section4P1
document.getElementById('section5-title').textContent = text.section5Title
document.getElementById('section5-p1').textContent = text.section5P1
document.getElementById('section5-p2').textContent = text.section5P2
document.getElementById('section6-title').textContent = text.section6Title
document.getElementById('section6-p1').textContent = text.section6P1
document.getElementById('section7-title').textContent = text.section7Title
document.getElementById('section7-p1').textContent = text.section7P1
document.getElementById('section8-title').textContent = text.section8Title
document.getElementById('section8-p1').textContent = text.section8P1
document.getElementById('confirm-title').textContent = text.confirmTitle
document.getElementById('confirm-desc-1').textContent = text.confirmDesc1
document.getElementById('confirm-desc-2').textContent = text.confirmDesc2
document.getElementById('checkbox-label').textContent = text.checkboxLabel
document.getElementById('continue-button').textContent = text.continueButton
document.getElementById('back-button').textContent = text.backButton
document.getElementById('confirm-hint').textContent = text.confirmHint
document.getElementById('fallback-back-button').textContent = text.backButton
const now = new Date()
document.getElementById('current-date').textContent =
locale === 'en-US'
? new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'long', day: 'numeric' }).format(now)
: `${now.getFullYear()}年${String(now.getMonth() + 1).padStart(2, '0')}月${String(now.getDate()).padStart(2, '0')}日`
const url = new URL(window.location.href)
const returnUrl = String(url.searchParams.get(DISCLAIMER_RETURN_URL_QUERY_KEY) || '').trim()
const backButton = document.getElementById('back-button')
const fallbackBackButton = document.getElementById('fallback-back-button')
const confirmSection = document.getElementById('confirm-section')
const pageActions = document.getElementById('page-actions')
if (returnUrl) {
backButton.setAttribute('href', returnUrl)
fallbackBackButton.setAttribute('href', returnUrl)
}
const checkbox = document.getElementById('accept-checkbox')
const continueButton = document.getElementById('continue-button')
let entry = ''
try {
if (returnUrl) {
const targetUrl = new URL(returnUrl, window.location.href)
entry = String(targetUrl.searchParams.get(DISCLAIMER_ENTRY_QUERY_KEY) || '').trim()
}
} catch {
entry = ''
}
if (!entry) {
confirmSection.style.display = 'none'
pageActions.style.display = 'flex'
}
let acceptedMap = {}
try {
const raw = window.localStorage.getItem(DISCLAIMER_ACCEPTANCE_STORAGE_KEY)
const parsed = raw ? JSON.parse(raw) : {}
acceptedMap = parsed && typeof parsed === 'object' ? parsed : {}
} catch {
acceptedMap = {}
}
const accepted = Boolean(entry && acceptedMap[entry] === true)
checkbox.checked = accepted
continueButton.disabled = !checkbox.checked
checkbox.addEventListener('change', () => {
continueButton.disabled = !checkbox.checked
})
continueButton.addEventListener('click', () => {
if (!checkbox.checked) return
try {
const next = acceptedMap && typeof acceptedMap === 'object' ? acceptedMap : {}
if (entry) {
next[entry] = true
}
window.localStorage.setItem(DISCLAIMER_ACCEPTANCE_STORAGE_KEY, JSON.stringify(next))
} catch {
// ignore local storage errors
}
window.location.href = returnUrl || '/'
})
</script>
</body>
</html>

View File

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

View File

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

View File

@ -0,0 +1,197 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { Languages } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { DEFAULT_LOCALE, setAppLocale, type AppLocale } from '@/i18n'
import {
DISCLAIMER_ENTRY_QUERY_KEY,
DISCLAIMER_RETURN_URL_QUERY_KEY,
hasAcceptedRestrictedDisclaimer,
persistRestrictedDisclaimerAcceptance,
readRestrictedEntryCodeFromUrl
} from '@/lib/workspace'
const { t, locale } = useI18n()
const accepted = ref(false)
const parsedParams = (() => {
try {
const url = new URL(window.location.href)
const returnUrl = String(url.searchParams.get(DISCLAIMER_RETURN_URL_QUERY_KEY) || '').trim()
const parsedReturnUrl = returnUrl ? new URL(returnUrl, window.location.href) : null
const entryFromReturnUrl = String(parsedReturnUrl?.searchParams.get(DISCLAIMER_ENTRY_QUERY_KEY) || '').trim()
return {
returnUrl,
entry: entryFromReturnUrl || readRestrictedEntryCodeFromUrl(parsedReturnUrl)
}
} catch {
return {
returnUrl: '',
entry: readRestrictedEntryCodeFromUrl()
}
}
})()
const hasRestrictedEntry = computed(() => Boolean(parsedParams.entry))
const backHref = computed(() => parsedParams.returnUrl || './?projectId=default')
const sections = computed(() => [
{
title: t('disclaimerPage.sections.standardBasisTitle'),
paragraphs: [
t('disclaimerPage.sections.standardBasisP1'),
t('disclaimerPage.sections.standardBasisP2'),
t('disclaimerPage.sections.standardBasisP3')
]
},
{
title: t('disclaimerPage.sections.referenceOnlyTitle'),
paragraphs: [t('disclaimerPage.sections.referenceOnlyP1')]
},
{
title: t('disclaimerPage.sections.accuracyTitle'),
paragraphs: [t('disclaimerPage.sections.accuracyP1')]
},
{
title: t('disclaimerPage.sections.riskTitle'),
paragraphs: [t('disclaimerPage.sections.riskP1')]
},
{
title: t('disclaimerPage.sections.liabilityTitle'),
paragraphs: [t('disclaimerPage.sections.liabilityP1'), t('disclaimerPage.sections.liabilityP2')]
},
{
title: t('disclaimerPage.sections.interruptionTitle'),
paragraphs: [t('disclaimerPage.sections.interruptionP1')]
},
{
title: t('disclaimerPage.sections.externalTitle'),
paragraphs: [t('disclaimerPage.sections.externalP1')]
},
{
title: t('disclaimerPage.sections.lawTitle'),
paragraphs: [t('disclaimerPage.sections.lawP1')]
}
])
const toggleLocale = () => {
const next = locale.value === 'en-US' ? 'zh-CN' : 'en-US'
setAppLocale(next as AppLocale)
}
const handleContinue = () => {
if (!accepted.value) return
if (parsedParams.entry) {
persistRestrictedDisclaimerAcceptance(parsedParams.entry)
}
window.location.href = backHref.value
}
watchEffect(() => {
const lang = locale.value || DEFAULT_LOCALE
document.documentElement.lang = lang
document.title = t('disclaimerPage.documentTitle')
})
accepted.value = parsedParams.entry ? hasAcceptedRestrictedDisclaimer(parsedParams.entry) : false
</script>
<template>
<main class="min-h-screen bg-[linear-gradient(180deg,#f7fbff_0%,#eef4f8_44%,#e4edf3_100%)] text-slate-900">
<div class="mx-auto w-full max-w-5xl px-4 py-6 sm:px-6 lg:px-8 lg:py-10">
<div class="mb-4 flex justify-end">
<Button
variant="outline"
size="sm"
class="h-9 cursor-pointer gap-2 rounded-full border-slate-300/80 bg-white/85 px-4 text-xs text-slate-700 shadow-sm backdrop-blur"
@click="toggleLocale"
>
<Languages class="h-3.5 w-3.5" />
<span>{{ locale === 'en-US' ? 'EN' : '中' }}</span>
<span class="hidden sm:inline">{{ t('disclaimerPage.actions.switchLocale') }}</span>
</Button>
</div>
<section class="overflow-hidden rounded-[28px] border border-slate-200/70 bg-white/85 shadow-[0_24px_80px_rgba(15,23,42,0.10)] backdrop-blur">
<div class="relative overflow-hidden px-5 py-6 sm:px-8 sm:py-8 lg:px-10">
<div class="pointer-events-none absolute inset-x-0 top-0 h-40 bg-[radial-gradient(circle_at_top,rgba(14,116,144,0.18),transparent_68%)]" />
<div class="relative">
<p class="text-xs font-bold tracking-[0.24em] text-teal-700">{{ t('disclaimerPage.eyebrow') }}</p>
<h1 class="mt-3 max-w-4xl text-2xl font-semibold leading-tight tracking-tight sm:text-3xl">
{{ t('disclaimerPage.pageTitle') }}
</h1>
<p class="mt-4 text-sm text-slate-500">
<span>{{ t('disclaimerPage.lastUpdatedLabel') }}</span>
<span>{{ t('disclaimerPage.lastUpdatedValue') }}</span>
</p>
<p class="mt-5 max-w-4xl text-sm leading-7 text-slate-600 sm:text-[15px]">
{{ t('disclaimerPage.leadText') }}
</p>
</div>
</div>
<div class="h-px bg-[linear-gradient(90deg,rgba(148,163,184,0),rgba(148,163,184,0.7),rgba(148,163,184,0))]" />
<div class="px-5 py-6 sm:px-8 lg:px-10 lg:py-8">
<section
v-for="section in sections"
:key="section.title"
class="mb-6 border-b border-slate-100 pb-6 last:mb-0 last:border-b-0 last:pb-0"
>
<h2 class="text-lg font-semibold leading-7 text-slate-900 sm:text-[20px]">{{ section.title }}</h2>
<p
v-for="paragraph in section.paragraphs"
:key="paragraph"
class="mt-3 text-sm leading-7 text-slate-600 sm:text-[15px]"
>
{{ paragraph }}
</p>
</section>
<section
v-if="hasRestrictedEntry"
class="mt-6 rounded-[24px] border border-teal-200/70 bg-[linear-gradient(135deg,rgba(15,118,110,0.08),rgba(14,165,233,0.08))] p-5 sm:p-6"
>
<h2 class="text-base font-semibold text-slate-900">{{ t('disclaimerPage.confirm.title') }}</h2>
<p class="mt-3 text-sm leading-7 text-slate-600">{{ t('disclaimerPage.confirm.desc1') }}</p>
<p class="mt-1 text-sm leading-7 text-slate-600">{{ t('disclaimerPage.confirm.desc2') }}</p>
<label class="mt-4 flex cursor-pointer items-start gap-3 rounded-2xl border border-slate-200/80 bg-white/80 px-4 py-3 text-sm leading-6 text-slate-700">
<input v-model="accepted" type="checkbox" class="mt-1 h-4 w-4 accent-teal-700" />
<span>{{ t('disclaimerPage.confirm.checkbox') }}</span>
</label>
<div class="mt-5 flex flex-col gap-3 sm:flex-row">
<button
type="button"
class="inline-flex min-h-11 items-center justify-center rounded-full bg-teal-700 px-5 text-sm font-semibold text-white shadow-[0_12px_24px_rgba(15,118,110,0.18)] transition hover:bg-teal-800 disabled:cursor-not-allowed disabled:opacity-55 disabled:shadow-none"
:disabled="!accepted"
@click="handleContinue"
>
{{ t('disclaimerPage.confirm.continue') }}
</button>
<a
:href="backHref"
class="inline-flex min-h-11 items-center justify-center rounded-full border border-slate-300 bg-white px-5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50"
>
{{ t('disclaimerPage.actions.back') }}
</a>
</div>
<p class="mt-4 text-xs leading-5 text-slate-500">{{ t('disclaimerPage.confirm.hint') }}</p>
</section>
<div v-else class="mt-6 flex">
<a
:href="backHref"
class="inline-flex min-h-11 items-center justify-center rounded-full border border-slate-300 bg-white px-5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50"
>
{{ t('disclaimerPage.actions.back') }}
</a>
</div>
</div>
</section>
</div>
</main>
</template>

View File

@ -38,7 +38,7 @@ export const enUS = {
pickExisting: 'Choose Existing'
},
disclaimer: {
link: 'Disclaimer: Go to disclaimer page',
link: 'View Disclaimer',
supportText: 'This calculator is provided with free technical support by Zhongwei Engineering Consulting Co., Ltd.'
},
dialog: {
@ -56,6 +56,47 @@ export const enUS = {
noProjectYet: 'No project available. Create a new project first.'
}
},
disclaimerPage: {
documentTitle: 'Budget Tool Disclaimer',
eyebrow: 'DISCLAIMER',
pageTitle: 'Disclaimer for Budget Preparation Tool under (T/GDHS017-2026) Cost Consulting Services for Transportation Engineering as Specifications for Budget Compilation',
lastUpdatedLabel: 'Last updated: ',
lastUpdatedValue: 'April 16, 2026',
leadText: 'Thank you for using the cost consulting budget preparation tool for the Specification for Budget Preparation of Cost Consulting Services for Transportation Engineering (T/GDHS 017-2026) provided on this website. Before using this tool, please read the following disclaimer carefully. By continuing to use this tool, you acknowledge that you have read, understood, and agreed to all terms of this disclaimer.',
sections: {
standardBasisTitle: '1. Standard Basis',
standardBasisP1: '1.1 This tool is based on the methodology set out in the group standard Specification for Budget Preparation of Cost Consulting Services for Transportation Engineering (T/GDHS 017-2026) issued by the Guangdong Province Highway Society "GDHS". Users must independently determine whether the standard applies to their specific projects and the regulatory requirements of their local authorities.',
standardBasisP2: '1.2 The standard version used by this tool is indicated on the interface as T/GDHS 017-2026. Should the standard be subsequently revised, supplemented, or replaced, this tool may not be updated in a timely manner. Users must independently confirm that the referenced version remains the latest valid version before use.',
standardBasisP3: '1.3 The calculation results generated by this tool are based on the budgeting methods, cost structure, and preparation rules in specified the standard. Requirements and calculation approaches may still vary across regions and project owners. This tool does not guarantee that its results will meet the review requirements of any specific project or authority.',
referenceOnlyTitle: '2. Results Are for Reference Only',
referenceOnlyP1: 'All results generated by this tool, including but not limited to values, breakdowns, summaries and preparation instructions, are produced automatically based on the parameters you provide (such as engineering industry, project scale, consulting category, engineering discipline, job responsibilities, adjustment factor, etc.) and the mathematical models and formulas in the standard. They are for reference purposes only and do not constitute professional advice or any official or mandatory basis for budget approval.',
accuracyTitle: '3. No Warranties of Accuracy or Completeness',
accuracyP1: 'Although we make reasonable efforts to ensure the tool\'s availability, it is provided on an as-is basis without any express or implied warranty. We do not guarantee that the results will always be accurate, error-free, or complete. Differences may arise due to input errors, formula selection, rounding, or system delays.',
riskTitle: '4. Users Bear Their Own Risks',
riskP1: 'You should independently assess the reliability of the calculation results and bear all risks and responsibilities arising from any decisions made based thereon. The output of this tool should not replace professional calculation or review. Before making important decisions, you should consult professionals holding a registered certificate in transportation engineering cost or perform manual verification based on the specific project context.',
liabilityTitle: '5. Limitation of Liability',
liabilityP1: 'To the fullest extent permitted by applicable law, the developers, administrators, publishers, and their affiliates shall not be liable for any direct, indirect, accidental, special, or consequential losses arising from the use of or inability to use this tool, even if advised of the possibility of such losses.',
liabilityP2: 'In particular, if any cost consulting enterprise or individual issues deliverables from the results generated by this tool, the issuing party bears sole responsibility for their quality. This tool assumes no responsibility for the accuracy, compliance, or disputes arising from any third-party deliverables.',
interruptionTitle: '6. Service Interruption and Changes',
interruptionP1: 'We reserve the right to modify, suspend, or terminate part or all of this tool at any time, with or without notice. We are not responsible for unavailability, data loss, or changes in calculation results caused by maintenance, network failures, third-party service interruptions, or updates to the standard.',
externalTitle: '7. External Links and Third-Party Content',
externalP1: 'If this tool references or links to the National Group Standards Information Platform, the Guangdong Highway Society website, or other third-party websites, such links are provided only for convenience and do not imply endorsement of their accuracy, timeliness, or completeness. We assume no responsibility for any information, services, or content provided by third parties.',
lawTitle: '8. Governing Law',
lawP1: 'This disclaimer shall be governed by the laws of the People\'s Republic of China. If any provision of this disclaimer is held invalid or unenforceable, the remaining provisions shall remain in effect.'
},
confirm: {
title: 'User Confirmation',
desc1: 'I have read, understood, and agree to the full contents of this disclaimer.',
desc2: 'You must check the box before continuing to use this tool.',
checkbox: 'I have read, understood, and agree to the full contents of this disclaimer.',
continue: 'Agree and Continue',
hint: 'Once checked, your acceptance will be recorded in this browser so the same restricted entry will not prompt you again.'
},
actions: {
back: 'Back to Home',
switchLocale: 'Switch Language'
}
},
tab: {
toolbar: {
light: 'Light',

View File

@ -38,7 +38,7 @@ export const zhCN = {
pickExisting: '选择已有项目'
},
disclaimer: {
link: '免责声明:跳转到免责声明页面',
link: '查看免责声明',
supportText: '本计算工具由众为工程咨询有限公司提供免费技术支持'
},
dialog: {
@ -56,6 +56,47 @@ export const zhCN = {
noProjectYet: '当前暂无可进入的项目,请先新建项目。'
}
},
disclaimerPage: {
documentTitle: '预算编制工具免责声明',
eyebrow: 'DISCLAIMER',
pageTitle: '《交通运输工程造价咨询服务预算编制规范》T/GDHS 017-2026预算编制工具免责声明',
lastUpdatedLabel: '最后更新日期:',
lastUpdatedValue: '2026年04月16日',
leadText: '感谢您使用本网站提供的《交通运输工程造价咨询服务预算编制规范》T/GDHS 017-2026造价咨询服务预算编制工具。在您使用本工具前请仔细阅读以下免责声明条款。您继续使用本工具即视为您已阅读、理解并同意接受本声明的全部内容。',
sections: {
standardBasisTitle: '1. 标准依据说明',
standardBasisP1: '1.1 本工具依据广东省公路学会发布的团体标准《交通运输工程造价咨询服务预算编制规范》T/GDHS 017-2026设定编制方法。使用者应自行判断该标准是否适用于其具体项目及所在地主管部门要求。',
standardBasisP2: '1.2 本工具所依据的规范版本已在工具界面标注为 T/GDHS 017-2026。如该规范后续发布修订内容、补充规定或被新版本替代本工具可能无法及时同步更新。使用者有责任在使用前确认所依据规范是否仍为最新有效版本。',
standardBasisP3: '1.3 本工具的计算结果基于本规范中的预算编制方法、费用组成及编制规则,但不同地区、不同项目法人对造价咨询服务预算编制的具体要求和计算方法可能存在差异。本工具不保证其计算结果符合任何特定项目或特定主管部门的审核要求。',
referenceOnlyTitle: '2. 计算结果仅供参考',
referenceOnlyP1: '本工具所提供的所有计算结果,包括但不限于数值、明细表、汇总报表及编制说明,均基于您输入的参数(如工程行业、项目规模、咨询类别、工程专业、工作内容、调整系数等)以及本规范中的数学模型与公式自动生成,仅供参考使用。这些结果不构成任何形式的专业建议,也不代表任何官方或强制性的预算审批依据。',
accuracyTitle: '3. 不保证准确性与完整性',
accuracyP1: '尽管我们尽力确保本工具可用,但本工具按现状提供,不附带任何明示或暗示的保证。我们无法保证计算结果在任何情况下均准确、无误或完整。由于数据输入错误、公式取舍、四舍五入或系统延迟等原因,结果可能与实际情况存在偏差。',
riskTitle: '4. 用户自行承担风险',
riskP1: '您应独立判断计算结果的可信性,并承担将其用于任何决策所产生的全部风险与责任。您不应依赖本工具替代专业人士的具体计算或复核。在作出重大决定前,建议咨询持有交通运输工程造价工程师注册证书的专业人员,或结合项目具体情况进行人工验证与复核。',
liabilityTitle: '5. 责任限制',
liabilityP1: '在适用法律允许的最大范围内,本工具的开发方、管理方、发布方及其关联方,不对因使用或无法使用本工具而导致的任何直接、间接、偶然、特殊或后果性损失承担法律责任,即使已被告知可能发生此类损失。',
liabilityP2: '特别声明:任何造价咨询企业或个人依据本工具计算结果出具的成果文件,其质量责任由出具方自行承担。本工具不对任何第三方成果文件的准确性、合规性或由此引发的争议承担责任。',
interruptionTitle: '6. 服务中断与修改',
interruptionP1: '我们保留随时修改、暂停或终止本工具部分或全部功能的权利,且可能不另行通知。对于因技术维护、网络故障、第三方服务中断、规范版本变更等原因导致的工具不可用、数据丢失或计算结果变化,我们不承担责任。',
externalTitle: '7. 外部链接与第三方内容',
externalP1: '如果本工具引用或链接至全国团体标准信息平台、广东省公路学会官网或其他第三方网站,该等链接仅为方便用户查阅规范原文而提供,不代表我们认可其内容的准确性、时效性或完整性。对于任何第三方网站或工具的信息、服务或内容,我们不承担责任。',
lawTitle: '8. 适用法律',
lawP1: '本声明的解释、效力及争议解决均适用中华人民共和国法律。若本声明任何条款被认定为无效或不可执行,不影响其余条款的效力。'
},
confirm: {
title: '用户确认',
desc1: '我已阅读、理解并同意本免责声明的全部内容。',
desc2: '勾选后方可继续使用本工具。',
checkbox: '我已阅读、理解并同意本免责声明的全部内容。',
continue: '同意并继续',
hint: '勾选后将记录当前浏览器的同意状态,后续从同一受限入口访问时不再重复提示。'
},
actions: {
back: '返回入口',
switchLocale: '切换语言'
}
},
tab: {
toolbar: {
light: '浅色',

View File

@ -282,13 +282,18 @@ export const consumePendingDisclaimerAction = () => {
export const buildDisclaimerUrl = (returnUrl?: string) => {
try {
const url = new URL('disclaimer.html', window.location.href)
const url = new URL(window.location.href)
const target = new URL(`${url.pathname}${url.search}`, url.origin)
if (returnUrl) {
url.searchParams.set(DISCLAIMER_RETURN_URL_QUERY_KEY, returnUrl)
target.hash = `#/disclaimer?${new URLSearchParams({
[DISCLAIMER_RETURN_URL_QUERY_KEY]: returnUrl
}).toString()}`
} else {
target.hash = '#/disclaimer'
}
return url.toString()
return target.toString()
} catch {
return './disclaimer.html'
return './#/disclaimer'
}
}

View File

@ -28,6 +28,7 @@ import { i18n } from '@/i18n'
import { ensureProjectIdInUrl, getProjectDbName } from '@/lib/workspace'
import { listProjects } from '@/lib/projectRegistry'
import { collectActiveProjectSessionLocks } from '@/lib/projectSessionLock'
import { router } from '@/router'
LicenseManager.setLicenseKey(
'[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762fb5d2ff740aed081e2af7b'
@ -52,7 +53,10 @@ const AG_GRID_MODULES = [
LocaleModule,ValidationModule ,CellSpanModule ,RowStyleModule ,RowSelectionModule ,ServerSideRowModelApiModule
]
const isDisclaimerRoute = () => String(window.location.hash || '').startsWith('#/disclaimer')
const pickBootstrapProjectId = () => {
if (isDisclaimerRoute()) return 'default'
try {
const url = new URL(window.location.href)
@ -87,4 +91,4 @@ uiPrefsStore.initFromStorage()
// 在应用启动时一次性注册 AG Grid 运行所需模块。
ModuleRegistry.registerModules(AG_GRID_MODULES)
createApp(App).use(pinia).use(i18n).mount('#app')
createApp(App).use(pinia).use(i18n).use(router).mount('#app')

19
src/router.ts Normal file
View File

@ -0,0 +1,19 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import DisclaimerPage from '@/features/disclaimer/components/DisclaimerPage.vue'
import WorkspaceShell from '@/features/app/components/WorkspaceShell.vue'
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
name: 'workspace',
component: WorkspaceShell
},
{
path: '/disclaimer',
name: 'disclaimer',
component: DisclaimerPage
}
]
})

View File

@ -1 +1 @@
{"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/features/ht/contracts.ts","./src/features/ht/importexport.ts","./src/features/ht/types.ts","./src/features/tab/importexport.ts","./src/features/tab/types.ts","./src/i18n/dictionary-en.ts","./src/i18n/index.ts","./src/i18n/locales/en-us.ts","./src/i18n/locales/zh-cn.ts","./src/lib/aggridreadonlyautoheight.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/projectevents.ts","./src/lib/projectkvstore.ts","./src/lib/projectregistry.ts","./src/lib/projectsessionlock.ts","./src/lib/projectworkspace.ts","./src/lib/reportexportbuilders.ts","./src/lib/servicepricing.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/uiprefs.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/scaleformulareadonlypane.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/router.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/aggridreadonlyautoheight.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/projectevents.ts","./src/lib/projectkvstore.ts","./src/lib/projectregistry.ts","./src/lib/projectsessionlock.ts","./src/lib/projectworkspace.ts","./src/lib/reportexportbuilders.ts","./src/lib/servicepricing.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/uiprefs.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/app/components/workspaceshell.vue","./src/features/disclaimer/components/disclaimerpage.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/scaleformulareadonlypane.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"}