1
This commit is contained in:
464
app/components/ConsoleInfoPanel.vue
Normal file
464
app/components/ConsoleInfoPanel.vue
Normal 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>
|
||||
|
||||
154
app/components/DeliverySettings.vue
Normal file
154
app/components/DeliverySettings.vue
Normal 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>
|
||||
|
||||
223
app/components/DeliveryTrendChart.vue
Normal file
223
app/components/DeliveryTrendChart.vue
Normal 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>
|
||||
|
||||
39
app/components/PrimeVue/index.js
Normal file
39
app/components/PrimeVue/index.js
Normal 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';
|
||||
|
||||
67
app/components/QrCodeSection.vue
Normal file
67
app/components/QrCodeSection.vue
Normal 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>
|
||||
|
||||
141
app/components/SettingsDialog.vue
Normal file
141
app/components/SettingsDialog.vue
Normal 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>
|
||||
|
||||
77
app/components/Sidebar.vue
Normal file
77
app/components/Sidebar.vue
Normal 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>
|
||||
45
app/components/StatsSection.vue
Normal file
45
app/components/StatsSection.vue
Normal 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>
|
||||
|
||||
200
app/components/UpdateDialog.vue
Normal file
200
app/components/UpdateDialog.vue
Normal 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>
|
||||
|
||||
122
app/components/UserInfoDialog.vue
Normal file
122
app/components/UserInfoDialog.vue
Normal 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
270
app/components/UserMenu.vue
Normal 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>
|
||||
|
||||
Reference in New Issue
Block a user