calculator2026/src/lib/workspace.ts
2026-04-13 14:40:46 +08:00

299 lines
9.7 KiB
TypeScript

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<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}`
}