Files
sbnews/sport-era-msg/pages/index/index.vue
T

1202 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="content">
<view class="panel-card">
<view class="panel-grid">
<view class="panel-cell" @click="showApiUrlDialog()">
<text class="cell-icon">短信</text>
<text class="cell-label">上传接口</text>
<text class="cell-desc">{{ apiUrl ? '已配置' : '未配置' }}</text>
</view>
<view class="panel-cell">
<picker :range="intervalLabels" :value="intervalIndex" @change="onIntervalChange" style="width: 100%;">
<view class="cell-inner">
<text class="cell-icon">轮询</text>
<text class="cell-label">轮询间隔</text>
<text class="cell-desc">{{ intervalLabels[intervalIndex] }}</text>
</view>
</picker>
</view>
<view class="panel-cell" @click="manualFetch">
<text class="cell-icon">刷新</text>
<text class="cell-label">手动执行</text>
<text class="cell-desc">{{ loading ? '读取中...' : permissionStatus }}</text>
</view>
</view>
<view class="panel-switches">
<view class="switch-item switch-left">
<text class="switch-label">轮询</text>
<switch :checked="polling" @change="onSwitchChange" color="#2563eb" />
</view>
<view class="switch-item switch-right" v-if="polling">
<text class="countdown-text">{{ countdownText }}</text>
</view>
</view>
</view>
<view class="modal-mask" v-if="showApiModal" @click="showApiModal = false">
<view class="modal-box" @click.stop>
<text class="modal-title">设置上传接口</text>
<textarea
class="modal-input"
v-model="apiUrlTemp"
placeholder="请输入接口地址"
auto-height
:maxlength="-1"
/>
<view class="modal-btns">
<button class="modal-btn" type="default" @click="showApiModal = false">取消</button>
<button class="modal-btn" type="primary" @click="confirmApiUrl">确定</button>
</view>
</view>
</view>
<view class="stats-card">
<view class="stats-row">
<view class="stats-item" @click="goLogPage('all')">
<text class="stats-num">{{ stats.totalUploads }}</text>
<text class="stats-label">上传次数</text>
</view>
<view class="stats-item stats-ok" @click="goLogPage('success')">
<text class="stats-num">{{ stats.successCount }}</text>
<text class="stats-label">成功</text>
</view>
<view class="stats-item stats-fail" @click="goLogPage('fail')">
<text class="stats-num">{{ stats.failCount }}</text>
<text class="stats-label">失败</text>
</view>
<view class="stats-item">
<text class="stats-num">{{ stats.skipCount }}</text>
<text class="stats-label">跳过</text>
</view>
<view class="stats-item">
<text class="stats-num">{{ stats.totalSmsCount }}</text>
<text class="stats-label">短信条数</text>
</view>
<view class="stats-item">
<text class="stats-num">{{ stats.totalCallCount }}</text>
<text class="stats-label">来电条数</text>
</view>
</view>
<view class="stats-footer">
<text class="stats-hint" v-if="stats.lastSuccessTime">最近成功 {{ stats.lastSuccessTime }}</text>
<text class="stats-hint" v-else>暂无上传记录</text>
<view class="stats-actions">
<text class="stats-reset" @click="clearActiveList">清空当前列表</text>
<text class="stats-reset" @click="resetStats">重置统计</text>
<text class="stats-reset" @click="manualFetch">手动刷新</text>
</view>
</view>
</view>
<view class="status-row">
<text class="summary" v-if="smsList.length || callList.length">
短信 {{ smsList.length }} / 来电 {{ callList.length }}
</text>
<text class="empty" v-else>{{ activeEmptyText }}</text>
<text class="upload-status" v-if="lastUploadTime">
上次上传 {{ lastUploadTime }} {{ lastUploadOk ? '成功' : '失败' }}
</text>
<text class="sms-warning" v-if="smsList.length > 400">短信已超过 400 建议手动清理部分短信</text>
</view>
<view class="record-tabs">
<view
:class="['record-tab', activeRecordType === 'sms' ? 'record-tab-active' : '']"
@click="activeRecordType = 'sms'"
>
短信 ({{ smsList.length }})
</view>
<view
:class="['record-tab', activeRecordType === 'call' ? 'record-tab-active' : '']"
@click="activeRecordType = 'call'"
>
来电 ({{ callList.length }})
</view>
</view>
<scroll-view class="record-list" scroll-y v-if="activeList.length">
<view class="record-item" v-for="(item, index) in activeList" :key="item.id || index">
<view class="record-header">
<text class="record-phone">{{ item.phone || '未知号码' }}</text>
<text class="record-type">
{{ activeRecordType === 'sms' ? getSmsTypeText(item.type) : item.typeText }}
</text>
</view>
<text class="record-time">{{ item.time || '' }}</text>
<text class="record-meta" v-if="activeRecordType === 'call'">
{{ item.name || '未命名联系人' }} · 通话时长 {{ formatDuration(item.duration) }}
</text>
<text class="record-body" v-if="activeRecordType === 'sms'">{{ item.body || '' }}</text>
<text class="record-body" v-else>{{ item.isNew ? '未读通话记录' : '已读通话记录' }}</text>
</view>
</scroll-view>
</view>
</template>
<script>
const SETTINGS_KEY = 'sms_settings';
const STATS_KEY = 'sms_upload_stats';
const LOGS_KEY = 'sms_upload_logs';
const SMS_IDS_KEY = 'sms_uploaded_ids';
const CALL_IDS_KEY = 'call_uploaded_ids';
const PERMISSIONS = {
readSms: 'android.permission.READ_SMS',
receiveSms: 'android.permission.RECEIVE_SMS',
readCallLog: 'android.permission.READ_CALL_LOG',
readPhoneState: 'android.permission.READ_PHONE_STATE'
};
const INTERVAL_OPTIONS = [
{ label: '3 秒', value: 3 },
{ label: '5 秒', value: 5 },
{ label: '10 秒', value: 10 },
{ label: '30 秒', value: 30 },
{ label: '1 分钟', value: 60 },
{ label: '30 分钟', value: 1800 },
{ label: '60 分钟', value: 3600 }
];
function formatDateTime(timestamp) {
const d = new Date(Number(timestamp) || Date.now());
const pad = n => (n < 10 ? '0' + n : '' + n);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function getCursorColumn(cursor, column) {
const index = cursor.getColumnIndex(column);
return index >= 0 ? index : -1;
}
function getCursorString(cursor, column) {
const index = getCursorColumn(cursor, column);
return index >= 0 ? cursor.getString(index) || '' : '';
}
function getCursorInt(cursor, column) {
const index = getCursorColumn(cursor, column);
return index >= 0 ? Number(cursor.getInt(index) || 0) : 0;
}
function getCursorLong(cursor, column) {
const index = getCursorColumn(cursor, column);
return index >= 0 ? Number(cursor.getLong(index) || 0) : 0;
}
function getCallTypeText(type) {
switch (Number(type)) {
case 1:
return '来电已接';
case 3:
return '来电未接';
case 5:
return '来电拒接';
default:
return '来电记录';
}
}
function getSmsTypeText(type) {
return Number(type) === 2 ? '已发送' : '已接收';
}
function readSmsFromDevice(limit = 1000) {
try {
const main = plus.android.runtimeMainActivity();
const Uri = plus.android.importClass('android.net.Uri');
plus.android.importClass('android.content.ContentResolver');
plus.android.importClass('android.database.Cursor');
const contentResolver = main.getContentResolver();
const smsUri = Uri.parse('content://sms');
const cursor = contentResolver.query(smsUri, null, null, null, 'date DESC');
const list = [];
if (cursor) {
let count = 0;
while (cursor.moveToNext() && count < limit) {
const id = getCursorString(cursor, '_id');
const phone = getCursorString(cursor, 'address');
const body = getCursorString(cursor, 'body');
const dateLong = getCursorLong(cursor, 'date');
const type = getCursorInt(cursor, 'type');
// 只保留收到的短信,过滤自己发出去的短信
if (type === 2) {
continue;
}
list.push({
id,
phone,
body,
time: formatDateTime(dateLong),
type,
timestamp: dateLong
});
count++;
}
cursor.close();
}
return { code: 200, data: list, count: list.length };
} catch (e) {
console.log('[SMS] readSmsFromDevice error', e);
return { code: -1, data: [], count: 0, msg: String(e) };
}
}
function readCallRecordsFromDevice(limit = 500) {
try {
const main = plus.android.runtimeMainActivity();
const Uri = plus.android.importClass('android.net.Uri');
plus.android.importClass('android.content.ContentResolver');
plus.android.importClass('android.database.Cursor');
const contentResolver = main.getContentResolver();
const callUri = Uri.parse('content://call_log/calls');
const cursor = contentResolver.query(callUri, null, null, null, 'date DESC');
const list = [];
const incomingTypes = [1, 3, 5];
if (cursor) {
let count = 0;
while (cursor.moveToNext() && count < limit) {
const type = getCursorInt(cursor, 'type');
// 只保留打进来的号码,过滤主动呼出的号码
if (!incomingTypes.includes(type)) {
continue;
}
const id = getCursorString(cursor, '_id');
const phone = getCursorString(cursor, 'number');
const name = getCursorString(cursor, 'cached_name');
const duration = getCursorInt(cursor, 'duration');
const dateLong = getCursorLong(cursor, 'date');
const isNew = getCursorInt(cursor, 'new') === 1;
list.push({
id,
phone,
name,
duration,
type,
typeText: getCallTypeText(type),
time: formatDateTime(dateLong),
timestamp: dateLong,
isNew
});
count++;
}
cursor.close();
}
return { code: 200, data: list, count: list.length };
} catch (e) {
console.log('[CALL] readCallRecordsFromDevice error', e);
return { code: -1, data: [], count: 0, msg: String(e) };
}
}
const KeepAliveHelper = {
notifyId: 1001,
channelId: 'sms_call_monitor_service',
showNotification(title, content) {
try {
const main = plus.android.runtimeMainActivity();
const Context = plus.android.importClass('android.content.Context');
const NotificationManager = plus.android.importClass('android.app.NotificationManager');
const nm = main.getSystemService(Context.NOTIFICATION_SERVICE);
const Build = plus.android.importClass('android.os.Build');
if (Build.VERSION.SDK_INT >= 26) {
const NotificationChannel = plus.android.importClass('android.app.NotificationChannel');
const channel = new NotificationChannel(
this.channelId,
'短信来电监听服务',
NotificationManager.IMPORTANCE_LOW
);
channel.setDescription('后台短信和来电监听常驻通知');
nm.createNotificationChannel(channel);
}
const NotificationBuilder = plus.android.importClass('android.app.Notification$Builder');
const builder = new NotificationBuilder(main, this.channelId);
builder.setContentTitle(title || '短信来电监听');
builder.setContentText(content || '后台短信和来电监听服务运行中');
builder.setSmallIcon(main.getApplicationInfo().icon);
builder.setOngoing(true);
nm.notify(this.notifyId, builder.build());
} catch (e) {
console.log('[KeepAlive] showNotification error', e);
}
},
requestIgnoreBattery() {
try {
const main = plus.android.runtimeMainActivity();
const Intent = plus.android.importClass('android.content.Intent');
const Settings = plus.android.importClass('android.provider.Settings');
const Uri = plus.android.importClass('android.net.Uri');
const PowerManager = plus.android.importClass('android.os.PowerManager');
const Context = plus.android.importClass('android.content.Context');
const pm = main.getSystemService(Context.POWER_SERVICE);
if (!pm.isIgnoringBatteryOptimizations(main.getPackageName())) {
const intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
intent.setData(Uri.parse('package:' + main.getPackageName()));
main.startActivity(intent);
}
} catch (e) {
console.log('[KeepAlive] requestIgnoreBattery error', e);
}
}
};
export default {
data() {
return {
title: '短信来电监听',
loading: false,
smsEmptyText: '点击手动刷新或开启轮询',
callEmptyText: '点击手动刷新或开启轮询',
smsList: [],
callList: [],
activeRecordType: 'sms',
apiUrl: 'https://test-server.sbnews.net/api/sms/uploadSms',
intervalIndex: 2,
polling: false,
countdown: 0,
nativeTimer: null,
countdownTimer: null,
lastUploadTime: '',
lastUploadOk: false,
intervalLabels: INTERVAL_OPTIONS.map(item => item.label),
showApiModal: false,
apiUrlTemp: '',
permissionStatus: '点击获取',
stats: {
totalUploads: 0,
successCount: 0,
failCount: 0,
skipCount: 0,
totalSmsCount: 0,
totalCallCount: 0,
lastSuccessTime: ''
}
};
},
computed: {
intervalSeconds() {
return INTERVAL_OPTIONS[this.intervalIndex].value;
},
countdownText() {
if (this.loading) return '读取中...';
if (this.countdown <= 0) return '等待中...';
if (this.countdown >= 60) {
const m = Math.floor(this.countdown / 60);
const s = this.countdown % 60;
return `${m}${s < 10 ? '0' : ''}${s}秒后刷新`;
}
return `${this.countdown}秒后刷新`;
},
activeList() {
return this.activeRecordType === 'sms' ? this.smsList : this.callList;
},
activeEmptyText() {
return this.activeRecordType === 'sms' ? this.smsEmptyText : this.callEmptyText;
}
},
onLoad() {
this.loadSettings();
this.refreshPermissionStatus();
if (this.isAppAndroid()) {
KeepAliveHelper.showNotification('短信来电监听', '后台短信和来电监听服务运行中');
KeepAliveHelper.requestIgnoreBattery();
}
},
onUnload() {
this.stopPolling();
},
onShow() {
this.refreshPermissionStatus();
if (!this._initFetched) {
this._initFetched = true;
this.doFetchAndUpload();
}
},
methods: {
getSmsTypeText,
isAppAndroid() {
return typeof plus !== 'undefined' && !!plus.android && uni.getSystemInfoSync().platform === 'android';
},
formatDuration(seconds) {
const total = Number(seconds) || 0;
if (total < 60) return `${total} 秒`;
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}${s} 秒`;
},
getStorageKey(type) {
return type === 'sms' ? SMS_IDS_KEY : CALL_IDS_KEY;
},
getApiUrl(type) {
return this.apiUrl;
},
getRecordLabel(type) {
return type === 'sms' ? '短信' : '来电';
},
logUploadRequest(type, payload, apiUrl, deviceId) {
console.log(`[UPLOAD][${type}] request`, JSON.stringify({
apiUrl,
deviceId,
payload
}));
},
logUploadResponse(type, res, treatAsSkip = false) {
console.log(`[UPLOAD][${type}] response`, JSON.stringify({
statusCode: res?.statusCode || 0,
treatAsSkip,
data: res?.data || null
}));
},
logUploadFail(type, err, payload, apiUrl, deviceId) {
console.log(`[UPLOAD][${type}] fail`, JSON.stringify({
apiUrl,
deviceId,
payload,
error: err || null
}));
},
getPayload(type, list, deviceId) {
if (type === 'sms') {
return { smsList: list, timestamp: Date.now(), deviceId };
}
return { callList: list, timestamp: Date.now(), deviceId };
},
loadSettings() {
const saved = uni.getStorageSync(SETTINGS_KEY);
if (saved) {
this.apiUrl = saved.apiUrl || this.apiUrl;
this.intervalIndex = saved.intervalIndex ?? 2;
}
const stats = uni.getStorageSync(STATS_KEY);
if (stats) {
this.stats = { ...this.stats, ...stats };
}
if (!this.stats.totalCallCount) {
const uploadedCallIds = uni.getStorageSync(CALL_IDS_KEY) || [];
this.stats.totalCallCount = uploadedCallIds.length;
}
},
saveSettings() {
uni.setStorageSync(SETTINGS_KEY, {
apiUrl: this.apiUrl,
intervalIndex: this.intervalIndex
});
},
showApiUrlDialog() {
this.apiUrlTemp = this.apiUrl;
this.showApiModal = true;
},
confirmApiUrl() {
this.apiUrl = this.apiUrlTemp.trim();
this.showApiModal = false;
this.saveSettings();
},
onIntervalChange(e) {
this.intervalIndex = Number(e.detail.value);
this.saveSettings();
if (this.polling) {
this.countdown = this.intervalSeconds;
this.stopNativeTimer();
this.stopCountdownTick();
this.startNativeTimer();
this.startCountdownTick();
}
},
async onSwitchChange(e) {
if (e.detail.value) {
const authorized = await this.ensurePermissions();
if (!authorized) {
this.$nextTick(() => {
this.polling = false;
});
uni.showToast({ title: '请先授予短信和通话记录权限', icon: 'none' });
return;
}
this.startPolling();
return;
}
this.stopPolling();
},
startPolling() {
this.polling = true;
this.countdown = this.intervalSeconds;
this.doFetchAndUpload();
this.startNativeTimer();
this.startCountdownTick();
},
stopPolling() {
this.polling = false;
this.stopNativeTimer();
this.stopCountdownTick();
},
startNativeTimer() {
if (!this.isAppAndroid()) return;
this.stopNativeTimer();
const interval = this.intervalSeconds * 1000;
const Timer = plus.android.importClass('java.util.Timer');
const timer = new Timer();
const that = this;
const task = plus.android.implements('java.util.TimerTask', {
run() {
that.countdown = that.intervalSeconds;
that.doFetchAndUpload();
}
});
timer.schedule(task, interval, interval);
this.nativeTimer = timer;
},
stopNativeTimer() {
if (!this.nativeTimer) return;
try {
this.nativeTimer.cancel();
} catch (e) {
console.log('[Timer] stopNativeTimer error', e);
}
this.nativeTimer = null;
},
startCountdownTick() {
this.stopCountdownTick();
this.countdownTimer = setInterval(() => {
if (this.countdown > 0) {
this.countdown--;
}
if (this.countdown <= 0 && this.polling) {
this.countdown = this.intervalSeconds;
this.doFetchAndUpload();
}
}, 1000);
},
stopCountdownTick() {
if (this.countdownTimer) {
clearInterval(this.countdownTimer);
this.countdownTimer = null;
}
this.countdown = 0;
},
checkNativePermission(perm) {
try {
const main = plus.android.runtimeMainActivity();
const PackageManager = plus.android.importClass('android.content.pm.PackageManager');
return main.checkSelfPermission(perm) === PackageManager.PERMISSION_GRANTED;
} catch (e) {
return false;
}
},
ensurePermissions() {
return new Promise(resolve => {
// #ifdef APP-PLUS
const platform = uni.getSystemInfoSync().platform;
if (platform !== 'android') {
resolve(true);
return;
}
const hasSmsPermission = this.checkNativePermission(PERMISSIONS.readSms);
const hasCallPermission = this.checkNativePermission(PERMISSIONS.readCallLog);
if (hasSmsPermission && hasCallPermission) {
this.permissionStatus = '已授权';
resolve(true);
return;
}
plus.android.requestPermissions(
[
PERMISSIONS.readSms,
PERMISSIONS.receiveSms,
PERMISSIONS.readCallLog,
PERMISSIONS.readPhoneState
],
result => {
this.refreshPermissionStatus();
const grantedSms = this.checkNativePermission(PERMISSIONS.readSms);
const grantedCall = this.checkNativePermission(PERMISSIONS.readCallLog);
if (grantedSms && grantedCall) {
resolve(true);
return;
}
console.log('[Permission] request result', JSON.stringify(result));
uni.showModal({
title: '权限提示',
content: '短信或通话记录权限被拒绝,请到系统设置中手动开启。',
confirmText: '去设置',
cancelText: '取消',
success: res => {
if (res.confirm) {
this.openAppSettings();
}
resolve(false);
}
});
},
error => {
console.log('[Permission] request error', error);
resolve(false);
}
);
// #endif
// #ifndef APP-PLUS
resolve(true);
// #endif
});
},
openAppSettings() {
try {
const main = plus.android.runtimeMainActivity();
const Intent = plus.android.importClass('android.content.Intent');
const Settings = plus.android.importClass('android.provider.Settings');
const Uri = plus.android.importClass('android.net.Uri');
const intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(Uri.parse('package:' + main.getPackageName()));
main.startActivity(intent);
} catch (e) {
console.log('[Permission] openAppSettings error', e);
}
},
refreshPermissionStatus() {
try {
// #ifdef APP-PLUS
const platform = uni.getSystemInfoSync().platform;
if (platform !== 'android') {
this.permissionStatus = '已授权';
return;
}
const hasSmsPermission = this.checkNativePermission(PERMISSIONS.readSms);
const hasCallPermission = this.checkNativePermission(PERMISSIONS.readCallLog);
if (hasSmsPermission && hasCallPermission) {
this.permissionStatus = '已授权';
} else if (hasSmsPermission || hasCallPermission) {
this.permissionStatus = '部分授权';
} else {
this.permissionStatus = '未授权';
}
// #endif
} catch (e) {
this.permissionStatus = '点击获取';
}
},
async manualFetch() {
if (this.loading) return;
const authorized = await this.ensurePermissions();
if (!authorized) return;
await this.doFetchAndUpload();
if (this.polling) {
this.countdown = this.intervalSeconds;
}
},
async doFetchAndUpload() {
if (this.loading) return;
this.loading = true;
try {
const smsData = readSmsFromDevice(1000);
this.handleFetchedRecords('sms', smsData, '暂无短信');
const callData = readCallRecordsFromDevice(500);
this.handleFetchedRecords('call', callData, '暂无来电记录');
} catch (error) {
console.log('[Monitor] doFetchAndUpload error', error);
this.smsEmptyText = '读取短信异常: ' + String(error);
this.callEmptyText = '读取来电异常: ' + String(error);
} finally {
this.loading = false;
}
},
handleFetchedRecords(type, result, emptyText) {
const listKey = type === 'sms' ? 'smsList' : 'callList';
const emptyKey = type === 'sms' ? 'smsEmptyText' : 'callEmptyText';
if (result.code !== 200) {
this[listKey] = [];
this[emptyKey] = result.msg || '读取失败';
return;
}
const list = result.data || [];
this[listKey] = list;
this[emptyKey] = list.length ? '' : emptyText;
const uploadedIds = new Set(uni.getStorageSync(this.getStorageKey(type)) || []);
const newList = list.filter(item => !uploadedIds.has(String(item.id)));
if (newList.length) {
this.uploadToApi(type, newList);
} else if (list.length) {
this.stats.skipCount++;
uni.setStorageSync(STATS_KEY, this.stats);
}
},
uploadToApi(type, list) {
const apiUrl = this.getApiUrl(type);
if (!apiUrl) {
console.log(`[${type}] apiUrl missing, skip upload`);
return;
}
let deviceId = '';
try {
const sysInfo = uni.getSystemInfoSync();
deviceId = sysInfo.deviceId || '';
} catch (e) {}
const payload = this.getPayload(type, list, deviceId);
this.logUploadRequest(type, payload, apiUrl, deviceId);
uni.request({
url: apiUrl,
method: 'POST',
header: { 'content-type': 'application/json' },
data: payload,
success: res => {
const ok = res.statusCode === 200 && res.data && res.data.code === 1;
this.logUploadResponse(type, res, false);
this.lastUploadOk = ok;
this.lastUploadTime = this.formatNow();
const msg = res.data?.msg || (ok ? '上传成功' : '服务端返回异常');
this.updateStats(type, ok, list, {
statusCode: res.statusCode,
message: msg,
deviceId,
apiUrl,
requestData: JSON.stringify(payload),
responseData: JSON.stringify(res.data)
});
},
fail: err => {
this.logUploadFail(type, err, payload, apiUrl, deviceId);
this.lastUploadOk = false;
this.lastUploadTime = this.formatNow();
this.updateStats(type, false, [], {
statusCode: 0,
message: String(err.errMsg || err),
deviceId,
apiUrl,
requestData: JSON.stringify(payload),
responseData: JSON.stringify(err)
});
}
});
},
clearActiveList() {
if (this.activeRecordType === 'sms') {
this.smsList = [];
this.smsEmptyText = '已清空,等待下次轮询';
} else {
this.callList = [];
this.callEmptyText = '已清空,等待下次轮询';
}
this.lastUploadTime = '';
},
updateStats(type, ok, list, extra) {
this.stats.totalUploads++;
const logEntry = {
ok,
time: this.formatFull(),
recordType: type,
recordTypeLabel: this.getRecordLabel(type),
recordCount: list.length,
smsCount: list.length,
statusCode: extra?.statusCode || '',
message: extra?.message || '',
apiUrl: extra?.apiUrl || this.getApiUrl(type),
requestData: extra?.requestData || '',
responseData: extra?.responseData || ''
};
if (ok && list.length) {
this.stats.successCount++;
this.stats.lastSuccessTime = this.formatNow();
const ids = uni.getStorageSync(this.getStorageKey(type)) || [];
const idSet = new Set(ids);
list.forEach(item => {
if (item.id) {
idSet.add(String(item.id));
}
});
const newIds = Array.from(idSet);
uni.setStorageSync(this.getStorageKey(type), newIds);
if (type === 'sms') {
this.stats.totalSmsCount = newIds.length;
} else {
this.stats.totalCallCount = newIds.length;
}
} else if (!ok) {
this.stats.failCount++;
}
const logs = uni.getStorageSync(LOGS_KEY) || [];
logs.unshift(logEntry);
if (logs.length > 200) logs.length = 200;
uni.setStorageSync(STATS_KEY, this.stats);
uni.setStorageSync(LOGS_KEY, logs);
},
resetStats() {
this.stats = {
totalUploads: 0,
successCount: 0,
failCount: 0,
skipCount: 0,
totalSmsCount: 0,
totalCallCount: 0,
lastSuccessTime: ''
};
uni.setStorageSync(STATS_KEY, this.stats);
uni.removeStorageSync(SMS_IDS_KEY);
uni.removeStorageSync(CALL_IDS_KEY);
uni.removeStorageSync(LOGS_KEY);
uni.showToast({ title: '统计已重置', icon: 'none' });
},
goLogPage(tab) {
uni.navigateTo({ url: `/pages/logs/logs?tab=${tab}` });
},
formatFull() {
return formatDateTime(Date.now());
},
formatNow() {
const d = new Date();
const pad = n => (n < 10 ? '0' + n : '' + n);
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
}
};
</script>
<style>
.content {
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
padding: 40rpx 32rpx 32rpx;
background-color: #f6f7fb;
box-sizing: border-box;
}
.panel-card,
.stats-card,
.record-tabs,
.record-list {
width: 100%;
box-sizing: border-box;
}
.panel-card,
.stats-card {
padding: 24rpx;
margin-bottom: 24rpx;
background-color: #ffffff;
border-radius: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(15, 23, 42, 0.06);
}
.panel-grid {
display: flex;
flex-wrap: nowrap;
gap: 16rpx;
}
.panel-cell {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx 12rpx;
background-color: #f8fafc;
border-radius: 16rpx;
position: relative;
}
.panel-cell:active {
background-color: #eef2ff;
}
.cell-inner {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.cell-icon {
font-size: 30rpx;
font-weight: 600;
margin-bottom: 8rpx;
color: #1d4ed8;
}
.cell-label {
font-size: 24rpx;
color: #374151;
font-weight: 500;
margin-bottom: 4rpx;
}
.cell-desc {
font-size: 20rpx;
color: #9ca3af;
}
.panel-switches {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
}
.switch-item {
display: flex;
align-items: center;
}
.switch-left {
flex: 0 0 auto;
}
.switch-right {
flex: 1;
justify-content: flex-end;
}
.switch-label,
.countdown-text {
font-size: 26rpx;
}
.countdown-text {
font-weight: 600;
color: #2563eb;
}
.stats-row {
display: flex;
flex-wrap: wrap;
}
.stats-item {
width: 33.33%;
display: flex;
flex-direction: column;
align-items: center;
padding: 12rpx 0;
}
.stats-num {
font-size: 36rpx;
font-weight: 700;
color: #1f2937;
}
.stats-ok .stats-num {
color: #16a34a;
}
.stats-fail .stats-num {
color: #dc2626;
}
.stats-label {
margin-top: 4rpx;
font-size: 22rpx;
color: #9ca3af;
}
.stats-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1rpx solid #f0f0f0;
}
.stats-hint {
font-size: 22rpx;
color: #9ca3af;
}
.stats-actions {
display: flex;
gap: 12rpx;
}
.stats-reset {
font-size: 22rpx;
color: #ef4444;
padding: 4rpx 16rpx;
border: 1rpx solid #fca5a5;
border-radius: 8rpx;
background-color: #fef2f2;
}
.status-row {
width: 100%;
margin-bottom: 20rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.summary,
.empty {
font-size: 26rpx;
color: #374151;
}
.upload-status {
font-size: 22rpx;
color: #6b7280;
}
.sms-warning {
font-size: 24rpx;
color: #d97706;
}
.record-tabs {
display: flex;
margin-bottom: 16rpx;
padding: 8rpx;
background: #ffffff;
border-radius: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(15, 23, 42, 0.06);
}
.record-tab {
flex: 1;
text-align: center;
padding: 18rpx 0;
font-size: 26rpx;
color: #6b7280;
border-radius: 14rpx;
}
.record-tab-active {
color: #1d4ed8;
font-weight: 600;
background-color: #eff6ff;
}
.record-list {
flex: 1;
min-height: 360rpx;
}
.record-item {
margin-bottom: 16rpx;
padding: 24rpx;
background-color: #ffffff;
border-radius: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
.record-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16rpx;
}
.record-phone {
flex: 1;
font-size: 28rpx;
font-weight: 600;
color: #111827;
word-break: break-all;
}
.record-type {
font-size: 22rpx;
color: #2563eb;
background-color: #eff6ff;
padding: 4rpx 12rpx;
border-radius: 999rpx;
}
.record-time,
.record-meta {
display: block;
margin-top: 10rpx;
font-size: 22rpx;
color: #6b7280;
}
.record-body {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.6;
color: #374151;
word-break: break-all;
}
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.modal-box {
width: 620rpx;
padding: 40rpx;
background-color: #ffffff;
border-radius: 24rpx;
}
.modal-title {
display: block;
margin-bottom: 24rpx;
text-align: center;
font-size: 32rpx;
font-weight: 600;
color: #1f2937;
}
.modal-input {
width: 100%;
min-height: 120rpx;
margin-bottom: 24rpx;
padding: 16rpx;
font-size: 26rpx;
line-height: 1.6;
border: 1rpx solid #e5e7eb;
border-radius: 12rpx;
box-sizing: border-box;
word-break: break-all;
}
.modal-btns {
display: flex;
gap: 16rpx;
}
.modal-btn {
flex: 1;
}
</style>