This commit is contained in:
zlwu committed 2026-07-11 17:15:17 +08:00
1 parent 843e13bce3
commit 7b1d4c8729
629 files changed
+74059 -7929

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
import { BASE_URL } from '@/config/api'
import { DEFAULT_PLACEHOLDER_IMAGE_URL } from '@/utils/staticAsset'
import { withOssVersion } from '@/utils/ossCache'
/** 未设置头像时统一使用叁强 logo(远程静态 images/logo.png) */
export const DEFAULT_USER_AVATAR_URL = DEFAULT_PLACEHOLDER_IMAGE_URL
/** 各角色默认已统一为 logo,保留参数仅为兼容旧调用 */
export function defaultAvatarPathByRole(_role) {
return DEFAULT_USER_AVATAR_URL
}
/**
* 将后端返回的头像地址转为小程序 `<image>` 可加载的绝对 URL。
* - 已是 http(s):// 或 // 则原样返回
* - 以 / 开头的站内路径(如 /uploads/...)拼接到 API 域名根(与静态资源同源)
*/
export function normalizeAvatarUrlForMini(avatarUrl) {
const u = String(avatarUrl || '').trim()
if (!u) return ''
if (/^https?:\/\//i.test(u) || u.startsWith('//')) return withOssVersion(u)
const origin = BASE_URL.replace(/\/$/, '')
const path = u.startsWith('/') ? u : `/${u}`
return withOssVersion(`${origin}${path}`)
}
/** 展示用头像:有自定义 URL 则转为可访问地址,否则用叁强 logo(本地路径) */
export function resolveUserAvatarUrl(avatarUrl, role) {
const raw = avatarUrl != null ? String(avatarUrl).trim() : ''
if (raw) return normalizeAvatarUrlForMini(raw)
return defaultAvatarPathByRole(role)
}
+30
View File
@@ -0,0 +1,30 @@
// 极简事件总线(避免引入额外依赖)
const listeners = new Map()
export function on(event, handler) {
if (!event || typeof handler !== 'function') return
const set = listeners.get(event) || new Set()
set.add(handler)
listeners.set(event, set)
}
export function off(event, handler) {
const set = listeners.get(event)
if (!set) return
set.delete(handler)
if (set.size === 0) listeners.delete(event)
}
export function emit(event, payload) {
const set = listeners.get(event)
if (!set || set.size === 0) return
for (const fn of Array.from(set)) {
try {
fn(payload)
} catch (e) {
// ignore
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import { request, parseResponseJson } from '@/utils/request'
/** GET /api/mini/common/china-region-tree,数据来自 bas_china_region */
export async function fetchChinaRegionTree() {
const res = await request({ url: '/mini/common/china-region-tree', method: 'GET' })
const body = parseResponseJson(res)
if (body?.code === 200 && Array.isArray(body.data?.tree)) {
return body.data.tree
}
return []
}
+12
View File
@@ -0,0 +1,12 @@
export function formatDateTime(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
if (Number.isNaN(d.getTime())) return ''
const yyyy = d.getFullYear()
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mi = String(d.getMinutes()).padStart(2, '0')
const ss = String(d.getSeconds()).padStart(2, '0')
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}`
}
+47
View File
@@ -0,0 +1,47 @@
export function formatYuan(v) {
if (v == null || v === '') return '0.00'
const n = Number(v)
if (Number.isNaN(n)) return '0.00'
return n.toFixed(2)
}
export function formatFenAsYuan(fen) {
if (fen == null || Number.isNaN(fen)) return '0.00'
return (fen / 100).toFixed(2)
}
/**
* 计量 + 单位 +(规格),如 500 克(礼盒装);无规格时不显示括号
*/
export function formatMeasureUnitSpec(measureValue, unitName, spec) {
const unitText = (unitName || '').trim()
const specText = spec != null ? String(spec).trim() : ''
let base = ''
if (measureValue != null && measureValue !== '') {
const num = Number(measureValue)
if (!Number.isNaN(num)) {
const measureText = String(num).replace(/\.?0+$/, '')
base = unitText ? `${measureText} ${unitText}` : measureText
} else {
const measureText = String(measureValue).trim()
base = unitText ? `${measureText} ${unitText}` : measureText
}
} else if (unitText) {
base = unitText
} else if (!specText) {
return '-'
}
if (specText) {
return base ? `${base}(${specText})` : `(${specText})`
}
return base || '-'
}
/** 数量 × 计量单位(规格),如 10×500 克(礼盒装) */
export function formatQtyMeasureUnitSpec(qty, measureValue, unitName, spec) {
const inner = formatMeasureUnitSpec(measureValue, unitName, spec)
if (inner === '-') return String(qty)
return `${qty}×${inner}`
}
+35
View File
@@ -0,0 +1,35 @@
import JSEncrypt from 'jsencrypt'
import { API_BASE, AUTH_API } from '@/config/api'
function fetchLoginPublicKey() {
const url = `${API_BASE}${AUTH_API.LOGIN_RSA_PUBLIC_KEY}?_t=${Date.now()}`
return new Promise((resolve, reject) => {
uni.request({
url,
method: 'GET',
success: (res) => resolve(res),
fail: (err) => reject(err)
})
})
}
export async function buildMiniEncryptedLoginBody(username, password) {
const keyRes = await fetchLoginPublicKey()
const body = keyRes.data
if (!body || body.code !== 200 || !body.data || !body.data.publicKeyPem) {
throw new Error((body && body.message) || '获取登录公钥失败')
}
const crypt = new JSEncrypt()
crypt.setPublicKey(body.data.publicKeyPem)
const payload = JSON.stringify({
username,
password,
ts: Date.now()
})
const encryptedLogin = crypt.encrypt(payload)
if (!encryptedLogin) {
throw new Error('登录加密失败,请重试')
}
return { encryptedLogin }
}
+81
View File
@@ -0,0 +1,81 @@
// 统一给 OSS/MinIO/静态域资源追加版本参数(用于绕过长缓存)。
// 约束:不要用 Date.now() 常态化破缓存;默认用“可控版本号”,需要强刷时只改版本号即可。
let _cachedVersion = null
// TODO(config-hardcoded): 资源版本号当前写死,后续改为构建产物自动注入(如 commit hash / CI build number)。
const STATIC_ASSETS_VERSION = '20260423'
// 作为兜底版本号来源(构建期静态注入)
// eslint-disable-next-line import/no-unresolved
import manifest from '@/manifest.json'
function fallbackVersionFromManifest() {
try {
const vn = String(manifest?.versionName || '').trim()
const vc = String(manifest?.versionCode || '').trim()
return [vn, vc].filter(Boolean).join('.')
} catch {
// ignore
}
return ''
}
/**
* 统一版本号:
* - 写死常量:`STATIC_ASSETS_VERSION`
* - 兜底:manifest.versionName/versionCode
* - 最终兜底:固定日期
*/
export function getStaticAssetsVersion() {
if (_cachedVersion != null) return _cachedVersion
const v =
STATIC_ASSETS_VERSION ||
fallbackVersionFromManifest() ||
'20260423'
_cachedVersion = String(v || '').trim()
return _cachedVersion
}
function hasSignedQuery(u) {
// 预签名/鉴权 URL:追加参数可能导致签名失效,必须跳过
return /(?:[?&](?:X-Amz-|x-amz-|OSSAccessKeyId|Signature|x-oss-|Expires)=)/.test(u)
}
function hasVersionParam(u) {
return /(?:[?&]v=)/i.test(u)
}
function looksLikeOurObjectStorage(u) {
// 只对我们自己的 OSS/MinIO/静态域处理,避免误伤第三方资源
return (
u.includes('/sqxp-static/') ||
u.includes('/sanqiang/') ||
u.includes('/minio/') ||
/:\/\/(?:www\.)?chengyantek\.cn\//i.test(u) ||
/:\/\/\d{1,3}(?:\.\d{1,3}){3}:20004\//.test(u)
)
}
function appendQueryParam(url, key, value) {
const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}`
}
/**
* 给 OSS/MinIO/静态域 URL 追加 `v=<version>`,用于“内容更新但 URL 不变”时强制刷新。
*
* - 已带 `v=`:不重复追加
* - 预签名 URL:不追加(避免签名失效)
* - 非 http(s) / // / 非本站存储域:不处理
*/
export function withOssVersion(rawUrl, version = getStaticAssetsVersion()) {
const u = String(rawUrl || '').trim()
if (!u) return ''
if (!(u.startsWith('http://') || u.startsWith('https://') || u.startsWith('//'))) return u
if (!version) return u
if (!looksLikeOurObjectStorage(u)) return u
if (hasVersionParam(u)) return u
if (hasSignedQuery(u)) return u
return appendQueryParam(u, 'v', version)
}
+16
View File
@@ -0,0 +1,16 @@
/** 规范化号码:去首尾空白与常见空格 */
export function normalizeDialNumber(phone) {
return String(phone ?? '')
.trim()
.replace(/\s/g, '')
}
/** 一键拨号;号码为空时提示 */
export function dialPhone(phone) {
const raw = normalizeDialNumber(phone)
if (!raw) {
uni.showToast({ title: '暂无号码', icon: 'none' })
return
}
uni.makePhoneCall({ phoneNumber: raw })
}
+375
View File
@@ -0,0 +1,375 @@
import { API_BASE, TOKEN_KEY, REFRESH_TOKEN_KEY, USER_INFO_KEY } from '@/config/api'
let clearingAuth = false
// TODO(config-hardcoded): 以下加密参数为临时写死,后续应迁移至构建期注入或安全配置下发。
// 写死配置:不从环境变量读取,避免小程序端注入差异导致行为不一致。
let API_ENCRYPT_ENABLED = true
const API_ENCRYPT_FAIL_OPEN = false
const API_ENCRYPT_FIXED_SECRET = 'shanxia-web-api-crypto-fixed-secret'
// TODO(config-hardcoded): 排除路径清单后续改为集中配置,避免多端重复维护。
const API_ENCRYPT_EXCLUDE_PATHS = String(
'/web/auth/login-rsa-public-key,/web/auth/login,/web/auth/refresh-token'
)
.split(',')
.map((item) => item.trim())
.filter(Boolean)
// #ifdef MP-WEIXIN
// 微信小程序运行时不保证 WebCrypto(getRandomValues/subtle),关闭前端加密避免全链路请求失败。
API_ENCRYPT_ENABLED = false
// #endif
function appendTimestampQuery(url) {
const base = String(url || '')
const ts = Date.now()
if (!base) return `${base}?_t=${ts}`
// 已有 _t 时覆盖为最新值,避免 query 反复堆叠
if (/[?&]_t=/.test(base)) {
return base.replace(/([?&]_t=)\d+/, `$1${ts}`)
}
return `${base}${base.includes('?') ? '&' : '?'}_t=${ts}`
}
function createUnauthorizedError(rawUrl = '') {
const loginApiPattern = /\/web\/auth\/login(?:$|\?)/
const message = loginApiPattern.test(String(rawUrl))
? '用户名或密码错误,请重新输入'
: '登录状态已失效,请重新登录'
const error = new Error(message)
error.name = 'UnauthorizedError'
return error
}
async function onHttpUnauthorized() {
if (clearingAuth) return
clearingAuth = true
// 先同步清理本地登录态,避免 store 动态导入失败时仍被识别为已登录
uni.removeStorageSync(TOKEN_KEY)
uni.removeStorageSync(REFRESH_TOKEN_KEY)
uni.removeStorageSync(USER_INFO_KEY)
try {
const { useUserStore } = await import('@/stores/user')
useUserStore().clearLoginInfo()
} catch (error) {
// ignore
}
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
const currentRoute = pages.length ? pages[pages.length - 1].route : ''
if (currentRoute === 'pages/login/login') {
clearingAuth = false
return
}
uni.showToast({ title: '登录已失效,请重新登录', icon: 'none' })
uni.reLaunch({
url: '/pages/login/login',
fail: () => {
// 某些端在请求回调中 reLaunch 可能失败,使用 redirectTo 兜底
uni.redirectTo({ url: '/pages/login/login' })
},
complete: () => {
setTimeout(() => {
clearingAuth = false
}, 800)
}
})
}
function isBizUnauthorized(data) {
if (!data || typeof data !== 'object') return false
return data.code === 401
}
function randomNonce() {
const bytes = new Uint8Array(12)
const c = globalThis.crypto
if (!c || typeof c.getRandomValues !== 'function') {
throw new Error('当前环境不支持随机数生成')
}
c.getRandomValues(bytes)
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
function hexToBytes(hex) {
const out = new Uint8Array(hex.length / 2)
for (let i = 0; i < out.length; i += 1) {
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16)
}
return out
}
function bytesToHex(bytes) {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
function bytesToBase64(bytes) {
let binary = ''
bytes.forEach((b) => {
binary += String.fromCharCode(b)
})
return btoa(binary)
}
function base64ToBytes(base64) {
const binary = atob(base64)
const out = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
out[i] = binary.charCodeAt(i)
}
return out
}
function toBase64Url(bytes) {
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}
function fromBase64Url(base64url) {
const base64 = String(base64url || '').replace(/-/g, '+').replace(/_/g, '/')
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
return base64ToBytes(base64 + padding)
}
async function sha256Hex(text) {
const c = globalThis.crypto
if (!c || !c.subtle || typeof c.subtle.digest !== 'function') {
throw new Error('当前环境不支持摘要算法')
}
const data = new TextEncoder().encode(String(text || ''))
const digest = await c.subtle.digest('SHA-256', data)
return bytesToHex(new Uint8Array(digest))
}
async function deriveKey(fixed, userId, ts, nonce) {
return sha256Hex(`${fixed}:${userId}:${ts}:${nonce}`)
}
async function encryptJson(payload, keyHex) {
const c = globalThis.crypto
if (!c || !c.subtle || typeof c.subtle.importKey !== 'function') {
throw new Error('当前环境不支持加密算法')
}
const iv = c.getRandomValues(new Uint8Array(12))
const key = await c.subtle.importKey('raw', hexToBytes(keyHex), 'AES-GCM', false, ['encrypt'])
const plain = new TextEncoder().encode(JSON.stringify(payload || {}))
const encrypted = await c.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain)
const merged = new Uint8Array(iv.length + encrypted.byteLength)
merged.set(iv, 0)
merged.set(new Uint8Array(encrypted), iv.length)
return toBase64Url(merged)
}
async function decryptJson(edata, keyHex) {
const c = globalThis.crypto
if (!c || !c.subtle || typeof c.subtle.decrypt !== 'function') {
throw new Error('当前环境不支持解密算法')
}
const raw = fromBase64Url(edata)
if (raw.length < 13) {
throw new Error('响应密文非法')
}
const iv = raw.slice(0, 12)
const cipher = raw.slice(12)
const key = await c.subtle.importKey('raw', hexToBytes(keyHex), 'AES-GCM', false, ['decrypt'])
const decrypted = await c.subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher)
const text = new TextDecoder().decode(new Uint8Array(decrypted))
return JSON.parse(text)
}
function parseUserIdFromToken(token) {
const raw = String(token || '')
const parts = raw.split('.')
if (parts.length < 2) return ''
try {
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
const pad = '='.repeat((4 - (b64.length % 4)) % 4)
const json = atob(b64 + pad)
const obj = JSON.parse(json)
return String(obj?.sub || '')
} catch (error) {
return ''
}
}
function normalizeRequestData(data) {
if (!data) return {}
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data)
return parsed && typeof parsed === 'object' ? parsed : {}
} catch (error) {
return {}
}
}
return typeof data === 'object' ? data : {}
}
function isNativeFormData(data) {
// 微信小程序等运行时可能不存在全局 FormData,直接 instanceof 会抛错
return typeof FormData !== 'undefined' && data instanceof FormData
}
function shouldEncryptRequest(rawUrl, userId, data) {
if (!API_ENCRYPT_ENABLED || !API_ENCRYPT_FIXED_SECRET) return false
if (!rawUrl || !(/\/web\//.test(String(rawUrl)) || /\/mini\//.test(String(rawUrl)))) return false
if (!userId) return false
if (isNativeFormData(data)) return false
return !API_ENCRYPT_EXCLUDE_PATHS.some((prefix) => String(rawUrl).startsWith(prefix))
}
function getEncryptSkipReason(rawUrl, userId, data) {
if (!API_ENCRYPT_ENABLED) return 'encrypt-disabled'
if (!API_ENCRYPT_FIXED_SECRET) return 'missing-fixed-secret'
if (!rawUrl || !(/\/web\//.test(String(rawUrl)) || /\/mini\//.test(String(rawUrl)))) return 'non-api-path'
if (!userId) return 'missing-user-id'
if (isNativeFormData(data)) return 'form-data'
if (API_ENCRYPT_EXCLUDE_PATHS.some((prefix) => String(rawUrl).startsWith(prefix))) return 'exclude-path'
return ''
}
export function request(options) {
if (clearingAuth) {
return Promise.reject(createUnauthorizedError(options?.url))
}
const rawToken = uni.getStorageSync(TOKEN_KEY)
const token = typeof rawToken === 'string' ? rawToken.trim() : ''
const rawUrl = options.url || ''
const method = String(options.method || 'GET').toUpperCase()
const userIdFromStorage = (() => {
const rawUserInfo = uni.getStorageSync(USER_INFO_KEY)
if (rawUserInfo && typeof rawUserInfo === 'object') {
return String(rawUserInfo.id || rawUserInfo.userId || '')
}
if (typeof rawUserInfo === 'string') {
try {
const parsed = JSON.parse(rawUserInfo)
return String(parsed?.id || parsed?.userId || '')
} catch (error) {
return ''
}
}
return ''
})()
const userId = userIdFromStorage || parseUserIdFromToken(token)
const normalizedUrl = rawUrl.startsWith('http')
? rawUrl
: `${API_BASE}${rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`}`
const url = appendTimestampQuery(normalizedUrl)
const shouldEncrypt = shouldEncryptRequest(rawUrl, userId, options.data)
const skipReason = shouldEncrypt ? '' : getEncryptSkipReason(rawUrl, userId, options.data)
return new Promise(async (resolve, reject) => {
const originalMethod = method
const originalData = options.data
const originalParams = options.params
let reqMethod = method
let reqData = options.data
const reqHeader = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.header || {})
}
// 便于在 HBuilderX/H5 的 Network 面板直接判断是否走了密文
reqHeader['X-Api-Crypto'] = shouldEncrypt ? 'on' : `off:${skipReason || 'unknown'}`
try {
if (shouldEncrypt) {
if (reqMethod === 'GET') {
reqHeader['X-HTTP-Method-Override'] = 'GET'
reqMethod = 'POST'
reqData = options.data || options.params || {}
}
const ts = Date.now()
const nonce = randomNonce()
const keyHex = await deriveKey(API_ENCRYPT_FIXED_SECRET, userId, ts, nonce)
const plainData = normalizeRequestData(reqData)
const edata = await encryptJson({ data: plainData }, keyHex)
reqData = {
enc: 1,
ts,
nonce,
edata
}
}
} catch (error) {
if (!API_ENCRYPT_FAIL_OPEN) {
reject(error)
return
}
}
uni.request({
...options,
method: reqMethod,
data: reqData,
url,
header: reqHeader,
success: async (res) => {
// 后端未启用密文中间件(或未支持 method override)时,GET->POST 传输会触发 405;
// 这里仅对“原始 GET + override”场景做一次降级回 GET,保证 H5 本地联调可用。
if (
res.statusCode === 405 &&
originalMethod === 'GET' &&
String(reqHeader['X-HTTP-Method-Override'] || '').toUpperCase() === 'GET'
) {
const fallbackHeader = {
...reqHeader
}
delete fallbackHeader['X-HTTP-Method-Override']
fallbackHeader['X-Api-Crypto'] = 'off:405-fallback'
uni.request({
...options,
method: 'GET',
data: originalData || originalParams,
url,
header: fallbackHeader,
success: (res2) => resolve(res2),
fail: (err2) => reject(err2)
})
return
}
let payload = res.data
if (shouldEncrypt && payload && payload.encrypted && payload.edata) {
try {
const respKeyHex = await deriveKey(
API_ENCRYPT_FIXED_SECRET,
userId,
Number(payload.ts),
String(payload.nonce || '')
)
payload = await decryptJson(payload.edata, respKeyHex)
res.data = payload
} catch (error) {
if (!API_ENCRYPT_FAIL_OPEN) {
reject(error)
return
}
}
}
if (res.statusCode === 401 || isBizUnauthorized(payload)) {
onHttpUnauthorized()
reject(createUnauthorizedError(rawUrl))
return
}
resolve(res)
},
fail: (err) => reject(err)
})
})
}
export function parseResponseJson(res) {
const d = res.data
if (d == null) return d
if (typeof d === 'string') {
try {
return JSON.parse(d)
} catch (error) {
return d
}
}
return d
}
+81
View File
@@ -0,0 +1,81 @@
const DEFAULT_NAV_CONTENT_HEIGHT = 44
const DEFAULT_GAP = 8
const DEFAULT_HORIZONTAL_PADDING = 8
function getSystemInfo() {
try {
return uni.getSystemInfoSync() || {}
} catch (error) {
return {}
}
}
function getMenuButtonRect() {
try {
const rect = uni.getMenuButtonBoundingClientRect?.()
return rect && rect.width ? rect : null
} catch (error) {
return null
}
}
export function computeSafeAreaMetrics() {
const systemInfo = getSystemInfo()
const menuRect = getMenuButtonRect()
const statusBarHeight = Number(systemInfo.statusBarHeight || 20)
const windowWidth = Number(systemInfo.windowWidth || 375)
let navContentHeight = DEFAULT_NAV_CONTENT_HEIGHT
let navTopGap = DEFAULT_GAP
let capsuleWidth = 0
let capsuleRightSafeWidth = 0
if (menuRect) {
navTopGap = Math.max(Number(menuRect.top || 0) - statusBarHeight, DEFAULT_GAP)
navContentHeight = Math.max(Number(menuRect.height || 0) + navTopGap * 2, DEFAULT_NAV_CONTENT_HEIGHT)
capsuleWidth = Number(menuRect.width || 0)
const rightInset = Math.max(windowWidth - Number(menuRect.right || windowWidth), 0)
capsuleRightSafeWidth = Math.max(capsuleWidth + rightInset + DEFAULT_HORIZONTAL_PADDING, 96)
} else {
// 无胶囊位(H5、无自定义导航栏等):不预留右侧占位;样式里已有 32rpx 水平内边距
capsuleRightSafeWidth = 0
}
const topSafeHeight = statusBarHeight
const headerHeight = topSafeHeight + navContentHeight
return {
statusBarHeight,
topSafeHeight,
navContentHeight,
headerHeight,
navTopGap,
capsuleWidth,
capsuleRightSafeWidth,
}
}
export function applySafeAreaCssVars(metrics) {
if (typeof document === 'undefined' || !document?.documentElement) return
const root = document.documentElement.style
root.setProperty('--sq-safe-top', `${metrics.topSafeHeight}px`)
root.setProperty('--sq-nav-height', `${metrics.navContentHeight}px`)
root.setProperty('--sq-header-height', `${metrics.headerHeight}px`)
root.setProperty('--sq-capsule-right-safe', `${metrics.capsuleRightSafeWidth}px`)
}
export function initGlobalSafeArea() {
const metrics = computeSafeAreaMetrics()
const app = getApp?.()
if (app) {
app.globalData = app.globalData || {}
app.globalData.safeArea = metrics
}
applySafeAreaCssVars(metrics)
return metrics
}
export function getGlobalSafeArea() {
const app = getApp?.()
return app?.globalData?.safeArea || computeSafeAreaMetrics()
}
+27
View File
@@ -0,0 +1,27 @@
import { request, parseResponseJson } from '@/utils/request'
/** 拉取运营在系统设置中配置的客服电话(需登录)。 */
export async function fetchServicePhone() {
try {
const res = await request({ url: '/mini/common/service-contact', method: 'GET' })
const body = parseResponseJson(res)
if (body.code === 200 && body.data && body.data.servicePhone) {
return String(body.data.servicePhone).trim()
}
} catch {
// ignore
}
return ''
}
/** 一键拨号(号码中的空格在拨号前去掉)。 */
export function dialServicePhone(phone) {
const raw = String(phone || '')
.trim()
.replace(/\s/g, '')
if (!raw) {
uni.showToast({ title: '暂未配置客服电话', icon: 'none' })
return
}
uni.makePhoneCall({ phoneNumber: raw })
}
+61
View File
@@ -0,0 +1,61 @@
import {
STATIC_ASSETS_BASE_URL_DEFAULT,
STATIC_ASSETS_BASE_URL_PRODUCTION,
STATIC_ASSETS_BASE_URL_TEST
} from '@/config/static-assets-defaults'
import { getStaticAssetsVersion, withOssVersion } from '@/utils/ossCache'
function normalizeBase(u) {
const s = (u || '').trim() || STATIC_ASSETS_BASE_URL_DEFAULT
return s.endsWith('/') ? s : `${s}/`
}
function isPlaceholderBaseUrl(value) {
const text = String(value || '').trim().toLowerCase()
if (!text) return true
return text.includes('replace_') || text.includes('replace_prod_static_host')
}
function resolveBase() {
const remote = STATIC_ASSETS_BASE_URL_PRODUCTION || STATIC_ASSETS_BASE_URL_TEST
if (isPlaceholderBaseUrl(remote)) return ''
return normalizeBase(remote)
}
function localStaticUrl(relativePath) {
const p = String(relativePath || '').replace(/^\/+/, '')
return `/static/${p}`
}
export function getStaticAssetsBaseUrl() {
return resolveBase()
}
/**
* 拼接静态资源完整 URL。
* 未配置远程 CDN 时,回退到项目内 `/static/` 目录(本地开发/框架阶段默认行为)。
*/
export function staticAssetUrl(relativePath) {
const p = String(relativePath || '').replace(/^\/+/, '')
const base = getStaticAssetsBaseUrl()
if (!base) {
return localStaticUrl(p)
}
const seg = p.split('/').filter(Boolean).map((s) => encodeURIComponent(s)).join('/')
const url = `${base}${seg}`
return withOssVersion(url, getStaticAssetsVersion())
}
export const DEFAULT_PLACEHOLDER_IMAGE_URL = staticAssetUrl('images/logo.gif')
export const IMG_DEFAULT_PRODUCT = staticAssetUrl('images/default-product.png')
/** 全端统一:空列表 / 暂无 等占位图 */
export const IMG_NOTHING = staticAssetUrl('images/nothing.svg')
export const IMG_EMPTY = IMG_NOTHING
export const IMG_LOGO_WATERMARK = staticAssetUrl('images/logo.gif')
export const IMG_CLIPBOARD = staticAssetUrl('images/logo.gif')
export const IMG_PASSWORD_CARD = staticAssetUrl('images/logo.gif')
export const IMG_LINK = staticAssetUrl('images/logo.gif')
export const IMG_SETTINGS_AVATAR = staticAssetUrl('images/logo.gif')
export const IMG_SETTINGS_PASSWORD = staticAssetUrl('images/logo.gif')
export const IMG_CUSTOMER_SERVICE = staticAssetUrl('images/logo.gif')
export const ICON_ARROW_BOTTOM = staticAssetUrl('images/logo.gif')