This commit is contained in:
张成
2025-12-22 16:26:59 +08:00
parent aa2d03ee30
commit e17d5610f5
54 changed files with 11735 additions and 3 deletions

15
.vscode/launch.json vendored
View File

@@ -9,7 +9,7 @@
"request": "launch",
"name": "后端调试",
"skipFiles": [
"<node_internals>/**",
"<node_internals>/**"
],
"resolveSourceMapLocations":[
"${workspaceFolder}/",
@@ -28,7 +28,16 @@
"npm run dev"
],
"cwd": "${workspaceFolder}/admin"
},
}
],
"compounds": [
{
"name": "启动全部(后端+前端)",
"configurations": [
"后端调试",
"后端前端"
],
"stopAll": true
}
]
}

278
app/App.vue Normal file
View File

@@ -0,0 +1,278 @@
<template>
<div class="container">
<header class="header">
<div class="header-left">
<h1>Boss - 远程监听服务 <span style="font-size: 0.7em; opacity: 0.8;">v{{ currentVersion }}</span></h1>
<div class="status-indicator">
<span :class="statusDotClass"></span>
<span>{{ isConnected ? '已连接' : '未连接' }}</span>
</div>
</div>
<div class="header-right">
<!-- 登录页面不显示 UserMenu -->
<UserMenu v-if="showSidebar" />
</div>
</header>
<div class="main-content">
<!-- 左侧菜单 - 登录页面不显示 -->
<Sidebar v-if="showSidebar" />
<!-- 内容区域 - 使用 router-view 切换页面 -->
<div class="content-area" :class="{ 'full-width': !showSidebar }">
<router-view />
</div>
</div>
<!-- 更新弹窗 - 使用 store 管理状态 -->
<UpdateDialog />
</div>
</template>
<script>
import Sidebar from './components/Sidebar.vue';
import UpdateDialog from './components/UpdateDialog.vue';
import UserMenu from './components/UserMenu.vue';
// 导入 Vuex helpers
import { mapState } from 'vuex';
// 导入 Mixins
import logMixin from './mixins/logMixin.js';
import authMixin from './mixins/authMixin.js';
import mqttMixin from './mixins/mqttMixin.js';
import taskMixin from './mixins/taskMixin.js';
import systemInfoMixin from './mixins/systemInfoMixin.js';
import platformMixin from './mixins/platformMixin.js';
import qrCodeMixin from './mixins/qrCodeMixin.js';
import updateMixin from './mixins/updateMixin.js';
import eventListenerMixin from './mixins/eventListenerMixin.js';
// Vue 应用主组件逻辑
export default {
name: 'App',
mixins: [
logMixin,
authMixin,
mqttMixin,
taskMixin,
systemInfoMixin,
platformMixin,
qrCodeMixin,
updateMixin,
eventListenerMixin
],
components: {
Sidebar,
UpdateDialog,
UserMenu
},
data() {
return {
// 应用启动时间(用于计算运行时间)
startTime: Date.now(),
// 应用加载状态
isLoading: true,
// 浏览器窗口状态
browserWindowVisible: false
};
},
mounted() {
this.init();
},
// beforeDestroy 已在 taskMixin 中定义
watch: {
// 监听登录状态变化
isLoggedIn(newVal, oldVal) {
// 未登录时自动跳转到登录页面
if (!newVal && this.$route.name !== 'Login') {
this.$router.push('/login');
}
// 登录状态从 false 变为 true 时,检查 MQTT 状态(连接由主进程自动处理)
if (newVal && !oldVal) {
// 延迟一下,确保 store 中的 snCode 已经更新
this.$nextTick(() => {
setTimeout(() => {
// MQTT 连接由主进程自动处理,这里只检查状态
if (this.checkMQTTStatus) {
this.checkMQTTStatus();
}
// 开始获取任务状态
if (this.startTaskStatusUpdate) {
this.startTaskStatusUpdate();
}
}, 500);
});
}
}
},
methods: {
async init() {
try {
// 先隐藏加载屏幕,避免一直显示
this.hideLoadingScreen();
if (window.electronAPI && window.electronAPI.invoke) {
const result = await window.electronAPI.invoke('system:get-version');
if (result && result.success && result.version) {
this.currentVersion = result.version;
this.addLog('info', `当前版本: v${this.currentVersion}`);
} else if (window.appInfo && window.appInfo.version) {
this.currentVersion = window.appInfo.version;
}
} else if (window.appInfo && window.appInfo.version) {
this.currentVersion = window.appInfo.version;
}
} catch (error) {
console.error('获取版本号失败:', error);
if (window.appInfo && window.appInfo.version) {
this.$store.commit('app/SET_VERSION', window.appInfo.version);
}
}
this.setupEventListeners();
await this.loadSavedConfig();
// 加载投递配置到 store
if (this.$store && this.$store.dispatch) {
await this.$store.dispatch('delivery/loadDeliveryConfig');
}
this.startSystemInfoUpdate();
// 尝试自动登录
const autoLoginSuccess = await this.tryAutoLogin();
// 如果已登录,开始获取任务状态
if (this.$store.state.auth.isLoggedIn) {
this.startTaskStatusUpdate();
// 检查 MQTT 连接状态
this.checkMQTTStatus();
} else if (!autoLoginSuccess) {
// 未登录时,自动跳转到登录页面
this.$router.push('/login');
}
},
hideLoadingScreen() {
setTimeout(() => {
const loadingScreen = document.getElementById('loading-screen');
const app = document.getElementById('app');
if (loadingScreen) {
loadingScreen.classList.add('hidden');
setTimeout(() => {
if (loadingScreen.parentNode) {
loadingScreen.remove();
}
}, 500);
}
if (app) {
app.style.display = 'block';
}
this.isLoading = false;
}, 500);
},
// setupEventListeners 已在 eventListenerMixin 中定义
// loadSavedConfig, checkActivationStatus, userLogin, tryAutoLogin 已在 authMixin 中定义
// startTaskStatusUpdate, updateCurrentTask, onTaskStatusUpdate 已在 taskMixin 中定义
// connectMQTT, disconnectMQTT, onMQTTConnected, onMQTTDisconnected, onMQTTMessage, onMQTTStatusChange 已在 mqttMixin 中定义
// handleMenuChange, handleUpdateDeliveryConfig, handleLoginFromPage, handleLoginFromDialog 已不需要,使用路由和组件内部处理
/**
* 检查 MQTT 连接状态,如果未连接则尝试重新连接
*/
async checkMQTTStatus() {
try {
if (!window.electronAPI || !window.electronAPI.invoke) {
console.warn('[App] electronAPI 不可用');
return;
}
// 获取当前 MQTT 状态
const status = await window.electronAPI.invoke('mqtt:status');
console.log('[App] MQTT 状态查询结果:', status);
if (status && status.isConnected) {
// 如果已经连接,更新状态
if (this.$store) {
this.$store.dispatch('mqtt/setConnected', true);
console.log('[App] MQTT 状态已更新为已连接');
}
} else {
// 如果未连接,尝试重新连接(需要 snCode
const snCode = this.$store?.state?.auth?.snCode;
if (snCode) {
console.log('[App] MQTT 未连接,尝试重新连接...');
try {
await window.electronAPI.invoke('mqtt:connect', snCode);
console.log('[App] MQTT 重新连接成功');
if (this.$store) {
this.$store.dispatch('mqtt/setConnected', true);
}
} catch (error) {
console.warn('[App] MQTT 重新连接失败:', error);
if (this.$store) {
this.$store.dispatch('mqtt/setConnected', false);
}
}
} else {
console.warn('[App] 无法重新连接 MQTT: 缺少 snCode');
}
}
} catch (error) {
console.error('[App] 检查 MQTT 状态失败:', error);
}
}
},
computed: {
...mapState('app', ['currentVersion']),
...mapState('mqtt', ['isConnected']),
...mapState('auth', ['isLoggedIn', 'userName', 'snCode', 'deviceId', 'remainingDays', 'phone']),
// 根据路由 meta 决定是否显示侧边栏,默认为 true
showSidebar() {
return this.$route.meta.showSidebar !== false;
},
statusDotClass() {
return {
'status-dot': true,
'connected': this.isConnected
};
}
// logEntries 已在 logMixin 中定义
}
// snCode watch 已在 authMixin 中定义
};
</script>
<style lang="less" scoped>
// 登录页面时内容区域不需要背景和padding
.content-area.full-width {
// 当侧边栏隐藏时,登录页面应该有特殊样式
&.login-page {
padding: 0;
background: transparent;
border-radius: 0;
box-shadow: none;
}
}
</style>

76
app/api/apply_records.js Normal file
View File

@@ -0,0 +1,76 @@
/**
* 投递记录 API 服务
* 封装投递记录相关的API调用
*/
import apiClient from '../utils/api.js';
class ApplyRecordsAPI {
/**
* 获取投递记录列表
* @param {Object} params - 查询参数
* @param {Object} params.seachOption - 搜索条件
* @param {Object} params.pageOption - 分页选项
* @returns {Promise}
*/
async getList(params = {}) {
try {
const result = await apiClient.post('/apply/list', params);
return result;
} catch (error) {
console.error('获取投递记录列表失败:', error);
throw error;
}
}
/**
* 获取投递统计
* @param {String} snCode - 设备SN码可选
* @returns {Promise}
*/
async getStatistics(snCode = null) {
try {
const params = snCode ? { sn_code: snCode } : {};
const result = await apiClient.get('/apply/statistics', params);
return result;
} catch (error) {
console.error('获取投递统计失败:', error);
throw error;
}
}
/**
* 获取近7天投递趋势数据
* @param {String} snCode - 设备SN码可选
* @returns {Promise}
*/
async getTrendData(snCode = null) {
try {
const params = snCode ? { sn_code: snCode } : {};
const result = await apiClient.get('/apply/trend', params);
return result;
} catch (error) {
console.error('获取投递趋势数据失败:', error);
throw error;
}
}
/**
* 获取投递记录详情
* @param {String|Number} recordId - 投递记录ID可以是 id 或 applyId
* @returns {Promise}
*/
async getDetail(recordId) {
try {
// 使用 id 参数名(数据库主键字段)
const result = await apiClient.get('/apply/detail', { id: recordId });
return result;
} catch (error) {
console.error('获取投递记录详情失败:', error);
throw error;
}
}
}
export default new ApplyRecordsAPI();

102
app/api/config.js Normal file
View File

@@ -0,0 +1,102 @@
/**
* 系统配置 API 服务
* 封装配置相关的API调用
*/
import apiClient from '../utils/api.js';
// API 基础 URL用于拼接图片路径去掉 /api 后缀)
const getBaseURL = () => {
const apiUrl = 'http://localhost:9097/api';
// 如果包含 /api去掉它
return apiUrl.replace(/\/api$/, '');
};
class ConfigAPI {
/**
* 获取配置
* @param {String|Array} configKeys - 配置键(单个字符串或数组)
* @returns {Promise}
*/
async getConfig(configKeys) {
try {
let params = {};
// 支持单个字符串或数组
if (Array.isArray(configKeys)) {
params.configKeys = configKeys.join(',');
} else if (typeof configKeys === 'string') {
params.configKey = configKeys;
} else {
throw new Error('配置键格式错误');
}
const result = await apiClient.get('/config/get', params);
return result;
} catch (error) {
console.error('获取配置失败:', error);
throw error;
}
}
/**
* 获取微信相关配置
* @returns {Promise<Object>} { wechatNumber, wechatQRCode }
*/
async getWechatConfig() {
try {
const result = await this.getConfig(['wx_num', 'wx_img']);
if (result && result.code === 0) {
let qrcodeUrl = result.data.wx_img || '';
// 如果二维码是相对路径转换为完整URL
if (qrcodeUrl && !qrcodeUrl.startsWith('http://') && !qrcodeUrl.startsWith('https://') && !qrcodeUrl.startsWith('data:')) {
const baseURL = getBaseURL();
// 如果以 / 开头拼接服务器基础URL
if (qrcodeUrl.startsWith('/')) {
qrcodeUrl = baseURL + qrcodeUrl;
} else {
// 如果不是以 / 开头,可能是文件路径,需要拼接
qrcodeUrl = baseURL + '/' + qrcodeUrl;
}
}
return {
wechatNumber: result.data.wx_num || '',
wechatQRCode: qrcodeUrl
};
}
return {
wechatNumber: '',
wechatQRCode: ''
};
} catch (error) {
console.error('获取微信配置失败:', error);
return {
wechatNumber: '',
wechatQRCode: ''
};
}
}
/**
* 获取价格套餐列表
* @returns {Promise<Array>} 价格套餐列表
*/
async getPricingPlans() {
try {
const result = await apiClient.get('/config/pricing-plans');
if (result && result.code === 0) {
return result.data || [];
}
return [];
} catch (error) {
console.error('获取价格套餐失败:', error);
return [];
}
}
}
export default new ConfigAPI();

View File

@@ -0,0 +1,45 @@
/**
* 投递配置 API 服务
* 封装投递配置相关的API调用
*/
import apiClient from '../utils/api.js';
class DeliveryConfigAPI {
/**
* 获取投递配置
* @param {String} snCode - 设备SN码
* @returns {Promise}
*/
async getConfig(snCode) {
try {
const result = await apiClient.post('/user/delivery-config/get', { sn_code: snCode });
return result;
} catch (error) {
console.error('获取投递配置失败:', error);
throw error;
}
}
/**
* 保存投递配置
* @param {String} snCode - 设备SN码
* @param {Object} deliverConfig - 投递配置对象
* @returns {Promise}
*/
async saveConfig(snCode, deliverConfig) {
try {
const result = await apiClient.post('/user/delivery-config/save', {
sn_code: snCode,
deliver_config: deliverConfig
});
return result;
} catch (error) {
console.error('保存投递配置失败:', error);
throw error;
}
}
}
export default new DeliveryConfigAPI();

64
app/api/feedback.js Normal file
View File

@@ -0,0 +1,64 @@
/**
* 意见反馈 API 服务
* 封装意见反馈相关的API调用
*/
import apiClient from '../utils/api.js';
import { mapState } from 'vuex';
class FeedbackAPI {
/**
* 提交反馈
* @param {Object} data - 反馈数据
* @param {String} data.type - 反馈类型
* @param {String} data.content - 反馈内容
* @param {String} data.contact - 联系方式(可选)
* @param {String} data.sn_code - 设备SN码可选从 store 获取)
* @returns {Promise}
*/
async submit(data) {
try {
const result = await apiClient.post('/feedback/submit', data);
return result;
} catch (error) {
console.error('提交反馈失败:', error);
throw error;
}
}
/**
* 获取反馈列表
* @param {Object} params - 查询参数
* @param {Number} params.page - 页码
* @param {Number} params.pageSize - 每页数量
* @param {String} params.sn_code - 设备SN码可选从 store 获取)
* @returns {Promise}
*/
async getList(params = {}) {
try {
const result = await apiClient.post('/feedback/list', params);
return result;
} catch (error) {
console.error('获取反馈列表失败:', error);
throw error;
}
}
/**
* 获取反馈详情
* @param {String|Number} feedbackId - 反馈ID
* @returns {Promise}
*/
async getDetail(feedbackId) {
try {
const result = await apiClient.get('/feedback/detail', { id: feedbackId });
return result;
} catch (error) {
console.error('获取反馈详情失败:', error);
throw error;
}
}
}
export default new FeedbackAPI();

83
app/api/invite.js Normal file
View File

@@ -0,0 +1,83 @@
/**
* 推广邀请 API 服务
* 封装推广邀请相关的API调用
*/
import apiClient from '../utils/api.js';
class InviteAPI {
/**
* 获取邀请信息
* @param {string} snCode 设备SN码
* @returns {Promise}
*/
async getInviteInfo(snCode) {
try {
const result = await apiClient.post('/invite/info', {
sn_code: snCode
});
return result;
} catch (error) {
console.error('获取邀请信息失败:', error);
throw error;
}
}
/**
* 获取邀请统计
* @param {string} snCode 设备SN码
* @returns {Promise}
*/
async getStatistics(snCode) {
try {
const result = await apiClient.post('/invite/statistics', {
sn_code: snCode
});
return result;
} catch (error) {
console.error('获取邀请统计失败:', error);
throw error;
}
}
/**
* 生成邀请码
* @param {string} snCode 设备SN码
* @returns {Promise}
*/
async generateInviteCode(snCode) {
try {
const result = await apiClient.post('/invite/generate', {
sn_code: snCode
});
return result;
} catch (error) {
console.error('生成邀请码失败:', error);
throw error;
}
}
/**
* 获取邀请记录列表
* @param {string} snCode 设备SN码
* @param {Object} params 分页参数
* @param {number} params.page 页码
* @param {number} params.pageSize 每页数量
* @returns {Promise}
*/
async getRecords(snCode, params = {}) {
try {
const result = await apiClient.post('/invite/records', {
sn_code: snCode,
page: params.page || 1,
pageSize: params.pageSize || 20
});
return result;
} catch (error) {
console.error('获取邀请记录列表失败:', error);
throw error;
}
}
}
export default new InviteAPI();

View File

