/**
- 客户端运行日志:App 端按日期写入系统「下载」目录根下的 .jsonl 文件(便于 U 盘/MTP/文件管理器查看),
- 仅保留最近 LOG_RETAIN_DAYS 个自然日文件并自动删除更早的。
- 可选内网 POST 批量上报(使用内存缓冲,与文件写入并行)。
*/
import {
LOG_CLIENT_RECORD_ENABLED,
LOG_REPORT_URL,
LOG_DEBUG_CONSOLE,
LOG_FILE_NAME_PREFIX,
LOG_RETAIN_DAYS
} from '@/utils/const.js'
const LEGACY_QUEUE_KEY = 'client_run_log_q_v1'
const MAX_TEXT = 6000
/* 单条日志里请求体 / 响应体各自最大字符数(JSON 字符串化后截断) /
const MAX_HTTP_BODY_CHARS = 14000
const UPLOAD_BUFFER_MAX = 120
let uploadBuffer = []
let flushing = false
let writeChain = Promise.resolve()
let pruneTimer = null
function now() {
return Date.now()
}
function rid() {
return ${now()}_${Math.random().toString(36).slice(2, 11)}
}
function safeJson(obj) {
try {
return JSON.stringify(obj)
} catch (e) {
return '"[unserializable]"'
}
}
function truncateText(s) {
if (s == null) return ''
const str = typeof s === 'string' ? s : safeJson(s)
if (str.length <= MAX_TEXT) return str
return str.slice(0, MAX_TEXT) + '…(truncated)'
}
const SENSITIVE_KEYS = new Set(
['password', 'token', 'oldpassword', 'newpassword', 'authorization', 'secret', 'accesstoken', 'refreshtoken']
)
function sanitize(obj, depth) {
if (depth > 6) return '[depth]'
if (obj == null) return obj
const t = typeof obj
if (t !== 'object') return obj
if (Array.isArray(obj)) return obj.map((x) => sanitize(x, depth + 1))
const out = {}
Object.keys(obj).forEach((k) => {
if (SENSITIVE_KEYS.has(String(k).toLowerCase())) {
out[k] = '[redacted]'
} else {
out[k] = sanitize(obj[k], depth + 1)
}
})
return out
}
function systemCtx() {
try {
const s = uni.getSystemInfoSync() || {}
return {
platform: s.platform,
system: s.system,
model: s.model,
uniPlatform: s.uniPlatform,
appName: s.appName,
appVersion: s.appVersion,
appVersionCode: s.appVersionCode
}
} catch (e) {
return {}
}
}
function formatLogDate(ts) {
const d = new Date(ts)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return ${y}-${m}-${day}
}
/* 日志用本地可读时间(非 UTC),格式 2026-03-01 12:59:30 /
export function formatDateTime(ts) {
const d = new Date(ts)
const p = (n) => String(n).padStart(2, '0')
return ${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}
}
/**
- 用于写入日志的请求/响应快照:脱敏 + 控制体积
- @returns {unknown}
*/
function snapshotForLog(value) {
if (value === undefined) {
return undefined
}
if (value === null) {
return null
}
let v = value
if (typeof v === 'object') {
try {
v = sanitize(v)
} catch (e) {
return {
_logError: String(e)
}
}
}
const s = safeJson(v)
if (s.length <= MAX_HTTP_BODY_CHARS) {
return v
}
return {
_logTruncated: true,
approxChars: s.length,
preview: s.slice(0, MAX_HTTP_BODY_CHARS)
}
}
function logFilePattern() {
return new RegExp(
^${LOG_FILE_NAME_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}_(\\d{4}-\\d{2}-\\d{2})\\.jsonl$
)
}
function startOfDayFromYmd(y, m, d) {
const x = new Date(y, m - 1, d)
x.setHours(0, 0, 0, 0)
return x.getTime()
}
function oldestKeptFileMidnight() {
const t = new Date()
t.setHours(0, 0, 0, 0)
t.setDate(t.getDate() - (LOG_RETAIN_DAYS - 1))
return t.getTime()
}
function isPlusFile() {
return typeof plus !== 'undefined' && plus.io
}
function requestAndroidWriteOnce() {
return new Promise((resolve) => {
if (!isPlusFile() || plus.os.name !== 'Android') {
resolve(true)
return
}
try {
const Build = plus.android.importClass('android.os.Build')
if (Build.VERSION.SDK_INT < 23) {
resolve(true)
return
}
const main = plus.android.runtimeMainActivity()
const PackageManager = plus.android.importClass('android.content.pm.PackageManager')
const perm = 'android.permission.WRITE_EXTERNAL_STORAGE'
if (main.checkSelfPermission(perm) === PackageManager.PERMISSION_GRANTED) {
resolve(true)
return
}
plus.android.requestPermissions(
[perm],
(e) => {
const ok = e && e.granted && e.granted.indexOf(perm) >= 0
resolve(!!ok)
},
() => resolve(false)
)
} catch (e) {
resolve(true)
}
})
}
let androidPermChain = null
function ensureAndroidWritePerm() {
if (!isPlusFile() || plus.os.name !== 'Android') {
return Promise.resolve(true)
}
if (!androidPermChain) {
androidPermChain = requestAndroidWriteOnce()
}
return androidPermChain
}
function resolveDownloadsDirEntry() {
return new Promise((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
'_downloads/',
(entry) => resolve(entry),
() => {
plus.io.requestFileSystem(
plus.io.PUBLIC_DOWNLOADS,
(fs) => {
resolve(fs.root)
},
(e) => reject(e || new Error('PUBLIC_DOWNLOADS fail'))
)
}
)
})
}
function readDirAllEntries(dirEntry) {
return new Promise((resolve, reject) => {
const reader = dirEntry.createReader()
const all = []
function readBatch() {
reader.readEntries(
(entries) => {
if (!entries || !entries.length) {
resolve(all)
return
}
all.push(...entries)
readBatch()
},
(e) => reject(e)
)
}
readBatch()
})
}
function readFileEntryAsText(fileEntry) {
return new Promise((resolve, reject) => {
fileEntry.file(
(file) => {
try {
const fr = new plus.io.FileReader()
fr.onloadend = (evt) => {
const r = evt.target.result
resolve(typeof r === 'string' ? r : '')
}
fr.onerror = () => resolve('')
fr.readAsText(file)
} catch (e) {
resolve('')
}
},
() => resolve('')
)
})
}
/* 序列化时把 timeText 放最前,便于直接打开文件看到访问时间 /
function rowToOrderedJson(row) {
if (row && row._compact === true) {
return safeJson({
timeText: row.timeText,
status: row.status,
requestType: row.requestType,
requestData: row.requestData,
responseData: row.responseData
})
}
const {
timeText,
ts,
id,
ctx,
...rest
} = row
return safeJson(
Object.assign({
timeText,
ts,
id
},
rest, {
ctx
}
)
)
}
function emitRow(row) {
addToUploadBuffer(row)
if (LOG_DEBUG_CONSOLE && typeof console !== 'undefined' && console.log) {
console.log('[client-log]', row.level || row.status || '', row.type || row.requestType || '', row.message || '')
}
if (isPlusFile()) {
queueFileAppend(row)
}
tryFlushSoon()
}
function appendLineToDateFile(row, dateStr) {
return new Promise((resolve, reject) => {
const name = ${LOG_FILE_NAME_PREFIX}_${dateStr}.jsonl
const line = rowToOrderedJson(row) + '\n'
resolveDownloadsDirEntry()
.then((dirEntry) => {
dirEntry.getFile(
name, {
create: true
},
(fe) => {
fe.file(
(file) => {
const size = file.size || 0
fe.createWriter(
(writer) => {
writer.onerror = (e) => reject(e)
writer.onwrite = () => {
schedulePruneOldFiles(dirEntry)
resolve()
}
writer.seek(size)
writer.write(line)
},
(e) => reject(e)
)
},
(e) => reject(e)
)
},
(e) => reject(e)
)
})
.catch(reject)
})
}
function schedulePruneOldFiles(dirEntry) {
if (pruneTimer) clearTimeout(pruneTimer)
pruneTimer = setTimeout(() => {
pruneTimer = null
pruneOldLogFiles(dirEntry).catch(() => {})
}, 1500)
}
function pruneOldLogFiles(dirEntry) {
const pat = logFilePattern()
const cutoff = oldestKeptFileMidnight()
return readDirAllEntries(dirEntry).then((entries) => {
const tasks = []
entries.forEach((entry) => {
if (!entry.isFile) return
const m = entry.name.match(pat)
if (!m) return
const [_, ymd] = m
const p = ymd.split('-').map(Number)
const fileMid = startOfDayFromYmd(p[0], p[1], p[2])
if (fileMid < cutoff) {
tasks.push(
new Promise((res) => {
entry.remove(res, res)
})
)
}
})
return Promise.all(tasks)
})
}
function queueFileAppend(row) {
const safeTs = Number.isFinite(row && row.ts) ? row.ts : now()
const dateStr = formatLogDate(safeTs)
writeChain = writeChain
.then(() => ensureAndroidWritePerm())
.then(() => appendLineToDateFile(row, dateStr))
.catch((e) => {
if (LOG_DEBUG_CONSOLE && typeof console !== 'undefined' && console.error) {
console.error('[logger] file append failed', e)
}
})
return writeChain
}
function addToUploadBuffer(row) {
if (!LOG_REPORT_URL || !LOG_CLIENT_RECORD_ENABLED) return
uploadBuffer.push(row)
if (uploadBuffer.length > UPLOAD_BUFFER_MAX) {
uploadBuffer = uploadBuffer.slice(-UPLOAD_BUFFER_MAX)
}
}
function enqueue(entry) {
if (!LOG_CLIENT_RECORD_ENABLED) return
const ts = now()
const timeText = formatDateTime(ts)
const withTimePrefix = (text) => {
if (text == null || text === '') {
return timeText
}
const s = String(text)
if (s.startsWith(timeText)) {
return s
}
return ${timeText} ${s}
}
const row = {
...entry,
timeText,
ts,
id: rid(),
message: withTimePrefix(entry.message),
ctx: systemCtx()
}
emitRow(row)
}
function tryFlushSoon() {
if (!LOG_CLIENT_RECORD_ENABLED || !LOG_REPORT_URL || flushing) return
setTimeout(() => {
flushLogs()
}, 50)
}
function migrateRemoveLegacyStorage() {
try {
uni.removeStorageSync(LEGACY_QUEUE_KEY)
} catch (e) {}
}
export function initLogger() {
migrateRemoveLegacyStorage()
setInterval(() => {
flushLogs()
}, 20000)
setInterval(() => {
if (!isPlusFile()) return
resolveDownloadsDirEntry()
.then((dir) => pruneOldLogFiles(dir))
.catch(() => {})
}, 6 60 60 * 1000)
}
export function flushLogs() {
if (!LOG_CLIENT_RECORD_ENABLED || !LOG_REPORT_URL || flushing) return
if (!uploadBuffer.length) return
flushing = true
const batch = uploadBuffer.slice(0, 25)
uni.request({
url: LOG_REPORT_URL,
method: 'POST',
header: {
'content-type': 'application/json;charset=utf-8'
},
data: {
logs: batch
},
success: (res) => {
const ok = res.statusCode >= 200 && res.statusCode < 300
if (!ok) return
uploadBuffer = uploadBuffer.slice(batch.length)
},
complete: () => {
flushing = false
}
})
}
export function logHttp(payload) {
const {
kind,
method,
path,
url,
durationMs,
requestData,
requestHeaders,
responseData,
statusCode,
bizCode,
error
} = payload
const errBrief =
error && (error.errMsg || error.message) ?
error.errMsg || error.message :
error ?
String(error) :
undefined
const api = ${method} ${path}
const timeText = formatDateTime(now())
let responseSnap
if (kind === 'network_error') {
responseSnap = snapshotForLog(
sanitize({
httpStatusCode: statusCode,
errMsg: errBrief,
err: error && typeof error === 'object' ? sanitize(error) : error
})
)
} else {
responseSnap = snapshotForLog(responseData)
}
const requestSnap = snapshotForLog(requestData)
const statusText = ${kind}(http:${statusCode != null ? statusCode : '-'},biz:${bizCode != null ? bizCode : '-'})
const ts = now()
emitRow({
_compact: true,
timeText,
ts,
status: statusText,
requestType: api,
requestData: requestSnap,
responseData: responseSnap
})
}
export function logVueError(err, info) {
const msg = err && err.message ? err.message : String(err)
const stack = err && err.stack ? err.stack : ''
enqueue({
level: 'error',
type: 'vue',
message: msg,
detail: truncateText(
safeJson(
sanitize({
info: info || '',
stack
})
)
)
})
}
export function logAppScriptError(msg) {
enqueue({
level: 'error',
type: 'app_onError',
message: typeof msg === 'string' ? msg : safeJson(msg),
detail: ''
})
}
export function logUnhandled(source, reason) {
const text =
reason && typeof reason === 'object' && reason.message ?
reason.message :
String(reason)
const stack = reason && reason.stack ? reason.stack : ''
enqueue({
level: 'error',
type: 'unhandled',
message: ${source}: ${text},
detail: truncateText(stack || safeJson(reason))
})
}
export function installUnhandledRejection() {
// #ifdef H5
if (typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('unhandledrejection', (e) => {
logUnhandled('unhandledrejection', e.reason)
})
}
// #endif
// #ifdef APP-PLUS
if (typeof globalThis !== 'undefined' && typeof globalThis.addEventListener === 'function') {
globalThis.addEventListener('unhandledrejection', (e) => {
logUnhandled('unhandledrejection', e.reason)
})
}
// #endif
if (typeof uni !== 'undefined' && typeof uni.onUnhandledRejection === 'function') {
uni.onUnhandledRejection((res) => {
logUnhandled('uni.onUnhandledRejection', res.reason)
})
}
}
/* 等待当前排队中的文件写入完成(再执行复制等) /
export function waitLogWritesDone() {
return writeChain.then(() => undefined)
}
/* 兼容旧名:与 waitLogWritesDone 相同 /
export function persistClientLogsNow() {
return waitLogWritesDone()
}
/**
- 从下载目录读取所有匹配前缀的日志文件,合并为行对象(顺序不保证严格按时间)。
- 非 App 端无文件时返回内存中待上报缓冲。
*/
export function getClientLogQueue() {
if (!isPlusFile()) {
return Promise.resolve([...uploadBuffer])
}
return waitLogWritesDone().then(() =>
resolveDownloadsDirEntry()
.then((dirEntry) => readDirAllEntries(dirEntry))
.then((entries) => {
const pat = logFilePattern()
const files = entries.filter((e) => e.isFile && pat.test(e.name))
files.sort((a, b) => a.name.localeCompare(b.name))
return Promise.all(files.map((fe) => readFileEntryAsText(fe))).then((texts) => {
const rows = []
texts.forEach((t) => {
const lines = (t || '').split('\n')
lines.forEach((line) => {
const s = line.trim()
if (!s) return
try {
rows.push(JSON.parse(s))
} catch (e) {}
})
})
return rows
})
})
.catch(() => [...uploadBuffer])
)
}
export function copyClientLogsToClipboard() {
return getClientLogQueue().then((arr) => {
let text = arr.map((x) => safeJson(x)).join('\n') || '[]'
const max = 180000
let truncated = false
if (text.length > max) {
text = text.slice(0, max) + '\n...(truncated for clipboard)'
truncated = true
}
return new Promise((resolve, reject) => {
uni.setClipboardData({
data: text,
success() {
resolve({
count: arr.length,
truncated
})
},
fail: reject
})
})
})
}
/**
- 日志已按日落盘在「下载」根目录,此函数仅返回说明文案(便于弹窗提示运维)。
*/
export function exportClientLogsToAppDocJsonl() {
return waitLogWritesDone().then(() =>
getClientLogQueue().then((arr) =>
Promise.resolve({
name:${LOG_FILE_NAME_PREFIX}_${formatLogDate(now())}.jsonl,
localUrl: '系统「下载」目录根下(_downloads/)',
count: arr.length
})
)
)
}