免责确认

This commit is contained in:
wintsa 2026-04-13 14:40:46 +08:00
parent aed6fe2bfa
commit aa67a66047
4 changed files with 500 additions and 61 deletions

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>免责声明</title>
<title>预算编制工具免责声明</title>
<style>
:root {
color-scheme: light;
@ -19,56 +19,189 @@
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans SC", sans-serif;
color: #0f172a;
background:
radial-gradient(circle at top, rgba(59, 130, 246, 0.12), transparent 32%),
linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
radial-gradient(circle at top, rgba(14, 116, 144, 0.14), transparent 30%),
linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%);
}
.page {
width: min(100%, 880px);
width: min(100%, 960px);
margin: 0 auto;
padding: 56px 20px 72px;
padding: 48px 20px 72px;
}
.card {
padding: 32px 28px;
border: 1px solid rgba(148, 163, 184, 0.24);
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 24px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.08);
backdrop-filter: blur(10px);
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 10px;
margin: 0 0 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.18em;
color: #475569;
letter-spacing: 0.16em;
color: #0f766e;
}
h1 {
margin: 0;
font-size: clamp(28px, 5vw, 40px);
line-height: 1.1;
font-size: clamp(28px, 4.6vw, 40px);
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: 20px 0 18px;
background: linear-gradient(90deg, rgba(148, 163, 184, 0), rgba(148, 163, 184, 0.7), rgba(148, 163, 184, 0));
margin: 24px 0 28px;
background: linear-gradient(90deg, rgba(148, 163, 184, 0), rgba(148, 163, 184, 0.8), rgba(148, 163, 184, 0));
}
p {
.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;
}
.signature {
.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;
color: #0f172a;
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: 24px 14px 40px;
}
.card {
padding: 24px 18px;
border-radius: 18px;
}
.section h2 {
font-size: 18px;
}
.actions {
flex-direction: column;
}
.button {
width: 100%;
}
}
</style>
</head>
@ -76,11 +209,147 @@
<main class="page">
<section class="card">
<p class="eyebrow">DISCLAIMER</p>
<h1>免责声明</h1>
<h1>《交通运输工程造价咨询服务预算编制规范》T/GDHS 017-2026预算编制工具免责声明</h1>
<p class="date">最后更新日期:<span id="current-date"></span></p>
<p class="lead">
感谢您使用本网站提供的《交通运输工程造价咨询服务预算编制规范》T/GDHS 017-2026造价咨询服务预算编制工具以下简称编制工具以下简称本工具。在您使用本工具前请仔细阅读以下免责声明条款。您继续使用本工具即视为您已阅读、理解并同意接受本声明的全部内容。
</p>
<div class="divider"></div>
<p>本计算工具由众为工程咨询有限公司提供免费技术支持。</p>
<p class="signature">众为工程咨询有限公司</p>
<section class="section">
<h2>1. 标准依据说明</h2>
<p>1.1 本工具依据广东省公路学会发布的团体标准《交通运输工程造价咨询服务预算编制规范》T/GDHS 017-2026以下简称本规范设定的编制方法。使用者应自行判断该标准是否适用于其具体项目及所在地区主管部门的要求。</p>
<p>1.2 本工具所依据的规范版本已在工具界面中标注T/GDHS 017-2026。如该规范后续发布修订内容、补充规定或被新版本替代本工具可能无法及时同步更新。使用者有责任在使用前确认所依据的规范版本是否为最新有效版本。</p>
<p>1.3 本工具的计算结果基于本规范中的预算编制方法、费用组成及编制规则,但不同地区、不同项目法人对造价咨询服务预算编制的具体要求和计算方法可能存在差异。本工具不保证其计算结果符合任何特定项目或特定主管部门的审核要求。</p>
</section>
<section class="section">
<h2>2. 计算结果仅供参考</h2>
<p>本工具所提供的所有计算结果(包括但不限于数值、明细表、汇总报表、编制说明等)均基于您输入的参数(如工程行业、项目规模、咨询类别、工程专业、工作内容、调整系数等)以及本规范中的数学模型与公式自动生成,仅供您参考使用。这些结果不构成任何形式的专业建议,也不代表任何官方或强制性的预算审批依据。</p>
</section>
<section class="section">
<h2>3. 不保证准确性与完整性</h2>
<p>尽管我们尽力确保工具的可用性,但本工具按现状和现有基础提供,不附带任何明示或暗示的保证。我们无法保证计算结果在任何情况下均准确、无误或完整。由于数据输入错误、公式取舍、四舍五入或系统延迟等原因,结果可能与实际情况存在偏差。</p>
</section>
<section class="section">
<h2>4. 用户自行承担风险</h2>
<p>您应当独立判断计算结果的可信性,并承担将其用于任何决策所产生的全部风险与责任。您不应依赖本工具的编制结果替代专业人士的具体计算或复核。在作出重大决定前,建议您咨询持有交通运输工程造价工程师注册证书的专业人员,或结合项目具体情况进行人工验证与复核。</p>
</section>
<section class="section">
<h2>5. 责任限制</h2>
<p>在适用法律允许的最大范围内,本工具的开发方、管理方、发布方及其关联方不对因使用或无法使用本工具而导致的任何直接、间接、偶然、特殊或后果性损失承担法律责任,即使已被告知可能发生此类损失。</p>
<p>特别声明:任何造价咨询企业或人员依据本工具计算结果出具的造价咨询成果文件,其质量责任由出具方自行承担。本工具不对任何第三方造价咨询成果的准确性、合规性或引发的任何纠纷承担任何责任。</p>
</section>
<section class="section">
<h2>6. 服务中断与修改</h2>
<p>我们保留随时修改、暂停或终止本工具部分或全部功能的权利,且可能不另行通知。对于因技术维护、网络故障、第三方服务中断、规范版本变更等原因导致的工具不可用、数据丢失或计算结果变化,我们不承担责任。</p>
</section>
<section class="section">
<h2>7. 外部链接与第三方内容</h2>
<p>如果本工具引用或链接至全国团体标准信息平台、广东省公路学会官网或其他第三方网站,该等链接仅为方便用户查阅规范原文而提供,不代表我们认可其内容的准确性、时效性或完整性。对于任何第三方网站或工具的信息、服务或内容,我们不承担任何责任。</p>
</section>
<section class="section">
<h2>8. 适用法律</h2>
<p>本声明的解释、效力及争议解决均适用中华人民共和国法律。若本声明任何条款被认定为无效或不可执行,不影响其余条款的效力。</p>
</section>
<section id="confirm-section" class="confirm">
<p class="confirm-title">用户确认</p>
<p>我已阅读、理解并同意本免责声明的全部内容。</p>
<p>(勾选后方可继续使用本工具)</p>
<label class="checkbox-row">
<input id="accept-checkbox" type="checkbox" />
<span>我已阅读、理解并同意本免责声明的全部内容。</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 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 now = new Date()
const yyyy = now.getFullYear()
const mm = String(now.getMonth() + 1).padStart(2, '0')
const dd = String(now.getDate()).padStart(2, '0')
document.getElementById('current-date').textContent = `${yyyy}年${mm}月${dd}日`
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