@@ -0,0 +1,464 @@
<template>
<div class="console-info-panel">
<h2 class="panel-title">系统状态</h2>
<div class="info-grid">
<!-- 用户登录状态 -->
<div class="info-card">
<div class="info-label">用户登录状态</div>
<div class="info-value">
<span :class="['status-badge', isLoggedIn ? 'status-success' : 'status-error']">
{{ isLoggedIn ? '已登录' : '未登录' }}
</span>
</div>
<div v-if="isLoggedIn && userName" class="info-detail">
<span class="detail-label">账号:</span>
<span class="detail-value">{{ userName }}</span>
</div>
<div v-if="isLoggedIn && remainingDays !== null" class="info-detail">
<span class="detail-label">剩余天数:</span>
<span :class="['detail-value', remainingDays <= 0 ? 'text-error' : remainingDays <= 3 ? 'text-warning' : '']">
{{ remainingDays }}
</span>
</div>
</div>
<!-- 当前平台 -->
<div class="info-card">
<div class="info-label">当前平台</div>
<div class="info-value">{{ currentPlatform }}</div>
<div class="info-detail">
<span class="detail-label">平台登录:</span>
<span :class="['status-badge', isPlatformLoggedIn ? 'status-success' : 'status-warning']">
{{ isPlatformLoggedIn ? '已登录' : '未登录' }}
</span>
</div>
</div>
<!-- 设备信息 -->
<div class="info-card">
<div class="info-label">设备信息</div>
<div class="info-detail">
<span class="detail-label">设备SN码:</span>
<span class="detail-value">{{ snCode || '-' }}</span>
</div>
<div class="info-detail">
<span class="detail-label">设备ID:</span>
<span class="detail-value">{{ deviceId || '-' }}</span>
</div>
</div>
<!-- 系统信息 -->
<div class="info-card system-info-card">
<div class="info-label">系统信息</div>
<div class="system-info-grid">
<div class="system-info-item">
<span class="system-info-label">运行时间:</span>
<span class="system-info-value">{{ uptime }}</span>
</div>
<div class="system-info-item">
<span class="system-info-label">CPU:</span>
<span class="system-info-value">{{ cpuUsage }}</span>
</div>
<div class="system-info-item">
<span class="system-info-label">内存:</span>
<span class="system-info-value">{{ memUsage }}</span>
</div>
</div>
</div>
<!-- 任务区域 -->
<div class="task-section">
<!-- 当前执行任务 - 100%宽度 -->
<div class="info-card task-card-full">
<div class="info-label">当前执行任务</div>
<div v-if="currentTask" class="task-info">
<div class="task-name">{{ currentTask.taskName || currentTask.taskType || '-' }}</div>
<div class="task-status">
<span :class="['status-badge', getTaskStatusClass(currentTask.status)]">
{{ getTaskStatusText(currentTask.status) }}
</span>
</div>
<div v-if="currentTask.progress !== null" class="task-progress">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: currentTask.progress + '%' }"
></div>
</div>
<span class="progress-text">{{ currentTask.progress }}%</span>
</div>
<div v-if="currentTask.currentStep" class="task-step">
<span class="step-label">当前步骤:</span>
<span class="step-value">{{ currentTask.currentStep }}</span>
</div>
</div>
<div v-else class="no-task">
<span class="no-task-text">暂无执行中的任务</span>
</div>
</div>
<!-- 即将执行的任务列表 -->
<div class="info-card task-card-full pending-tasks-card">
<div class="info-label">即将执行的任务</div>
<div v-if="pendingTasks && pendingTasks.length > 0" class="pending-tasks-list">
<div v-for="(task, index) in pendingTasks" :key="index" class="pending-task-item">
<div class="pending-task-name">{{ task.taskName || task.taskType || '未知任务' }}</div>
<div class="pending-task-meta">
<span :class="['status-badge', getTaskStatusClass(task.status)]">
{{ getTaskStatusText(task.status) }}
</span>
<span v-if="task.scheduledTime" class="task-time">
计划时间: {{ formatTime(task.scheduledTime) }}
</span>
</div>
</div>
</div>
<div v-else class="no-task">
<span class="no-task-text">暂无待执行的任务</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ConsoleInfoPanel',
props: {
isLoggedIn: {
type: Boolean,
default: false
},
userName: {
type: String,
default: ''
},
remainingDays: {
type: Number,
default: null
},
currentPlatform: {
type: String,
default: '-'
},
isPlatformLoggedIn: {
type: Boolean,
default: false
},
snCode: {
type: String,
default: ''
},
deviceId: {
type: String,
default: ''
},
currentTask: {
type: Object,
default: null
},
pendingTasks: {
type: Array,
default: () => []
},
uptime: {
type: String,
default: '0分钟'
},
cpuUsage: {
type: String,
default: '0%'
},
memUsage: {
type: String,
default: '0MB'
}
},
methods: {
formatTime(timeStr) {
if (!timeStr) return '-';
try {
const date = new Date(timeStr);
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
return timeStr;
}
},
getTaskStatusClass(status) {
const statusMap = {
'running': 'status-info',
'pending': 'status-warning',
'completed': 'status-success',
'failed': 'status-error',
'timeout': 'status-error',
'cancelled': 'status-warning'
};
return statusMap[status] || 'status-warning';
},
getTaskStatusText(status) {
const statusMap = {
'running': '执行中',
'pending': '待执行',
'completed': '已完成',
'failed': '失败',
'timeout': '超时',
'cancelled': '已取消'
};
return statusMap[status] || status || '未知';
}
}
};
</script>
<style lang="less" scoped>
.console-info-panel {
margin-bottom: 30px;
}
.panel-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 20px;
color: #333;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.info-card {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.task-section {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: 20px;
}
.task-card-full {
width: 100%;
}
.pending-tasks-card {
margin-top: 0;
}
.pending-tasks-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.pending-task-item {
padding: 12px;
background: #f9f9f9;
border-radius: 6px;
border-left: 3px solid #ff9800;
.pending-task-name {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
}
.pending-task-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
.task-time {
color: #666;
}
}
}
.info-label {
font-size: 14px;
color: #666;
margin-bottom: 12px;
font-weight: 500;
}
.info-value {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
}
.info-detail {
display: flex;
align-items: center;
margin-top: 8px;
font-size: 14px;
.detail-label {
color: #666;
margin-right: 8px;
}
.detail-value {
color: #333;
font-weight: 500;
&.text-error {
color: #f44336;
}
&.text-warning {
color: #ff9800;
}
}
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
&.status-success {
background: #e8f5e9;
color: #4caf50;
}
&.status-error {
background: #ffebee;
color: #f44336;
}
&.status-warning {
background: #fff3e0;
color: #ff9800;
}
&.status-info {
background: #e3f2fd;
color: #2196f3;
}
}
.task-info {
.task-name {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
}
.task-status {
margin-bottom: 10px;
}
.task-progress {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
.progress-bar {
flex: 1;
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4caf50, #8bc34a);
transition: width 0.3s ease;
}
}
.progress-text {
font-size: 12px;
color: #666;
min-width: 40px;
}
}
.task-step {
font-size: 14px;
color: #666;
.step-label {
margin-right: 8px;
}
.step-value {
color: #333;
font-weight: 500;
}
}
}
.no-task {
text-align: center;
padding: 20px;
color: #999;
.no-task-text {
font-size: 14px;
}
}
.system-info-card {
.system-info-grid {
display: flex;
flex-direction: row;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.system-info-item {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.system-info-label {
font-weight: 500;
color: #666;
font-size: 13px;
}
.system-info-value {
color: #333;
font-weight: 600;
font-size: 13px;
}
}
@media (max-width: 768px) {
.info-grid {
grid-template-columns: 1fr;
}
.task-section {
grid-column: 1;
}
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<div class="settings-section">
<div class="settings-form-horizontal">
<div class="form-row">
<div class="form-item">
<label class="form-label">自动投递</label>
<ToggleSwitch v-model="config.autoDelivery" @change="updateConfig('autoDelivery', $event)" />
<span class="switch-label">{{ config.autoDelivery ? '开启' : '关闭' }}</span>
</div>
<div class="form-item">
<label class="form-label">投递开始时间</label>
<InputText type="time" :value="config.startTime" @input="updateConfig('startTime', $event.target.value)" class="form-input" />
</div>
<div class="form-item">
<label class="form-label">投递结束时间</label>
<InputText type="time" :value="config.endTime" @input="updateConfig('endTime', $event.target.value)" class="form-input" />
</div>
<div class="form-item">
<label class="form-label">是否仅工作日</label>
<Select
v-model="config.workdaysOnly"
:options="workdaysOptions"
optionLabel="label"
optionValue="value"
@change="updateConfig('workdaysOnly', $event.value)"
class="form-input"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import ToggleSwitch from 'primevue/toggleswitch';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import logMixin from '../mixins/logMixin.js';
export default {
name: 'DeliverySettings',
mixins: [logMixin],
components: {
ToggleSwitch,
InputText,
Select
},
props: {
config: {
type: Object,
required: true
}
},
data() {
return {
workdaysOptions: [
{ label: '全部日期', value: false },
{ label: '仅工作日', value: true }
]
};
},
methods: {
...mapActions('delivery', ['updateDeliveryConfig', 'saveDeliveryConfig']),
updateConfig(key, value) {
// ToggleSwitch 的 change 事件传递的是布尔值
const boolValue = typeof value === 'boolean' ? value : (value?.value !== undefined ? value.value : value);
this.updateDeliveryConfig({ key, value: boolValue });
},
async handleSave() {
const result = await this.saveDeliveryConfig();
if (result && result.success) {
this.addLog('success', '投递配置已保存');
} else {
this.addLog('error', result?.error || '保存配置失败');
}
}
}
};
</script>
<style lang="less" scoped>
.settings-section {
margin-bottom: 30px;
}
.page-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 20px;
color: #333;
}
.settings-form-horizontal {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.form-row {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.form-item {
display: flex;
align-items: center;
gap: 8px;
.form-label {
font-size: 14px;
color: #333;
font-weight: 500;
white-space: nowrap;
}
.form-input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
&:focus {
outline: none;
border-color: #4CAF50;
}
}
.switch-label {
font-size: 14px;
color: #666;
}
}
@media (max-width: 768px) {
.form-row {
flex-direction: column;
align-items: stretch;
}
.form-item {
justify-content: space-between;
}
}
</style>

View File

@@ -0,0 +1,223 @@
<template>
<div class="delivery-trend-chart">
<div class="chart-container">
<canvas ref="chartCanvas" :width="chartWidth" :height="chartHeight"></canvas>
</div>
<div class="chart-legend">
<div v-for="(item, index) in chartData" :key="index" class="legend-item">
<span class="legend-date">{{ item.date }}</span>
<span class="legend-value">{{ item.value }}</span>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'DeliveryTrendChart',
props: {
data: {
type: Array,
default: () => []
}
},
data() {
return {
chartWidth: 600,
chartHeight: 200,
padding: { top: 20, right: 20, bottom: 30, left: 40 }
};
},
computed: {
chartData() {
// 如果没有数据生成7天的空数据
if (!this.data || this.data.length === 0) {
const today = new Date();
return Array.from({ length: 7 }, (_, i) => {
const date = new Date(today);
date.setDate(date.getDate() - (6 - i));
return {
date: this.formatDate(date),
value: 0
};
});
}
return this.data;
},
maxValue() {
const values = this.chartData.map(item => item.value);
const max = Math.max(...values, 1); // 至少为1避免除零
return Math.ceil(max * 1.2); // 增加20%的顶部空间
}
},
mounted() {
this.drawChart();
},
watch: {
chartData: {
handler() {
this.$nextTick(() => {
this.drawChart();
});
},
deep: true
}
},
methods: {
formatDate(date) {
const month = date.getMonth() + 1;
const day = date.getDate();
return `${month}/${day}`;
},
drawChart() {
const canvas = this.$refs.chartCanvas;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const { width, height } = canvas;
const { padding } = this;
// 清空画布
ctx.clearRect(0, 0, width, height);
// 计算绘图区域
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
// 绘制背景
ctx.fillStyle = '#f9f9f9';
ctx.fillRect(padding.left, padding.top, chartWidth, chartHeight);
// 绘制网格线
ctx.strokeStyle = '#e0e0e0';
ctx.lineWidth = 1;
// 水平网格线5条
for (let i = 0; i <= 5; i++) {
const y = padding.top + (chartHeight / 5) * i;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(padding.left + chartWidth, y);
ctx.stroke();
}
// 垂直网格线7条对应7天
for (let i = 0; i <= 7; i++) {
const x = padding.left + (chartWidth / 7) * i;
ctx.beginPath();
ctx.moveTo(x, padding.top);
ctx.lineTo(x, padding.top + chartHeight);
ctx.stroke();
}
// 绘制数据点和折线
if (this.chartData.length > 0) {
const points = this.chartData.map((item, index) => {
const x = padding.left + (chartWidth / (this.chartData.length - 1)) * index;
const y = padding.top + chartHeight - (item.value / this.maxValue) * chartHeight;
return { x, y, value: item.value };
});
// 绘制折线
ctx.strokeStyle = '#4CAF50';
ctx.lineWidth = 2;
ctx.beginPath();
points.forEach((point, index) => {
if (index === 0) {
ctx.moveTo(point.x, point.y);
} else {
ctx.lineTo(point.x, point.y);
}
});
ctx.stroke();
// 绘制数据点
ctx.fillStyle = '#4CAF50';
points.forEach(point => {
ctx.beginPath();
ctx.arc(point.x, point.y, 4, 0, Math.PI * 2);
ctx.fill();
// 显示数值
ctx.fillStyle = '#666';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.fillText(point.value.toString(), point.x, point.y - 10);
ctx.fillStyle = '#4CAF50';
});
// 绘制区域填充(渐变)
const gradient = ctx.createLinearGradient(
padding.left,
padding.top,
padding.left,
padding.top + chartHeight
);
gradient.addColorStop(0, 'rgba(76, 175, 80, 0.2)');
gradient.addColorStop(1, 'rgba(76, 175, 80, 0.05)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.moveTo(padding.left, padding.top + chartHeight);
points.forEach(point => {
ctx.lineTo(point.x, point.y);
});
ctx.lineTo(padding.left + chartWidth, padding.top + chartHeight);
ctx.closePath();
ctx.fill();
}
// 绘制Y轴标签
ctx.fillStyle = '#666';
ctx.font = '11px Arial';
ctx.textAlign = 'right';
for (let i = 0; i <= 5; i++) {
const value = Math.round((this.maxValue / 5) * (5 - i));
const y = padding.top + (chartHeight / 5) * i;
ctx.fillText(value.toString(), padding.left - 10, y + 4);
}
}
}
};
</script>
<style lang="less" scoped>
.delivery-trend-chart {
.chart-container {
width: 100%;
overflow-x: auto;
canvas {
display: block;
max-width: 100%;
}
}
.chart-legend {
display: flex;
justify-content: space-around;
padding: 10px 0;
border-top: 1px solid #f0f0f0;
margin-top: 10px;
.legend-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
.legend-date {
font-size: 11px;
color: #999;
}
.legend-value {
font-size: 13px;
font-weight: 600;
color: #4CAF50;
}
}
}
}
</style>

View File

@@ -0,0 +1,39 @@
/**
* PrimeVue 组件统一导出
* 方便统一管理和使用
*/
// 表单组件
export { default as Button } from 'primevue/button';
export { default as InputText } from 'primevue/inputtext';
export { default as Password } from 'primevue/password';
export { default as Textarea } from 'primevue/textarea';
export { default as InputNumber } from 'primevue/inputnumber';
export { default as Dropdown } from 'primevue/dropdown';
export { default as Checkbox } from 'primevue/checkbox';
export { default as InputSwitch } from 'primevue/inputswitch';
export { default as Calendar } from 'primevue/calendar';
// 数据展示组件
export { default as DataTable } from 'primevue/datatable';
export { default as Column } from 'primevue/column';
export { default as Card } from 'primevue/card';
export { default as Tag } from 'primevue/tag';
export { default as Badge } from 'primevue/badge';
// 对话框和覆盖层
export { default as Dialog } from 'primevue/dialog';
export { default as Toast } from 'primevue/toast';
export { default as Message } from 'primevue/message';
// 分页和导航
export { default as Paginator } from 'primevue/paginator';
// 进度和加载
export { default as ProgressBar } from 'primevue/progressbar';
export { default as ProgressSpinner } from 'primevue/progressspinner';
// 其他
export { default as Divider } from 'primevue/divider';
export { default as Panel } from 'primevue/panel';

View File

@@ -0,0 +1,67 @@
<template>
<div class="config-section mt60">
<h3 class="config-section-title">
<label>登录二维码 </label>
<span class="btn-qr-code" v-if="!isPlatformLoggedIn" @click="handleGetQrCode"> 获取二维码 </span>
</h3>
<div class="qr-code-display">
<div v-if="!qrCodeUrl" class="qr-code-placeholder">
<p>点击下方"获取二维码"按钮获取二维码</p>
</div>
<div v-else style="text-align: center;">
<img :src="qrCodeUrl" alt="登录二维码"
style="max-width: 100%; max-height: 200px; border-radius: 5px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);">
<div class="qr-code-info" style="margin-top: 10px; font-size: 11px; color: #666;">
<p>请使用微信扫描二维码登录</p>
<p v-if="qrCodeCountdownActive && qrCodeCountdown > 0"
style="margin-top: 8px; font-size: 12px; color: #667eea; font-weight: bold;">
{{ qrCodeCountdown }}秒后自动刷新
</p>
<p v-if="qrCodeExpired"
style="margin-top: 8px; font-size: 12px; color: #ff4757; font-weight: bold;">
请重新刷新二维码这个二维码就不能用了
</p>
</div>
</div>
</div>
</div>
</template>
<script>
import qrCodeMixin from '../mixins/qrCodeMixin';
import { mapState } from 'vuex';
import platformMixin from '../mixins/platformMixin';
import logMixin from '../mixins/logMixin.js';
export default {
name: 'QrCodeSection',
mixins: [qrCodeMixin, platformMixin, logMixin],
computed: {
...mapState('qrCode', ['qrCodeUrl', 'qrCodeCountdownActive', 'qrCodeCountdown', 'qrCodeExpired'])
},
methods: {
async handleGetQrCode() {
const success = await this.getQrCode();
if (success) {
// 获取成功后启动自动刷新
this.startQrCodeAutoRefresh();
}
}
},
mounted() {
// 如果已有二维码,启动自动刷新
if (this.qrCodeUrl && !this.isPlatformLoggedIn) {
this.startQrCodeAutoRefresh();
}
},
beforeUnmount() {
// 组件销毁时清理
this.stopQrCodeAutoRefresh();
}
};
</script>
<style lang="less" scoped>
// 样式已经在全局 CSS 中定义
</style>

View File

@@ -0,0 +1,141 @@
<template>
<Dialog
v-model:visible="dialogVisible"
modal
header="系统设置"
:style="{ width: '500px' }"
@hide="handleClose"
>
<div class="settings-content">
<div class="settings-section">
<h4 class="section-title">应用设置</h4>
<div class="setting-item">
<label>自动启动</label>
<InputSwitch v-model="settings.autoStart" @change="handleSettingChange('autoStart', $event)" />
</div>
<div class="setting-item">
<label>开机自启</label>
<InputSwitch v-model="settings.startOnBoot" @change="handleSettingChange('startOnBoot', $event)" />
</div>
</div>
<div class="settings-section">
<h4 class="section-title">通知设置</h4>
<div class="setting-item">
<label>启用通知</label>
<InputSwitch v-model="settings.enableNotifications" @change="handleSettingChange('enableNotifications', $event)" />
</div>
<div class="setting-item">
<label>声音提醒</label>
<InputSwitch v-model="settings.soundAlert" @change="handleSettingChange('soundAlert', $event)" />
</div>
</div>
</div>
<template #footer>
<Button label="取消" severity="secondary" @click="handleClose" />
<Button label="保存" @click="handleSave" />
</template>
</Dialog>
</template>
<script>
import { Dialog, Button, InputSwitch } from '../components/PrimeVue';
import logMixin from '../mixins/logMixin.js';
export default {
name: 'SettingsDialog',
mixins: [logMixin],
components: {
Dialog,
Button,
InputSwitch
},
props: {
visible: {
type: Boolean,
default: false
}
},
computed: {
dialogVisible: {
get() {
return this.visible;
},
set(value) {
if (!value) {
this.handleClose();
}
}
}
},
computed: {
settings: {
get() {
return this.$store.state.config.appSettings;
},
set(value) {
this.$store.dispatch('config/updateAppSettings', value);
}
}
},
methods: {
handleSettingChange(key, value) {
// InputSwitch 的 change 事件传递的是布尔值
const boolValue = typeof value === 'boolean' ? value : value.value;
this.$store.dispatch('config/updateAppSetting', { key, value: boolValue });
},
handleSave() {
// 设置已保存在 store 中,不需要额外操作
this.addLog('success', '设置已保存');
this.$emit('save', this.settings);
this.handleClose();
},
handleClose() {
this.$emit('close');
}
}
};
</script>
<style lang="less" scoped>
.settings-content {
padding: 10px 0;
}
.settings-section {
margin-bottom: 25px;
&:last-child {
margin-bottom: 0;
}
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0 0 15px 0;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
&:last-child {
border-bottom: none;
}
label {
font-size: 14px;
color: #333;
font-weight: 500;
}
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<div class="sidebar">
<ul class="sidebar-menu">
<li v-if="isLoggedIn">
<router-link to="/console" :class="['menu-item', { active: $route.name === 'Console' }]">
<span class="menu-icon"></span>
<span class="menu-text">控制台</span>
</router-link>
</li>
<li v-if="isLoggedIn">
<router-link to="/log" :class="['menu-item', { active: $route.name === 'Log' }]">
<span class="menu-icon"></span>
<span class="menu-text">运行日志</span>
</router-link>
</li>
<li v-if="isLoggedIn">
<router-link to="/delivery" :class="['menu-item', { active: $route.name === 'Delivery' }]">
<span class="menu-icon"></span>
<span class="menu-text">投递管理</span>
</router-link>
</li>
<li v-if="isLoggedIn">
<router-link to="/invite" :class="['menu-item', { active: $route.name === 'Invite' }]">
<span class="menu-icon"></span>
<span class="menu-text">推广邀请</span>
</router-link>
</li>
<li v-if="isLoggedIn">
<router-link to="/feedback" :class="['menu-item', { active: $route.name === 'Feedback' }]">
<span class="menu-icon"></span>
<span class="menu-text">意见反馈</span>
</router-link>
</li>
<li v-if="isLoggedIn">
<router-link to="/purchase" :class="['menu-item', { active: $route.name === 'Purchase' }]">
<span class="menu-icon"></span>
<span class="menu-text">如何购买</span>
</router-link>
</li>
<li v-if="!isLoggedIn">
<router-link to="/login" :class="['menu-item', { active: $route.name === 'Login' }]">
<span class="menu-icon"></span>
<span class="menu-text">用户登录</span>
</router-link>
</li>
</ul>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'Sidebar',
computed: {
...mapState('auth', ['isLoggedIn'])
}
};
</script>
<style lang="less" scoped>
.menu-item {
display: block;
text-decoration: none;
color: inherit;
&.router-link-active,
&.active {
background-color: #4CAF50;
color: #fff;
}
}
</style>
<style lang="less" scoped>
// 样式已经在全局 CSS 中定义</style>

View File

@@ -0,0 +1,45 @@
<template>
<div class="stats-section">
<h2 class="page-title">统计信息</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">今日投递</div>
<div class="stat-value">{{ stats.todayCount || 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">本周投递</div>
<div class="stat-value">{{ stats.weekCount || 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">本月投递</div>
<div class="stat-value">{{ stats.monthCount || 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">累计投递</div>
<div class="stat-value">{{ stats.totalCount || 0 }}</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'StatsSection',
props: {
stats: {
type: Object,
default: () => ({
todayCount: 0,
weekCount: 0,
monthCount: 0,
totalCount: 0
})
}
}
};
</script>
<style lang="less" scoped>
// 样式已经在全局 CSS 中定义
</style>

View File

@@ -0,0 +1,200 @@
<template>
<Dialog
v-model:visible="dialogVisible"
:modal="true"
:closable="!updateInfo?.forceUpdate"
header="发现新版本"
:style="{ width: '500px' }"
@hide="handleClose"
>
<div class="update-content">
<div class="update-info">
<p><strong>新版本号:</strong> {{ updateInfo?.version || '未知' }}</p>
<p v-if="updateInfo?.fileSize > 0"><strong>文件大小:</strong> {{ formatFileSize(updateInfo.fileSize) }}</p>
<div v-if="updateInfo?.releaseNotes" class="release-notes">
<p><strong>更新内容:</strong></p>
<pre>{{ updateInfo.releaseNotes }}</pre>
</div>
</div>
<!-- 下载进度 -->
<div v-if="isDownloading" class="update-progress">
<ProgressBar :value="updateProgress" />
<div class="progress-text">
<span>下载进度: {{ updateProgress }}%</span>
<span v-if="downloadState?.totalBytes > 0">
({{ formatFileSize(downloadState.downloadedBytes) }} / {{ formatFileSize(downloadState.totalBytes) }})
</span>
</div>
</div>
</div>
<template #footer>
<Button
v-if="!isDownloading && updateProgress === 0"
label="立即更新"
@click="handleStartDownload"
/>
<Button
v-if="!isDownloading && updateProgress === 100"
label="立即安装"
@click="handleInstallUpdate"
/>
<Button
v-if="!updateInfo?.forceUpdate && !isDownloading && updateProgress === 0"
label="稍后提醒"
severity="secondary"
@click="handleClose"
/>
<Button
v-if="!updateInfo?.forceUpdate && !isDownloading && updateProgress === 100"
label="稍后安装"
severity="secondary"
@click="handleClose"
/>
</template>
</Dialog>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import { Dialog, Button, ProgressBar } from '../components/PrimeVue';
export default {
name: 'UpdateDialog',
components: {
Dialog,
Button,
ProgressBar
},
computed: {
...mapState('update', [
'updateDialogVisible',
'updateInfo',
'updateProgress',
'isDownloading',
'downloadState'
]),
dialogVisible: {
get() {
return this.updateDialogVisible && !!this.updateInfo;
},
set(value) {
if (!value) {
this.handleClose();
}
}
}
},
methods: {
...mapActions('update', [
'hideUpdateDialog'
]),
handleClose() {
this.hideUpdateDialog();
},
async handleStartDownload() {
try {
const updateInfoData = this.updateInfo;
if (!updateInfoData) {
console.error('更新信息不存在');
return;
}
if (this.$store) {
this.$store.dispatch('update/setDownloading', true);
this.$store.dispatch('update/setUpdateProgress', 0);
}
if (!window.electronAPI || !window.electronAPI.update) {
throw new Error('Electron API不可用');
}
const result = await window.electronAPI.invoke('update:download', updateInfoData.downloadUrl);
if (!result.success) {
throw new Error(result.error || '下载失败');
}
} catch (error) {
console.error('开始下载更新失败:', error);
if (this.$store) {
this.$store.dispatch('update/setDownloading', false);
}
}
},
async handleInstallUpdate() {
try {
if (!window.electronAPI || !window.electronAPI.update) {
throw new Error('Electron API不可用');
}
if (this.$store) {
this.$store.dispatch('update/setDownloading', true);
}
const result = await window.electronAPI.invoke('update:install');
if (result.success) {
if (this.$store) {
this.$store.dispatch('update/hideUpdateDialog');
}
} else {
throw new Error(result.error || '安装失败');
}
} catch (error) {
console.error('安装更新失败:', error);
if (this.$store) {
this.$store.dispatch('update/setDownloading', false);
}
}
},
formatFileSize(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
}
};
</script>
<style lang="less" scoped>
.update-content {
padding: 10px 0;
}
.update-info {
margin-bottom: 20px;
p {
margin: 8px 0;
}
}
.release-notes {
margin-top: 15px;
pre {
background: #f5f5f5;
padding: 10px;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.5;
}
}
.update-progress {
margin-top: 20px;
.progress-text {
margin-top: 10px;
text-align: center;
font-size: 13px;
color: #666;
}
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<Dialog
v-model:visible="dialogVisible"
modal
header="个人信息"
:style="{ width: '500px' }"
@hide="handleClose"
>
<div class="info-content">
<div class="info-item">
<label>用户名</label>
<span>{{ userInfo.userName || '-' }}</span>
</div>
<div class="info-item">
<label>手机号</label>
<span>{{ userInfo.phone || '-' }}</span>
</div>
<div class="info-item">
<label>设备SN码</label>
<span>{{ userInfo.snCode || '-' }}</span>
</div>
<div class="info-item">
<label>设备ID</label>
<span>{{ userInfo.deviceId || '-' }}</span>
</div>
<div class="info-item">
<label>剩余天数</label>
<span :class="remainingDaysClass">
{{ userInfo.remainingDays !== null ? userInfo.remainingDays + ' 天' : '-' }}
</span>
</div>
</div>
<template #footer>
<Button label="关闭" @click="handleClose" />
</template>
</Dialog>
</template>
<script>
import { Dialog, Button } from '../components/PrimeVue';
export default {
name: 'UserInfoDialog',
components: {
Dialog,
Button
},
props: {
visible: {
type: Boolean,
default: false
},
userInfo: {
type: Object,
default: () => ({
userName: '',
phone: '',
snCode: '',
deviceId: '',
remainingDays: null
})
}
},
computed: {
dialogVisible: {
get() {
return this.visible;
},
set(value) {
if (!value) {
this.handleClose();
}
}
},
remainingDaysClass() {
if (this.userInfo.remainingDays === null) return '';
if (this.userInfo.remainingDays <= 0) return 'text-error';
if (this.userInfo.remainingDays <= 3) return 'text-warning';
return '';
}
},
methods: {
handleClose() {
this.$emit('close');
}
}
};
</script>
<style lang="less" scoped>
.info-content {
padding: 10px 0;
}
.info-item {
display: flex;
align-items: center;
margin-bottom: 15px;
label {
font-weight: 600;
color: #333;
min-width: 100px;
margin-right: 10px;
}
span {
color: #666;
flex: 1;
&.text-error {
color: #f44336;
}
&.text-warning {
color: #ff9800;
}
}
}
</style>

270
app/components/UserMenu.vue Normal file
View File

@@ -0,0 +1,270 @@
<template>
<div class="user-menu">
<div class="user-info" @click="toggleMenu">
<span class="user-name">{{ userName || '未登录' }}</span>
<span class="dropdown-icon"></span>
</div>
<div v-if="menuVisible" class="user-menu-dropdown">
<div class="menu-item" @click="showUserInfo">
<span class="menu-icon">👤</span>
<span class="menu-text">个人信息</span>
</div>
<div class="menu-item" @click="showSettings">
<span class="menu-icon"></span>
<span class="menu-text">设置</span>
</div>
<div class="menu-divider"></div>
<div class="menu-item" @click="handleLogout">
<span class="menu-icon">🚪</span>
<span class="menu-text">退出登录</span>
</div>
</div>
<!-- 个人信息对话框 -->
<UserInfoDialog
:visible="showUserInfoDialog"
:user-info="userInfo"
@close="showUserInfoDialog = false"
/>
<!-- 设置对话框 -->
<SettingsDialog
:visible="showSettingsDialog"
@close="showSettingsDialog = false"
@save="handleSettingsSave"
/>
</div>
</template>
<script>
import { mapState } from 'vuex';
import UserInfoDialog from './UserInfoDialog.vue';
import SettingsDialog from './SettingsDialog.vue';
import logMixin from '../mixins/logMixin.js';
export default {
name: 'UserMenu',
mixins: [logMixin],
components: {
UserInfoDialog,
SettingsDialog
},
data() {
return {
menuVisible: false,
showUserInfoDialog: false,
showSettingsDialog: false
};
},
computed: {
...mapState('auth', ['isLoggedIn', 'userName', 'snCode', 'deviceId', 'remainingDays', 'phone']),
...mapState('mqtt', ['isConnected']),
userInfo() {
return {
userName: this.userName || '',
phone: this.phone || '',
snCode: this.snCode || '',
deviceId: this.deviceId || '',
remainingDays: this.remainingDays
};
}
},
methods: {
toggleMenu() {
this.menuVisible = !this.menuVisible;
},
showUserInfo() {
this.menuVisible = false;
this.showUserInfoDialog = true;
},
showSettings() {
this.menuVisible = false;
this.showSettingsDialog = true;
},
async handleLogout() {
this.menuVisible = false;
if (!this.isLoggedIn) {
return;
}
try {
this.addLog('info', '正在注销登录...');
// 断开MQTT连接
if (this.isConnected && window.electronAPI && window.electronAPI.mqtt) {
try {
await window.electronAPI.invoke('mqtt:disconnect');
} catch (error) {
console.error('断开MQTT连接失败:', error);
}
}
// 调用后端退出接口(如果可用)
if (window.electronAPI && window.electronAPI.invoke) {
try {
await window.electronAPI.invoke('auth:logout');
} catch (error) {
console.error('调用退出接口失败:', error);
}
}
// 使用 store 清理认证状态(无论接口是否成功都要执行)
if (this.$store) {
this.$store.dispatch('auth/logout');
// 清空平台状态
this.$store.commit('platform/SET_PLATFORM_LOGIN_STATUS', {
status: '-',
color: '#FF9800',
isLoggedIn: false
});
// 停止任务更新定时器
const taskTimer = this.$store.state.task.taskUpdateTimer;
if (taskTimer) {
clearInterval(taskTimer);
this.$store.dispatch('task/setTaskUpdateTimer', null);
}
}
// 跳转到登录页面
if (this.$router) {
this.$router.push('/login').catch(err => {
// 忽略重复导航错误
if (err.name !== 'NavigationDuplicated') {
console.error('跳转登录页失败:', err);
}
});
}
this.addLog('success', '注销登录成功');
} catch (error) {
console.error('退出登录异常:', error);
// 即使出错,也强制清空状态并跳转
if (this.$store) {
this.$store.dispatch('auth/logout');
}
if (this.$router) {
this.$router.push('/login').catch(() => {});
}
this.addLog('error', `注销登录异常: ${error.message}`);
}
},
handleSettingsSave(settings) {
// 设置已保存,可以在这里处理额外的逻辑
console.log('设置已保存:', settings);
}
},
mounted() {
// 点击外部关闭菜单
this.handleClickOutside = (e) => {
if (this.$el && !this.$el.contains(e.target)) {
this.menuVisible = false;
}
};
document.addEventListener('click', this.handleClickOutside);
},
beforeDestroy() {
// 清理事件监听器
if (this.handleClickOutside) {
document.removeEventListener('click', this.handleClickOutside);
}
}
};
</script>
<style lang="less" scoped>
.user-menu {
position: relative;
z-index: 1000;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 15px;
background: rgba(255, 255, 255, 0.2);
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
color: white;
font-size: 14px;
&:hover {
background: rgba(255, 255, 255, 0.3);
}
}
.user-name {
font-weight: 500;
}
.dropdown-icon {
font-size: 10px;
transition: transform 0.3s ease;
.user-info:hover & {
transform: rotate(180deg);
}
}
.user-menu-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 160px;
overflow: hidden;
animation: slideDown 0.2s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.menu-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
cursor: pointer;
transition: background 0.2s ease;
color: #333;
font-size: 14px;
&:hover {
background: #f5f5f5;
}
}
.menu-icon {
font-size: 16px;
}
.menu-text {
flex: 1;
}
.menu-divider {
height: 1px;
background: #e0e0e0;
margin: 4px 0;
}
</style>

353
app/css/index.less Normal file
View File

@@ -0,0 +1,353 @@
// ============================================
// 全局变量定义
// ============================================
@primary-color: #667eea;
@secondary-color: #764ba2;
@text-color: #333;
@text-secondary: #666;
@text-muted: #999;
@border-color: #ddd;
@background-light: #f5f5f5;
@white: #fff;
@gradient-primary: linear-gradient(135deg, @primary-color 0%, @secondary-color 100%);
// 边距
@spacing-xs: 5px;
@spacing-sm: 10px;
@spacing-md: 15px;
@spacing-lg: 20px;
@spacing-xl: 30px;
// 圆角
@radius-sm: 5px;
@radius-md: 8px;
@radius-lg: 10px;
@radius-xl: 12px;
// 阴影
@shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.1);
@shadow-md: 0 4px 15px rgba(0, 0, 0, 0.1);
@shadow-lg: 0 10px 40px rgba(0, 0, 0, 0.1);
// ============================================
// 基础样式重置
// ============================================
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Microsoft YaHei', sans-serif;
background: @background-light;
color: @text-color;
}
// ============================================
// 全局容器布局(主应用容器)
// ============================================
.container {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
padding: @spacing-sm;
background: @gradient-primary;
overflow: hidden;
}
.main-content {
flex: 1;
display: flex;
gap: @spacing-sm;
min-height: 0;
overflow: hidden;
}
// ============================================
// 全局工具类
// ============================================
.mt10 {
margin-top: @spacing-sm;
}
.mt60 {
margin-top: @spacing-xl;
}
// ============================================
// 头部样式
// ============================================
.header {
text-align: left;
color: @white;
margin-bottom: @spacing-sm;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
h1 {
font-size: 20px;
margin-bottom: 0;
text-align: left;
}
}
.header-left {
display: flex;
align-items: center;
gap: @spacing-sm;
flex: 1;
}
.header-right {
display: flex;
align-items: center;
gap: @spacing-sm;
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.2);
border-radius: 20px;
font-size: 14px;
white-space: nowrap;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ff4444;
&.connected {
background: #44ff44;
animation: pulse 2s infinite;
}
}
// ============================================
// 侧边栏样式
// ============================================
.sidebar {
width: 200px;
background: rgba(255, 255, 255, 0.95);
border-radius: @radius-lg;
padding: @spacing-lg 0;
box-shadow: @shadow-sm;
flex-shrink: 0;
}
.sidebar-menu {
list-style: none;
padding: 0;
margin: 0;
}
.menu-item {
padding: 15px 20px;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
transition: all 0.3s ease;
border-left: 3px solid transparent;
color: @text-color;
&:hover {
background: rgba(102, 126, 234, 0.1);
}
&.active {
background: rgba(102, 126, 234, 0.15);
border-left-color: @primary-color;
color: @primary-color;
font-weight: bold;
.menu-icon {
color: @primary-color;
}
}
}
.menu-icon {
font-size: 18px;
width: 20px;
display: inline-block;
text-align: center;
color: @text-secondary;
flex-shrink: 0;
}
.menu-text {
font-size: 15px;
}
// ============================================
// 内容区域
// ============================================
.content-area {
flex: 1;
background: rgba(255, 255, 255, 0.95);
border-radius: @radius-lg;
padding: @spacing-xl;
box-shadow: @shadow-sm;
overflow-y: auto;
min-width: 0;
position: relative;
&.full-width {
padding: 0;
background: transparent;
border-radius: 0;
box-shadow: none;
overflow: hidden;
}
}
.page-title {
font-size: 24px;
font-weight: bold;
color: @text-color;
margin-bottom: @spacing-xl;
padding-bottom: 15px;
border-bottom: 2px solid @primary-color;
}
.placeholder-content {
text-align: center;
padding: 40px 20px;
color: @text-muted;
font-size: 16px;
}
// ============================================
// 启动加载动画(全局,在 HTML 中使用)
// ============================================
.loading-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: @gradient-primary;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
opacity: 1;
transition: opacity 0.5s ease-out;
&.hidden {
opacity: 0;
pointer-events: none;
}
}
.loading-content {
text-align: center;
color: @white;
}
.loading-logo {
margin-bottom: @spacing-xl;
}
.logo-circle {
width: 80px;
height: 80px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: @white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto;
}
.loading-text {
font-size: 18px;
font-weight: 500;
margin-bottom: @spacing-lg;
animation: pulse 2s ease-in-out infinite;
}
.loading-progress {
width: 200px;
height: 4px;
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
overflow: hidden;
margin: 0 auto;
}
.progress-bar-animated {
height: 100%;
background: @white;
border-radius: 2px;
animation: progress 1.5s ease-in-out infinite;
}
// ============================================
// 全局动画
// ============================================
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideDown {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes progress {
0% {
width: 0%;
transform: translateX(0);
}
50% {
width: 70%;
transform: translateX(0);
}
100% {
width: 100%;
transform: translateX(0);
}
}

31
app/index.html Normal file
View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>boss - 远程监听服务</title>
</head>
<body>
<!-- 启动加载动画 -->
<div id="loading-screen" class="loading-screen">
<div class="loading-content">
<div class="loading-logo">
<div class="logo-circle"></div>
</div>
<div class="loading-text">正在启动...</div>
<div class="loading-progress">
<div class="progress-bar-animated"></div>
</div>
</div>
</div>
<!-- Vue 应用挂载点 -->
<div id="app" ></div>
<!-- 在 body 底部加载 Vue 应用脚本 -->
<script type="module" src="./main.js"></script>
</body>
</html>

45
app/main.js Normal file
View File

@@ -0,0 +1,45 @@
// Vue 应用入口文件
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import store from './store';
// 引入全局样式
import './css/index.less';
// 引入 PrimeVue
import PrimeVue from 'primevue/config';
import Aura from '@primevue/themes/aura';
import 'primeicons/primeicons.css';
// 创建并挂载 Vue 应用
const app = createApp(App);
// 配置 PrimeVue使用 Aura 主题,扁平化设计)
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
darkModeSelector: false, // 暂时不使用深色模式
cssLayer: false
}
}
});
// 使用 Vue Router 和 Vuex
app.use(router);
app.use(store);
// 等待 DOM 加载完成后再挂载应用
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
app.mount('#app');
window.app = app;
console.log('Vue 应用已挂载');
});
} else {
app.mount('#app');
window.app = app;
console.log('Vue 应用已挂载');
}

