1202 lines
31 KiB
Vue
1202 lines
31 KiB
Vue
<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>
|