提交
This commit is contained in:
1 parent
843e13bce3
commit
7b1d4c8729
629 files changed
+74059
-7929
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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')
|
||||
Reference in new issue
Block a user