230
app/mixins/authMixin.js Normal file
View File

@@ -0,0 +1,230 @@
/**
* 用户认证管理 Mixin
*/
import { getToken } from '../utils/api';
export default {
data() {
return {
phone: '',
password: '',
isLoggedIn: false,
loginButtonText: '登录',
userName: '',
remainingDays: null,
snCode: '',
deviceId: '-',
listenChannel: '-',
userMenuInfo: {
userName: '',
snCode: ''
}
};
},
methods: {
async loadSavedConfig() {
try {
// 从 store 加载保存的手机号
if (this.$store) {
const savedPhone = this.$store.state.config.phone || this.$store.state.auth.phone;
if (savedPhone) {
this.phone = savedPhone;
}
}
// 注意:现在数据都在 store 中,通过持久化插件自动恢复
// 如果需要从主进程同步数据,可以在这里调用,但通常不需要
// 因为登录成功后已经通过 syncUserInfo 同步到主进程了
} catch (error) {
console.error('加载配置失败:', error);
if (this.addLog) {
this.addLog('error', `加载配置失败: ${error.message}`);
}
}
},
// checkActivationStatus 方法已移除
// 现在登录状态由 Vuex Store 管理,通过持久化插件自动恢复
// 不再需要从主进程获取状态
// 用户登录(只调用主进程接口,业务逻辑由主进程处理)
async userLogin(password, rememberMe = true) {
// 基本验证
if (!this.phone) {
if (this.addLog) {
this.addLog('error', '请输入手机号');
}
return { success: false, error: '请输入手机号' };
}
if (!password) {
if (this.addLog) {
this.addLog('error', '请输入密码');
}
return { success: false, error: '请输入密码' };
}
if (!window.electronAPI) {
if (this.addLog) {
this.addLog('error', 'Electron API不可用');
}
return { success: false, error: 'Electron API不可用' };
}
try {
if (this.addLog) {
this.addLog('info', `正在使用手机号 ${this.phone} 登录...`);
}
const result = await window.electronAPI.invoke('auth:login', {
phone: this.phone,
password: password
});
if (result.success && result.data) {
// 登录成功,通过 store 更新状态(业务逻辑由主进程处理)
if (this.$store) {
await this.$store.dispatch('auth/login', {
phone: this.phone,
password: password,
deviceId: result.data.device_id || ''
});
if (rememberMe) {
this.$store.dispatch('config/setRememberMe', true);
this.$store.dispatch('config/setPhone', this.phone);
}
}
// MQTT 连接由主进程自动处理,这里只检查状态
if (this.checkMQTTStatus) {
setTimeout(() => {
this.checkMQTTStatus();
}, 1000);
}
// 开始获取任务状态
if (this.startTaskStatusUpdate) {
this.startTaskStatusUpdate();
}
return { success: true, data: result.data };
} else {
if (this.addLog) {
this.addLog('error', `登录失败: ${result.error || '未知错误'}`);
}
return { success: false, error: result.error || '未知错误' };
}
} catch (error) {
if (this.addLog) {
this.addLog('error', `登录过程中发生错误: ${error.message}`);
}
return { success: false, error: error.message };
}
},
async tryAutoLogin() {
try {
if (!this.$store) {
return false;
}
// 从 store 检查是否有保存的登录信息
const savedPhone = this.$store.state.config.phone || this.$store.state.auth.phone;
const userLoggedOut = this.$store.state.config.userLoggedOut || this.$store.state.auth.userLoggedOut;
// 如果用户手动退出,不自动登录
if (userLoggedOut) {
return false;
}
if (!savedPhone) {
return false;
}
// 检查 store 中是否有有效的登录信息token 和用户信息)
const token = getToken();
const storeSnCode = this.$store ? this.$store.state.auth.snCode : '';
const storeUserName = this.$store ? this.$store.state.auth.userName : '';
// 如果有 token 和用户信息,说明已登录(数据已通过持久化插件恢复)
if (token && (storeSnCode || storeUserName)) {
// 更新登录状态
this.$store.commit('auth/SET_LOGGED_IN', true);
this.$store.commit('auth/SET_LOGIN_BUTTON_TEXT', '注销登录');
if (this.addLog) {
this.addLog('info', '自动登录成功');
}
// 连接MQTT
// MQTT 连接由主进程自动处理,这里只检查状态
if (this.checkMQTTStatus) {
setTimeout(() => {
this.checkMQTTStatus();
}, 1000);
}
return true; // 自动登录成功
}
return false; // 未登录
} catch (error) {
console.error('自动登录失败:', error);
if (this.addLog) {
this.addLog('error', `自动登录失败: ${error.message}`);
}
return false;
}
},
// 注销登录(只调用主进程接口,业务逻辑由主进程处理)
async logoutDevice() {
if (!window.electronAPI) {
if (this.addLog) {
this.addLog('error', 'Electron API不可用');
}
return;
}
try {
if (this.addLog) {
this.addLog('info', '正在注销登录...');
}
await window.electronAPI.invoke('auth:logout');
// 停止任务状态更新
if (this.stopTaskStatusUpdate) {
this.stopTaskStatusUpdate();
}
// 更新 store 状态
if (this.$store) {
this.$store.dispatch('auth/logout');
this.$store.dispatch('config/setUserLoggedOut', true);
}
if (this.addLog) {
this.addLog('success', '注销登录成功');
}
// 触发跳转到登录页面
if (this.$emit) {
this.$emit('logout-success');
}
} catch (error) {
if (this.addLog) {
this.addLog('error', `注销登录异常: ${error.message}`);
}
}
}
},
watch: {
snCode(newVal) {
this.userMenuInfo.snCode = newVal;
}
}
};

View File

@@ -0,0 +1,178 @@
/**
* 事件监听器管理 Mixin
*/
import platformMixin from './platformMixin.js';
export default {
mixins: [platformMixin],
data() {
return {
// 存储已注册的事件监听器,用于清理
_registeredEventListeners: []
};
},
methods: {
setupEventListeners() {
console.log('[事件监听] setupEventListeners 开始执行');
if (!window.electronAPI || !window.electronEvents) {
console.error('[事件监听] Electron API不可用', {
hasElectronAPI: !!window.electronAPI,
hasElectronEvents: !!window.electronEvents
});
if (this.addLog) {
this.addLog('error', 'Electron API不可用');
}
return;
}
const electronEvents = window.electronEvents;
console.log('[事件监听] electronEvents 对象:', electronEvents);
// 定义需要注册的事件监听器
const listeners = [
// MQTT 事件
{ channel: 'mqtt:connected', handler: (data) => {
console.log('[事件监听] 收到 mqtt:connected 事件,数据:', data);
this.onMQTTConnected(data);
}},
{ channel: 'mqtt:disconnected', handler: (data) => {
console.log('[事件监听] 收到 mqtt:disconnected 事件,数据:', data);
this.onMQTTDisconnected(data);
}},
{ channel: 'mqtt:message', handler: (data) => {
console.log('[事件监听] 收到 mqtt:message 事件');
this.onMQTTMessage(data);
}},
{ channel: 'mqtt:status', handler: (data) => {
console.log('[事件监听] 收到 mqtt:status 事件,数据:', data);
this.onMQTTStatusChange(data);
}},
// 指令执行结果
{ channel: 'command:result', handler: (data) => {
if (this.addLog) {
if (data.success) {
this.addLog('success', `指令执行成功 : ${JSON.stringify(data.data).length}字符`);
} else {
this.addLog('error', `指令执行失败: ${data.error}`);
}
}
}},
// 系统信息
{ channel: 'system:info', handler: (data) => this.updateSystemInfo(data) },
{ channel: 'log:message', handler: (data) => {
console.log('[事件监听] 收到 log:message 事件,数据:', data);
if (this.addLog) {
this.addLog(data.level, data.message);
}
}},
// 通知
{ channel: 'notification', handler: (data) => {
if (this.showNotification) {
this.showNotification(data.title, data.body);
}
}},
// 更新相关
{ channel: 'update:available', handler: (updateInfo) => this.onUpdateAvailable(updateInfo) },
{ channel: 'update:progress', handler: (progressData) => this.onUpdateProgress(progressData) },
{ channel: 'update:downloaded', handler: (data) => this.onUpdateDownloaded(data) },
{ channel: 'update:error', handler: (errorData) => this.onUpdateError(errorData) },
// 设备工作状态
{
channel: 'device:work-status',
handler: (workStatus) => {
this.onDeviceWorkStatus(workStatus);
}
},
// 平台登录状态
{ channel: 'platform:login-status-updated', handler: (data) => {
// 优先使用组件中定义的方法
if (this.onPlatformLoginStatusUpdated) {
this.onPlatformLoginStatusUpdated(data);
} else {
console.warn('[事件监听] 无法更新平台登录状态:组件未定义 onPlatformLoginStatusUpdated 且 store 不可用');
}
}}
];
// 注册所有事件监听器
listeners.forEach(({ channel, handler }) => {
electronEvents.on(channel, handler);
// 保存到清理列表
this._registeredEventListeners.push({ channel, handler });
});
console.log('[事件监听] 所有事件监听器已设置完成');
// 优先使用 store 的 dispatch 方法添加日志
if (this.$store && this.$store.dispatch) {
this.$store.dispatch('log/addLog', { level: 'info', message: '事件监听器设置完成' });
console.log('[事件监听] 已通过 store.dispatch 添加日志');
} else if (this.addLog) {
this.addLog('info', '事件监听器设置完成');
console.log('[事件监听] 已通过 addLog 方法添加日志');
} else {
console.log('[事件监听] 日志系统暂不可用,将在组件完全初始化后记录');
}
},
/**
* 清理所有已注册的事件监听器
*/
cleanupEventListeners() {
console.log('[事件监听] 开始清理事件监听器');
if (!window.electronEvents || !this._registeredEventListeners) {
return;
}
// 移除所有已注册的监听器
this._registeredEventListeners.forEach(({ channel, handler }) => {
try {
window.electronEvents.off(channel, handler);
} catch (error) {
console.warn(`[事件监听] 移除监听器失败: ${channel}`, error);
}
});
// 清空列表
this._registeredEventListeners = [];
console.log('[事件监听] 事件监听器清理完成');
},
showNotification(title, body) {
if ('Notification' in window) {
if (Notification.permission === 'granted') {
new Notification(title, {
body: body,
icon: '/assets/icon.png'
});
} else if (Notification.permission !== 'denied') {
Notification.requestPermission().then(permission => {
if (permission === 'granted') {
new Notification(title, {
body: body,
icon: '/assets/icon.png'
});
}
});
}
}
if (this.addLog) {
this.addLog('info', `[通知] ${title}: ${body}`);
}
}
},
};

