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

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>