组件封装
This commit is contained in:
108
src/components/CameraStatus/index.vue
Normal file
108
src/components/CameraStatus/index.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="status-item status-button" @click="show">
|
||||
<i-lucide-camera class="status-icon" />
|
||||
<span class="status-label">摄像头状态:</span>
|
||||
<span class="status-value error">{{ cameraStatus }}</span>
|
||||
</div>
|
||||
|
||||
<a-modal v-model:open="dialogVisible" :title="modalTitle" :footer="null">
|
||||
<a-row :gutter="[0, 12]">
|
||||
<template v-for="(item, index) in modalItems" :key="index">
|
||||
<a-col :span="8" class="modal-label">{{ item.label }}</a-col>
|
||||
<a-col :span="16" class="modal-value">{{ item.value }}</a-col>
|
||||
</template>
|
||||
</a-row>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useDialog } from '@/utils/useDialog';
|
||||
|
||||
// useDialog管理弹窗状态
|
||||
const { visible: dialogVisible, show, hide } = useDialog();
|
||||
|
||||
const cameraStatus = ref('异常');
|
||||
const modalTitle = '摄像头状态';
|
||||
const modalItems = ref([
|
||||
{ label: '当前状态', value: cameraStatus.value },
|
||||
{ label: '摄像头型号', value: '待赋值' },
|
||||
{ label: 'IP地址', value: '待赋值' },
|
||||
{ label: '分辨率', value: '待赋值' },
|
||||
{ label: '错误信息', value: '待赋值' },
|
||||
{ label: '最后在线', value: '待赋值' }
|
||||
]);
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
show,
|
||||
hide
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/assets/styles/_variables.scss';
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.status-button {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #8395B6;
|
||||
|
||||
&.success {
|
||||
color: $success-color;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: $error-color;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: $warning-color;
|
||||
}
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 13px;
|
||||
color: #8395B6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
&.success {
|
||||
color: $success-color;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: $error-color;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: $warning-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-label {
|
||||
font-size: $text-size;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
109
src/components/MesStatus/index.vue
Normal file
109
src/components/MesStatus/index.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<!-- Status Item -->
|
||||
<div class="status-item status-button" @click="show">
|
||||
<i-lucide-database class="status-icon" />
|
||||
<span class="status-label">MES 登录状态:</span>
|
||||
<span class="status-value success">{{ mesStatus }}</span>
|
||||
</div>
|
||||
|
||||
<a-modal v-model:open="dialogVisible" :title="modalTitle" :footer="null">
|
||||
<a-row :gutter="[0, 12]">
|
||||
<template v-for="(item, index) in modalItems" :key="index">
|
||||
<a-col :span="8" class="modal-label">{{ item.label }}</a-col>
|
||||
<a-col :span="16" class="modal-value">{{ item.value }}</a-col>
|
||||
</template>
|
||||
</a-row>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useDialog } from '@/utils/useDialog';
|
||||
|
||||
// useDialog管理弹窗状态
|
||||
const { visible: dialogVisible, show, hide } = useDialog();
|
||||
|
||||
// Modal配置数据
|
||||
const mesStatus = ref('已登录');
|
||||
const modalTitle = ref('MES 登录状态');
|
||||
const modalItems = ref([
|
||||
{ label: 'MES 服务器', value: '192.168.1.100:8080'},
|
||||
{ label: '登录状态', value: mesStatus.value},
|
||||
{ label: '用户名', value: 'admin'},
|
||||
{ label: '连接时间', value: '2024-01-15 09:30:25'},
|
||||
{ label: '最后活动', value: '2024-01-15 14:25:30'}
|
||||
]);
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
show,
|
||||
hide
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/assets/styles/_variables.scss';
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.status-button {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #8395B6;
|
||||
|
||||
&.success {
|
||||
color: $success-color;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: $error-color;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: $warning-color;
|
||||
}
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 13px;
|
||||
color: #8395B6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
&.success {
|
||||
color: $success-color;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: $error-color;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: $warning-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-label {
|
||||
font-size: $text-size;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
121
src/components/NetworkStatus/index.vue
Normal file
121
src/components/NetworkStatus/index.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div class="status-item status-button" @click="show">
|
||||
<i-lucide-wifi class="status-icon" />
|
||||
<span class="status-label">网络通讯状态:</span>
|
||||
<span class="status-value success">{{ networkStatus }}</span>
|
||||
</div>
|
||||
|
||||
<a-modal v-model:open="dialogVisible" :title="modalTitle" :footer="null">
|
||||
<a-row :gutter="[0, 12]">
|
||||
<template v-for="(item, index) in modalItems" :key="index">
|
||||
<a-col :span="8" class="modal-label">{{ item.label }}</a-col>
|
||||
<a-col :span="16" class="modal-value">{{ item.value }}</a-col>
|
||||
</template>
|
||||
</a-row>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useDialog } from '@/utils/useDialog';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
networkStatus: {
|
||||
type: String,
|
||||
default: '正常'
|
||||
},
|
||||
developingData: {
|
||||
type: String,
|
||||
default: '待赋值'
|
||||
}
|
||||
});
|
||||
|
||||
// useDialog管理弹窗状态
|
||||
const { visible: dialogVisible, show, hide } = useDialog();
|
||||
|
||||
// Modal配置数据
|
||||
const networkStatus = ref(props.networkStatus);
|
||||
const modalTitle = ref('网络通讯状态');
|
||||
const modalItems = ref([
|
||||
{ label: '网络状态', value: networkStatus.value },
|
||||
{ label: 'IP地址', value: '192.168.1.100' },
|
||||
{ label: '子网掩码', value: '255.255.255.0' },
|
||||
{ label: '网关', value: '192.168.1.1' },
|
||||
{ label: 'DNS服务器', value: '8.8.8.8' },
|
||||
{ label: '开发数据', value: props.developingData }
|
||||
]);
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
show,
|
||||
hide
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/assets/styles/_variables.scss';
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.status-button {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #8395B6;
|
||||
|
||||
&.success {
|
||||
color: $success-color;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: $error-color;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: $warning-color;
|
||||
}
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 13px;
|
||||
color: #8395B6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
&.success {
|
||||
color: $success-color;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: $error-color;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: $warning-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-label {
|
||||
font-size: $text-size;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
109
src/components/PlcStatus/index.vue
Normal file
109
src/components/PlcStatus/index.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<!-- Status Item -->
|
||||
<div class="status-item status-button" @click="show">
|
||||
<i-lucide-zap class="status-icon" />
|
||||
<span class="status-label">PLC 通讯状态:</span>
|
||||
<span class="status-value success">{{ plcStatus }}</span>
|
||||
</div>
|
||||
|
||||
<a-modal v-model:open="dialogVisible" :title="modalTitle" :footer="null">
|
||||
<a-row :gutter="[0, 12]">
|
||||
<template v-for="(item, index) in modalItems" :key="index">
|
||||
<a-col :span="8" class="modal-label">{{ item.label }}</a-col>
|
||||
<a-col :span="16" class="modal-value">{{ item.value }}</a-col>
|
||||
</template>
|
||||
</a-row>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useDialog } from '@/utils/useDialog';
|
||||
|
||||
// useDialog管理弹窗状态
|
||||
const { visible: dialogVisible, show, hide } = useDialog();
|
||||
|
||||
const plcStatus = ref('正常');
|
||||
const modalTitle = ref('PLC 通讯状态');
|
||||
const modalItems = ref([
|
||||
{ label: 'PLC状态', value: plcStatus.value },
|
||||
{ label: 'PLC型号', value: 'Siemens S7-1200' },
|
||||
{ label: 'IP地址', value: '192.168.1.10' },
|
||||
{ label: '通讯协议', value: 'Modbus TCP' },
|
||||
{ label: '连接时间', value: '2024-01-15 08:30:00' },
|
||||
{ label: '开发数据', value: '' }
|
||||
]);
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
show,
|
||||
hide
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/assets/styles/_variables.scss';
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.status-button {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #8395B6;
|
||||
|
||||
&.success {
|
||||
color: $success-color;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: $error-color;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: $warning-color;
|
||||
}
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 13px;
|
||||
color: #8395B6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
&.success {
|
||||
color: $success-color;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: $error-color;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: $warning-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-label {
|
||||
font-size: $text-size;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
327
src/components/SSELogs/index.vue
Normal file
327
src/components/SSELogs/index.vue
Normal file
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<div class="sse-logs" ref="sseLogsRef">
|
||||
<div class="sse-status-info">
|
||||
<div class="status-item">
|
||||
<span class="label">当前状态</span>
|
||||
<span class="value" :class="sseStatusClass">{{ sseStatusText }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="label">客户端ID</span>
|
||||
<span class="value">{{ sseClientId }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="label">服务器地址</span>
|
||||
<span class="value">{{ sseConnectTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sse-logs-content" ref="logContainer" @mouseenter="showClearButton = true" @mouseleave="showClearButton = false">
|
||||
<div v-for="(log, index) in internalLogs" :key="index" class="log-item" :class="getLogClass(log)">
|
||||
{{ log }}
|
||||
</div>
|
||||
|
||||
<div v-if="internalLogs.length === 0" class="empty-logs">
|
||||
<i-lucide-radio class="empty-icon" />
|
||||
<span>暂无SSE连接日志</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
// 定义组件属性
|
||||
interface Props {
|
||||
logs?: string[];
|
||||
sseStatusText?: string;
|
||||
sseStatusClass?: string;
|
||||
sseClientId?: string;
|
||||
sseConnectTime?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
logs: () => [],
|
||||
sseStatusText: '未连接',
|
||||
sseStatusClass: 'disconnected',
|
||||
sseClientId: '未分配',
|
||||
sseConnectTime: '未连接'
|
||||
});
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits<{
|
||||
clear: [];
|
||||
}>();
|
||||
|
||||
// 响应式数据
|
||||
const showClearButton = ref(false);
|
||||
const logContainer = ref<HTMLElement>();
|
||||
const sseLogsRef = ref<HTMLElement>();
|
||||
const internalLogs = ref<string[]>([...props.logs]);
|
||||
|
||||
// 监听外部logs变化
|
||||
watch(() => props.logs, (newLogs) => {
|
||||
internalLogs.value = [...newLogs];
|
||||
|
||||
scrollToBottom();
|
||||
}, { deep: true });
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
if (logContainer.value) {
|
||||
nextTick(() => {
|
||||
if (logContainer.value) {
|
||||
logContainer.value.scrollTop = logContainer.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 获取日志样式类
|
||||
const getLogClass = (log: string) => {
|
||||
if (log.includes('错误') || log.includes('失败') || log.includes('Error')) {
|
||||
return 'error';
|
||||
}
|
||||
if (log.includes('警告') || log.includes('Warning')) {
|
||||
return 'warning';
|
||||
}
|
||||
if (log.includes('连接') || log.includes('成功') || log.includes('Success')) {
|
||||
return 'success';
|
||||
}
|
||||
return 'info';
|
||||
};
|
||||
|
||||
// 处理清空按钮点击
|
||||
const handleClearClick = () => {
|
||||
internalLogs.value = [];
|
||||
emit('clear');
|
||||
resetClickState();
|
||||
};
|
||||
|
||||
// 重置点击状态
|
||||
const resetClickState = () => {
|
||||
showClearButton.value = false;
|
||||
};
|
||||
|
||||
// 全局点击监听
|
||||
const handleGlobalClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
if (sseLogsRef.value && !sseLogsRef.value.contains(target)) {
|
||||
resetClickState();
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时添加全局监听
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleGlobalClick);
|
||||
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
// 组件卸载时移除全局监听
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleGlobalClick);
|
||||
});
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
scrollToBottom
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 变量定义
|
||||
$primary-color: #4a90e2;
|
||||
$primary-light: #5ba0f2;
|
||||
$success-color: #52c41a;
|
||||
$warning-color: #faad14;
|
||||
$error-color: #ff4d4f;
|
||||
$bg-dark: #283D62;
|
||||
$bg-light: rgba(255, 255, 255, 0.1);
|
||||
$bg-light-hover: rgba(255, 255, 255, 0.2);
|
||||
$border-light: rgba(255, 255, 255, 0.3);
|
||||
$text-white: #ffffff;
|
||||
$text-gray: #cccccc;
|
||||
$text-dark: #333333;
|
||||
$spacing-xs: 4px;
|
||||
$spacing-sm: 8px;
|
||||
$spacing-md: 12px;
|
||||
$spacing-lg: 16px;
|
||||
$border-radius: 6px;
|
||||
|
||||
// 混合器
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@mixin flex-align-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@mixin icon-base($size) {
|
||||
width: $size;
|
||||
height: $size;
|
||||
}
|
||||
|
||||
@mixin transition-ease {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sse-logs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.sse-status-info {
|
||||
background: $bg-light;
|
||||
border: 1px solid $border-light;
|
||||
border-radius: $border-radius;
|
||||
margin-bottom: $spacing-md;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.status-item {
|
||||
@include flex-align-center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: $spacing-sm;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: $text-dark;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: $text-dark;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
|
||||
&.connected {
|
||||
color: $success-color;
|
||||
}
|
||||
|
||||
&.disconnected {
|
||||
color: $error-color;
|
||||
}
|
||||
|
||||
&.connecting {
|
||||
color: $warning-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sse-logs-content {
|
||||
flex: 1;
|
||||
background: $bg-dark;
|
||||
border: 1px solid $border-light;
|
||||
border-radius: $border-radius;
|
||||
padding: $spacing-md;
|
||||
overflow-y: auto;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
position: relative;
|
||||
backdrop-filter: blur(10px);
|
||||
min-height: 200px;
|
||||
max-height: 200px;
|
||||
|
||||
.clear-button {
|
||||
position: absolute;
|
||||
right: $spacing-sm;
|
||||
top: $spacing-sm;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@include flex-center;
|
||||
background: $bg-light;
|
||||
border-radius: $border-radius;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
@include transition-ease;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid $border-light;
|
||||
z-index: 10;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $bg-light-hover;
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
@include icon-base(18px);
|
||||
color: $error-color;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
.log-item {
|
||||
color: $text-white;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 3px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
|
||||
&.success {
|
||||
color: $success-color;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: $error-color;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: $warning-color;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-logs {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
height: calc(200px - 2 * $spacing-md - 2px);
|
||||
color: $text-gray;
|
||||
opacity: 0.6;
|
||||
font-size: 13px;
|
||||
|
||||
.empty-icon {
|
||||
@include icon-base(32px);
|
||||
margin-bottom: $spacing-sm;
|
||||
color: $text-gray;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: $bg-light;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: $primary-color;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: $primary-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
76
src/components/SettingsModal/index.vue
Normal file
76
src/components/SettingsModal/index.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="right-info" @click="show">
|
||||
<span class="time-info">{{ currentTime }}</span>
|
||||
<span class="date-info">{{ currentDate }}</span>
|
||||
</div>
|
||||
|
||||
<a-modal v-model:open="dialogVisible" :title="modalTitle" :footer="null">
|
||||
<a-row :gutter="[0, 12]">
|
||||
<template v-for="(item, index) in modalItems" :key="index">
|
||||
<a-col :span="8" class="modal-label">{{ item.label }}</a-col>
|
||||
<a-col :span="16" class="modal-value">{{ item.value }}</a-col>
|
||||
</template>
|
||||
</a-row>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useDialog } from '@/utils/useDialog';
|
||||
import { useRealTime } from '@/utils/dateUtils';
|
||||
|
||||
// 实时时间
|
||||
const { currentTime } = useRealTime('HH:mm:ss');
|
||||
const currentDate = ref('');
|
||||
|
||||
const now = new Date();
|
||||
currentDate.value = `${now.getFullYear()}年${String(now.getMonth() + 1).padStart(2, '0')}月${String(now.getDate()).padStart(2, '0')}日`;
|
||||
|
||||
|
||||
const { visible: dialogVisible, show, hide } = useDialog();
|
||||
|
||||
const modalTitle = '系统设置';
|
||||
const modalItems = ref([
|
||||
{ label: '系统版本', value: 'v1.0.0' },
|
||||
{ label: '运行环境', value: '生产环境' },
|
||||
{ label: '数据库状态', value: '正常' },
|
||||
{ label: '运行时长', value: '24小时30分钟' },
|
||||
{ label: '内存使用', value: '2.1GB / 8GB' }
|
||||
]);
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/assets/styles/_variables.scss';
|
||||
|
||||
.modal-label {
|
||||
font-size: $text-size;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.right-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
padding: 0 $spacing-md;
|
||||
@include button-base;
|
||||
@include button-hover($bg-overlay, rgba(255, 255, 255, 0.2), $white);
|
||||
|
||||
.time-info {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
.date-info {
|
||||
font-size: 12px;
|
||||
color: $text-light;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
360
src/components/SseStatus/index.vue
Normal file
360
src/components/SseStatus/index.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<div class="status-item status-button" @click="show">
|
||||
<i-lucide-radio class="status-icon" />
|
||||
<span class="status-label">SSE 连接状态:</span>
|
||||
<span class="status-value" :class="sseStatusClass">{{ sseStatus }}</span>
|
||||
</div>
|
||||
|
||||
<a-modal v-model:open="dialogVisible" :title="modalTitle" :footer="null" width="600px">
|
||||
<a-row :gutter="[0, 12]">
|
||||
<template v-for="(item, index) in modalItems" :key="index">
|
||||
<a-col :span="8" class="modal-label">{{ item.label }}</a-col>
|
||||
<a-col :span="16" class="modal-value"
|
||||
:class="item.label === '当前状态' ? sseStatusClass : null">
|
||||
{{ item.value }}
|
||||
</a-col>
|
||||
</template>
|
||||
</a-row>
|
||||
<div class="sse-controls">
|
||||
<a-button type="primary" @click="connect" v-if="!isConnected"
|
||||
:loading="isConnecting" :disabled="isInCooldown">
|
||||
{{ isConnecting ? '连接中...' : (isInCooldown ? `冷却中 (${cooldownRemaining}s)` : '连接') }}
|
||||
</a-button>
|
||||
<a-button danger @click="sseDisconnect" v-else>
|
||||
断开连接
|
||||
</a-button>
|
||||
<a-button @click="handleClearSseLogs" class="clear-btn">
|
||||
<i-lucide-trash-2 />
|
||||
清空日志
|
||||
</a-button>
|
||||
<a-button @click="test" class="clear-btn">
|
||||
<i-lucide-trash-2 />
|
||||
测试
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div class="sse-logs" ref="logContainer">
|
||||
<div v-for="(log, index) in logs" :key="index" class="log-item" :class="log.type">
|
||||
{{ log.message }}
|
||||
</div>
|
||||
|
||||
<div v-if="logs.length === 0" class="empty-logs">
|
||||
<i-lucide-radio class="empty-icon" />
|
||||
<span>暂无SSE连接日志</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
||||
import { useDialog } from '@/utils/useDialog';
|
||||
import { useSSE } from '@/utils/useSSE';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
onL1Event: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
onL4Event: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
onMesEvent: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
onSseMessage: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
// useDialog管理弹窗状态
|
||||
const { visible: dialogVisible, show, hide } = useDialog();
|
||||
|
||||
// 冷却时间管理
|
||||
const isInCooldown = ref(false);
|
||||
const cooldownRemaining = ref(0);
|
||||
let cooldownTimer: number | null = null;
|
||||
|
||||
// 内部日志管理
|
||||
const logContainer = ref();
|
||||
function test() {
|
||||
addSseLog('测试日志');
|
||||
}
|
||||
// 使用SSE工具函数
|
||||
const {
|
||||
clientId,
|
||||
serverUrl,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
sseStatusText,
|
||||
sseStatusClass,
|
||||
logs,
|
||||
connect,
|
||||
disconnect,
|
||||
} = useSSE({
|
||||
onMessage: (data) => {
|
||||
addSseLog(data);
|
||||
// 调用父组件传入的消息处理函数
|
||||
if (props.onSseMessage) {
|
||||
props.onSseMessage(data);
|
||||
}
|
||||
},
|
||||
onL1Event: (data) => {
|
||||
if (props.onL1Event) {
|
||||
props.onL1Event(data);
|
||||
}
|
||||
},
|
||||
onL4Event: (data) => {
|
||||
if (props.onL4Event) {
|
||||
props.onL4Event(data);
|
||||
}
|
||||
},
|
||||
onMesEvent: (data) => {
|
||||
if (props.onMesEvent) {
|
||||
props.onMesEvent(data);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log('SSE 连接错误:', error);
|
||||
addSseLog('连接错误', 'error');
|
||||
},
|
||||
onConnect: () => {
|
||||
addSseLog('已连接到服务器', 'success');
|
||||
}
|
||||
});
|
||||
|
||||
const sseStatus = ref(sseStatusText.value);
|
||||
const modalTitle = ref('SSE 连接状态');
|
||||
const modalItems = ref([
|
||||
{ label: '当前状态', value: sseStatus.value },
|
||||
{ label: '客户端ID', value: clientId || 'hmi-main-client' },
|
||||
{ label: '服务器地址', value: serverUrl.value }
|
||||
]);
|
||||
|
||||
// 启动连接冷却时间
|
||||
const startCooldown = () => {
|
||||
isInCooldown.value = true;
|
||||
cooldownRemaining.value = 5;
|
||||
|
||||
const updateCooldown = () => {
|
||||
if (cooldownRemaining.value > 0) {
|
||||
cooldownRemaining.value--;
|
||||
cooldownTimer = window.setTimeout(updateCooldown, 1000);
|
||||
} else {
|
||||
isInCooldown.value = false;
|
||||
cooldownTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
updateCooldown();
|
||||
};
|
||||
|
||||
// 包装断开函数,添加冷却逻辑
|
||||
const sseDisconnect = () => {
|
||||
disconnect();
|
||||
startCooldown();
|
||||
};
|
||||
|
||||
// 添加 SSE 日志函数
|
||||
const addSseLog = (message: string, type: 'success' | 'error' | 'log' = 'log') => {
|
||||
const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
||||
const logEntry = `${timestamp} - ${message}`;
|
||||
logs.value.push({ message: logEntry, type });
|
||||
|
||||
return logEntry;
|
||||
};
|
||||
|
||||
// 处理清空日志
|
||||
const handleClearSseLogs = () => {
|
||||
logs.value = [];
|
||||
};
|
||||
|
||||
// 监听日志变化,自动滚动
|
||||
watch(() => logs.value.length, () => {
|
||||
const { scrollTop = 0, scrollHeight = 0, clientHeight = 0 } = logContainer.value;
|
||||
const isUserAtBottom = scrollTop + clientHeight >= scrollHeight - 10;
|
||||
nextTick(() => {
|
||||
if (logContainer.value && isUserAtBottom) {
|
||||
logContainer.value.scrollTop = logContainer.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 暴露方法和数据
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
logs
|
||||
});
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
connect();
|
||||
});
|
||||
|
||||
// 清理
|
||||
onBeforeUnmount(() => {
|
||||
if (cooldownTimer) {
|
||||
clearTimeout(cooldownTimer);
|
||||
cooldownTimer = null;
|
||||
}
|
||||
disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/assets/styles/_variables.scss';
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.status-button {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #8395B6;
|
||||
|
||||
&.success {
|
||||
color: $success-color;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: $error-color;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: $warning-color;
|
||||
}
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 13px;
|
||||
color: #8395B6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
&.success {
|
||||
color: $success-color;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: $error-color;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: $warning-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-label {
|
||||
font-size: $text-size;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sse-controls {
|
||||
margin: $spacing-md 0;
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
@include transition-ease;
|
||||
|
||||
.clear-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sse-logs {
|
||||
flex: 1;
|
||||
background-color: #283D63;
|
||||
border: 1px solid $border-light;
|
||||
border-radius: $border-radius;
|
||||
padding: $spacing-md;
|
||||
overflow-y: auto;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
position: relative;
|
||||
backdrop-filter: blur(10px);
|
||||
min-height: 200px;
|
||||
max-height: 200px;
|
||||
|
||||
.log-item {
|
||||
color: $white;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 3px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
|
||||
&.success {
|
||||
color: $success-color;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: $error-color;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-logs {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
height: calc(200px - 2 * $spacing-md - 2px);
|
||||
color: $text-gray;
|
||||
opacity: 0.6;
|
||||
font-size: 13px;
|
||||
|
||||
.empty-icon {
|
||||
@include icon-base(32px);
|
||||
margin-bottom: $spacing-sm;
|
||||
color: $text-gray;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: $bg-light;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: $primary-color;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: $primary-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
394
src/components/common/ActionButtons/index.vue
Normal file
394
src/components/common/ActionButtons/index.vue
Normal file
@@ -0,0 +1,394 @@
|
||||
<template>
|
||||
<div class="bottom-actions">
|
||||
<div class="pagination-container">
|
||||
<div class="pagination-control left" v-if="showPagination">
|
||||
<div class="pagination-btn-wrapper">
|
||||
<a-button :disabled="currentPage === 1" @click="goToPrevPage" class="pagination-btn prev-btn">
|
||||
<template #icon>
|
||||
<LeftOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
<div class="page-tooltip" v-if="currentPage > 1">
|
||||
{{ currentPage - 1 }} / {{ totalPages }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="buttons-container">
|
||||
<a-row :gutter="16" justify="center">
|
||||
<a-col v-for="button in currentPageButtons" :key="button.label">
|
||||
<a-button :type="button.type" size="large" @click="executeButtonAction(button.handler)"
|
||||
:class="['action-button', getButtonStatusClass(button)]">
|
||||
<component :is="getButtonStatusIcon(button)" v-if="getButtonStatusIcon(button)"
|
||||
class="status-icon-btn" />
|
||||
{{ button.label }}
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<div class="pagination-control right" v-if="showPagination">
|
||||
<div class="pagination-btn-wrapper">
|
||||
<a-button :disabled="currentPage === totalPages" @click="goToNextPage" class="pagination-btn next-btn">
|
||||
<template #icon>
|
||||
<RightOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
<div class="page-tooltip" v-if="currentPage < totalPages">
|
||||
{{ currentPage + 1 }} / {{ totalPages }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
type ButtonType = 'primary' | 'default' | 'dashed' | 'text' | 'link';
|
||||
|
||||
interface ActionButton {
|
||||
label: string;
|
||||
handler: string;
|
||||
type: ButtonType;
|
||||
status?: 'running' | 'paused' | 'idle';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
buttons: ActionButton[];
|
||||
buttonsPerPage?: number;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'execute', handlerName: string): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
buttonsPerPage: 6
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
// 底部按钮分页控制
|
||||
const currentPage = ref(1);
|
||||
const buttonsPerPage = ref(props.buttonsPerPage);
|
||||
|
||||
// 获取按钮状态样式类
|
||||
const getButtonStatusClass = (button: ActionButton) => {
|
||||
const statusClasses = {
|
||||
running: 'status-running',
|
||||
paused: 'status-paused',
|
||||
idle: 'status-idle'
|
||||
};
|
||||
return statusClasses[button.status || 'idle'];
|
||||
};
|
||||
|
||||
// 获取按钮状态图标
|
||||
const getButtonStatusIcon = (button: ActionButton) => {
|
||||
const statusIcons = {
|
||||
running: 'i-play-circle',
|
||||
paused: 'i-pause-circle',
|
||||
idle: ''
|
||||
};
|
||||
return statusIcons[button.status || 'idle'];
|
||||
};
|
||||
|
||||
// 计算当前页显示的按钮
|
||||
const currentPageButtons = computed(() => {
|
||||
const start = (currentPage.value - 1) * buttonsPerPage.value;
|
||||
const end = start + buttonsPerPage.value;
|
||||
return props.buttons.slice(start, end);
|
||||
});
|
||||
|
||||
// 计算总页数
|
||||
const totalPages = computed(() => {
|
||||
return Math.ceil(props.buttons.length / buttonsPerPage.value);
|
||||
});
|
||||
|
||||
// 是否显示分页控制
|
||||
const showPagination = computed(() => {
|
||||
return totalPages.value > 1;
|
||||
});
|
||||
|
||||
// 分页控制函数
|
||||
const goToPrevPage = () => {
|
||||
if (currentPage.value > 1) {
|
||||
currentPage.value--;
|
||||
}
|
||||
};
|
||||
|
||||
const goToNextPage = () => {
|
||||
if (currentPage.value < totalPages.value) {
|
||||
currentPage.value++;
|
||||
}
|
||||
};
|
||||
|
||||
// 执行按钮操作
|
||||
const executeButtonAction = (handlerName: string) => {
|
||||
emit('execute', handlerName);
|
||||
};
|
||||
|
||||
// 响应式调整按钮数量
|
||||
const updateButtonsPerPage = () => {
|
||||
nextTick(() => {
|
||||
const buttonsContainer = document.querySelector('.buttons-container') as HTMLElement;
|
||||
if (!buttonsContainer) {
|
||||
buttonsPerPage.value = props.buttonsPerPage; // 默认值
|
||||
return;
|
||||
}
|
||||
|
||||
const containerWidth = buttonsContainer.offsetWidth;
|
||||
const buttonWidth = 100; // 单个操作按钮最小宽度
|
||||
const buttonGap = 16; // 按钮间距
|
||||
|
||||
// 计算可以放置的按钮数量
|
||||
const maxButtons = Math.floor((containerWidth + buttonGap) / (buttonWidth + buttonGap));
|
||||
|
||||
// 设置最小和最大按钮数量
|
||||
buttonsPerPage.value = Math.max(3, maxButtons);
|
||||
});
|
||||
};
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 初始化按钮数量
|
||||
updateButtonsPerPage();
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', updateButtonsPerPage);
|
||||
});
|
||||
|
||||
// 清理事件监听
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateButtonsPerPage);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 变量定义
|
||||
$primary-color: #4a90e2;
|
||||
$primary-light: #5ba0f2;
|
||||
$success-color: #52c41a;
|
||||
$success-light: #73d13d;
|
||||
$success-bg: rgba(82, 196, 26, 0.1);
|
||||
$success-bg-hover: rgba(82, 196, 26, 0.2);
|
||||
$warning-color: #faad14;
|
||||
$warning-light: #ffc53d;
|
||||
$warning-bg: rgba(250, 173, 20, 0.1);
|
||||
$warning-bg-hover: rgba(250, 173, 20, 0.2);
|
||||
$error-color: #ff4d4f;
|
||||
$error-light: #ff7875;
|
||||
$bg-dark: rgba(0, 0, 0, 0.4);
|
||||
$bg-overlay: rgba(255, 255, 255, 0.1);
|
||||
$bg-overlay-hover: rgba(255, 255, 255, 0.2);
|
||||
$text-light: #cccccc;
|
||||
$text-success: #52c41a;
|
||||
$text-warning: #faad14;
|
||||
$white: #ffffff;
|
||||
$spacing-xs: 4px;
|
||||
$spacing-sm: 8px;
|
||||
$spacing-md: 12px;
|
||||
$spacing-lg: 16px;
|
||||
$border-radius: 6px;
|
||||
$border-radius-lg: 8px;
|
||||
$transition: all 0.3s ease;
|
||||
|
||||
// 混合器
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@mixin button-base {
|
||||
border-radius: $border-radius;
|
||||
font-weight: 500;
|
||||
transition: $transition;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 底部操作按钮区域 */
|
||||
.bottom-actions {
|
||||
height: 80px;
|
||||
background: $bg-dark;
|
||||
border-top: 2px solid $primary-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-sm $spacing-lg;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
@include flex-center;
|
||||
width: 100%;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.pagination-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pagination-btn-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
width: 35px;
|
||||
height: 50px;
|
||||
border-radius: $border-radius-lg;
|
||||
@include flex-center;
|
||||
background: rgba(74, 144, 226, 0.1);
|
||||
border: 1px solid $primary-color;
|
||||
color: $text-light;
|
||||
transition: $transition;
|
||||
|
||||
&:hover {
|
||||
background: rgba(74, 144, 226, 0.2);
|
||||
border-color: $primary-light;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: $bg-overlay;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.page-tooltip {
|
||||
position: absolute;
|
||||
top: -30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: $white;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
border-radius: $border-radius;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1000;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 4px solid transparent;
|
||||
border-top-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-btn-wrapper:hover .page-tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.buttons-container {
|
||||
flex: 1;
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
min-width: 100px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
@include button-base;
|
||||
}
|
||||
|
||||
/* 按钮状态样式 */
|
||||
.action-button {
|
||||
&.status-running {
|
||||
background: $success-bg !important;
|
||||
border-color: $success-color !important;
|
||||
color: $text-success !important;
|
||||
box-shadow: 0 0 8px rgba(82, 196, 26, 0.3);
|
||||
|
||||
&:hover {
|
||||
background: $success-bg-hover !important;
|
||||
border-color: $success-light !important;
|
||||
color: $white !important;
|
||||
box-shadow: 0 0 12px rgba(82, 196, 26, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&.status-paused {
|
||||
background: $warning-bg !important;
|
||||
border-color: $warning-color !important;
|
||||
color: $text-warning !important;
|
||||
box-shadow: 0 0 8px rgba(250, 173, 20, 0.3);
|
||||
|
||||
&:hover {
|
||||
background: $warning-bg-hover !important;
|
||||
border-color: $warning-light !important;
|
||||
color: $white !important;
|
||||
box-shadow: 0 0 12px rgba(250, 173, 20, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&.status-idle {
|
||||
background: $bg-overlay;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
color: $text-light;
|
||||
|
||||
&:hover {
|
||||
background: $bg-overlay-hover;
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-icon-btn {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1366px) {
|
||||
.bottom-actions .ant-btn {
|
||||
min-width: 90px;
|
||||
height: 36px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.bottom-actions {
|
||||
height: 70px;
|
||||
|
||||
.ant-row {
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
min-width: 80px;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +1,19 @@
|
||||
<template>
|
||||
<div class="execution-result" ref="executionResultRef">
|
||||
<div class="execution-result">
|
||||
<h4 class="section-title">
|
||||
<i-lucide-activity class="title-icon" />
|
||||
{{ title }}
|
||||
</h4>
|
||||
<div class="log-container" ref="logContainer" @mouseenter="showClearButton = true" @mouseleave="showClearButton = false">
|
||||
<div
|
||||
class="clear-button"
|
||||
:class="{ 'visible': showClearButton }"
|
||||
<div class="log-container" ref="logContainer">
|
||||
<div
|
||||
class="clear-button"
|
||||
@click.stop="handleClearClick"
|
||||
title="清空执行结果"
|
||||
v-if="internalLogs.length !== 0"
|
||||
>
|
||||
<i-lucide-trash-2 class="clear-icon" />
|
||||
</div>
|
||||
<div v-for="(log, index) in internalLogs" :key="index" class="log-item">
|
||||
<div v-for="(log, index) in internalLogs" :key="index" class="log-item" :class="{'error': log.includes('错误'), 'warning': log.includes('警告')}">
|
||||
{{ log }}
|
||||
</div>
|
||||
<div v-if="internalLogs.length === 0" class="empty-logs">
|
||||
@@ -25,19 +25,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import type { ExecutionResultProps } from './types';
|
||||
|
||||
// 定义组件属性
|
||||
interface Props {
|
||||
title?: string;
|
||||
logs?: string[];
|
||||
autoScroll?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<ExecutionResultProps>(), {
|
||||
title: '执行结果',
|
||||
logs: () => [],
|
||||
autoScroll: true
|
||||
logs: () => []
|
||||
});
|
||||
|
||||
// 定义事件
|
||||
@@ -46,18 +39,13 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
// 响应式数据
|
||||
const showClearButton = ref(false);
|
||||
const logContainer = ref<HTMLElement>();
|
||||
const executionResultRef = ref<HTMLElement>();
|
||||
const internalLogs = ref<string[]>([...props.logs]);
|
||||
|
||||
// 监听外部logs变化
|
||||
watch(() => props.logs, (newLogs) => {
|
||||
internalLogs.value = [...newLogs];
|
||||
|
||||
if (props.autoScroll) {
|
||||
scrollToBottom();
|
||||
}
|
||||
scrollToBottom();
|
||||
}, { deep: true });
|
||||
|
||||
// 滚动到底部
|
||||
@@ -73,53 +61,16 @@ const scrollToBottom = () => {
|
||||
|
||||
// 处理清空按钮点击
|
||||
const handleClearClick = () => {
|
||||
// 单次点击即清空日志
|
||||
internalLogs.value = [];
|
||||
emit('clear');
|
||||
resetClickState();
|
||||
};
|
||||
|
||||
// 重置点击状态
|
||||
const resetClickState = () => {
|
||||
showClearButton.value = false;
|
||||
};
|
||||
|
||||
// 全局点击监听,点击其他地方重置状态
|
||||
const handleGlobalClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// 如果点击的不是当前组件内的清空按钮,则重置状态
|
||||
if (executionResultRef.value && !executionResultRef.value.contains(target)) {
|
||||
resetClickState();
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时添加全局监听
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleGlobalClick);
|
||||
|
||||
// 初始滚动到底部
|
||||
if (props.autoScroll) {
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
// 组件卸载时移除全局监听
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleGlobalClick);
|
||||
});
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
scrollToBottom
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 变量定义
|
||||
$primary-color: #4a90e2;
|
||||
$primary-light: #5ba0f2;
|
||||
$success-color: #52c41a;
|
||||
$warning-color: #faad14;
|
||||
$error-color: #ff4d4f;
|
||||
$bg-dark: rgba(0, 0, 0, 0.3);
|
||||
$bg-light: rgba(255, 255, 255, 0.1);
|
||||
@@ -225,12 +176,17 @@ $border-radius: 4px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
position: relative;
|
||||
|
||||
&:hover .clear-button {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
position: absolute;
|
||||
right: $spacing-sm;
|
||||
top: $spacing-sm;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@include flex-center;
|
||||
background: $bg-light;
|
||||
border-radius: $border-radius;
|
||||
@@ -242,19 +198,12 @@ $border-radius: 4px;
|
||||
border: 1px solid $border-light;
|
||||
z-index: 10;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $bg-light-hover;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
@include icon-base(14px);
|
||||
@include icon-base(18px);
|
||||
color: $error-color;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
@@ -266,6 +215,14 @@ $border-radius: 4px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
|
||||
&.error {
|
||||
color: $error-color;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: $warning-color;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-logs {
|
||||
@@ -1,7 +1,7 @@
|
||||
// ExecutionResult组件相关类型定义
|
||||
export interface ExecutionResultProps {
|
||||
title?: string;
|
||||
logs: string[];
|
||||
logs?: string[];
|
||||
}
|
||||
|
||||
// 日志项类型
|
||||
@@ -1,74 +0,0 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
title="登录"
|
||||
ok-text="登录"
|
||||
cancel-text="取消"
|
||||
@close="handleClose"
|
||||
@cancel="handleClose"
|
||||
@ok="handleLogin"
|
||||
>
|
||||
<a-form :value="loginForm"
|
||||
:label-col="{ span: 4 }"
|
||||
:wrapper-col="{ span: 18 }">
|
||||
<a-form-item label="用户名">
|
||||
<a-input v-model:value="loginForm.username" />
|
||||
</a-form-item>
|
||||
<a-form-item label="密码">
|
||||
<a-input v-model:value="loginForm.password" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits([
|
||||
'ok',
|
||||
'close',
|
||||
'cancel',
|
||||
'update:modelValue',
|
||||
]);
|
||||
|
||||
const handleLogin = () => {
|
||||
console.log('loginForm: ', loginForm);
|
||||
console.log('ok')
|
||||
handleClose();
|
||||
}
|
||||
|
||||
const visible = ref(props.modelValue);
|
||||
// 关闭模态框
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
emit('update:modelValue', false);
|
||||
};
|
||||
|
||||
// 监听外部变化
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
visible.value = newVal;
|
||||
});
|
||||
|
||||
// 监听内部变化
|
||||
watch(visible, (newVal) => {
|
||||
if (newVal !== props.modelValue) {
|
||||
emit('update:modelValue', newVal);
|
||||
}
|
||||
});
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.ant-modal-header {
|
||||
margin-bottom: 30px !important;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user