48
app/mixins/logMixin.js Normal file
View File

@@ -0,0 +1,48 @@
/**
* 日志管理 Mixin
* 直接使用 store 中的 log 状态
*/
export default {
methods: {
/**
* 添加日志
* @param {string} level - 日志级别: 'info', 'success', 'warn', 'error'
* @param {string} message - 日志消息
*/
addLog(level, message) {
if (this.$store) {
this.$store.dispatch('log/addLog', { level, message });
}
},
/**
* 清空日志
*/
clearLogs() {
if (this.$store) {
this.$store.dispatch('log/clearLogs');
}
},
/**
* 导出日志
*/
exportLogs() {
if (this.$store) {
this.$store.dispatch('log/exportLogs');
}
}
},
computed: {
/**
* 获取日志条目(从 store 获取)
*/
logEntries() {
if (this.$store) {
return this.$store.getters['log/logEntries'] || [];
}
return [];
}
}
};

97
app/mixins/mqttMixin.js Normal file
View File

@@ -0,0 +1,97 @@
/**
* MQTT 管理 Mixin
*/
export default {
computed: {
isConnected() {
return this.$store ? this.$store.state.mqtt.isConnected : false;
},
mqttStatus() {
return this.$store ? this.$store.state.mqtt.mqttStatus : '未连接';
}
},
methods: {
// MQTT 连接已由主进程自动处理,客户端不再需要手动连接
// 只保留状态查询方法
async checkMQTTStatus() {
try {
if (!window.electronAPI) {
return;
}
const statusResult = await window.electronAPI.invoke('mqtt:status');
if (statusResult && typeof statusResult.isConnected !== 'undefined') {
if (this.$store) {
this.$store.dispatch('mqtt/setConnected', statusResult.isConnected);
}
}
} catch (error) {
console.warn('查询MQTT状态失败:', error);
}
},
async disconnectMQTT() {
try {
if (!window.electronAPI) {
return;
}
await window.electronAPI.invoke('mqtt:disconnect');
if (this.addLog) {
this.addLog('info', '服务断开连接指令已发送');
}
} catch (error) {
if (this.addLog) {
this.addLog('error', `断开服务连接异常: ${error.message}`);
}
}
},
onMQTTConnected(data) {
console.log('[MQTT] onMQTTConnected 被调用,数据:', data);
if (this.$store) {
this.$store.dispatch('mqtt/setConnected', true);
console.log('[MQTT] 状态已更新为已连接');
}
if (this.addLog) {
this.addLog('success', 'MQTT 服务已连接');
}
},
onMQTTDisconnected(data) {
if (this.$store) {
this.$store.dispatch('mqtt/setConnected', false);
}
if (this.addLog) {
this.addLog('warn', `服务连接断开: ${data.reason || '未知原因'}`);
}
},
onMQTTMessage(data) {
const action = data.payload?.action || 'unknown';
if (this.addLog) {
this.addLog('info', `收到远程指令: ${action}`);
}
},
onMQTTStatusChange(data) {
console.log('[MQTT] onMQTTStatusChange 被调用,数据:', data);
// 根据状态数据更新连接状态
if (data && typeof data.isConnected !== 'undefined') {
if (this.$store) {
this.$store.dispatch('mqtt/setConnected', data.isConnected);
console.log('[MQTT] 通过 isConnected 更新状态:', data.isConnected);
}
} else if (data && data.status) {
// 如果数据中有 status 字段,根据状态字符串判断
const isConnected = data.status === 'connected' || data.status === '已连接';
if (this.$store) {
this.$store.dispatch('mqtt/setConnected', isConnected);
console.log('[MQTT] 通过 status 更新状态:', isConnected);
}
}
}
}
};

View File

@@ -0,0 +1,65 @@
/**
* 平台信息管理 Mixin
*/
export default {
computed: {
currentPlatform() {
return this.$store ? this.$store.state.platform.currentPlatform : '-';
},
platformLoginStatus() {
return this.$store ? this.$store.state.platform.platformLoginStatus : '-';
},
platformLoginStatusColor() {
return this.$store ? this.$store.state.platform.platformLoginStatusColor : '#FF9800';
},
isPlatformLoggedIn() {
return this.$store ? this.$store.state.platform.isPlatformLoggedIn : false;
}
},
methods: {
// 接收主进程推送的平台登录状态(不做处理,直接更新 store
onPlatformLoginStatusUpdated(data) {
console.log('[PlatformMixin] 收到平台登录状态更新:', data);
if (this.$store && data) {
// 直接使用主进程提供的格式化数据
if (data.platform !== undefined) {
this.$store.dispatch('platform/updatePlatform', data.platform);
}
// 修复:确保正确处理 isLoggedIn 状态
const isLoggedIn = data.isLoggedIn !== undefined ? data.isLoggedIn : false;
this.$store.dispatch('platform/updatePlatformLoginStatus', {
status: data.status || (isLoggedIn ? '已登录' : '未登录'),
color: data.color || (isLoggedIn ? '#4CAF50' : '#FF9800'),
isLoggedIn: isLoggedIn
});
}
},
/**
* 主动检查平台登录状态
* 调用主进程接口检查状态,主进程会自动更新并通知前端
*/
async checkPlatformLoginStatus() {
if (!window.electronAPI || !window.electronAPI.invoke) {
console.warn('[PlatformMixin] electronAPI 不可用,无法检查平台登录状态');
return;
}
try {
const result = await window.electronAPI.invoke('auth:platform-login-status');
if (result && result.success) {
// 主进程已经通过 platform:login-status-updated 事件通知前端了
// 这里不需要再次更新 store因为事件处理已经更新了
console.log('[PlatformMixin] 平台登录状态检查完成:', {
platform: result.platformType,
isLoggedIn: result.isLoggedIn
});
}
} catch (error) {
console.error('[PlatformMixin] 检查平台登录状态失败:', error);
}
}
}
};

220
app/mixins/qrCodeMixin.js Normal file
View File

@@ -0,0 +1,220 @@
/**
* 二维码管理 Mixin
* 二维码刷新逻辑在渲染层处理,主进程只负责执行获取二维码的操作
*/
export default {
data() {
return {
qrCodeAutoRefreshInterval: null, // 二维码自动刷新定时器
qrCodeCountdownInterval: null, // 倒计时定时器
};
},
computed: {
qrCodeUrl() {
return this.$store ? this.$store.state.qrCode.qrCodeUrl : null;
},
qrCodeCountdown() {
return this.$store ? this.$store.state.qrCode.qrCodeCountdown : 0;
},
qrCodeCountdownActive() {
return this.$store ? this.$store.state.qrCode.qrCodeCountdownActive : false;
},
qrCodeExpired() {
return this.$store ? this.$store.state.qrCode.qrCodeExpired : false;
},
qrCodeRefreshCount() {
return this.$store ? this.$store.state.qrCode.qrCodeRefreshCount : 0;
},
isPlatformLoggedIn() {
return this.$store ? this.$store.state.platform.isPlatformLoggedIn : false;
}
},
methods: {
/**
* 获取二维码
*/
async getQrCode() {
try {
if (!window.electronAPI || !window.electronAPI.invoke) {
console.error('[二维码] electronAPI 不可用');
return;
}
const result = await window.electronAPI.invoke('command:execute', {
platform: 'boss',
action: 'get_login_qr_code',
data: { type: 'app' },
source: 'renderer'
});
if (result.success && result.data) {
const qrCodeUrl = result.data.qrCodeUrl || result.data.qr_code_url || result.data.oos_url || result.data.imageData;
const qrCodeOosUrl = result.data.qrCodeOosUrl || result.data.oos_url || null;
if (qrCodeUrl && this.$store) {
this.$store.dispatch('qrCode/setQrCode', { url: qrCodeUrl, oosUrl: qrCodeOosUrl });
if (this.addLog) {
this.addLog('success', '二维码已获取');
}
return true;
}
} else {
console.error('[二维码] 获取失败:', result.error || '未知错误');
if (this.addLog) {
this.addLog('error', `获取二维码失败: ${result.error || '未知错误'}`);
}
}
} catch (error) {
console.error('[二维码] 获取失败:', error);
if (this.addLog) {
this.addLog('error', `获取二维码失败: ${error.message}`);
}
}
return false;
},
/**
* 启动二维码自动刷新
*/
startQrCodeAutoRefresh() {
// 如果定时器已存在,不重复启动
if (this.qrCodeAutoRefreshInterval) {
return;
}
// 如果平台已登录,不启动自动刷新
if (this.isPlatformLoggedIn) {
return;
}
const QR_CODE_REFRESH_INTERVAL = 25000; // 25秒
const MAX_REFRESH_COUNT = 3; // 最大刷新次数
// 重置刷新次数
if (this.$store) {
this.$store.dispatch('qrCode/setQrCodeCountdown', {
countdown: 25,
isActive: true,
refreshCount: 0,
isExpired: false
});
}
// 立即获取一次二维码
this.getQrCode();
// 启动定时器每25秒刷新一次
this.qrCodeAutoRefreshInterval = setInterval(async () => {
// 检查平台登录状态
if (this.isPlatformLoggedIn) {
this.stopQrCodeAutoRefresh();
return;
}
// 检查刷新次数
const refreshCount = this.qrCodeRefreshCount;
if (refreshCount >= MAX_REFRESH_COUNT) {
// 已达到最大次数
if (this.$store) {
this.$store.dispatch('qrCode/setQrCodeCountdown', {
countdown: 0,
isActive: false,
refreshCount: refreshCount,
isExpired: true
});
}
this.stopQrCodeAutoRefresh();
return;
}
// 刷新二维码
const success = await this.getQrCode();
if (success) {
// 更新刷新次数
if (this.$store) {
this.$store.dispatch('qrCode/setQrCodeCountdown', {
countdown: 25,
isActive: true,
refreshCount: refreshCount + 1,
isExpired: false
});
}
}
}, QR_CODE_REFRESH_INTERVAL);
// 启动倒计时
this.startQrCodeCountdown();
},
/**
* 停止二维码自动刷新
*/
stopQrCodeAutoRefresh() {
if (this.qrCodeAutoRefreshInterval) {
clearInterval(this.qrCodeAutoRefreshInterval);
this.qrCodeAutoRefreshInterval = null;
}
this.stopQrCodeCountdown();
if (this.$store) {
this.$store.dispatch('qrCode/setQrCodeCountdown', {
countdown: 0,
isActive: false,
refreshCount: this.qrCodeRefreshCount,
isExpired: this.qrCodeExpired
});
}
},
/**
* 启动二维码倒计时
*/
startQrCodeCountdown() {
// 如果倒计时定时器已存在,不重复启动
if (this.qrCodeCountdownInterval) {
return;
}
// 每秒更新一次倒计时
this.qrCodeCountdownInterval = setInterval(() => {
if (this.qrCodeCountdownActive && this.qrCodeCountdown > 0) {
const newCountdown = this.qrCodeCountdown - 1;
if (this.$store) {
this.$store.dispatch('qrCode/setQrCodeCountdown', {
countdown: newCountdown,
isActive: true,
refreshCount: this.qrCodeRefreshCount,
isExpired: false
});
}
} else {
// 倒计时结束
this.stopQrCodeCountdown();
}
}, 1000);
},
/**
* 停止二维码倒计时
*/
stopQrCodeCountdown() {
if (this.qrCodeCountdownInterval) {
clearInterval(this.qrCodeCountdownInterval);
this.qrCodeCountdownInterval = null;
}
},
},
watch: {
// 监听平台登录状态,如果已登录则停止刷新
isPlatformLoggedIn(newVal) {
if (newVal) {
this.stopQrCodeAutoRefresh();
}
}
},
beforeUnmount() {
// 组件销毁时清理定时器
this.stopQrCodeAutoRefresh();
}
};

View File

@@ -0,0 +1,48 @@
/**
* 系统信息管理 Mixin
*/
export default {
computed: {
uptime() {
return this.$store ? this.$store.state.system.uptime : '0分钟';
},
cpuUsage() {
return this.$store ? this.$store.state.system.cpuUsage : '0%';
},
memUsage() {
return this.$store ? this.$store.state.system.memUsage : '0MB';
},
deviceId() {
return this.$store ? this.$store.state.system.deviceId : '-';
}
},
methods: {
// 更新运行时间(仅计算,不请求)
startSystemInfoUpdate() {
const updateUptime = () => {
if (this.$store && this.startTime) {
const elapsed = Math.floor((Date.now() - this.startTime) / 1000 / 60);
this.$store.dispatch('system/updateUptime', `${elapsed}分钟`);
}
};
updateUptime();
setInterval(updateUptime, 5000);
},
// 接收主进程推送的系统信息(不做处理,直接更新 store
updateSystemInfo(data) {
if (this.$store && data) {
if (data.cpu !== undefined) {
this.$store.dispatch('system/updateCpuUsage', data.cpu);
}
if (data.memory !== undefined) {
this.$store.dispatch('system/updateMemUsage', data.memory);
}
if (data.deviceId !== undefined) {
this.$store.dispatch('system/updateDeviceId', data.deviceId);
}
}
}
}
};

61
app/mixins/taskMixin.js Normal file
View File

@@ -0,0 +1,61 @@
/**
* 设备工作状态管理 Mixin
*/
export default {
computed: {
displayText() {
return this.$store ? this.$store.state.task.displayText : null;
},
nextExecuteTimeText() {
return this.$store ? this.$store.state.task.nextExecuteTimeText : null;
},
currentActivity() {
return this.$store ? this.$store.state.task.currentActivity : null;
},
pendingQueue() {
return this.$store ? this.$store.state.task.pendingQueue : null;
},
deviceStatus() {
return this.$store ? this.$store.state.task.deviceStatus : null;
}
},
methods: {
startTaskStatusUpdate() {
// 设备工作状态已通过 MQTT 实时推送,不再使用接口轮询
// 设备工作状态通过 device_work_status_${snCode} 主题推送,由 mqttService 处理并发送到渲染进程
// 通过 onDeviceWorkStatus 方法接收并更新状态
console.log('[TaskMixin] 设备工作状态更新已启动,使用 MQTT 实时推送');
},
stopTaskStatusUpdate() {
if (this.$store) {
this.$store.dispatch('task/clearDeviceWorkStatus');
}
},
/**
* 处理设备工作状态通知
* 服务端已格式化好显示文本,客户端直接使用,不做复杂处理
*/
onDeviceWorkStatus(workStatus) {
if (!workStatus || !this.$store) {
console.warn('[Renderer] 收到设备工作状态但数据无效:', workStatus);
return;
}
// 直接更新设备工作状态到 store服务端已处理好所有显示逻辑
try {
this.$store.dispatch('task/updateDeviceWorkStatus', workStatus);
// 验证更新是否成功
this.$nextTick(() => {
const state = this.$store.state.task;
});
} catch (error) {
console.error('[Renderer] 更新设备工作状态失败:', error);
}
}
}
};

138
app/mixins/updateMixin.js Normal file
View File

@@ -0,0 +1,138 @@
/**
* 更新管理 Mixin
*/
export default {
computed: {
updateDialogVisible() {
return this.$store ? this.$store.state.update.updateDialogVisible : false;
},
updateInfo() {
return this.$store ? this.$store.state.update.updateInfo : null;
},
updateProgress() {
return this.$store ? this.$store.state.update.updateProgress : 0;
},
isDownloading() {
return this.$store ? this.$store.state.update.isDownloading : false;
},
downloadState() {
return this.$store ? this.$store.state.update.downloadState : {
progress: 0,
downloadedBytes: 0,
totalBytes: 0
};
}
},
methods: {
// 接收主进程推送的更新信息(不做处理,直接更新 store
onUpdateAvailable(updateInfo) {
if (!updateInfo) {
return;
}
if (this.$store) {
this.$store.dispatch('update/setUpdateInfo', updateInfo);
this.$store.dispatch('update/showUpdateDialog');
}
if (this.addLog) {
this.addLog('info', `发现新版本: ${updateInfo.version || '未知'}`);
}
},
// 接收主进程推送的更新进度(不做处理,直接更新 store
onUpdateProgress(progressData) {
if (this.$store && progressData) {
this.$store.dispatch('update/setDownloadState', {
progress: progressData.progress || 0,
downloadedBytes: progressData.downloadedBytes || 0,
totalBytes: progressData.totalBytes || 0
});
this.$store.dispatch('update/setUpdateProgress', progressData.progress || 0);
this.$store.dispatch('update/setDownloading', true);
}
},
// 接收主进程推送的下载完成通知(不做处理,直接更新 store
onUpdateDownloaded(data) {
if (this.$store) {
this.$store.dispatch('update/setDownloading', false);
this.$store.dispatch('update/setUpdateProgress', 100);
}
if (this.addLog) {
this.addLog('success', '更新包下载完成');
}
if (this.showNotification) {
this.showNotification('更新下载完成', '更新包已下载完成,是否立即安装?');
}
},
// 接收主进程推送的更新错误(不做处理,直接显示)
onUpdateError(errorData) {
if (this.$store) {
this.$store.dispatch('update/setDownloading', false);
}
const errorMsg = errorData?.error || '更新失败';
if (this.addLog) {
this.addLog('error', `更新错误: ${errorMsg}`);
}
if (this.showNotification) {
this.showNotification('更新失败', errorMsg);
}
},
closeUpdateDialog() {
if (this.$store) {
this.$store.dispatch('update/hideUpdateDialog');
}
},
// 下载更新(调用主进程接口,不做业务逻辑处理)
async startDownload() {
const updateInfoData = this.updateInfo;
if (!updateInfoData || !updateInfoData.downloadUrl) {
if (this.addLog) {
this.addLog('error', '更新信息不存在');
}
return;
}
if (!window.electronAPI) {
if (this.addLog) {
this.addLog('error', 'Electron API不可用');
}
return;
}
try {
await window.electronAPI.invoke('update:download', updateInfoData.downloadUrl);
} catch (error) {
if (this.addLog) {
this.addLog('error', `下载更新失败: ${error.message}`);
}
}
},
// 安装更新(调用主进程接口,不做业务逻辑处理)
async installUpdate() {
if (!window.electronAPI) {
if (this.addLog) {
this.addLog('error', 'Electron API不可用');
}
return;
}
try {
await window.electronAPI.invoke('update:install');
setTimeout(() => {
this.closeUpdateDialog();
}, 1000);
} catch (error) {
if (this.addLog) {
this.addLog('error', `安装更新失败: ${error.message}`);
}
}
}
}
};

1834
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
app/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "boss-automation-renderer",
"version": "1.0.8",
"description": "BOSS自动化工具 - 前端渲染进程",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@primevue/themes": "^4.0.0",
"axios": "^1.13.2",
"primeicons": "^7.0.0",
"primevue": "^4.5.4",
"vue": "^3.3.0",
"vue-router": "^4.6.4",
"vuex": "^4.1.0",
"vuex-persistedstate": "^4.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.0",
"less": "^4.2.0",
"vite": "^5.4.21"
}
}

99
app/router/index.js Normal file
View File

@@ -0,0 +1,99 @@
/**
* Vue Router 路由配置
*/
import { createRouter, createWebHashHistory } from 'vue-router';
import LoginPage from '../views/LoginPage.vue';
import ConsolePage from '../views/ConsolePage.vue';
import DeliveryPage from '../views/DeliveryPage.vue';
import InvitePage from '../views/InvitePage.vue';
import FeedbackPage from '../views/FeedbackPage.vue';
import PurchasePage from '../views/PurchasePage.vue';
import LogPage from '../views/LogPage.vue';
import store from '../store';
const routes = [
{
path: '/',
redirect: '/console'
},
{
path: '/login',
name: 'Login',
component: LoginPage,
meta: {
requiresAuth: false,
showSidebar: false
}
},
{
path: '/console',
name: 'Console',
component: ConsolePage,
meta: {
requiresAuth: true
}
},
{
path: '/delivery',
name: 'Delivery',
component: DeliveryPage,
meta: {
requiresAuth: true
}
},
{
path: '/invite',
name: 'Invite',
component: InvitePage,
meta: {
requiresAuth: true
}
},
{
path: '/feedback',
name: 'Feedback',
component: FeedbackPage,
meta: {
requiresAuth: true
}
},
{
path: '/log',
name: 'Log',
component: LogPage,
meta: {
requiresAuth: true
}
},
{
path: '/purchase',
name: 'Purchase',
component: PurchasePage,
meta: {
requiresAuth: true
}
}
];
const router = createRouter({
history: createWebHashHistory(),
routes
});
// 路由守卫
router.beforeEach((to, from, next) => {
const isLoggedIn = store.state.auth.isLoggedIn;
if (to.meta.requiresAuth && !isLoggedIn) {
// 需要登录但未登录,跳转到登录页
next('/login');
} else if (to.path === '/login' && isLoggedIn) {
// 已登录访问登录页,跳转到控制台
next('/console');
} else {
next();
}
});
export default router;

49
app/store/index.js Normal file
View File

@@ -0,0 +1,49 @@
/**
* Vuex Store 主入口
*/
import { createStore } from 'vuex';
import app from './modules/app';
import auth from './modules/auth';
import mqtt from './modules/mqtt';
import task from './modules/task';
import system from './modules/system';
import platform from './modules/platform';
import qrCode from './modules/qrCode';
import update from './modules/update';
import delivery from './modules/delivery';
import log from './modules/log';
import config from './modules/config';
import createPersistedState from 'vuex-persistedstate'
const store = createStore({
modules: {
app,
auth,
mqtt,
task,
system,
platform,
qrCode,
update,
delivery,
log,
config
},
plugins: [createPersistedState({
key: 'boss-auto-app',
storage: window.localStorage, // 或 sessionStorage
paths: ['auth', 'config'] // 只持久化这些
})]
});
// 调试输出localStorage中保存的持久化数据
console.log('[Store] localStorage中保存的数据:', {
'boss-auto-app': localStorage.getItem('boss-auto-app'),
'api_token': localStorage.getItem('api_token')
});
// 应用启动时,从 store 恢复登录状态
store.dispatch('auth/restoreLoginStatus');
export default store;