@ -27,11 +27,16 @@ import {
SelectViewport
} from 'reka-ui'
import {
buildDisclaimerUrl,
buildProjectUrl,
consumePendingDisclaimerAction,
DEFAULT_PROJECT_ID,
hasAcceptedRestrictedDisclaimer,
FORCE_HOME_QUERY_KEY,
isDisclaimerAcceptanceRequired,
NEW_PROJECT_QUERY_KEY,
OPEN_PROJECT_DIALOG_QUERY_KEY,
setPendingDisclaimerAction,
PROJECT_TAB_ID,
QUICK_PROJECT_ID,
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
@ -94,6 +99,7 @@ const homeImportConfirmOpen = ref(false)
const pendingHomeImportFile = ref<File | null>(null)
const pendingHomeImportFileName = ref('')
const existingProjectDialogOpen = ref(false)
const disclaimerRequired = ref(false)
const existingProjects = ref<Array<{ id: string; name: string; updatedAt: string }>>([])
const existingProjectLoading = ref(false)
const hasExistingProjects = ref(false)
@ -174,8 +180,31 @@ const loadProjectDefaults = async () => {
}
const openProjectCalc = async () => {
await runWithDisclaimerGuard({ type: 'project' }, async () => {
await loadProjectDefaults()
projectDialogOpen.value = true
})
}
const redirectToDisclaimerPage = () => {
const returnUrl = window.location.href
window.location.href = buildDisclaimerUrl(returnUrl)
}
const runWithDisclaimerGuard = async (
pendingAction: { type: 'project' | 'quick' | 'import' | 'existing-project'; projectId?: string },
action: () => void | Promise<void>
) => {
if (!disclaimerRequired.value || hasAcceptedRestrictedDisclaimer()) {
await action()
return
}
setPendingDisclaimerAction(pendingAction)
redirectToDisclaimerPage()
}
const syncDisclaimerRequirement = () => {
disclaimerRequired.value = isDisclaimerAcceptanceRequired()
}
const syncExistingProjectOpenedState = (projectIds: string[]) => {
@ -228,9 +257,11 @@ const startExistingProjectPolling = () => {
}
const openExistingProjectDialog = async () => {
await runWithDisclaimerGuard({ type: 'existing-project' }, async () => {
existingProjectDialogOpen.value = true
await refreshExistingProjects()
startExistingProjectPolling()
})
}
const closeExistingProjectDialog = () => {
@ -322,6 +353,7 @@ const enterQuickCalc = (contractName: string) => {
}
const openQuickCalc = async () => {
await runWithDisclaimerGuard({ type: 'quick' }, async () => {
await loadQuickDefaults()
const contractName = quickContractName.value.trim() || QUICK_CONTRACT_FALLBACK_NAME
const industry = quickIndustry.value.trim()
@ -351,6 +383,7 @@ const openQuickCalc = async () => {
} finally {
quickSubmitting.value = false
}
})
}
const handleHomeImportChange = (event: Event) => {
@ -364,7 +397,33 @@ const handleHomeImportChange = (event: Event) => {
}
const openHomeImport = () => {
void runWithDisclaimerGuard({ type: 'import' }, () => {
homeImportInputRef.value?.click()
})
}
const replayPendingDisclaimerAction = async () => {
if (!disclaimerRequired.value || !hasAcceptedRestrictedDisclaimer()) return
const pendingAction = consumePendingDisclaimerAction()
if (!pendingAction) return
if (pendingAction.type === 'project') {
await loadProjectDefaults()
projectDialogOpen.value = true
return
}
if (pendingAction.type === 'quick') {
await openQuickCalc()
return
}
if (pendingAction.type === 'import') {
homeImportInputRef.value?.click()
return
}
if (pendingAction.type === 'existing-project') {
existingProjectDialogOpen.value = true
await refreshExistingProjects()
startExistingProjectPolling()
}
}
const cancelHomeImportConfirm = () => {
@ -394,18 +453,16 @@ const handleHomeVisibilityChange = () => {
}
const openDisclaimerPage = () => {
try {
const href = new URL('disclaimer.html', window.location.href).toString()
const href = buildDisclaimerUrl(window.location.href)
window.open(href, '_blank', 'noopener')
} catch {
window.open('./disclaimer.html', '_blank', 'noopener')
}
}
onMounted(() => {
syncDisclaimerRequirement()
void refreshExistingProjects()
void loadProjectDefaults()
void loadQuickDefaults()
void replayPendingDisclaimerAction()
window.addEventListener('focus', handleHomeWindowFocus)
document.addEventListener('visibilitychange', handleHomeVisibilityChange)
try {

View File

@ -96,6 +96,7 @@ import type {
} from '@/features/tab/types'
import {
buildProjectUrl,
DISCLAIMER_ACCEPTANCE_STORAGE_KEY,
getProjectDbName,
readCurrentProjectId,
PROJECT_TAB_ID,
@ -1706,6 +1707,8 @@ const handleReset = async () => {
typeof localStorage !== 'undefined' ? localStorage.getItem(I18N_LOCALE_KEY) : null
const uiPrefsSnapshot =
typeof localStorage !== 'undefined' ? localStorage.getItem(UI_PREFS_STORAGE_KEY) : null
const disclaimerAcceptanceSnapshot =
typeof localStorage !== 'undefined' ? localStorage.getItem(DISCLAIMER_ACCEPTANCE_STORAGE_KEY) : null
// 1)
localStorage.clear()
@ -1718,6 +1721,9 @@ const handleReset = async () => {
if (uiPrefsSnapshot != null) {
localStorage.setItem(UI_PREFS_STORAGE_KEY, uiPrefsSnapshot)
}
if (disclaimerAcceptanceSnapshot != null) {
localStorage.setItem(DISCLAIMER_ACCEPTANCE_STORAGE_KEY, disclaimerAcceptanceSnapshot)
}
sessionStorage.clear()
await projectDefaultForage.clear()

View File

@ -13,9 +13,15 @@ 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 DISCLAIMER_ENTRY_QUERY_KEY = 'from'
export const DISCLAIMER_ENTRY_QUERY_VALUE = 'gov'
export const DEFAULT_PROJECT_ID = 'default'
export const QUICK_PROJECT_ID = 'quick'
export const PROJECT_DB_NAME_PREFIX = 'DB'
export const DISCLAIMER_ACCEPTANCE_STORAGE_KEY = 'jgjs-disclaimer-accepted-v1'
export const DISCLAIMER_ACCEPTED_EVENT = 'jgjs-disclaimer-accepted'
export const DISCLAIMER_PENDING_ACTION_STORAGE_KEY = 'jgjs-disclaimer-pending-action-v1'
export const DISCLAIMER_RETURN_URL_QUERY_KEY = 'returnUrl'
export const QUICK_CONTRACT_ID = 'quick-contract-default'
export const QUICK_CONTRACT_META_KEY = 'quick-contract-meta-v1'
@ -42,6 +48,11 @@ export interface QuickContractMeta {
updatedAt: string
}
export interface DisclaimerPendingAction {
type: 'project' | 'quick' | 'import' | 'existing-project'
projectId?: string
}
export const readWorkspaceMode = (): WorkspaceMode => {
@ -185,6 +196,102 @@ export const buildProjectUrl = (
}
}
const readDisclaimerAcceptedEntries = () => {
try {
const raw = window.localStorage.getItem(DISCLAIMER_ACCEPTANCE_STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, unknown>
if (!parsed || typeof parsed !== 'object') return {}
return parsed
} catch {
return {}
}
}
export const readRestrictedEntryCodeFromUrl = (href?: string | URL | null) => {
try {
const url = href instanceof URL ? href : new URL(href || window.location.href, window.location.href)
const entry = String(url.searchParams.get(DISCLAIMER_ENTRY_QUERY_KEY) || '').trim()
return entry
} catch {
return ''
}
}
export const isRestrictedDisclaimerEntry = (entryRaw: string) =>
String(entryRaw || '').trim() === DISCLAIMER_ENTRY_QUERY_VALUE
export const isDisclaimerAcceptanceRequired = (href?: string | URL | null) =>
isRestrictedDisclaimerEntry(readRestrictedEntryCodeFromUrl(href))
export const hasAcceptedRestrictedDisclaimer = (entryRaw?: string) => {
const entry = String(entryRaw || readRestrictedEntryCodeFromUrl() || '').trim()
if (!isRestrictedDisclaimerEntry(entry)) return false
const acceptedMap = readDisclaimerAcceptedEntries()
return acceptedMap[entry] === true
}
export const persistRestrictedDisclaimerAcceptance = (entryRaw?: string) => {
const entry = String(entryRaw || readRestrictedEntryCodeFromUrl() || '').trim()
if (!isRestrictedDisclaimerEntry(entry)) return false
try {
const acceptedMap = readDisclaimerAcceptedEntries()
acceptedMap[entry] = true
window.localStorage.setItem(DISCLAIMER_ACCEPTANCE_STORAGE_KEY, JSON.stringify(acceptedMap))
window.dispatchEvent(new CustomEvent(DISCLAIMER_ACCEPTED_EVENT, {
detail: {
entry
}
}))
return true
} catch {
return false
}
}
export const setPendingDisclaimerAction = (action: DisclaimerPendingAction | null) => {
try {
if (!action) {
window.sessionStorage.removeItem(DISCLAIMER_PENDING_ACTION_STORAGE_KEY)
return
}
window.sessionStorage.setItem(DISCLAIMER_PENDING_ACTION_STORAGE_KEY, JSON.stringify(action))
} catch {
// ignore session storage errors
}
}
export const consumePendingDisclaimerAction = () => {
try {
const raw = window.sessionStorage.getItem(DISCLAIMER_PENDING_ACTION_STORAGE_KEY)
window.sessionStorage.removeItem(DISCLAIMER_PENDING_ACTION_STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as DisclaimerPendingAction
if (!parsed || typeof parsed !== 'object') return null
const type = String(parsed.type || '').trim()
if (!type) return null
if (!['project', 'quick', 'import', 'existing-project'].includes(type)) return null
return {
type: type as DisclaimerPendingAction['type'],
projectId: typeof parsed.projectId === 'string' ? parsed.projectId.trim() : undefined
}
} catch {
return null
}
}
export const buildDisclaimerUrl = (returnUrl?: string) => {
try {
const url = new URL('disclaimer.html', window.location.href)
if (returnUrl) {
url.searchParams.set(DISCLAIMER_RETURN_URL_QUERY_KEY, returnUrl)
}
return url.toString()
} catch {
return './disclaimer.html'
}
}
export const getProjectDbName = (projectIdRaw: string) => {
const projectId = normalizeProjectId(projectIdRaw)
return `${PROJECT_DB_NAME_PREFIX}-${projectId}`