import { i18n } from '@/i18n' import localforage from 'localforage' export type WorkspaceMode = 'project' | 'quick' export const PROJECT_TAB_ID = 'ProjectCalcView' export const QUICK_TAB_ID = 'QuickCalcView' export const LEGACY_PROJECT_TAB_ID = 'ProjectCalcView' 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 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' export const QUICK_CONTRACT_FALLBACK_NAME = i18n.global.t('quickCalc.projectName') export const QUICK_CONTRACT_TAB_ID = `contract-${QUICK_CONTRACT_ID}` export const QUICK_PROJECT_INFO_KEY = 'quick-xm-base-info-v1' export const QUICK_PROJECT_SCALE_KEY = 'quick-xm-info-v3' export const QUICK_CONSULT_CATEGORY_FACTOR_KEY = 'quick-xm-consult-category-factor-v1' export const QUICK_MAJOR_FACTOR_KEY = 'quick-xm-major-factor-v1' const HOME_IMPORT_TEMP_DB_NAME = 'JGJS-HOME-IMPORT-TEMP' const HOME_IMPORT_TEMP_STORE_NAME = 'home-import' const HOME_IMPORT_TEMP_FILE_KEY = 'pending-file' const HOME_IMPORT_SKIP_CONFIRM_KEY = 'jgjs-home-import-skip-confirm' const homeImportTempForage = localforage.createInstance({ name: HOME_IMPORT_TEMP_DB_NAME, storeName: HOME_IMPORT_TEMP_STORE_NAME }) export interface QuickContractMeta { id: string name: string updatedAt: string } export interface DisclaimerPendingAction { type: 'project' | 'quick' | 'import' | 'existing-project' projectId?: string } export const readWorkspaceMode = (): WorkspaceMode => { try { return window.localStorage.getItem(WORKSPACE_MODE_STORAGE_KEY) as WorkspaceMode } catch { return 'project' } } export const createDefaultQuickContractMeta = (): QuickContractMeta => ({ id: QUICK_CONTRACT_ID, name: QUICK_CONTRACT_FALLBACK_NAME, updatedAt: new Date().toISOString() }) export const writeWorkspaceMode = (mode: WorkspaceMode) => { try { window.localStorage.setItem(WORKSPACE_MODE_STORAGE_KEY, mode) } catch { // 忽略只读或隐私模式下的写入失败。 } } export const setPendingHomeImportFile = ( file: File | null, options?: { skipWorkspaceConfirm?: boolean } ) => { return (async () => { try { if (file) { await homeImportTempForage.setItem(HOME_IMPORT_TEMP_FILE_KEY, { name: file.name, type: file.type, lastModified: file.lastModified, blob: file }) } else { await homeImportTempForage.removeItem(HOME_IMPORT_TEMP_FILE_KEY) } } catch { // ignore temp file persistence errors } try { window.sessionStorage.setItem(HOME_IMPORT_SKIP_CONFIRM_KEY, options?.skipWorkspaceConfirm ? '1' : '0') } catch { // ignore session storage errors } })() } export const consumePendingHomeImportFile = async () => { try { const payload = await homeImportTempForage.getItem<{ name?: string type?: string lastModified?: number blob?: Blob }>(HOME_IMPORT_TEMP_FILE_KEY) await homeImportTempForage.removeItem(HOME_IMPORT_TEMP_FILE_KEY) if (!payload?.blob) return null const name = String(payload.name || 'import.zw').trim() || 'import.zw' return new File([payload.blob], name, { type: typeof payload.type === 'string' ? payload.type : '', lastModified: typeof payload.lastModified === 'number' ? payload.lastModified : Date.now() }) } catch { return null } } export const consumePendingHomeImportSkipConfirm = () => { try { const skip = window.sessionStorage.getItem(HOME_IMPORT_SKIP_CONFIRM_KEY) === '1' window.sessionStorage.removeItem(HOME_IMPORT_SKIP_CONFIRM_KEY) return skip } catch { return false } } export const normalizeProjectId = (value: unknown) => { const raw = String(value || '').trim() if (!raw) return DEFAULT_PROJECT_ID return raw.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64) || DEFAULT_PROJECT_ID } export const readCurrentProjectId = () => { try { const url = new URL(window.location.href) return normalizeProjectId(url.searchParams.get(PROJECT_ID_QUERY_KEY)) } catch { return DEFAULT_PROJECT_ID } } export const writeProjectIdToUrl = (projectIdRaw: string) => { try { const projectId = normalizeProjectId(projectIdRaw) const url = new URL(window.location.href) url.searchParams.set(PROJECT_ID_QUERY_KEY, projectId) window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`) } catch (error) { console.error('write project id to url failed:', error) } } export const ensureProjectIdInUrl = () => { const id = readCurrentProjectId() writeProjectIdToUrl(id) return id } 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 { return `/?${PROJECT_ID_QUERY_KEY}=${encodeURIComponent(normalizeProjectId(projectIdRaw))}` } } const readDisclaimerAcceptedEntries = () => { try { const raw = window.localStorage.getItem(DISCLAIMER_ACCEPTANCE_STORAGE_KEY) if (!raw) return {} const parsed = JSON.parse(raw) as Record 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}` }