28
app/store/modules/app.js Normal file
View File

@@ -0,0 +1,28 @@
/**
* 应用全局状态管理
*/
export default {
namespaced: true,
state: {
currentVersion: '1.0.0',
isLoading: true,
startTime: Date.now()
},
mutations: {
SET_VERSION(state, version) {
state.currentVersion = version;
},
SET_LOADING(state, loading) {
state.isLoading = loading;
}
},
actions: {
setVersion({ commit }, version) {
commit('SET_VERSION', version);
},
setLoading({ commit }, loading) {
commit('SET_LOADING', loading);
}
}
};

291
app/store/modules/auth.js Normal file
View File

@@ -0,0 +1,291 @@
/**
* 用户认证状态管理
*/
import { login as apiLogin, setToken, clearToken, getToken } from '../../utils/api';
export default {
namespaced: true,
state: {
email: '',
password: '',
isLoggedIn: false,
loginButtonText: '登录',
userName: '',
remainingDays: null,
snCode: '',
deviceId: null,
platformType: null,
userId: null,
listenChannel: '-',
userLoggedOut: false,
rememberMe: false,
userMenuInfo: {
userName: '',
snCode: ''
}
},
mutations: {
SET_EMAIL(state, email) {
state.email = email;
},
SET_PASSWORD(state, password) {
state.password = password;
},
SET_LOGGED_IN(state, isLoggedIn) {
state.isLoggedIn = isLoggedIn;
},
SET_LOGIN_BUTTON_TEXT(state, text) {
state.loginButtonText = text;
},
SET_USER_NAME(state, userName) {
state.userName = userName;
state.userMenuInfo.userName = userName;
},
SET_REMAINING_DAYS(state, days) {
state.remainingDays = days;
},
SET_SN_CODE(state, snCode) {
state.snCode = snCode;
state.userMenuInfo.snCode = snCode;
state.listenChannel = snCode ? `request_${snCode}` : '-';
},
SET_DEVICE_ID(state, deviceId) {
state.deviceId = deviceId;
},
SET_PLATFORM_TYPE(state, platformType) {
state.platformType = platformType;
},
SET_USER_ID(state, userId) {
state.userId = userId;
},
SET_USER_LOGGED_OUT(state, value) {
state.userLoggedOut = value;
},
SET_REMEMBER_ME(state, value) {
state.rememberMe = value;
},
SET_USER_MENU_INFO(state, info) {
state.userMenuInfo = { ...state.userMenuInfo, ...info };
},
CLEAR_AUTH(state) {
state.isLoggedIn = false;
state.loginButtonText = '登录';
state.listenChannel = '-';
state.snCode = '';
state.deviceId = null;
state.platformType = null;
state.userId = null;
state.userName = '';
state.remainingDays = null;
state.userMenuInfo = {
userName: '',
snCode: ''
};
state.password = '';
state.userLoggedOut = true;
}
},
actions: {
/**
* 从 store 恢复登录状态
* 状态已通过持久化插件从 localStorage 恢复
*/
async restoreLoginStatus({ commit, state }) {
try {
const token = getToken();
console.log('[Auth Store] 尝试恢复登录状态:', {
hasToken: !!token,
userLoggedOut: state.userLoggedOut,
snCode: state.snCode,
userName: state.userName,
isLoggedIn: state.isLoggedIn,
persistedState: {
email: state.email,
platformType: state.platformType,
userId: state.userId,
deviceId: state.deviceId
}
});
// 如果有 token 且用户没有手动退出,且有用户信息,则恢复登录状态
if (token && !state.userLoggedOut && (state.snCode || state.userName)) {
console.log('[Auth Store] 满足恢复登录条件,开始恢复...');
commit('SET_LOGGED_IN', true);
commit('SET_LOGIN_BUTTON_TEXT', '注销登录');
// 恢复登录状态后,同步用户信息到主进程(确保 platform_type 等数据同步)
if (window.electronAPI && window.electronAPI.invoke) {
try {
await window.electronAPI.invoke('auth:sync-user-info', {
platform_type: state.platformType || null,
sn_code: state.snCode || '',
user_id: state.userId || null,
user_name: state.userName || '',
device_id: state.deviceId || null
});
console.log('[Auth Store] 用户信息已同步到主进程');
// 恢复登录状态后,手动连接 MQTT
if (state.snCode) {
try {
const mqttResult = await window.electronAPI.invoke('mqtt:connect', state.snCode);
console.log('[Auth Store] 恢复登录后 MQTT 连接结果:', mqttResult);
// 如果连接成功立即更新MQTT状态到store
if (mqttResult && mqttResult.success && mqttResult.isConnected) {
commit('mqtt/SET_CONNECTED', true, { root: true });
commit('mqtt/SET_STATUS', '已连接', { root: true });
console.log('[Auth Store] 恢复登录后 MQTT 状态已更新到 store');
}
} catch (mqttError) {
console.warn('[Auth Store] 恢复登录后 MQTT 连接失败:', mqttError);
}
}
} catch (error) {
console.warn('[Auth Store] 恢复登录状态时同步用户信息到主进程失败:', error);
}
} else {
console.warn('[Auth Store] electronAPI 不可用,无法同步用户信息到主进程');
}
console.log('[Auth Store] 登录状态恢复完成');
} else {
console.log('[Auth Store] 不满足恢复登录条件,跳过恢复');
}
} catch (error) {
console.error('[Auth Store] 恢复登录状态失败:', error);
}
},
/**
* 用户登录
* @param {Object} context Vuex context
* @param {Object} payload 登录参数
* @param {string} payload.email 邮箱
* @param {string} payload.password 密码
* @param {string} payload.deviceId 设备ID可选
* @returns {Promise<Object>} 登录结果
*/
async login({ commit }, { email, password, deviceId = null }) {
try {
// 调用登录接口
const response = await apiLogin(email, password, deviceId);
if (response.code === 0 && response.data) {
// 登录成功,更新状态
const { token, user, device_id } = response.data;
// token 已在 apiLogin 中设置,这里不需要重复设置
commit('SET_EMAIL', email);
commit('SET_PASSWORD', password);
commit('SET_LOGGED_IN', true);
commit('SET_LOGIN_BUTTON_TEXT', '注销登录');
commit('SET_USER_NAME', user?.name || email);
commit('SET_REMAINING_DAYS', user?.remaining_days || null);
commit('SET_SN_CODE', user?.sn_code || '');
commit('SET_DEVICE_ID', device_id || deviceId);
commit('SET_PLATFORM_TYPE', user?.platform_type || null);
commit('SET_USER_ID', user?.id || null);
commit('SET_USER_LOGGED_OUT', false);
// 登录成功后,同步更新 platform store 的显示名称
if (user?.platform_type) {
const platformNames = {
'boss': 'BOSS直聘',
'liepin': '猎聘',
'zhilian': '智联招聘',
'1': 'BOSS直聘'
};
const displayName = platformNames[user.platform_type] || user.platform_type;
commit('platform/SET_CURRENT_PLATFORM', displayName, { root: true });
// 初始化平台登录状态为未登录(需要用户通过平台登录)
commit('platform/SET_PLATFORM_LOGIN_STATUS', {
status: '未登录',
color: '#FF9800',
isLoggedIn: false
}, { root: true });
console.log('[Auth Store] 平台信息已初始化');
}
// 同步用户信息到主进程的 authService确保主进程也能获取到 platform_type 和 token
if (window.electronAPI && window.electronAPI.invoke) {
try {
await window.electronAPI.invoke('auth:sync-user-info', {
token: token, // 同步 token 到主进程
platform_type: user?.platform_type || null,
sn_code: user?.sn_code || '',
user_id: user?.id || null,
user_name: user?.name || email, // 修复:使用 email 而不是 phone
device_id: device_id || deviceId
});
// 登录成功后,手动连接 MQTT
if (user?.sn_code) {
try {
const mqttResult = await window.electronAPI.invoke('mqtt:connect', user.sn_code);
console.log('[Auth Store] MQTT 连接结果:', mqttResult);
// 如果连接成功立即更新MQTT状态到store
if (mqttResult && mqttResult.success && mqttResult.isConnected) {
commit('mqtt/SET_CONNECTED', true, { root: true });
commit('mqtt/SET_STATUS', '已连接', { root: true });
console.log('[Auth Store] MQTT 状态已更新到 store');
}
} catch (mqttError) {
console.warn('[Auth Store] MQTT 连接失败:', mqttError);
}
}
} catch (error) {
console.warn('[Auth Store] 同步用户信息到主进程失败:', error);
}
}
return {
success: true,
data: response.data
};
} else {
// 登录失败
return {
success: false,
error: response.message || '登录失败'
};
}
} catch (error) {
console.error('[Auth Store] 登录失败:', error);
return {
success: false,
error: error.message || '登录过程中发生错误'
};
}
},
logout({ commit }) {
commit('CLEAR_AUTH');
clearToken();
},
updateUserInfo({ commit }, userInfo) {
if (userInfo.name) commit('SET_USER_NAME', userInfo.name);
if (userInfo.sn_code) commit('SET_SN_CODE', userInfo.sn_code);
if (userInfo.device_id) commit('SET_DEVICE_ID', userInfo.device_id);
if (userInfo.remaining_days !== undefined) {
commit('SET_REMAINING_DAYS', userInfo.remaining_days);
}
}
},
getters: {
isLoggedIn: state => state.isLoggedIn,
userInfo: state => ({
email: state.email,
userName: state.userName,
snCode: state.snCode,
deviceId: state.deviceId,
remainingDays: state.remainingDays
})
}
};

View File

@@ -0,0 +1,72 @@
/**
* 应用配置状态管理
* 统一管理所有配置,不再使用 localStorage
*/
export default {
namespaced: true,
state: {
// 用户相关配置
email: '',
userLoggedOut: false,
rememberMe: false,
// 应用设置
appSettings: {
autoStart: false,
startOnBoot: false,
enableNotifications: true,
soundAlert: true
},
// 设备ID客户端生成需要持久化
deviceId: null
},
mutations: {
SET_EMAIL(state, email) {
state.email = email;
},
SET_USER_LOGGED_OUT(state, value) {
state.userLoggedOut = value;
},
SET_REMEMBER_ME(state, value) {
state.rememberMe = value;
},
SET_APP_SETTINGS(state, settings) {
state.appSettings = { ...state.appSettings, ...settings };
},
UPDATE_APP_SETTING(state, { key, value }) {
state.appSettings[key] = value;
},
SET_DEVICE_ID(state, deviceId) {
state.deviceId = deviceId;
}
},
actions: {
setEmail({ commit }, email) {
commit('SET_EMAIL', email);
},
setUserLoggedOut({ commit }, value) {
commit('SET_USER_LOGGED_OUT', value);
},
setRememberMe({ commit }, value) {
commit('SET_REMEMBER_ME', value);
},
updateAppSettings({ commit }, settings) {
commit('SET_APP_SETTINGS', settings);
},
updateAppSetting({ commit }, { key, value }) {
commit('UPDATE_APP_SETTING', { key, value });
},
setDeviceId({ commit }, deviceId) {
commit('SET_DEVICE_ID', deviceId);
}
},
getters: {
email: state => state.email,
userLoggedOut: state => state.userLoggedOut,
rememberMe: state => state.rememberMe,
appSettings: state => state.appSettings,
deviceId: state => state.deviceId
}
};

View File

@@ -0,0 +1,165 @@
/**
* 投递配置状态管理
*/
export default {
namespaced: true,
state: {
deliveryStats: {
todayCount: 0,
weekCount: 0,
monthCount: 0,
totalCount: 0,
successCount: 0,
failedCount: 0,
pendingCount: 0,
interviewCount: 0,
successRate: 0,
interviewRate: 0
},
deliveryConfig: {
autoDelivery: false,
interval: 30,
minSalary: 15000,
maxSalary: 30000,
scrollPages: 3,
maxPerBatch: 10,
filterKeywords: '',
excludeKeywords: '',
startTime: '09:00',
endTime: '18:00',
workdaysOnly: true
}
},
mutations: {
SET_DELIVERY_STATS(state, stats) {
state.deliveryStats = { ...state.deliveryStats, ...stats };
},
SET_DELIVERY_CONFIG(state, config) {
state.deliveryConfig = { ...state.deliveryConfig, ...config };
},
UPDATE_DELIVERY_CONFIG(state, { key, value }) {
state.deliveryConfig[key] = value;
}
},
actions: {
updateDeliveryStats({ commit }, stats) {
commit('SET_DELIVERY_STATS', stats);
},
async loadDeliveryStats({ commit, rootState }) {
try {
const snCode = rootState.auth.snCode;
if (!snCode) {
console.warn('[Delivery Store] 没有 snCode无法加载统计数据');
return { success: false, error: '请先登录' };
}
// 动态导入 API
const applyRecordsAPI = (await import('../../api/apply_records.js')).default;
const result = await applyRecordsAPI.getStatistics(snCode);
// 后端返回格式:{ code: 0, data: {...} }
if (result && result.code === 0 && result.data) {
commit('SET_DELIVERY_STATS', result.data);
return { success: true };
} else {
const errorMsg = result?.message || '加载统计失败';
console.error('[Delivery Store] 加载统计失败:', result);
return { success: false, error: errorMsg };
}
} catch (error) {
console.error('[Delivery Store] 加载统计失败:', error);
return { success: false, error: error.message || '加载统计失败' };
}
},
updateDeliveryConfig({ commit }, { key, value }) {
commit('UPDATE_DELIVERY_CONFIG', { key, value });
},
setDeliveryConfig({ commit }, config) {
commit('SET_DELIVERY_CONFIG', config);
},
async saveDeliveryConfig({ state, rootState }) {
try {
const snCode = rootState.auth.snCode;
if (!snCode) {
return { success: false, error: '请先登录' };
}
// 将前端配置格式转换为后端格式
const deliverConfig = {
auto_delivery: state.deliveryConfig.autoDelivery || false,
interval: state.deliveryConfig.interval || 30,
min_salary: state.deliveryConfig.minSalary || 0,
max_salary: state.deliveryConfig.maxSalary || 0,
scroll_pages: state.deliveryConfig.scrollPages || 3,
max_per_batch: state.deliveryConfig.maxPerBatch || 10,
filter_keywords: state.deliveryConfig.filterKeywords || '',
exclude_keywords: state.deliveryConfig.excludeKeywords || '',
start_time: state.deliveryConfig.startTime || '09:00',
end_time: state.deliveryConfig.endTime || '18:00',
// 将布尔值转换为数字 1/0后端期望数字类型
workdays_only: state.deliveryConfig.workdaysOnly ? 1 : 0
};
// 动态导入 API
const deliveryConfigAPI = (await import('../../api/delivery_config.js')).default;
const result = await deliveryConfigAPI.saveConfig(snCode, deliverConfig);
// 后端返回格式:{ code: 0, message: 'success', data: {...} }
// 或者:{ success: true, ... }
if (result && (result.code === 0 || result.success === true)) {
return { success: true };
} else {
const errorMsg = result?.message || result?.error || '保存失败';
console.error('[Delivery Store] 保存配置失败:', result);
return { success: false, error: errorMsg };
}
} catch (error) {
console.error('[Delivery Store] 保存配置失败:', error);
return { success: false, error: error.message || '保存配置失败' };
}
},
async loadDeliveryConfig({ commit, rootState }) {
try {
const snCode = rootState.auth.snCode;
if (!snCode) {
// 如果没有登录,使用默认配置
return { success: true };
}
// 动态导入 API
const deliveryConfigAPI = (await import('../../api/delivery_config.js')).default;
const result = await deliveryConfigAPI.getConfig(snCode);
// 后端返回格式:{ code: 0, data: { deliver_config: {...} } }
if (result && result.data && result.data.deliver_config) {
const deliverConfig = result.data.deliver_config;
// 将后端格式转换为前端格式
const frontendConfig = {
autoDelivery: deliverConfig.auto_delivery || false,
interval: deliverConfig.interval || 30,
minSalary: deliverConfig.min_salary || 0,
maxSalary: deliverConfig.max_salary || 0,
scrollPages: deliverConfig.scroll_pages || 3,
maxPerBatch: deliverConfig.max_per_batch || 10,
filterKeywords: deliverConfig.filter_keywords || '',
excludeKeywords: deliverConfig.exclude_keywords || '',
startTime: deliverConfig.start_time || '09:00',
endTime: deliverConfig.end_time || '18:00',
// 将数字 1/0 转换为布尔值,前端使用布尔值
workdaysOnly: deliverConfig.workdays_only === 1 || deliverConfig.workdays_only === true
};
commit('SET_DELIVERY_CONFIG', frontendConfig);
return { success: true };
} else {
// 如果获取失败,使用默认配置
return { success: true };
}
} catch (error) {
console.error('[Delivery Store] 加载配置失败:', error);
// 如果加载失败,使用默认配置
return { success: true };
}
}
}
};

63
app/store/modules/log.js Normal file
View File

