mirror of
https://gitee.com/bootx/dax-pay-h5
synced 2026-08-10 14:56:07 +08:00
feat(h5): 接入站点配置展示与本地缓存
启动时缓存优先 apply,远程 contentHash 一致则跳过。 首页展示 Logo/系统名与版权备案联系方式;favicon 受控更新。 lint-staged 限定为源码后缀,避免对图片跑 eslint。
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
<html lang="zh-cmn-Hans" id="htmlRoot">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<!-- id 固定, 由 init-website-config 受控更新; 禁止空 href -->
|
||||
<link id="favicon" rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title><%= title %></title>
|
||||
</head>
|
||||
|
||||
@@ -92,6 +92,6 @@
|
||||
"commit-msg": "npx --no-install commitlint --edit $1"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "eslint --fix"
|
||||
"*.{js,ts,tsx,vue}": "eslint --fix"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ export async function createMobileApp() {
|
||||
setupI18n(app)
|
||||
// 初始化全局主题:跟随系统 prefers-color-scheme(不支持手动修改)
|
||||
useDesignSettingWithOut().initSystemListener()
|
||||
// 站点配置: 缓存先 apply 防闪, 再远程 hash 比对
|
||||
const { initWebsiteConfig } = await import('@/shared/logics/init-website-config')
|
||||
await initWebsiteConfig()
|
||||
// 挂载路由
|
||||
setupRouter(app)
|
||||
await router.isReady()
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import WebsiteFooter from '@/shared/components/WebsiteFooter.vue'
|
||||
import {
|
||||
getLogoUrl,
|
||||
getSystemName,
|
||||
websiteConfig,
|
||||
} from '@/shared/logics/init-website-config'
|
||||
|
||||
defineOptions({ name: 'HomePage' })
|
||||
|
||||
@@ -8,22 +15,36 @@ const { t } = useI18n()
|
||||
// 由 vite define 注入的项目信息
|
||||
const { pkg, lastBuildTime } = __APP_INFO__
|
||||
const version = pkg.version
|
||||
|
||||
// 配置 logo, 空则默认 /logo.svg
|
||||
const logoUrl = computed(() => {
|
||||
void websiteConfig.value
|
||||
return getLogoUrl()
|
||||
})
|
||||
|
||||
// 有 systemName 用配置, 否则 i18n 欢迎语
|
||||
const titleText = computed(() => {
|
||||
void websiteConfig.value
|
||||
const name = getSystemName()
|
||||
return name || t('home.welcome')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home">
|
||||
<div class="home__body">
|
||||
<div class="home__welcome">
|
||||
<!-- DaxPay 文字字标(public/logo.svg,与 PC 首页同源) -->
|
||||
<img class="home__logo" src="/logo.svg" alt="DaxPay">
|
||||
<!-- 欢迎语 -->
|
||||
<!-- 站点 logo(配置优先, 默认 public/logo.svg) -->
|
||||
<img class="home__logo" :src="logoUrl" :alt="titleText">
|
||||
<!-- 欢迎语 / 系统名 -->
|
||||
<div class="home__title">
|
||||
{{ t('home.welcome') }}
|
||||
{{ titleText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 底部项目信息 -->
|
||||
<!-- 底部: 站点页脚 + 工程版本信息 -->
|
||||
<div class="home__footer">
|
||||
<WebsiteFooter />
|
||||
<p>{{ t('home.version') }}: v{{ version }}</p>
|
||||
<p>{{ t('home.buildTime') }}: {{ lastBuildTime }}</p>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,9 @@ export async function createPCApp() {
|
||||
setupPCStore(app)
|
||||
// 挂载国际化(PC 端独立实例,与移动端共用同一 i18n 模块)
|
||||
setupI18n(app)
|
||||
// 站点配置: 缓存先 apply 防闪, 再远程 hash 比对
|
||||
const { initWebsiteConfig } = await import('@/shared/logics/init-website-config')
|
||||
await initWebsiteConfig()
|
||||
setupPCRouter(app)
|
||||
await pcRouter.isReady()
|
||||
return app
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import WebsiteFooter from '@/shared/components/WebsiteFooter.vue'
|
||||
import {
|
||||
getLogoUrl,
|
||||
getSystemName,
|
||||
websiteConfig,
|
||||
} from '@/shared/logics/init-website-config'
|
||||
|
||||
defineOptions({ name: 'PcHome' })
|
||||
|
||||
@@ -8,22 +15,32 @@ const { t } = useI18n()
|
||||
// 由 vite define 注入的项目信息(与移动端首页一致)
|
||||
const { pkg, lastBuildTime } = __APP_INFO__
|
||||
const version = pkg.version
|
||||
|
||||
const logoUrl = computed(() => {
|
||||
void websiteConfig.value
|
||||
return getLogoUrl()
|
||||
})
|
||||
|
||||
const titleText = computed(() => {
|
||||
void websiteConfig.value
|
||||
const name = getSystemName()
|
||||
return name || t('home.welcome')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pc-home">
|
||||
<div class="pc-home__body">
|
||||
<div class="pc-home__welcome">
|
||||
<!-- DaxPay 文字徽标(与移动端首页默认 logo 一致,public/logo.svg) -->
|
||||
<img class="pc-home__logo" src="/logo.svg" alt="DaxPay">
|
||||
<!-- 欢迎语 -->
|
||||
<!-- 站点 logo(配置优先, 默认 public/logo.svg) -->
|
||||
<img class="pc-home__logo" :src="logoUrl" :alt="titleText">
|
||||
<div class="pc-home__title">
|
||||
{{ t('home.welcome') }}
|
||||
{{ titleText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 底部项目信息 -->
|
||||
<div class="pc-home__footer">
|
||||
<WebsiteFooter />
|
||||
<p>{{ t('home.version') }}: v{{ version }}</p>
|
||||
<p>{{ t('home.buildTime') }}: {{ lastBuildTime }}</p>
|
||||
</div>
|
||||
@@ -53,7 +70,7 @@ const version = pkg.version
|
||||
}
|
||||
|
||||
.pc-home__logo {
|
||||
width: 180px;
|
||||
width: 200px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@@ -62,32 +79,33 @@ const version = pkg.version
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
color: #1d2129;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.pc-home__footer {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
bottom: 32px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
opacity: 0.5;
|
||||
line-height: 1.6;
|
||||
color: #4e5969;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.pc-home__footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* PC 页脚组件内尺寸保持 px, 不被 vw 转换(组件在 shared, 但本页 scoped 不穿透;
|
||||
WebsiteFooter 自身用 12px, PC 可接受; 若被 mobile-forever 误转则依赖 postcss exclude) */
|
||||
@media (max-width: 768px) {
|
||||
.pc-home__logo {
|
||||
width: 140px;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.pc-home__title {
|
||||
font-size: 22px;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
39
src/shared/api/website-config.ts
Normal file
39
src/shared/api/website-config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { RequestEnum } from '@/shared/enums/httpEnum'
|
||||
import { http } from '@/shared/utils/http/axios'
|
||||
|
||||
/**
|
||||
* 平台站点显示内容配置
|
||||
*/
|
||||
export interface WebsiteConfig {
|
||||
systemName?: string
|
||||
companyName?: string
|
||||
companyPhone?: string
|
||||
companyEmail?: string
|
||||
companyWechat?: string
|
||||
logo?: string
|
||||
logoDark?: string
|
||||
icpInfo?: string
|
||||
icpLink?: string
|
||||
mpsInfo?: string
|
||||
mpsLink?: string
|
||||
pcacInfo?: string
|
||||
pcacLink?: string
|
||||
icpPlusInfo?: string
|
||||
icpPlusLink?: string
|
||||
copyright?: string
|
||||
/** 配置内容哈希(只读, 供客户端缓存比对) */
|
||||
contentHash?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取站点配置(免登录)
|
||||
*/
|
||||
export function getWebsiteConfig(): Promise<WebsiteConfig> {
|
||||
return http.request<WebsiteConfig>({
|
||||
url: '/platform/config/website/get',
|
||||
method: RequestEnum.GET,
|
||||
}, {
|
||||
// 静默拉取, 不弹全局提示
|
||||
isShowMessage: false,
|
||||
})
|
||||
}
|
||||
BIN
src/shared/assets/system/beian.png
Normal file
BIN
src/shared/assets/system/beian.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/shared/assets/system/icp.png
Normal file
BIN
src/shared/assets/system/icp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
BIN
src/shared/assets/system/zf.png
Normal file
BIN
src/shared/assets/system/zf.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
BIN
src/shared/assets/system/zz.png
Normal file
BIN
src/shared/assets/system/zz.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
160
src/shared/components/WebsiteFooter.vue
Normal file
160
src/shared/components/WebsiteFooter.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import beianIcon from '@/shared/assets/system/beian.png'
|
||||
import icpIcon from '@/shared/assets/system/icp.png'
|
||||
import zfIcon from '@/shared/assets/system/zf.png'
|
||||
import zzIcon from '@/shared/assets/system/zz.png'
|
||||
import {
|
||||
getCompanyEmail,
|
||||
getCompanyPhone,
|
||||
getCompanyWechat,
|
||||
getCopyright,
|
||||
getIcpInfo,
|
||||
getIcpLink,
|
||||
getIcpPlusInfo,
|
||||
getIcpPlusLink,
|
||||
getMpsInfo,
|
||||
getMpsLink,
|
||||
getPcacInfo,
|
||||
getPcacLink,
|
||||
hasWebsiteFooterContent,
|
||||
websiteConfig,
|
||||
} from '@/shared/logics/init-website-config'
|
||||
|
||||
defineOptions({ name: 'WebsiteFooter' })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
const visible = computed(() => {
|
||||
void websiteConfig.value
|
||||
return hasWebsiteFooterContent()
|
||||
})
|
||||
|
||||
const phone = computed(() => {
|
||||
void websiteConfig.value
|
||||
return getCompanyPhone()
|
||||
})
|
||||
const email = computed(() => {
|
||||
void websiteConfig.value
|
||||
return getCompanyEmail()
|
||||
})
|
||||
const wechat = computed(() => {
|
||||
void websiteConfig.value
|
||||
return getCompanyWechat()
|
||||
})
|
||||
const copyright = computed(() => {
|
||||
void websiteConfig.value
|
||||
return getCopyright()
|
||||
})
|
||||
|
||||
const filings = computed(() => {
|
||||
void websiteConfig.value
|
||||
const items: { icon: string, text: string, link: string }[] = []
|
||||
const icp = getIcpInfo()
|
||||
if (icp) {
|
||||
items.push({ icon: icpIcon, text: icp, link: getIcpLink() })
|
||||
}
|
||||
const mps = getMpsInfo()
|
||||
if (mps) {
|
||||
items.push({ icon: beianIcon, text: mps, link: getMpsLink() })
|
||||
}
|
||||
const pcac = getPcacInfo()
|
||||
if (pcac) {
|
||||
items.push({ icon: zfIcon, text: pcac, link: getPcacLink() })
|
||||
}
|
||||
const icpPlus = getIcpPlusInfo()
|
||||
if (icpPlus) {
|
||||
items.push({ icon: zzIcon, text: icpPlus, link: getIcpPlusLink() })
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const hasContact = computed(() => !!(phone.value || email.value || wechat.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="website-footer">
|
||||
<div v-if="hasContact" class="website-footer__contact">
|
||||
<span v-if="phone">{{ t('home.footerPhone') }}{{ phone }}</span>
|
||||
<span v-if="email">
|
||||
{{ t('home.footerEmail') }}
|
||||
<a :href="`mailto:${email}`" class="website-footer__link">{{ email }}</a>
|
||||
</span>
|
||||
<span v-if="wechat">{{ t('home.footerWechat') }}{{ wechat }}</span>
|
||||
</div>
|
||||
<div v-if="copyright" class="website-footer__copyright">
|
||||
Copyright © {{ year }}
|
||||
<span class="website-footer__brand">{{ copyright }}</span>
|
||||
</div>
|
||||
<div v-if="filings.length" class="website-footer__filings">
|
||||
<a
|
||||
v-for="item in filings"
|
||||
:key="item.text"
|
||||
class="website-footer__filing"
|
||||
:href="item.link || 'javascript:void(0)'"
|
||||
:target="item.link ? '_blank' : undefined"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img :src="item.icon" alt="" class="website-footer__filing-icon">
|
||||
<span>{{ item.text }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 尺寸用相对单位, mobile 侧由 less 进 mobile-forever; PC 首页单独覆盖时可包一层 */
|
||||
.website-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.website-footer__contact {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.website-footer__copyright {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.website-footer__brand {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.website-footer__link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.website-footer__filings {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.website-footer__filing {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.website-footer__filing-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"welcome": "Welcome to DaxPay",
|
||||
"version": "Version",
|
||||
"buildTime": "Build time"
|
||||
"buildTime": "Build time",
|
||||
"footerPhone": "Phone: ",
|
||||
"footerEmail": "Email: ",
|
||||
"footerWechat": "WeChat: "
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"welcome": "欢迎使用 DaxPay",
|
||||
"version": "版本号",
|
||||
"buildTime": "构建时间"
|
||||
"buildTime": "构建时间",
|
||||
"footerPhone": "电话:",
|
||||
"footerEmail": "邮箱:",
|
||||
"footerWechat": "微信:"
|
||||
}
|
||||
|
||||
247
src/shared/logics/init-website-config.ts
Normal file
247
src/shared/logics/init-website-config.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import type { WebsiteConfig } from '@/shared/api/website-config'
|
||||
import { ref } from 'vue'
|
||||
import { getWebsiteConfig } from '@/shared/api/website-config'
|
||||
import { useGlobSetting } from '@/shared/hooks/setting'
|
||||
|
||||
/** localStorage 键: 站点配置缓存 envelope */
|
||||
const STORAGE_KEY = 'daxpay-website-config'
|
||||
|
||||
/** H5 静态默认 */
|
||||
const DEFAULT_BRAND = {
|
||||
logo: '/logo.svg',
|
||||
favicon: '/logo.svg',
|
||||
} as const
|
||||
|
||||
interface WebsiteConfigCacheEnvelope {
|
||||
hash: string
|
||||
data: WebsiteConfig
|
||||
}
|
||||
|
||||
/** 全局站点配置(响应式) */
|
||||
export const websiteConfig = ref<WebsiteConfig>({})
|
||||
|
||||
/** 当前已应用到 favicon 的 logo 文件 id */
|
||||
let appliedLogoId: string | undefined
|
||||
|
||||
/** 当前本地缓存 hash */
|
||||
let localHash: string | undefined
|
||||
|
||||
/**
|
||||
* 初始化站点配置
|
||||
*
|
||||
* 同步读缓存 apply → 异步拉远程 → hash 一致 skip
|
||||
*/
|
||||
export async function initWebsiteConfig() {
|
||||
const cached = readCache()
|
||||
if (cached) {
|
||||
websiteConfig.value = cached.data
|
||||
localHash = cached.hash
|
||||
applyWebsiteBranding(cached.data)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getWebsiteConfig()
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
const remoteHash = resolveRemoteHash(data)
|
||||
if (localHash && remoteHash && remoteHash === localHash) {
|
||||
return
|
||||
}
|
||||
persistWebsiteConfig(data, remoteHash)
|
||||
}
|
||||
catch {
|
||||
// 失败保留缓存/默认, 不阻断启动
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制落盘并 apply
|
||||
*/
|
||||
export function persistWebsiteConfig(raw: WebsiteConfig, hash?: string) {
|
||||
const data = stripContentHash(raw)
|
||||
const nextHash = hash || raw.contentHash || clientHash(data)
|
||||
websiteConfig.value = data
|
||||
localHash = nextHash
|
||||
writeCache({ hash: nextHash, data })
|
||||
applyWebsiteBranding(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用品牌: favicon + document.title(有 systemName 时)
|
||||
*/
|
||||
export function applyWebsiteBranding(config: WebsiteConfig) {
|
||||
const apiPrefix = getApiPrefix()
|
||||
const logoId = config.logo?.trim() || ''
|
||||
applyFavicon(logoId, apiPrefix)
|
||||
|
||||
const name = config.systemName?.trim()
|
||||
if (name) {
|
||||
document.title = name
|
||||
}
|
||||
}
|
||||
|
||||
function applyFavicon(logoId: string, apiPrefix: string) {
|
||||
const link = document.getElementById('favicon') as HTMLLinkElement | null
|
||||
if (!link) {
|
||||
return
|
||||
}
|
||||
const nextId = logoId || ''
|
||||
if (nextId === (appliedLogoId ?? '')) {
|
||||
return
|
||||
}
|
||||
appliedLogoId = nextId
|
||||
link.href = nextId
|
||||
? `${apiPrefix}/file/platform/access/${nextId}`
|
||||
: DEFAULT_BRAND.favicon
|
||||
}
|
||||
|
||||
function getApiPrefix() {
|
||||
const { urlPrefix, apiUrl } = useGlobSetting()
|
||||
// 与其它公开文件访问一致: 优先 urlPrefix
|
||||
return urlPrefix || apiUrl || ''
|
||||
}
|
||||
|
||||
// ---------- getters ----------
|
||||
|
||||
export function getSystemName() {
|
||||
return websiteConfig.value.systemName?.trim() || ''
|
||||
}
|
||||
|
||||
export function getLogoUrl() {
|
||||
const id = websiteConfig.value.logo?.trim()
|
||||
if (!id) {
|
||||
return DEFAULT_BRAND.logo
|
||||
}
|
||||
return `${getApiPrefix()}/file/platform/access/${id}`
|
||||
}
|
||||
|
||||
export function getCompanyName() {
|
||||
return websiteConfig.value.companyName?.trim() || ''
|
||||
}
|
||||
|
||||
export function getCompanyPhone() {
|
||||
return websiteConfig.value.companyPhone?.trim() || ''
|
||||
}
|
||||
|
||||
export function getCompanyEmail() {
|
||||
return websiteConfig.value.companyEmail?.trim() || ''
|
||||
}
|
||||
|
||||
export function getCompanyWechat() {
|
||||
return websiteConfig.value.companyWechat?.trim() || ''
|
||||
}
|
||||
|
||||
export function getCopyright() {
|
||||
return websiteConfig.value.copyright?.trim() || getCompanyName()
|
||||
}
|
||||
|
||||
export function getIcpInfo() {
|
||||
return websiteConfig.value.icpInfo?.trim() || ''
|
||||
}
|
||||
|
||||
export function getIcpLink() {
|
||||
return websiteConfig.value.icpLink?.trim() || ''
|
||||
}
|
||||
|
||||
export function getMpsInfo() {
|
||||
return websiteConfig.value.mpsInfo?.trim() || ''
|
||||
}
|
||||
|
||||
export function getMpsLink() {
|
||||
return websiteConfig.value.mpsLink?.trim() || ''
|
||||
}
|
||||
|
||||
export function getPcacInfo() {
|
||||
return websiteConfig.value.pcacInfo?.trim() || ''
|
||||
}
|
||||
|
||||
export function getPcacLink() {
|
||||
return websiteConfig.value.pcacLink?.trim() || ''
|
||||
}
|
||||
|
||||
export function getIcpPlusInfo() {
|
||||
return websiteConfig.value.icpPlusInfo?.trim() || ''
|
||||
}
|
||||
|
||||
export function getIcpPlusLink() {
|
||||
return websiteConfig.value.icpPlusLink?.trim() || ''
|
||||
}
|
||||
|
||||
export function hasWebsiteFooterContent() {
|
||||
return !!(
|
||||
getCopyright()
|
||||
|| getIcpInfo()
|
||||
|| getMpsInfo()
|
||||
|| getPcacInfo()
|
||||
|| getIcpPlusInfo()
|
||||
|| getCompanyPhone()
|
||||
|| getCompanyEmail()
|
||||
|| getCompanyWechat()
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- cache / hash ----------
|
||||
|
||||
function readCache(): WebsiteConfigCacheEnvelope | null {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as WebsiteConfigCacheEnvelope | WebsiteConfig
|
||||
if (
|
||||
parsed
|
||||
&& typeof parsed === 'object'
|
||||
&& 'data' in parsed
|
||||
&& 'hash' in parsed
|
||||
&& (parsed as WebsiteConfigCacheEnvelope).data
|
||||
&& typeof (parsed as WebsiteConfigCacheEnvelope).hash === 'string'
|
||||
) {
|
||||
return parsed as WebsiteConfigCacheEnvelope
|
||||
}
|
||||
return {
|
||||
hash: '',
|
||||
data: stripContentHash(parsed as WebsiteConfig),
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(envelope: WebsiteConfigCacheEnvelope) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(envelope))
|
||||
}
|
||||
|
||||
function stripContentHash(config: WebsiteConfig): WebsiteConfig {
|
||||
const { contentHash: _h, ...rest } = config
|
||||
return rest
|
||||
}
|
||||
|
||||
function resolveRemoteHash(data: WebsiteConfig): string {
|
||||
if (data.contentHash) {
|
||||
return data.contentHash
|
||||
}
|
||||
return clientHash(stripContentHash(data))
|
||||
}
|
||||
|
||||
function clientHash(data: WebsiteConfig): string {
|
||||
const keys = Object.keys(data).sort() as (keyof WebsiteConfig)[]
|
||||
const normalized: Record<string, unknown> = {}
|
||||
for (const key of keys) {
|
||||
if (key === 'contentHash') {
|
||||
continue
|
||||
}
|
||||
const value = data[key]
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
normalized[key] = value
|
||||
}
|
||||
}
|
||||
const str = JSON.stringify(normalized)
|
||||
let hash = 5381
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash * 33) ^ str.charCodeAt(i)
|
||||
}
|
||||
return (hash >>> 0).toString(16)
|
||||
}
|
||||
Reference in New Issue
Block a user