@@ -0,0 +1,63 @@
/**
* 日志状态管理
*/
export default {
namespaced: true,
state: {
logs: [],
maxLogs: 1000
},
mutations: {
ADD_LOG(state, logEntry) {
state.logs.push(logEntry);
if (state.logs.length > state.maxLogs) {
state.logs.shift();
}
},
CLEAR_LOGS(state) {
state.logs = [];
}
},
actions: {
addLog({ commit }, { level, message }) {
const timestamp = new Date().toLocaleString();
const logEntry = {
time: timestamp,
level: level.toUpperCase(),
message: message
};
commit('ADD_LOG', logEntry);
},
clearLogs({ commit }) {
commit('CLEAR_LOGS');
commit('ADD_LOG', {
time: new Date().toLocaleString(),
level: 'INFO',
message: '日志已清空'
});
},
exportLogs({ state, commit }) {
const logText = state.logs.map(log =>
`[${log.time}] [${log.level}] ${log.message}`
).join('\n');
const blob = new Blob([logText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `logs_${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
a.click();
URL.revokeObjectURL(url);
commit('ADD_LOG', {
time: new Date().toLocaleString(),
level: 'INFO',
message: '日志已导出'
});
}
},
getters: {
logEntries: state => state.logs
}
};

25
app/store/modules/mqtt.js Normal file
View File

@@ -0,0 +1,25 @@
/**
* MQTT 连接状态管理
*/
export default {
namespaced: true,
state: {
isConnected: false,
mqttStatus: '未连接'
},
mutations: {
SET_CONNECTED(state, isConnected) {
state.isConnected = isConnected;
},
SET_STATUS(state, status) {
state.mqttStatus = status;
}
},
actions: {
setConnected({ commit }, isConnected) {
commit('SET_CONNECTED', isConnected);
commit('SET_STATUS', isConnected ? '已连接' : '未连接');
}
}
};

View File

@@ -0,0 +1,31 @@
/**
* 平台信息状态管理
*/
export default {
namespaced: true,
state: {
currentPlatform: '-',
platformLoginStatus: '-',
platformLoginStatusColor: '#FF9800',
isPlatformLoggedIn: false
},
mutations: {
SET_CURRENT_PLATFORM(state, platform) {
state.currentPlatform = platform;
},
SET_PLATFORM_LOGIN_STATUS(state, { status, color, isLoggedIn }) {
state.platformLoginStatus = status;
state.platformLoginStatusColor = color || '#FF9800';
state.isPlatformLoggedIn = isLoggedIn || false;
}
},
actions: {
updatePlatform({ commit }, platform) {
commit('SET_CURRENT_PLATFORM', platform);
},
updatePlatformLoginStatus({ commit }, { status, color, isLoggedIn }) {
commit('SET_PLATFORM_LOGIN_STATUS', { status, color, isLoggedIn });
}
}
};

View File

@@ -0,0 +1,42 @@
/**
* 二维码状态管理
*/
export default {
namespaced: true,
state: {
qrCodeUrl: null,
qrCodeOosUrl: null,
qrCodeCountdown: 0,
qrCodeCountdownActive: false,
qrCodeRefreshCount: 0,
qrCodeExpired: false
},
mutations: {
SET_QR_CODE_URL(state, { url, oosUrl }) {
state.qrCodeUrl = url;
state.qrCodeOosUrl = oosUrl || null;
},
SET_QR_CODE_COUNTDOWN(state, { countdown, isActive, refreshCount, isExpired }) {
state.qrCodeCountdown = countdown || 0;
state.qrCodeCountdownActive = isActive || false;
state.qrCodeRefreshCount = refreshCount || 0;
state.qrCodeExpired = isExpired || false;
},
CLEAR_QR_CODE(state) {
state.qrCodeUrl = null;
state.qrCodeOosUrl = null;
}
},
actions: {
setQrCode({ commit }, { url, oosUrl }) {
commit('SET_QR_CODE_URL', { url, oosUrl });
},
setQrCodeCountdown({ commit }, data) {
commit('SET_QR_CODE_COUNTDOWN', data);
},
clearQrCode({ commit }) {
commit('CLEAR_QR_CODE');
}
}
};

View File

@@ -0,0 +1,41 @@
/**
* 系统信息状态管理
*/
export default {
namespaced: true,
state: {
uptime: '0分钟',
cpuUsage: '0%',
memUsage: '0MB',
deviceId: '-'
},
mutations: {
SET_UPTIME(state, uptime) {
state.uptime = uptime;
},
SET_CPU_USAGE(state, usage) {
state.cpuUsage = usage;
},
SET_MEM_USAGE(state, usage) {
state.memUsage = usage;
},
SET_DEVICE_ID(state, deviceId) {
state.deviceId = deviceId || '-';
}
},
actions: {
updateUptime({ commit }, uptime) {
commit('SET_UPTIME', uptime);
},
updateCpuUsage({ commit }, usage) {
commit('SET_CPU_USAGE', `${usage.toFixed(1)}%`);
},
updateMemUsage({ commit }, usage) {
commit('SET_MEM_USAGE', `${usage}MB`);
},
updateDeviceId({ commit }, deviceId) {
commit('SET_DEVICE_ID', deviceId);
}
}
};

81
app/store/modules/task.js Normal file
View File

@@ -0,0 +1,81 @@
/**
* 设备工作状态和任务统计管理
*/
export default {
namespaced: true,
state: {
// 设备工作状态
displayText: null, // 服务端格式化好的显示文本
nextExecuteTimeText: null, // 服务端格式化好的下次执行时间文本
currentActivity: null, // 当前活动(任务或指令)
pendingQueue: null, // 待执行队列信息
deviceStatus: null, // 设备状态
// 任务统计
taskStats: {
todayCount: 0,
weekCount: 0,
monthCount: 0,
totalCount: 0,
completedCount: 0,
runningCount: 0,
pendingCount: 0,
failedCount: 0,
completionRate: 0
}
},
mutations: {
SET_DEVICE_WORK_STATUS(state, workStatus) {
state.displayText = workStatus.displayText || null;
state.nextExecuteTimeText = workStatus.pendingQueue?.nextExecuteTimeText || null;
state.currentActivity = workStatus.currentActivity || null;
state.pendingQueue = workStatus.pendingQueue || null;
state.deviceStatus = workStatus.deviceStatus || null;
},
CLEAR_DEVICE_WORK_STATUS(state) {
state.displayText = null;
state.nextExecuteTimeText = null;
state.currentActivity = null;
state.pendingQueue = null;
state.deviceStatus = null;
},
SET_TASK_STATS(state, stats) {
state.taskStats = { ...state.taskStats, ...stats };
}
},
actions: {
updateDeviceWorkStatus({ commit }, workStatus) {
commit('SET_DEVICE_WORK_STATUS', workStatus);
},
clearDeviceWorkStatus({ commit }) {
commit('CLEAR_DEVICE_WORK_STATUS');
},
async loadTaskStats({ commit, rootState }) {
try {
const snCode = rootState.auth.snCode;
if (!snCode) {
console.warn('[Task Store] 没有 snCode无法加载任务统计');
return { success: false, error: '请先登录' };
}
// 调用任务统计接口
const result = await window.electronAPI.invoke('http:get', '/task/statistics', { sn_code: snCode });
// 后端返回格式:{ code: 0, data: {...} }
if (result && result.code === 0 && result.data) {
commit('SET_TASK_STATS', result.data);
return { success: true };
} else {
const errorMsg = result?.message || '加载任务统计失败';
console.error('[Task Store] 加载任务统计失败:', result);
return { success: false, error: errorMsg };
}
} catch (error) {
console.error('[Task Store] 加载任务统计失败:', error);
return { success: false, error: error.message || '加载任务统计失败' };
}
}
}
};

View File

@@ -0,0 +1,55 @@
/**
* 更新状态管理
*/
export default {
namespaced: true,
state: {
updateDialogVisible: false,
updateInfo: null,
updateProgress: 0,
isDownloading: false,
downloadState: {
progress: 0,
downloadedBytes: 0,
totalBytes: 0
}
},
mutations: {
SET_UPDATE_DIALOG_VISIBLE(state, visible) {
state.updateDialogVisible = visible;
},
SET_UPDATE_INFO(state, info) {
state.updateInfo = info;
},
SET_UPDATE_PROGRESS(state, progress) {
state.updateProgress = progress;
},
SET_DOWNLOADING(state, downloading) {
state.isDownloading = downloading;
},
SET_DOWNLOAD_STATE(state, downloadState) {
state.downloadState = { ...state.downloadState, ...downloadState };
}
},
actions: {
showUpdateDialog({ commit }) {
commit('SET_UPDATE_DIALOG_VISIBLE', true);
},
hideUpdateDialog({ commit }) {
commit('SET_UPDATE_DIALOG_VISIBLE', false);
},
setUpdateInfo({ commit }, info) {
commit('SET_UPDATE_INFO', info);
},
setUpdateProgress({ commit }, progress) {
commit('SET_UPDATE_PROGRESS', progress);
},
setDownloading({ commit }, downloading) {
commit('SET_DOWNLOADING', downloading);
},
setDownloadState({ commit }, state) {
commit('SET_DOWNLOAD_STATE', state);
}
}
};

409
app/utils/api.js Normal file
View File

@@ -0,0 +1,409 @@
/**
* 前端 API 工具类
* 用于在渲染进程Vue中直接调用后端 API
*/
// API 基础 URL从环境变量或配置中获取
/**
* HTTP 请求工具
*/
class ApiClient {
constructor() {
this.token = this.getToken();
// 缓存 electronAPI 可用状态,避免每次检查
this._electronAPIChecked = false;
this._electronAPIAvailable = false;
}
/**
* 检查 window.electronAPI 是否可用(快速检查,不等待)
* @param {boolean} forceCheck 强制重新检查(默认 false
* @returns {boolean} 是否可用
*/
isElectronAPIAvailable(forceCheck = false) {
// 如果已经检查过且不需要强制检查,直接返回缓存结果
if (this._electronAPIChecked && !forceCheck) {
return this._electronAPIAvailable;
}
// 快速检查
const available = !!(window.electronAPI && window.electronAPI.http);
// 缓存结果
this._electronAPIChecked = true;
this._electronAPIAvailable = available;
return available;
}
/**
* 等待 window.electronAPI 可用(仅在首次检查时等待)
* @param {number} maxWaitTime 最大等待时间毫秒默认500ms减少等待时间
* @param {number} checkInterval 检查间隔毫秒默认50ms加快检查频率
* @returns {Promise<boolean>} 是否可用
*/
async waitForElectronAPI(maxWaitTime = 500, checkInterval = 50) {
// 如果已经确认可用,直接返回
if (this.isElectronAPIAvailable()) {
return true;
}
// 如果已经检查过但不可用,直接返回 false不重复等待
if (this._electronAPIChecked && !this._electronAPIAvailable) {
return false;
}
// 首次检查时短暂等待preload 脚本是同步的,通常立即可用)
const startTime = Date.now();
return new Promise((resolve) => {
const check = () => {
if (window.electronAPI && window.electronAPI.http) {
this._electronAPIChecked = true;
this._electronAPIAvailable = true;
resolve(true);
return;
}
const elapsed = Date.now() - startTime;
if (elapsed >= maxWaitTime) {
// 超时后标记为不可用
this._electronAPIChecked = true;
this._electronAPIAvailable = false;
resolve(false);
return;
}
setTimeout(check, checkInterval);
};
check();
});
}
/**
* 使用 electronAPI.http 发送请求
* @param {string} method HTTP 方法
* @param {string} endpoint API 端点
* @param {Object} data 请求数据
* @returns {Promise<Object>}
*/
/**
* 将响应式对象Proxy转换为普通对象以便通过 IPC 传递
* @param {any} obj 要转换的对象
* @returns {any} 普通对象
*/
toPlainObject(obj) {
if (obj === null || obj === undefined) {
return obj;
}
// 如果是基本类型,直接返回
if (typeof obj !== 'object') {
return obj;
}
// 如果是 Date 对象,转换为 ISO 字符串
if (obj instanceof Date) {
return obj.toISOString();
}
// 如果是 RegExp 对象,转换为字符串
if (obj instanceof RegExp) {
return obj.toString();
}
// 尝试使用 JSON 序列化/反序列化(最可靠的方法)
try {
return JSON.parse(JSON.stringify(obj));
} catch (e) {
// 如果 JSON 序列化失败,使用递归方法
console.warn('[API] JSON 序列化失败,使用递归方法:', e);
// 如果是数组,递归处理每个元素
if (Array.isArray(obj)) {
return obj.map(item => this.toPlainObject(item));
}
// 如果是对象,递归处理每个属性
const plainObj = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
try {
plainObj[key] = this.toPlainObject(obj[key]);
} catch (err) {
// 如果无法访问属性,跳过
console.warn(`[API] 无法访问属性 ${key}:`, err);
}
}
}
return plainObj;
}
}
async requestWithElectronAPI(method, endpoint, data = null) {
const fullEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
// 将响应式对象转换为普通对象(避免 Proxy 无法序列化的问题)
const plainData = data ? this.toPlainObject(data) : null;
// 主进程不再缓存 token每次请求都从浏览器端实时获取这里不需要同步
try {
let result;
if (method === 'GET') {
result = await window.electronAPI.http.get(fullEndpoint, plainData || {});
} else {
result = await window.electronAPI.http.post(fullEndpoint, plainData || {});
}
// 输出详细的响应日志
console.log('requestWithElectronAPI result', method, fullEndpoint ,plainData, result);
return result;
} catch (error) {
// 输出详细的错误日志
console.error('========================================');
console.error(`[API错误] ${method}`);
console.error(`[错误信息]`, error.message || error);
console.error(`[错误堆栈]`, error.stack);
console.error('========================================');
throw error;
}
}
/**
* 设置认证 token
* @param {string} token
*/
setToken(token) {
this.token = token;
if (token) {
localStorage.setItem('api_token', token);
// 主进程不再缓存 token每次请求都从浏览器端实时获取
} else {
localStorage.removeItem('api_token');
}
}
/**
* 获取认证 token
* @returns {string|null}
*/
getToken() {
if (!this.token) {
this.token = localStorage.getItem('api_token');
}
return this.token;
}
/**
* 构建请求头
* @returns {Object}
*/
buildHeaders() {
const headers = {
'Content-Type': 'application/json'
};
const token = this.getToken();
if (token) {
headers['applet-token'] = `${token}`;
}
return headers;
}
/**
* 处理错误
* @param {Error|Response} error
* @returns {Promise<Object>}
*/
async handleError(error) {
if (error.response) {
// 服务器返回了错误响应
const { status, data } = error.response;
return {
code: status,
message: data?.message || `请求失败: ${status}`,
data: null
};
} else if (error.request) {
// 请求已发出但没有收到响应
return {
code: -1,
message: '网络错误,请检查网络连接',
data: null
};
} else {
// 其他错误
return {
code: -1,
message: error.message || '未知错误',
data: null
};
}
}
/**
* 显示错误提示
* @param {string} message 错误消息
*/
showError(message) {
// 优先使用 electronAPI 的日志功能
if (window.electronAPI && typeof window.electronAPI.log === 'function') {
try {
window.electronAPI.log('error', `[API错误] ${message}`);
} catch (error) {
console.error('调用 electronAPI.log 失败:', error);
}
}
console.error('[API错误]', message);
}
/**
* 确保 electronAPI 可用并发送请求
* @param {string} method HTTP 方法
* @param {string} endpoint API 端点
* @param {Object} data 请求数据
* @returns {Promise<Object>}
*/
async _ensureElectronAPIAndRequest(method, endpoint, data = null) {
// 检查 electronAPI 是否可用
if (!this.isElectronAPIAvailable()) {
// 首次检查时,短暂等待一次
const electronAPIAvailable = await this.waitForElectronAPI();
if (!electronAPIAvailable) {
const errorMsg = 'electronAPI 不可用,无法发送请求';
this.showError(errorMsg);
throw new Error(errorMsg);
}
}
// 发送请求
const result = await this.requestWithElectronAPI(method, endpoint, data);
// 检查返回结果,如果 code 不为 0显示错误提示
if (result && result.code !== undefined && result.code !== 0) {
const errorMsg = result.message || '请求失败';
this.showError(errorMsg);
}
return result;
}
/**
* 发送 HTTP 请求
* @param {string} method HTTP 方法
* @param {string} endpoint API 端点
* @param {Object} data 请求数据
* @returns {Promise<Object>}
*/
async request(method, endpoint, data = null) {
try {
const res = await this._ensureElectronAPIAndRequest(method, endpoint, data);
return res
} catch (error) {
const errorMsg = error.message || '请求失败';
this.showError(errorMsg);
throw error;
}
}
/**
* GET 请求
* @param {string} endpoint API 端点
* @param {Object} params 查询参数
* @returns {Promise<Object>}
*/
async get(endpoint, params = {}) {
try {
return await this._ensureElectronAPIAndRequest('GET', endpoint, params);
} catch (error) {
const errorMsg = error.message || '请求失败';
this.showError(errorMsg);
throw error;
}
}
/**
* POST 请求
* @param {string} endpoint API 端点
* @param {Object} data 请求数据
* @returns {Promise<Object>}
*/
async post(endpoint, data = {}) {
try {
const result = await this.request('POST', endpoint, data);
return result;
} catch (error) {
console.error(`[ApiClient] POST 请求失败:`, { error: error.message, endpoint, data });
const errorResult = await this.handleError(error);
// 如果 handleError 返回了错误结果,显示错误提示
if (errorResult && errorResult.code !== 0) {
this.showError(errorResult.message || '请求失败');
}
return errorResult;
}
}
}
// 创建默认实例
const apiClient = new ApiClient();
/**
* 用户登录
* @param {string} email 邮箱
* @param {string} password 密码
* @param {string} deviceId 设备ID可选
* @returns {Promise<Object>}
*/
export async function login(email, password, deviceId = null) {
const requestData = {
login_name: email, // 后端使用 login_name 字段
password
};
if (deviceId) {
requestData.device_id = deviceId;
}
const response = await apiClient.post('/user/login', requestData);
// 如果登录成功,保存 token
if (response.code === 0 && response.data && response.data.token) {
apiClient.setToken(response.data.token);
}
return response;
}
/**
* 设置 API token用于其他地方设置的 token
* @param {string} token
*/
export function setToken(token) {
apiClient.setToken(token);
}
/**
* 获取 API token
* @returns {string|null}
*/
export function getToken() {
return apiClient.getToken();
}
/**
* 清除 API token
*/
export function clearToken() {
apiClient.setToken(null);
}
// 导出默认实例,方便直接使用
export default apiClient;

1328
app/views/ConsolePage.vue Normal file

File diff suppressed because it is too large Load Diff

671
app/views/DeliveryPage.vue Normal file
View File

@@ -0,0 +1,671 @@
<template>
<div class="page-delivery">
<h2 class="page-title">投递管理</h2>
<!-- 统计信息 -->
<div class="stats-section" v-if="statistics">
<Card class="stat-card">
<template #content>
<div class="stat-value">{{ statistics.totalCount || 0 }}</div>
<div class="stat-label">总投递数</div>
</template>
</Card>
<Card class="stat-card">
<template #content>
<div class="stat-value">{{ statistics.successCount || 0 }}</div>
<div class="stat-label">成功数</div>
</template>
</Card>
<Card class="stat-card">
<template #content>
<div class="stat-value">{{ statistics.interviewCount || 0 }}</div>
<div class="stat-label">面试邀约</div>
</template>
</Card>
<Card class="stat-card">
<template #content>
<div class="stat-value">{{ statistics.successRate || 0 }}%</div>
<div class="stat-label">成功率</div>
</template>
</Card>
</div>
<!-- 筛选 -->
<div class="filter-section">
<div class="filter-box">
<Dropdown
v-model="searchOption.platform"
:options="platformOptions"
optionLabel="label"
optionValue="value"
placeholder="全部平台"
class="filter-select"
@change="handleSearch"
/>
<Dropdown
v-model="searchOption.applyStatus"
:options="applyStatusOptions"
optionLabel="label"
optionValue="value"
placeholder="全部状态"
class="filter-select"
@change="handleSearch"
/>
<Dropdown
v-model="searchOption.feedbackStatus"
:options="feedbackStatusOptions"
optionLabel="label"
optionValue="value"
placeholder="全部反馈"
class="filter-select"
@change="handleSearch"
/>
</div>
</div>
<!-- 投递记录列表 -->
<div class="table-section">
<ProgressSpinner v-if="loading" />
<div v-else-if="records.length === 0" class="empty">暂无投递记录</div>
<DataTable v-else :value="records" tableStyle="min-width: 50rem">
<Column field="jobTitle" header="岗位名称">
<template #body="{ data }">
{{ data.jobTitle || '-' }}
</template>
</Column>
<Column field="companyName" header="公司名称">
<template #body="{ data }">
{{ data.companyName || '-' }}
</template>
</Column>
<Column field="platform" header="平台">
<template #body="{ data }">
<Tag
:value="data.platform === 'boss' ? 'Boss直聘' : data.platform === 'liepin' ? '猎聘' : data.platform"
severity="info"
/>
</template>
</Column>
<Column field="applyStatus" header="投递状态">
<template #body="{ data }">
<Tag
:value="getApplyStatusText(data.applyStatus)"
:severity="getApplyStatusSeverity(data.applyStatus)"
/>
</template>
</Column>
<Column field="feedbackStatus" header="反馈状态">
<template #body="{ data }">
<Tag
:value="getFeedbackStatusText(data.feedbackStatus)"
:severity="getFeedbackStatusSeverity(data.feedbackStatus)"
/>
</template>
</Column>
<Column field="applyTime" header="投递时间">
<template #body="{ data }">
{{ formatTime(data.applyTime) }}
</template>
</Column>
<Column header="操作">
<template #body="{ data }">
<Button label="查看详情" size="small" @click="handleViewDetail(data)" />
</template>
</Column>
</DataTable>
</div>
<!-- 分页 -->
<Paginator
v-if="total > 0"
:rows="pageOption.pageSize"
:totalRecords="total"
:first="(currentPage - 1) * pageOption.pageSize"
@page="onPageChange"
/>
<!-- 详情弹窗 -->
<Dialog
v-model:visible="showDetail"
modal
header="投递详情"
:style="{ width: '600px' }"
@hide="closeDetail"
>
<div v-if="currentRecord" class="detail-content">
<div class="detail-item">
<label>岗位名称</label>
<span>{{ currentRecord.jobTitle || '-' }}</span>
</div>
<div class="detail-item">
<label>公司名称</label>
<span>{{ currentRecord.companyName || '-' }}</span>
</div>
<div class="detail-item">
<label>薪资范围</label>
<span>{{ currentRecord.salary || '-' }}</span>
</div>
<div class="detail-item">
<label>工作地点</label>
<span>{{ currentRecord.location || '-' }}</span>
</div>
<div class="detail-item">
<label>投递状态</label>
<span>{{ getApplyStatusText(currentRecord.applyStatus) }}</span>
</div>
<div class="detail-item">
<label>反馈状态</label>
<span>{{ getFeedbackStatusText(currentRecord.feedbackStatus) }}</span>
</div>
<div class="detail-item">
<label>投递时间</label>
<span>{{ formatTime(currentRecord.applyTime) }}</span>
</div>
<div class="detail-item" v-if="currentRecord.feedbackContent">
<label>反馈内容</label>
<span>{{ currentRecord.feedbackContent }}</span>
</div>
</div>
</Dialog>
</div>
</template>
<script>
import applyRecordsAPI from '../api/apply_records.js';
import { mapState } from 'vuex';
import { Card, Dropdown, DataTable, Column, Tag, Button, Dialog, Paginator, ProgressSpinner } from '../components/PrimeVue';
import logMixin from '../mixins/logMixin.js';
export default {
name: 'DeliveryPage',
mixins: [logMixin],
components: {
Card,
Dropdown,
DataTable,
Column,
Tag,
Button,
Dialog,
Paginator,
ProgressSpinner
},
data() {
return {
loading: false,
records: [],
statistics: null,
searchOption: {
key: 'jobTitle',
value: '',
platform: '',
applyStatus: '',
feedbackStatus: ''
},
platformOptions: [
{ label: '全部平台', value: '' },
{ label: 'Boss直聘', value: 'boss' },
{ label: '猎聘', value: 'liepin' }
],
applyStatusOptions: [
{ label: '全部状态', value: '' },
{ label: '待投递', value: 'pending' },
{ label: '投递中', value: 'applying' },
{ label: '投递成功', value: 'success' },
{ label: '投递失败', value: 'failed' }
],
feedbackStatusOptions: [
{ label: '全部反馈', value: '' },
{ label: '无反馈', value: 'none' },
{ label: '已查看', value: 'viewed' },
{ label: '感兴趣', value: 'interested' },
{ label: '面试邀约', value: 'interview' }
],
pageOption: {
page: 1,
pageSize: 10
},
total: 0,
currentPage: 1,
showDetail: false,
currentRecord: null
};
},
computed: {
...mapState('auth', ['snCode']),
totalPages() {
return Math.ceil(this.total / this.pageOption.pageSize);
}
},
mounted() {
this.loadStatistics();
this.loadRecords();
},
methods: {
/**
* 加载统计数据
*/
async loadStatistics() {
try {
// 获取 snCode 用于请求
const snCode = this.$store?.state?.auth?.snCode;
if (!snCode) {
console.warn('未获取到设备SN码无法加载统计数据');
return;
}
const result = await applyRecordsAPI.getStatistics(snCode);
if (result && result.code === 0) {
this.statistics = result.data;
}
} catch (error) {
console.error('加载统计数据失败:', error);
}
},
/**
* 加载投递记录
*/
async loadRecords() {
this.loading = true;
try {
const params = {
sn_code: this.snCode,
seachOption: this.searchOption,
pageOption: this.pageOption
};
const result = await applyRecordsAPI.getList(params);
if (result && result.code === 0) {
this.records = result.data.rows || result.data.list || [];
this.total = result.data.count || result.data.total || 0;
this.currentPage = this.pageOption.page;
console.log('[投递管理] 加载记录成功:', {
recordsCount: this.records.length,
total: this.total,
currentPage: this.currentPage
});
} else {
console.warn('[投递管理] 响应格式异常:', result);
}
} catch (error) {
console.error('加载投递记录失败:', error);
if (this.addLog) {
this.addLog('error', '加载投递记录失败: ' + (error.message || '未知错误'));
}
} finally {
this.loading = false;
}
},
/**
* 搜索
*/
handleSearch() {
this.pageOption.page = 1;
this.loadRecords();
},
/**
* 分页切换
*/
handlePageChange(page) {
this.pageOption.page = page;
this.currentPage = page;
this.loadRecords();
},
/**
* Paginator 分页事件
*/
onPageChange(event) {
this.currentPage = Math.floor(event.first / this.pageOption.pageSize) + 1;
this.pageOption.page = this.currentPage;
this.loadRecords();
},
/**
* 获取投递状态严重程度(用于 Tag 组件)
*/
getApplyStatusSeverity(status) {
const severityMap = {
'pending': 'warning',
'applying': 'info',
'success': 'success',
'failed': 'danger',
'duplicate': 'secondary'
};
return severityMap[status] || 'secondary';
},
/**
* 获取反馈状态严重程度(用于 Tag 组件)
*/
getFeedbackStatusSeverity(status) {
const severityMap = {
'none': 'secondary',
'viewed': 'info',
'interested': 'success',
'not_suitable': 'danger',
'interview': 'success'
};
return severityMap[status] || 'secondary';
},
/**
* 查看详情
*/
async handleViewDetail(record) {
try {
const result = await applyRecordsAPI.getDetail(record.id || record.applyId);
if (result && result.code === 0) {
this.currentRecord = result.data || record; // 如果没有返回详情,使用当前记录
this.showDetail = true;
}
} catch (error) {
console.error('获取详情失败:', error);
// 如果获取详情失败,直接使用当前记录显示
this.currentRecord = record;
this.showDetail = true;
if (this.addLog) {
this.addLog('error', '获取详情失败: ' + (error.message || '未知错误'));
}
}
},
/**
* 关闭详情
*/
closeDetail() {
this.showDetail = false;
this.currentRecord = null;
},
/**
* 获取投递状态文本
*/
getApplyStatusText(status) {
const statusMap = {
'pending': '待投递',
'applying': '投递中',
'success': '投递成功',
'failed': '投递失败',
'duplicate': '重复投递'
};
return statusMap[status] || status || '-';
},
/**
* 获取反馈状态文本
*/
getFeedbackStatusText(status) {
const statusMap = {
'none': '无反馈',
'viewed': '已查看',
'interested': '感兴趣',
'not_suitable': '不合适',
'interview': '面试邀约'
};
return statusMap[status] || status || '-';
},
/**
* 格式化时间
*/
formatTime(time) {
if (!time) return '-';
const date = new Date(time);
return date.toLocaleString('zh-CN');
}
}
};
</script>
<style lang="less" scoped>
.page-delivery {
padding: 20px;
height: 100%;
overflow-y: auto;
}
.page-title {
margin: 0 0 20px 0;
font-size: 24px;
font-weight: 600;
color: #333;
}
.stats-section {
display: flex;
gap: 15px;
margin-bottom: 20px;
}
.stat-card {
flex: 1;
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text-align: center;
}
.stat-value {
font-size: 32px;
font-weight: 600;
color: #4CAF50;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: #666;
}
.filter-section {
background: #fff;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
.filter-box {
display: flex;
gap: 10px;
}
.filter-select {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.table-section {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.loading, .empty {
padding: 40px;
text-align: center;
color: #999;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table thead {
background: #f5f5f5;
}
.data-table th,
.data-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
.data-table th {
font-weight: 600;
color: #333;
}
.platform-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
&.boss {
background: #e3f2fd;
color: #1976d2;
}
&.liepin {
background: #e8f5e9;
color: #388e3c;
}
}
.status-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
background: #f5f5f5;
color: #666;
&.success {
background: #e8f5e9;
color: #388e3c;
}
&.failed {
background: #ffebee;
color: #d32f2f;
}
&.pending {
background: #fff3e0;
color: #f57c00;
}
&.interview {
background: #e3f2fd;
color: #1976d2;
}
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
&.btn-primary {
background: #4CAF50;
color: #fff;
&:hover {
background: #45a049;
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn-small {
padding: 4px 8px;
font-size: 12px;
}
.pagination-section {
display: flex;
justify-content: center;
align-items: center;
gap: 15px;
margin-top: 20px;
}
.page-info {
color: #666;
font-size: 14px;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: #fff;
border-radius: 8px;
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #eee;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
}
.btn-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
&:hover {
color: #333;
}
}
.modal-body {
padding: 20px;
}
.detail-item {
margin-bottom: 15px;
label {
font-weight: 600;
color: #333;
margin-right: 8px;
}
span {
color: #666;
}
}
</style>

643
app/views/FeedbackPage.vue Normal file
View File

@@ -0,0 +1,643 @@
<template>
<div class="page-feedback">
<h2 class="page-title">意见反馈</h2>
<!-- 反馈表单 -->
<div class="feedback-form-section">
<h3>提交反馈</h3>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label>反馈类型 <span class="required">*</span></label>
<Dropdown
v-model="formData.type"
:options="typeOptions"
optionLabel="label"
optionValue="value"
placeholder="请选择反馈类型"
class="form-control"
required
/>
</div>
<div class="form-group">
<label>反馈内容 <span class="required">*</span></label>
<Textarea
v-model="formData.content"
rows="6"
placeholder="请详细描述您的问题或建议..."
class="form-control"
required
/>
</div>
<div class="form-group">
<label>联系方式可选</label>
<InputText
v-model="formData.contact"
placeholder="请输入您的联系方式(手机号、邮箱等)"
class="form-control"
/>
</div>
<div class="form-actions">
<Button
type="submit"
label="提交反馈"
:disabled="submitting"
:loading="submitting"
/>
<Button
label="重置"
severity="secondary"
@click="handleReset"
/>
</div>
</form>
</div>
<!-- 反馈历史 -->
<div class="feedback-history-section">
<h3>反馈历史</h3>
<ProgressSpinner v-if="loading" />
<div v-else-if="feedbacks.length === 0" class="empty">暂无反馈记录</div>
<div v-else class="feedback-table-wrapper">
<table class="feedback-table">
<thead>
<tr>
<th>反馈类型</th>
<th>反馈内容</th>
<th>状态</th>
<th>提交时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="feedback in feedbacks" :key="feedback.id">
<td>
<Tag :value="getTypeText(feedback.type)" severity="info" />
</td>
<td class="content-cell">
<div class="content-text">{{ feedback.content }}</div>
</td>
<td>
<Tag
v-if="feedback.status"
:value="getStatusText(feedback.status)"
:severity="getStatusSeverity(feedback.status)"
/>
</td>
<td class="time-cell">{{ formatTime(feedback.createTime) }}</td>
<td>
<Button
label="查看详情"
size="small"
@click="handleViewDetail(feedback)"
/>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<Paginator
v-if="total > 0"
:rows="pageSize"
:totalRecords="total"
:first="(currentPage - 1) * pageSize"
@page="onPageChange"
/>
</div>
<!-- 详情弹窗 -->
<Dialog
v-model:visible="showDetail"
modal
header="反馈详情"
:style="{ width: '600px' }"
@hide="closeDetail"
>
<div v-if="currentFeedback" class="detail-content">
<div class="detail-item">
<label>反馈类型</label>
<span>{{ getTypeText(currentFeedback.type) }}</span>
</div>
<div class="detail-item">
<label>反馈内容</label>
<div class="detail-content">{{ currentFeedback.content }}</div>
</div>
<div class="detail-item" v-if="currentFeedback.contact">
<label>联系方式</label>
<span>{{ currentFeedback.contact }}</span>
</div>
<div class="detail-item">
<label>处理状态</label>
<Tag
:value="getStatusText(currentFeedback.status)"
:severity="getStatusSeverity(currentFeedback.status)"
/>
</div>
<div class="detail-item">
<label>提交时间</label>
<span>{{ formatTime(currentFeedback.createTime) }}</span>
</div>
<div class="detail-item" v-if="currentFeedback.reply_content">
<label>回复内容</label>
<div class="detail-content">{{ currentFeedback.reply_content }}</div>
</div>
<div class="detail-item" v-if="currentFeedback.reply_time">
<label>回复时间</label>
<span>{{ formatTime(currentFeedback.reply_time) }}</span>
</div>
</div>
</Dialog>
<!-- 成功提示 -->
<div v-if="showSuccess" class="success-message">
{{ successMessage }}
</div>
</div>
</template>
<script>
import feedbackAPI from '../api/feedback.js';
import { mapState } from 'vuex';
import { Dropdown, Textarea, InputText, Button, Card, Tag, Dialog, Paginator, ProgressSpinner } from '../components/PrimeVue';
import logMixin from '../mixins/logMixin.js';
export default {
name: 'FeedbackPage',
mixins: [logMixin],
components: {
Dropdown,
Textarea,
InputText,
Button,
Card,
Tag,
Dialog,
Paginator,
ProgressSpinner
},
data() {
return {
formData: {
type: '',
content: '',
contact: ''
},
typeOptions: [
{ label: 'Bug反馈', value: 'bug' },
{ label: '功能建议', value: 'suggestion' },
{ label: '使用问题', value: 'question' },
{ label: '其他', value: 'other' }
],
submitting: false,
feedbacks: [],
loading: false,
showSuccess: false,
successMessage: '',
currentPage: 1,
pageSize: 10,
total: 0,
showDetail: false,
currentFeedback: null
};
},
computed: {
...mapState('auth', ['snCode']),
totalPages() {
return Math.ceil(this.total / this.pageSize);
}
},
mounted() {
this.loadFeedbacks();
},
methods: {
/**
* 提交反馈
*/
async handleSubmit() {
if (!this.formData.type || !this.formData.content) {
if (this.addLog) {
this.addLog('warn', '请填写完整的反馈信息');
}
return;
}
if (!this.snCode) {
if (this.addLog) {
this.addLog('error', '请先登录');
}
return;
}
this.submitting = true;
try {
const result = await feedbackAPI.submit({
...this.formData,
sn_code: this.snCode
});
if (result && result.code === 0) {
this.showSuccessMessage('反馈提交成功,感谢您的反馈!');
this.handleReset();
this.loadFeedbacks();
} else {
if (this.addLog) {
this.addLog('error', result.message || '提交失败');
}
}
} catch (error) {
console.error('提交反馈失败:', error);
if (this.addLog) {
this.addLog('error', '提交反馈失败: ' + (error.message || '未知错误'));
}
} finally {
this.submitting = false;
}
},
/**
* 重置表单
*/
handleReset() {
this.formData = {
type: '',
content: '',
contact: ''
};
},
/**
* 加载反馈列表
*/
async loadFeedbacks() {
if (!this.snCode) {
return;
}
this.loading = true;
try {
const result = await feedbackAPI.getList({
sn_code: this.snCode,
page: this.currentPage,
pageSize: this.pageSize
});
if (result && result.code === 0) {
this.feedbacks = result.data.rows || result.data.list || [];
this.total = result.data.total || result.data.count || 0;
} else {
console.warn('[反馈管理] 响应格式异常:', result);
}
} catch (error) {
console.error('加载反馈列表失败:', error);
if (this.addLog) {
this.addLog('error', '加载反馈列表失败: ' + (error.message || '未知错误'));
}
} finally {
this.loading = false;
}
},
/**
* 查看详情
*/
async handleViewDetail(feedback) {
try {
const result = await feedbackAPI.getDetail(feedback.id);
if (result && result.code === 0) {
this.currentFeedback = result.data || feedback;
this.showDetail = true;
} else {
// 如果获取详情失败,直接使用当前记录显示
this.currentFeedback = feedback;
this.showDetail = true;
}
} catch (error) {
console.error('获取详情失败:', error);
// 如果获取详情失败,直接使用当前记录显示
this.currentFeedback = feedback;
this.showDetail = true;
if (this.addLog) {
this.addLog('error', '获取详情失败: ' + (error.message || '未知错误'));
}
}
},
/**
* 关闭详情
*/
closeDetail() {
this.showDetail = false;
this.currentFeedback = null;
},
/**
* 分页切换
*/
handlePageChange(page) {
this.currentPage = page;
this.loadFeedbacks();
},
/**
* Paginator 分页事件
*/
onPageChange(event) {
this.currentPage = Math.floor(event.first / this.pageSize) + 1;
this.loadFeedbacks();
},
/**
* 获取状态严重程度(用于 Tag 组件)
*/
getStatusSeverity(status) {
const severityMap = {
'pending': 'warning',
'processing': 'info',
'completed': 'success',
'rejected': 'danger'
};
return severityMap[status] || 'secondary';
},
/**
* 获取类型文本
*/
getTypeText(type) {
const typeMap = {
'bug': 'Bug反馈',
'suggestion': '功能建议',
'question': '使用问题',
'other': '其他'
};
return typeMap[type] || type || '-';
},
/**
* 获取状态文本
*/
getStatusText(status) {
const statusMap = {
'pending': '待处理',
'processing': '处理中',
'completed': '已完成',
'rejected': '已拒绝'
};
return statusMap[status] || status || '-';
},
/**
* 格式化时间
*/
formatTime(time) {
if (!time) return '-';
const date = new Date(time);
return date.toLocaleString('zh-CN');
},
/**
* 显示成功消息
*/
showSuccessMessage(message) {
this.successMessage = message;
this.showSuccess = true;
setTimeout(() => {
this.showSuccess = false;
}, 3000);
}
}
};
</script>
<style lang="less" scoped>
.page-feedback {
padding: 20px;
height: 100%;
overflow-y: auto;
}
.page-title {
margin: 0 0 20px 0;
font-size: 24px;
font-weight: 600;
color: #333;
}
.feedback-form-section {
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
h3 {
margin: 0 0 20px 0;
font-size: 18px;
color: #333;
}
}
.form-group {
margin-bottom: 20px;
label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
.required {
color: #f44336;
}
}
}
.form-control {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
&:focus {
outline: none;
border-color: #4CAF50;
}
}
textarea.form-control {
resize: vertical;
min-height: 120px;
}
.form-actions {
display: flex;
gap: 10px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
&.btn-primary {
background: #4CAF50;
color: #fff;
&:hover:not(:disabled) {
background: #45a049;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&:not(.btn-primary) {
background: #f5f5f5;
color: #333;
&:hover {
background: #e0e0e0;
}
}
}
.feedback-history-section {
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
h3 {
margin: 0 0 20px 0;
font-size: 18px;
color: #333;
}
}
.loading, .empty {
padding: 40px;
text-align: center;
color: #999;
}
.feedback-table-wrapper {
overflow-x: auto;
}
.feedback-table {
width: 100%;
border-collapse: collapse;
background: #fff;
thead {
background: #f5f5f5;
th {
padding: 12px 16px;
text-align: left;
font-weight: 600;
color: #333;
border-bottom: 2px solid #e0e0e0;
white-space: nowrap;
}
}
tbody {
tr {
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s;
&:hover {
background-color: #f9f9f9;
}
&:last-child {
border-bottom: none;
}
}
td {
padding: 12px 16px;
vertical-align: middle;
color: #666;
}
}
}
.content-cell {
max-width: 400px;
.content-text {
max-height: 60px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.5;
}
}
.time-cell {
white-space: nowrap;
font-size: 14px;
color: #999;
}
.success-message {
position: fixed;
top: 20px;
right: 20px;
background: #4CAF50;
color: #fff;
padding: 12px 20px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
z-index: 1000;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.detail-item {
margin-bottom: 15px;
label {
font-weight: 600;
color: #333;
margin-right: 8px;
display: inline-block;
min-width: 100px;
}
span {
color: #666;
}
}
.detail-content {
color: #666;
line-height: 1.6;
margin-top: 5px;
padding: 10px;
background: #f9f9f9;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-word;
}
</style>

733
app/views/InvitePage.vue Normal file
View File

@@ -0,0 +1,733 @@
<template>
<div class="page-invite">
<h2 class="page-title">推广邀请</h2>
<!-- 邀请信息卡片 -->
<Card class="invite-card">
<template #title>我的邀请链接</template>
<template #content>
<div class="invite-code-section">
<div class="link-display">
<span class="link-label">邀请链接</span>
<span class="link-value">{{ inviteInfo.invite_link || '加载中...' }}</span>
<Button
label="复制链接"
size="small"
@click="handleCopyLink"
v-if="inviteInfo.invite_link"
/>
</div>
<div class="code-display" v-if="inviteInfo.invite_code">
<span class="code-label">邀请码</span>
<span class="code-value">{{ inviteInfo.invite_code }}</span>
<Button label="复制" size="small" @click="handleCopyCode" />
</div>
</div>
<div class="invite-tip">
<p>💡 分享邀请链接给好友好友通过链接注册后您将获得 <strong>3天试用期</strong> 奖励</p>
</div>
</template>
</Card>
<!-- 邀请记录列表 -->
<div class="records-section">
<div class="section-header">
<h3>邀请记录
<Badge :value="(statistics && statistics.totalInvites) || 0" severity="success" class="stat-value" />
</h3>
<Button
label="刷新"
@click="loadRecords"
:loading="recordsLoading"
:disabled="recordsLoading"
/>
</div>
<ProgressSpinner v-if="recordsLoading" />
<div v-else-if="recordsList && recordsList.length > 0" class="records-list">
<Card
v-for="record in recordsList"
:key="record.id"
class="record-item"
>
<template #content>
<div class="record-info">
<div class="record-phone">{{ record.invitee_phone || '未知' }}</div>
<div class="record-time">{{ formatTime(record.register_time) }}</div>
</div>
<div class="record-status">
<Tag
:value="record.reward_status === 1 ? '已奖励' : '待奖励'"
:severity="record.reward_status === 1 ? 'success' : 'warning'"
/>
<span v-if="record.reward_status === 1" class="reward-info">
+{{ record.reward_value || 3 }}
</span>
</div>
</template>
</Card>
</div>
<div v-else class="empty-tip">暂无邀请记录</div>
<!-- 分页 -->
<Paginator
v-if="recordsTotal > 0"
:rows="pageSize"
:totalRecords="recordsTotal"
:first="(currentPage - 1) * pageSize"
@page="onPageChange"
/>
</div>
<!-- 邀请说明 -->
<div class="info-section">
<h3>邀请说明</h3>
<ul class="info-list">
<li>分享您的邀请链接给好友</li>
<li>好友通过您的邀请链接注册后您将自动获得 <strong>3天试用期</strong> 奖励</li>
<li>每成功邀请一位用户注册您将获得3天试用期</li>
<li>试用期将自动累加到您的账户剩余天数中</li>
</ul>
</div>
<!-- 成功提示 -->
<Message
v-if="showSuccess"
severity="success"
:closable="true"
@close="showSuccess = false"
class="success-message"
>
{{ successMessage }}
</Message>
</div>
</template>
<script>
import inviteAPI from '../api/invite.js';
import { mapState } from 'vuex';
import { Card, Button, Badge, Tag, Paginator, Message, ProgressSpinner } from '../components/PrimeVue';
import logMixin from '../mixins/logMixin.js';
export default {
name: 'InvitePage',
mixins: [logMixin],
components: {
Card,
Button,
Badge,
Tag,
Paginator,
Message,
ProgressSpinner
},
data() {
return {
inviteInfo: {},
statistics: {},
showSuccess: false,
successMessage: '',
recordsList: [],
recordsLoading: false,
recordsTotal: 0,
currentPage: 1,
pageSize: 20
};
},
computed: {
...mapState('auth', ['snCode', 'isLoggedIn']),
totalPages() {
return Math.ceil(this.recordsTotal / this.pageSize);
}
},
watch: {
// 监听登录状态变化
isLoggedIn(newVal) {
if (newVal && this.snCode) {
this.loadInviteInfo();
this.loadStatistics();
this.loadRecords();
}
},
// 监听SN码变化
snCode(newVal) {
if (newVal && this.isLoggedIn) {
this.loadInviteInfo();
this.loadStatistics();
this.loadRecords();
}
}
},
mounted() {
// 确保在登录状态且SN码存在时才加载数据
this.$nextTick(() => {
if (this.isLoggedIn && this.snCode) {
this.loadInviteInfo();
this.loadStatistics();
this.loadRecords();
}
});
},
activated() {
// 当页面被激活时(切换到这个页面时)重新加载邀请信息
// 确保在登录状态且SN码存在时才加载
this.$nextTick(() => {
if (this.isLoggedIn && this.snCode) {
this.loadInviteInfo();
this.loadStatistics();
this.loadRecords();
}
});
},
methods: {
/**
* 加载邀请信息
*/
async loadInviteInfo() {
try {
if (!this.snCode) {
console.warn('SN码不存在无法加载邀请信息');
return;
}
const result = await inviteAPI.getInviteInfo(this.snCode);
// 支持新的响应格式:{ code: 0 } 或旧的格式:{ success: true }
if (result && (result.code === 0 || result.success)) {
this.inviteInfo = result.data || {};
// 如果没有邀请链接,自动生成
if (!this.inviteInfo.invite_link) {
await this.handleGenerateCode();
}
}
} catch (error) {
console.error('加载邀请信息失败:', error);
if (this.addLog) {
this.addLog('error', '加载邀请信息失败: ' + (error.message || '未知错误'));
}
}
},
/**
* 加载统计数据
*/
async loadStatistics() {
try {
if (!this.snCode) {
return;
}
const result = await inviteAPI.getStatistics(this.snCode);
// 支持新的响应格式:{ code: 0 } 或旧的格式:{ success: true }
if (result && (result.code === 0 || result.success)) {
this.statistics = result.data;
}
} catch (error) {
console.error('加载统计数据失败:', error);
}
},
/**
* 生成新邀请码
*/
async handleGenerateCode() {
try {
if (!this.snCode) {
console.warn('SN码不存在无法生成邀请码');
return;
}
const result = await inviteAPI.generateInviteCode(this.snCode);
// 支持新的响应格式:{ code: 0 } 或旧的格式:{ success: true }
if (result && (result.code === 0 || result.success)) {
this.inviteInfo = result.data || {};
if (!this.showSuccess) {
this.showSuccessMessage('邀请链接已生成!');
}
}
} catch (error) {
console.error('生成邀请码失败:', error);
if (this.addLog) {
this.addLog('error', '生成邀请码失败: ' + (error.message || '未知错误'));
}
}
},
/**
* 复制邀请码
*/
async handleCopyCode() {
if (!this.inviteInfo.invite_code) return;
try {
if (window.electronAPI && window.electronAPI.clipboard) {
await window.electronAPI.clipboard.writeText(this.inviteInfo.invite_code);
this.showSuccessMessage('邀请码已复制到剪贴板');
} else {
// 使用浏览器API
await navigator.clipboard.writeText(this.inviteInfo.invite_code);
this.showSuccessMessage('邀请码已复制到剪贴板');
}
} catch (error) {
console.error('复制失败:', error);
if (this.addLog) {
this.addLog('error', '复制失败: ' + (error.message || '未知错误'));
}
}
},
/**
* 复制邀请链接
*/
async handleCopyLink() {
if (!this.inviteInfo.invite_link) return;
try {
if (window.electronAPI && window.electronAPI.clipboard) {
await window.electronAPI.clipboard.writeText(this.inviteInfo.invite_link);
this.showSuccessMessage('邀请链接已复制到剪贴板');
} else {
// 使用浏览器API
await navigator.clipboard.writeText(this.inviteInfo.invite_link);
this.showSuccessMessage('邀请链接已复制到剪贴板');
}
} catch (error) {
console.error('复制失败:', error);
if (this.addLog) {
this.addLog('error', '复制失败: ' + (error.message || '未知错误'));
}
}
},
/**
* 显示成功消息
*/
showSuccessMessage(message) {
this.successMessage = message;
this.showSuccess = true;
setTimeout(() => {
this.showSuccess = false;
}, 3000);
},
/**
* 加载邀请记录列表
*/
async loadRecords() {
try {
if (!this.snCode) {
return;
}
this.recordsLoading = true;
const result = await inviteAPI.getRecords(this.snCode, {
page: this.currentPage,
pageSize: this.pageSize
});
if (result && (result.code === 0 || result.success)) {
this.recordsList = result.data?.list || [];
this.recordsTotal = result.data?.total || 0;
}
} catch (error) {
console.error('加载邀请记录列表失败:', error);
if (this.addLog) {
this.addLog('error', '加载邀请记录列表失败: ' + (error.message || '未知错误'));
}
} finally {
this.recordsLoading = false;
}
},
/**
* 切换页码
*/
changePage(page) {
if (page >= 1 && page <= this.totalPages) {
this.currentPage = page;
this.loadRecords();
}
},
/**
* Paginator 分页事件
*/
onPageChange(event) {
this.currentPage = Math.floor(event.first / this.pageSize) + 1;
this.loadRecords();
},
/**
* 格式化时间
*/
formatTime(time) {
if (!time) return '-';
const date = new Date(time);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
}
};
</script>
<style lang="less" scoped>
.page-invite {
padding: 20px;
height: 100%;
overflow-y: auto;
}
.page-title {
margin: 0 0 20px 0;
font-size: 24px;
font-weight: 600;
color: #333;
}
.invite-card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #eee;
h3 {
margin: 0;
font-size: 18px;
color: #333;
}
}
.card-body {
padding: 20px;
}
.invite-code-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.invite-tip {
margin-top: 20px;
padding: 15px;
background: #e8f5e9;
border-radius: 4px;
border-left: 4px solid #4CAF50;
p {
margin: 0;
color: #2e7d32;
font-size: 14px;
line-height: 1.6;
strong {
color: #1b5e20;
font-weight: 600;
}
}
}
.code-display,
.link-display {
display: flex;
align-items: center;
gap: 10px;
padding: 15px;
background: #f5f5f5;
border-radius: 4px;
}
.code-label,
.link-label {
font-weight: 600;
color: #333;
min-width: 80px;
}
.code-value,
.link-value {
flex: 1;
font-family: 'Courier New', monospace;
color: #4CAF50;
font-size: 16px;
word-break: break-all;
}
.btn-copy {
padding: 6px 12px;
background: #4CAF50;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
&:hover {
background: #45a049;
}
}
.stats-section {
display: flex;
gap: 15px;
margin-bottom: 20px;
}
.stat-card {
flex: 1;
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text-align: center;
max-width: 300px;
}
.records-section {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
padding: 20px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
h3 {
margin: 0;
font-size: 18px;
color: #333;
display: flex;
align-items: center;
gap: 10px;
}
.stat-value {
display: inline-block;
padding: 2px 10px;
background: #4CAF50;
color: #fff;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
min-width: 24px;
text-align: center;
line-height: 1.5;
}
}
.btn-refresh {
padding: 6px 12px;
background: #4CAF50;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
&:hover:not(:disabled) {
background: #45a049;
}
&:disabled {
background: #ccc;
cursor: not-allowed;
}
}
.loading-tip,
.empty-tip {
text-align: center;
padding: 40px 20px;
color: #999;
font-size: 14px;
}
.records-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.record-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
background: #f9f9f9;
border-radius: 4px;
border-left: 3px solid #4CAF50;
transition: background 0.3s;
&:hover {
background: #f0f0f0;
}
}
.record-info {
flex: 1;
.record-phone {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 5px;
}
.record-time {
font-size: 12px;
color: #999;
}
}
.record-status {
display: flex;
align-items: center;
gap: 10px;
}
.status-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
&.status-success {
background: #e8f5e9;
color: #2e7d32;
}
&.status-pending {
background: #fff3e0;
color: #e65100;
}
}
.reward-info {
font-size: 14px;
color: #4CAF50;
font-weight: 600;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 15px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.page-btn {
padding: 6px 16px;
background: #4CAF50;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
&:hover:not(:disabled) {
background: #45a049;
}
&:disabled {
background: #ccc;
cursor: not-allowed;
}
}
.page-info {
font-size: 14px;
color: #666;
}
.stat-label {
font-size: 14px;
color: #666;
}
.info-section {
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
h3 {
margin: 0 0 15px 0;
font-size: 18px;
color: #333;
}
}
.info-list {
margin: 0;
padding-left: 20px;
li {
margin-bottom: 10px;
color: #666;
line-height: 1.6;
}
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
&.btn-primary {
background: #4CAF50;
color: #fff;
&:hover {
background: #45a049;
}
}
}
.success-message {
position: fixed;
top: 20px;
right: 20px;
background: #4CAF50;
color: #fff;
padding: 12px 20px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
z-index: 1000;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
</style>

163
app/views/LogPage.vue Normal file
View File

@@ -0,0 +1,163 @@
<template>
<div class="page-log">
<h2 class="page-title">运行日志</h2>
<!-- 日志控制 -->
<div class="log-controls-section">
<div class="log-controls">
<Button class="btn" @click="handleClearLogs">清空日志</Button>
<Button class="btn" @click="handleExportLogs">导出日志</Button>
</div>
</div>
<!-- 日志内容 -->
<div class="log-content-section">
<div class="log-container" id="log-container">
<div v-for="(log, index) in logEntries" :key="index" class="log-entry">
<span class="log-time">[{{ log.time }}]</span>
<span :class="['log-level', log.level.toLowerCase()]">[{{ log.level }}]</span>
<span class="log-message">{{ log.message }}</span>
</div>
<div v-if="logEntries.length === 0" class="log-empty">
<p>暂无日志记录</p>
</div>
</div>
</div>
</div>
</template>
<script>
import { Button } from '../components/PrimeVue';
import { mapState, mapActions } from 'vuex';
export default {
name: 'LogPage',
components: {
Button
},
computed: {
...mapState('log', ['logs']),
logEntries() {
return this.logs;
}
},
mounted() {
this.scrollToBottom();
},
updated() {
this.scrollToBottom();
},
methods: {
...mapActions('log', ['clearLogs', 'exportLogs']),
handleClearLogs() {
this.clearLogs();
},
handleExportLogs() {
this.exportLogs();
},
scrollToBottom() {
this.$nextTick(() => {
const logContainer = document.getElementById('log-container');
if (logContainer) {
logContainer.scrollTop = logContainer.scrollHeight;
}
});
}
},
watch: {
logEntries() {
this.scrollToBottom();
}
}
};
</script>
<style lang="less" scoped>
.page-log {
padding: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.log-controls-section {
margin-bottom: 10px;
display: flex;
justify-content: flex-end;
}
.log-controls {
display: flex;
gap: 10px;
}
.log-content-section {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.log-container {
flex: 1;
padding: 12px;
overflow-y: auto;
background: #1e1e1e;
color: #d4d4d4;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 12px;
line-height: 1.5;
}
.log-entry {
margin-bottom: 4px;
word-wrap: break-word;
white-space: pre-wrap;
}
.log-time {
color: #808080;
margin-right: 8px;
}
.log-level {
margin-right: 8px;
font-weight: 600;
&.info {
color: #4ec9b0;
}
&.success {
color: #4caf50;
}
&.warn {
color: #ffa726;
}
&.error {
color: #f44336;
}
&.debug {
color: #90caf9;
}
}
.log-message {
color: #d4d4d4;
}
.log-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #808080;
font-size: 14px;
}
</style>

361
app/views/LoginPage.vue Normal file
View File

@@ -0,0 +1,361 @@
<template>
<div class="page-login">
<div class="login-container">
<div class="login-header">
<h1>用户登录</h1>
<p class="login-subtitle">请输入您的邮箱和密码进行登录</p>
</div>
<div class="login-form">
<Message
v-if="errorMessage"
severity="error"
:closable="false"
class="error-message"
>
{{ errorMessage }}
</Message>
<div class="form-group">
<label class="form-label">邮箱</label>
<InputText
v-model="email"
type="email"
placeholder="请输入邮箱地址"
autocomplete="email"
class="form-input"
@keyup.enter="handleLogin"
/>
</div>
<div class="form-group">
<label class="form-label">密码</label>
<InputText
v-model="password"
type="password"
placeholder="请输入密码"
autocomplete="current-password"
class="form-input"
@keyup.enter="handleLogin"
/>
</div>
<div class="form-options">
<div class="remember-me">
<Checkbox v-model="rememberMe" inputId="remember" binary />
<label for="remember" class="remember-label">记住登录信息</label>
</div>
</div>
<div class="form-actions">
<Button
label="登录"
@click="handleLogin"
:disabled="isLoading || !email || !password"
:loading="isLoading"
class="btn-block"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import { InputText, Password, Button, Checkbox, Message } from '../components/PrimeVue';
export default {
name: 'LoginPage',
components: {
InputText,
Password,
Button,
Checkbox,
Message
},
data() {
return {
email: '',
password: '',
rememberMe: true,
isLoading: false,
errorMessage: ''
};
},
mounted() {
// 从 store 加载保存的邮箱
if (this.$store) {
const savedEmail = this.$store.state.config.email || this.$store.state.auth.email;
if (savedEmail) {
this.email = savedEmail;
}
const rememberMe = this.$store.state.config.rememberMe;
if (rememberMe !== undefined) {
this.rememberMe = rememberMe;
}
}
},
methods: {
/**
* 生成设备ID基于浏览器特征
* @returns {string} 设备ID
*/
generateDeviceId() {
// 尝试从 store 获取已保存的设备ID
if (this.$store) {
let deviceId = this.$store.state.config.deviceId || this.$store.state.auth.deviceId;
if (deviceId) {
return deviceId;
}
}
// 基于浏览器特征生成设备ID
try {
const navigatorInfo = [
navigator.userAgent,
navigator.language,
navigator.platform,
screen.width,
screen.height,
screen.colorDepth,
new Date().getTimezoneOffset()
].join('|');
// 使用简单的哈希算法生成ID
let hash = 0;
for (let i = 0; i < navigatorInfo.length; i++) {
const char = navigatorInfo.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
const deviceId = 'web_' + Math.abs(hash).toString(16).substring(0, 32);
// 保存生成的 device_id 到 store
if (this.$store) {
this.$store.dispatch('config/setDeviceId', deviceId);
this.$store.commit('auth/SET_DEVICE_ID', deviceId);
}
return deviceId;
} catch (error) {
console.error('生成设备ID失败:', error);
// 如果生成失败,使用随机字符串作为后备方案
const fallbackId = 'web_' + Date.now().toString(36) + Math.random().toString(36).substring(2, 15);
if (this.$store) {
this.$store.dispatch('config/setDeviceId', fallbackId);
this.$store.commit('auth/SET_DEVICE_ID', fallbackId);
}
return fallbackId;
}
},
async handleLogin() {
if (!this.email) {
this.errorMessage = '请输入邮箱地址';
return;
}
if (!this.password) {
this.errorMessage = '请输入密码';
return;
}
// 验证邮箱格式
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(this.email)) {
this.errorMessage = '邮箱格式不正确,请输入有效的邮箱地址';
return;
}
this.isLoading = true;
this.errorMessage = '';
try {
// 生成或获取设备ID
const deviceId = this.generateDeviceId();
// 调用 Vuex store 的 login action会自动调用 HTTP 接口)
const result = await this.$store.dispatch('auth/login', {
email: this.email,
password: this.password,
deviceId: deviceId
});
if (result.success) {
// 保存记住登录选项到 store
if (this.$store) {
this.$store.dispatch('config/setRememberMe', this.rememberMe);
if (this.rememberMe) {
this.$store.dispatch('config/setEmail', this.email);
}
}
// 等待 store 状态更新完成后再跳转
await this.$nextTick();
// 跳转到控制台App.vue 的 watch 会自动连接 MQTT
this.$router.push('/console');
} else {
this.errorMessage = result.error || '登录失败,请检查邮箱和密码';
this.isLoading = false;
}
} catch (error) {
this.errorMessage = `登录失败: ${error.message}`;
this.isLoading = false;
}
},
setError(message) {
this.errorMessage = message;
this.isLoading = false;
},
reset() {
this.password = '';
this.errorMessage = '';
this.isLoading = false;
}
}
};
</script>
<style lang="less" scoped>
.page-login {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
overflow: auto;
}
.login-container {
background: #fff;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
width: 100%;
max-width: 420px;
padding: 40px;
box-sizing: border-box;
}
.login-header {
text-align: center;
margin-bottom: 30px;
h1 {
font-size: 28px;
font-weight: 600;
color: #333;
margin: 0 0 10px 0;
}
.login-subtitle {
font-size: 14px;
color: #666;
margin: 0;
}
}
.login-form {
.form-group {
margin-bottom: 20px;
.form-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #333;
font-weight: 500;
}
// PrimeVue InputText 样式覆盖
:deep(.p-inputtext) {
width: 100%;
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s, box-shadow 0.3s;
&:enabled:hover {
border-color: #667eea;
}
&:enabled:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
&::placeholder {
color: #999;
}
}
}
.form-options {
margin-bottom: 24px;
.remember-me {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #666;
.remember-label {
cursor: pointer;
user-select: none;
}
}
}
.form-actions {
:deep(.p-button) {
width: 100%;
padding: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 6px;
font-size: 15px;
font-weight: 500;
transition: transform 0.2s, box-shadow 0.3s;
&:enabled:hover {
background: linear-gradient(135deg, #5568d3 0%, #653a8b 100%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
&:enabled:active {
transform: translateY(0);
}
}
}
.error-message {
margin-bottom: 20px;
}
}
@media (max-width: 480px) {
.page-login {
padding: 15px;
}
.login-container {
padding: 30px 20px;
}
.login-header h1 {
font-size: 24px;
}
}
</style>

501
app/views/PurchasePage.vue Normal file
View File

@@ -0,0 +1,501 @@
<template>
<div class="page-purchase">
<h2 class="page-title">如何购买</h2>
<!-- 联系方式 -->
<div class="contact-section">
<Card class="contact-card">
<template #title>联系购买</template>
<template #content>
<p class="contact-desc">扫描下方二维码或添加微信号联系我们</p>
<div class="contact-content">
<div class="qr-code-wrapper">
<div class="qr-code-placeholder">
<img v-if="wechatQRCode" :src="wechatQRCode" alt="微信二维码" class="qr-code-image" />
<div v-else class="qr-code-placeholder-text">
<span>微信二维码</span>
<small>请上传二维码图片</small>
</div>
</div>
</div>
<div class="contact-info">
<div class="info-item">
<span class="info-label">微信号</span>
<span class="info-value">{{ wechatNumber }}</span>
<Button label="复制" size="small" @click="handleCopyWechat" />
</div>
</div>
</div>
</template>
</Card>
</div>
<!-- 价格套餐 -->
<div class="pricing-section">
<h3 class="section-title">价格套餐</h3>
<div class="pricing-grid">
<Card
v-for="plan in pricingPlans"
:key="plan.id"
:class="['pricing-card', { 'featured': plan.featured }]"
>
<template #header>
<Badge v-if="plan.featured" value="推荐" severity="success" />
</template>
<template #content>
<div class="plan-header">
<h4 class="plan-name">{{ plan.name }}</h4>
<div class="plan-duration">{{ plan.duration }}</div>
</div>
<div class="plan-price">
<div v-if="plan.originalPrice && plan.originalPrice > plan.price" class="original-price">
<span class="original-price-text">原价 ¥{{ plan.originalPrice }}</span>
</div>
<div class="current-price">
<span class="price-symbol">¥</span>
<span class="price-amount">{{ plan.price }}</span>
<span class="price-unit" v-if="plan.unit">{{ plan.unit }}</span>
</div>
<Badge v-if="plan.discount" :value="plan.discount" severity="danger" class="discount-badge" />
</div>
<div class="plan-features">
<div class="feature-item" v-for="feature in plan.features" :key="feature">
<span class="feature-icon"></span>
<span>{{ feature }}</span>
</div>
</div>
<div class="plan-action">
<Button label="立即购买" @click="handleContact(plan)" class="btn-full-width" />
</div>
</template>
</Card>
</div>
</div>
<!-- 购买说明 -->
<div class="notice-section">
<Card class="notice-card">
<template #title>购买说明</template>
<template #content>
<ul class="notice-list">
<li>购买后请联系客服激活账号</li>
<li>终生套餐享受永久使用权限</li>
<li>如有疑问请添加微信号咨询</li>
</ul>
</template>
</Card>
</div>
<!-- 成功提示 -->
<Message
v-if="showSuccess"
severity="success"
:closable="true"
@close="showSuccess = false"
class="success-message"
>
{{ successMessage }}
</Message>
</div>
</template>
<script>
import configAPI from '../api/config.js';
import { Card, Button, Badge, Message } from '../components/PrimeVue';
import logMixin from '../mixins/logMixin.js';
export default {
name: 'PurchasePage',
mixins: [logMixin],
components: {
Card,
Button,
Badge,
Message
},
data() {
return {
wechatNumber: '', // 从配置中获取
wechatQRCode: '', // 从配置中获取
showSuccess: false,
successMessage: '',
loading: false,
pricingPlans: [] // 从接口获取
};
},
mounted() {
this.loadWechatConfig();
this.loadPricingPlans();
},
methods: {
/**
* 加载微信配置
*/
async loadWechatConfig() {
this.loading = true;
try {
const config = await configAPI.getWechatConfig();
if (config.wechatNumber) {
this.wechatNumber = config.wechatNumber;
}
if (config.wechatQRCode) {
this.wechatQRCode = config.wechatQRCode;
}
} catch (error) {
console.error('加载微信配置失败:', error);
if (this.addLog) {
this.addLog('error', '加载微信配置失败: ' + (error.message || '未知错误'));
}
} finally {
this.loading = false;
}
},
/**
* 加载价格套餐列表
*/
async loadPricingPlans() {
try {
const plans = await configAPI.getPricingPlans();
if (plans && plans.length > 0) {
this.pricingPlans = plans;
}
} catch (error) {
console.error('加载价格套餐失败:', error);
if (this.addLog) {
this.addLog('error', '加载价格套餐失败: ' + (error.message || '未知错误'));
}
}
},
/**
* 复制微信号
*/
async handleCopyWechat() {
try {
if (window.electronAPI && window.electronAPI.clipboard) {
await window.electronAPI.clipboard.writeText(this.wechatNumber);
this.showSuccessMessage('微信号已复制到剪贴板');
} else {
throw new Error('electronAPI 不可用,无法复制');
}
} catch (error) {
console.error('复制失败:', error);
if (this.addLog) {
this.addLog('error', '复制失败: ' + (error.message || '未知错误'));
}
}
},
/**
* 联系购买
*/
handleContact(plan) {
const message = `我想购买【${plan.name}】(${plan.duration}),价格:¥${plan.price}${plan.unit}`;
this.showSuccessMessage(`请添加微信号 ${this.wechatNumber} 并发送:"${message}"`);
},
/**
* 显示成功消息
*/
showSuccessMessage(message) {
this.successMessage = message;
this.showSuccess = true;
setTimeout(() => {
this.showSuccess = false;
}, 3000);
}
}
};
</script>
<style lang="less" scoped>
.page-purchase {
padding: 15px;
height: 100%;
overflow-y: auto;
background: #f5f5f5;
}
.page-title {
margin: 0 0 20px 0;
font-size: 24px;
font-weight: 600;
color: #333;
text-align: center;
}
.contact-section {
margin-bottom: 20px;
}
.contact-card {
max-width: 600px;
margin: 0 auto;
}
.contact-desc {
text-align: center;
margin: 0 0 15px 0;
color: #666;
font-size: 13px;
}
.contact-content {
display: flex;
gap: 20px;
align-items: flex-start;
}
.qr-code-wrapper {
flex-shrink: 0;
}
.qr-code-placeholder {
width: 150px;
height: 150px;
border: 2px dashed #ddd;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
.qr-code-image {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 6px;
}
.qr-code-placeholder-text {
text-align: center;
color: #999;
span {
display: block;
font-size: 14px;
margin-bottom: 5px;
}
small {
display: block;
font-size: 12px;
}
}
.qr-code-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
font-size: 14px;
}
}
.contact-info {
flex: 1;
}
.info-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 10px;
background: #f9f9f9;
border-radius: 6px;
.info-label {
font-weight: 600;
color: #333;
min-width: 70px;
font-size: 14px;
}
.info-value {
flex: 1;
color: #666;
font-size: 14px;
}
}
.pricing-section {
margin-bottom: 20px;
}
.section-title {
text-align: center;
font-size: 20px;
font-weight: 600;
color: #333;
margin: 0 0 20px 0;
}
.pricing-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 15px;
max-width: 1200px;
margin: 0 auto;
}
.pricing-card {
position: relative;
text-align: center;
transition: transform 0.3s, box-shadow 0.3s;
&:hover {
transform: translateY(-5px);
}
&.featured {
border: 2px solid #4CAF50;
transform: scale(1.05);
}
}
.plan-header {
margin-bottom: 15px;
.plan-name {
margin: 0 0 6px 0;
font-size: 18px;
font-weight: 600;
color: #333;
}
.plan-duration {
font-size: 13px;
color: #999;
}
}
.plan-price {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
position: relative;
.original-price {
margin-bottom: 8px;
.original-price-text {
font-size: 14px;
color: #999;
text-decoration: line-through;
}
}
.current-price {
.price-symbol {
font-size: 18px;
color: #4CAF50;
vertical-align: top;
}
.price-amount {
font-size: 40px;
font-weight: 700;
color: #4CAF50;
line-height: 1;
}
.price-unit {
font-size: 14px;
color: #666;
margin-left: 4px;
}
}
.discount-badge {
position: absolute;
top: -8px;
right: 0;
}
}
.plan-features {
margin-bottom: 15px;
text-align: left;
}
.feature-item {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
font-size: 13px;
color: #666;
.feature-icon {
color: #4CAF50;
font-weight: 700;
font-size: 14px;
}
}
.plan-action {
:deep(.p-button) {
width: 100%;
}
}
.notice-section {
max-width: 800px;
margin: 0 auto;
}
.notice-card {
// Card 组件已包含样式
}
.notice-list {
margin: 0;
padding-left: 18px;
list-style: none;
li {
position: relative;
padding-left: 20px;
margin-bottom: 8px;
color: #666;
line-height: 1.5;
font-size: 13px;
&::before {
content: '•';
position: absolute;
left: 0;
color: #4CAF50;
font-weight: 700;
font-size: 18px;
}
}
}
.success-message {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
max-width: 400px;
}
@media (max-width: 768px) {
.contact-content {
flex-direction: column;
align-items: center;
}
.pricing-grid {
grid-template-columns: 1fr;
}
.pricing-card.featured {
transform: scale(1);
}
}
</style>

49
app/vite.config.js Normal file
View File

@@ -0,0 +1,49 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
let base = './'
if (process.env.NODE_ENV === 'production') {
base = './app'
}
export default defineConfig({
plugins: [vue()],
root: resolve(__dirname, '.'),
base: base,
css: {
preprocessorOptions: {
less: {
javascriptEnabled: true
}
}
},
build: {
outDir: resolve(__dirname, './dist'),
emptyOutDir: true,
rollupOptions: {
input: resolve(__dirname, 'index.html')
},
assetsDir: 'assets',
chunkSizeWarningLimit: 1000,
// 对于 Electron需要确保模块能正确加载
modulePreload: false, // 禁用 module preload避免 file:// 协议问题
commonjsOptions: {
include: []
}
},
server: {
port: 3000,
strictPort: true
},
resolve: {
alias: {
'@': resolve(__dirname, '.')
},
extensions: ['.js', '.vue', '.json']
}
});