feat: 完善告警规则编辑功能和增强设备管理界面
主要更新: 1. **修复告警规则编辑抽屉提交按钮问题** - 重构 alerrules-operate-drawer.vue 组件布局 - 将footer按钮改为内联形式,确保始终可见 - 优化抽屉内容区域滚动和布局 2. **完善AlertRuleWizard智能配置向导** - 实现完整的4步向导流程 - 支持单体征和复合规则配置 - 集成正确的API调用和数据验证 - 增强TAlertRulesUpdateDTO支持完整编辑功能 3. **优化告警规则列表界面** - 增强监控指标显示,支持颜色标识 - 改进复合规则指标组合展示 - 优化智能向导按钮样式 4. **增强设备信息管理界面** - 新增设备统计概览卡片 - 实现设备状态、佩戴状态、充电状态分布图表 - 添加电池统计和设备型号分布 - 提供实时在线率、佩戴率、充电率等关键指标 5. **后端功能增强** - 完善AlertRuleEngine批量处理能力 - 优化数据上传处理性能 - 增强告警规则缓存管理 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
This commit is contained in:
@@ -533,7 +533,7 @@ const local: App.I18n.Schema = {
|
||||
male: 'Male',
|
||||
female: 'Female'
|
||||
},
|
||||
selectTreeIsEmptyTip: 'Please select the organization to view user items'
|
||||
selectTreeIsEmptyTip: 'Please select a department to view employee list'
|
||||
},
|
||||
menu: {
|
||||
home: 'Home',
|
||||
|
||||
@@ -1198,7 +1198,7 @@ const local: App.I18n.Schema = {
|
||||
male: '男',
|
||||
female: '女'
|
||||
},
|
||||
selectTreeIsEmptyTip: '请选择组织查看员工列表'
|
||||
selectTreeIsEmptyTip: '请选择部门查看员工列表'
|
||||
},
|
||||
menu: {
|
||||
home: '首页',
|
||||
|
||||
+5
@@ -54,8 +54,13 @@ declare module 'vue' {
|
||||
IconLocalLogo: typeof import('~icons/local/logo')['default']
|
||||
'IconMdi:alertCircleOutline': typeof import('~icons/mdi/alert-circle-outline')['default']
|
||||
'IconMdi:alertOutline': typeof import('~icons/mdi/alert-outline')['default']
|
||||
'IconMdi:batteryCharging': typeof import('~icons/mdi/battery-charging')['default']
|
||||
'IconMdi:cellphone': typeof import('~icons/mdi/cellphone')['default']
|
||||
'IconMdi:devices': typeof import('~icons/mdi/devices')['default']
|
||||
'IconMdi:messageOutline': typeof import('~icons/mdi/message-outline')['default']
|
||||
'IconMdi:power': typeof import('~icons/mdi/power')['default']
|
||||
'IconMdi:progressCheck': typeof import('~icons/mdi/progress-check')['default']
|
||||
'IconMdi:watch': typeof import('~icons/mdi/watch')['default']
|
||||
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
|
||||
IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
|
||||
IconMdiDrag: typeof import('~icons/mdi/drag')['default']
|
||||
|
||||
@@ -99,11 +99,31 @@ const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagi
|
||||
多指标组合 ({indicators.length})
|
||||
</NTag>
|
||||
<div class="composite-indicators flex flex-wrap gap-1">
|
||||
{indicators.slice(0, 3).map((indicator, index) => (
|
||||
<NTag key={index} type="info" size="tiny" class="text-xs">
|
||||
{dictTag('health_data_type', indicator)?.props?.children || indicator}
|
||||
</NTag>
|
||||
))}
|
||||
{indicators.slice(0, 3).map((indicator, index) => {
|
||||
const getIndicatorConfig = (physicalSign) => {
|
||||
const configs = {
|
||||
'heart_rate': { text: '心率', type: 'error' },
|
||||
'blood_oxygen': { text: '血氧', type: 'success' },
|
||||
'temperature': { text: '体温', type: 'warning' },
|
||||
'pressure_high': { text: '收缩压', type: 'error' },
|
||||
'pressure_low': { text: '舒张压', type: 'info' },
|
||||
'blood_pressure': { text: '血压', type: 'error' },
|
||||
'step': { text: '步数', type: 'success' },
|
||||
'calorie': { text: '卡路里', type: 'warning' },
|
||||
'distance': { text: '距离', type: 'info' },
|
||||
'sleep': { text: '睡眠', type: 'primary' },
|
||||
'stress': { text: '压力', type: 'error' },
|
||||
'event': { text: '事件告警', type: 'warning' }
|
||||
};
|
||||
return configs[physicalSign] || { text: physicalSign, type: 'default' };
|
||||
};
|
||||
const config = getIndicatorConfig(indicator);
|
||||
return (
|
||||
<NTag key={index} type={config.type} size="tiny" class="text-xs">
|
||||
{config.text}
|
||||
</NTag>
|
||||
);
|
||||
})}
|
||||
{indicators.length > 3 && (
|
||||
<NTooltip trigger="hover">
|
||||
{{
|
||||
@@ -114,11 +134,30 @@ const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagi
|
||||
),
|
||||
default: () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
{indicators.slice(3).map((indicator, index) => (
|
||||
<span key={index} class="text-xs">
|
||||
{dictTag('health_data_type', indicator)?.props?.children || indicator}
|
||||
</span>
|
||||
))}
|
||||
{indicators.slice(3).map((indicator, index) => {
|
||||
const getIndicatorText = (physicalSign) => {
|
||||
const configs = {
|
||||
'heart_rate': '心率',
|
||||
'blood_oxygen': '血氧',
|
||||
'temperature': '体温',
|
||||
'pressure_high': '收缩压',
|
||||
'pressure_low': '舒张压',
|
||||
'blood_pressure': '血压',
|
||||
'step': '步数',
|
||||
'calorie': '卡路里',
|
||||
'distance': '距离',
|
||||
'sleep': '睡眠',
|
||||
'stress': '压力',
|
||||
'event': '事件告警'
|
||||
};
|
||||
return configs[physicalSign] || physicalSign;
|
||||
};
|
||||
return (
|
||||
<span key={index} class="text-xs">
|
||||
{getIndicatorText(indicator)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
@@ -148,23 +187,35 @@ const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagi
|
||||
);
|
||||
}
|
||||
// 单体征规则:显示具体指标
|
||||
const indicator = dictTag('health_data_type', row.physicalSign);
|
||||
if (indicator) {
|
||||
if (row.physicalSign) {
|
||||
const getIndicatorConfig = (physicalSign) => {
|
||||
const configs = {
|
||||
'heart_rate': { text: '心率', type: 'error' },
|
||||
'blood_oxygen': { text: '血氧', type: 'success' },
|
||||
'temperature': { text: '体温', type: 'warning' },
|
||||
'pressure_high': { text: '收缩压', type: 'error' },
|
||||
'pressure_low': { text: '舒张压', type: 'info' },
|
||||
'blood_pressure': { text: '血压', type: 'error' },
|
||||
'step': { text: '步数', type: 'success' },
|
||||
'calorie': { text: '卡路里', type: 'warning' },
|
||||
'distance': { text: '距离', type: 'info' },
|
||||
'sleep': { text: '睡眠', type: 'primary' },
|
||||
'stress': { text: '压力', type: 'error' },
|
||||
'event': { text: '事件告警', type: 'warning' }
|
||||
};
|
||||
return configs[physicalSign] || { text: physicalSign, type: 'default' };
|
||||
};
|
||||
|
||||
const config = getIndicatorConfig(row.physicalSign);
|
||||
return (
|
||||
<NTag type="primary" size="small">
|
||||
<NIcon size="12" class="mr-1">
|
||||
<i class="i-material-symbols:monitor-heart"></i>
|
||||
</NIcon>
|
||||
{indicator}
|
||||
<NTag type={config.type} size="small">
|
||||
{config.text}
|
||||
</NTag>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NTag type="default" size="small">
|
||||
<NIcon size="12" class="mr-1">
|
||||
<i class="i-material-symbols:help"></i>
|
||||
</NIcon>
|
||||
{row.physicalSign || '未设置'}
|
||||
未设置
|
||||
</NTag>
|
||||
);
|
||||
}
|
||||
@@ -816,7 +867,7 @@ function onWizardSuccess() {
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- 智能向导按钮 -->
|
||||
<NButton v-if="hasAuth('t:alert:rules:add')" size="small" type="primary" ghost @click="openWizard">
|
||||
<NButton v-if="hasAuth('t:alert:rules:add')" size="medium" type="primary" @click="openWizard">
|
||||
<template #icon>
|
||||
<NIcon>
|
||||
<i class="i-material-symbols:auto-awesome"></i>
|
||||
@@ -1037,17 +1088,6 @@ function onWizardSuccess() {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 多指标组合显示样式优化 */
|
||||
.composite-indicators {
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.composite-indicators .n-tag {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.composite-conditions {
|
||||
max-width: 140px;
|
||||
@@ -1080,6 +1120,7 @@ function onWizardSuccess() {
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
}
|
||||
|
||||
|
||||
/* 监控指标图标美化 */
|
||||
.indicator-icon {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -387,16 +387,17 @@ watch(visible, () => {
|
||||
<NInput v-model:value="model.ruleTags" placeholder="规则标签 (可选)" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<NSpace class="w-full justify-end">
|
||||
<NButton quaternary @click="closeDrawer">取消</NButton>
|
||||
<NButton type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
{{ isAdd ? '创建' : '更新' }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
<!-- 内联操作按钮 -->
|
||||
<div class="form-actions">
|
||||
<NSpace class="w-full justify-end">
|
||||
<NButton quaternary @click="closeDrawer">取消</NButton>
|
||||
<NButton type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
{{ isAdd ? '创建' : '更新' }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
</div>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</template>
|
||||
@@ -404,6 +405,31 @@ watch(visible, () => {
|
||||
<style scoped>
|
||||
.form-container {
|
||||
padding: 16px 0;
|
||||
min-height: calc(100vh - 120px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 32px;
|
||||
padding: 20px 0 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 确保抽屉内容区域正确布局 */
|
||||
:deep(.n-drawer-content) {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.n-drawer-body-content-wrapper) {
|
||||
height: calc(100vh - 60px);
|
||||
overflow-y: auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
:deep(.n-divider .n-divider__title) {
|
||||
@@ -441,8 +467,4 @@ watch(visible, () => {
|
||||
:deep(.n-time-picker .n-input .n-input-wrapper) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.form-container :deep(.n-drawer-body-content-wrapper) {
|
||||
padding: 0 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="tsx">
|
||||
import { NButton, NIcon, NPopconfirm, NTag, type TagProps } from 'naive-ui';
|
||||
import { computed, h, onMounted, ref, shallowRef, watch } from 'vue';
|
||||
import { NButton, NCard, NIcon, NPopconfirm, NTag, type TagProps } from 'naive-ui';
|
||||
import type { Ref } from 'vue';
|
||||
import { onMounted, ref, shallowRef, watch } from 'vue';
|
||||
import { BatteryChargingOutline, BatteryDeadOutline, BatteryFullOutline } from '@vicons/ionicons5';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import SvgIcon from '@/components/custom/svg-icon.vue';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
@@ -32,6 +34,29 @@ const editingData: Ref<Api.Health.DeviceInfo | null> = ref(null);
|
||||
const customerId = authStore.userInfo?.customerId;
|
||||
const deviceInactiveThreshold = ref(60000); // 默认60秒,动态获取
|
||||
|
||||
// 设备状态中英文映射
|
||||
const deviceStatusMap = {
|
||||
ACTIVE: '在线',
|
||||
INACTIVE: '离线',
|
||||
CHARGING: '充电中',
|
||||
LOW_BATTERY: '低电量',
|
||||
MAINTENANCE: '维护中'
|
||||
};
|
||||
|
||||
// 佩戴状态中英文映射
|
||||
const wearStatusMap = {
|
||||
WEARING: '佩戴',
|
||||
NOT_WEARING: '未佩戴',
|
||||
UNKNOWN: '未知'
|
||||
};
|
||||
|
||||
// 充电状态中英文映射
|
||||
const chargingStatusMap = {
|
||||
CHARGING: '充电中',
|
||||
'fully charged': '已充满',
|
||||
NOT_CHARGING: '未充电'
|
||||
};
|
||||
|
||||
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination, searchParams, resetSearchParams } = useTable({
|
||||
apiFn: fetchGetDeviceInfoList,
|
||||
apiParams: {
|
||||
@@ -276,6 +301,128 @@ watch(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 设备统计数据计算
|
||||
const deviceStatistics = computed(() => {
|
||||
if (!data.value || data.value.length === 0) {
|
||||
return {
|
||||
total: 0,
|
||||
byStatus: {},
|
||||
byWearStatus: {},
|
||||
byChargingStatus: {},
|
||||
byModel: {},
|
||||
batteryStats: {
|
||||
high: 0, // >= 80%
|
||||
medium: 0, // 20% - 79%
|
||||
low: 0, // < 20%
|
||||
average: 0
|
||||
},
|
||||
onlineRate: 0,
|
||||
wearingRate: 0,
|
||||
chargingRate: 0,
|
||||
lowBatteryCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
const stats = {
|
||||
total: data.value.length,
|
||||
byStatus: {} as Record<string, number>,
|
||||
byWearStatus: {} as Record<string, number>,
|
||||
byChargingStatus: {} as Record<string, number>,
|
||||
byModel: {} as Record<string, number>,
|
||||
batteryStats: {
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
average: 0
|
||||
},
|
||||
onlineRate: 0,
|
||||
wearingRate: 0,
|
||||
chargingRate: 0,
|
||||
lowBatteryCount: 0
|
||||
};
|
||||
|
||||
let totalBattery = 0;
|
||||
let onlineCount = 0;
|
||||
let wearingCount = 0;
|
||||
let chargingCount = 0;
|
||||
|
||||
data.value.forEach(device => {
|
||||
// 计算实时设备状态
|
||||
const now = Date.now();
|
||||
const deviceTime = new Date(device.timestamp || device.updateTime).getTime();
|
||||
const diffMs = now - deviceTime;
|
||||
const actualStatus = diffMs > deviceInactiveThreshold.value ? 'INACTIVE' : device.status;
|
||||
|
||||
// 按状态统计(翻译为中文)
|
||||
const statusKey = deviceStatusMap[actualStatus as keyof typeof deviceStatusMap] || actualStatus || '未知';
|
||||
stats.byStatus[statusKey] = (stats.byStatus[statusKey] || 0) + 1;
|
||||
|
||||
// 按佩戴状态统计(翻译为中文)
|
||||
const wearStatusKey = wearStatusMap[device.wearableStatus as keyof typeof wearStatusMap] || device.wearableStatus || '未知';
|
||||
stats.byWearStatus[wearStatusKey] = (stats.byWearStatus[wearStatusKey] || 0) + 1;
|
||||
|
||||
// 按充电状态统计(翻译为中文)
|
||||
const chargingStatusKey = chargingStatusMap[device.chargingStatus as keyof typeof chargingStatusMap] || device.chargingStatus || '未知';
|
||||
stats.byChargingStatus[chargingStatusKey] = (stats.byChargingStatus[chargingStatusKey] || 0) + 1;
|
||||
|
||||
// 按设备型号统计
|
||||
const systemVersion = device.systemSoftwareVersion || '';
|
||||
const match = systemVersion.match(/^([A-Z0-9-]+)CN/);
|
||||
const model = match ? match[1] : 'Unknown';
|
||||
stats.byModel[model] = (stats.byModel[model] || 0) + 1;
|
||||
|
||||
// 电池统计
|
||||
const batteryLevel = device.batteryLevel || 0;
|
||||
totalBattery += batteryLevel;
|
||||
|
||||
if (batteryLevel >= 80) {
|
||||
stats.batteryStats.high++;
|
||||
} else if (batteryLevel >= 20) {
|
||||
stats.batteryStats.medium++;
|
||||
} else {
|
||||
stats.batteryStats.low++;
|
||||
stats.lowBatteryCount++;
|
||||
}
|
||||
|
||||
// 在线、佩戴、充电设备计数
|
||||
if (actualStatus === 'ACTIVE') onlineCount++;
|
||||
if (device.wearableStatus === 'WEARING') wearingCount++;
|
||||
if (device.chargingStatus === 'CHARGING') chargingCount++;
|
||||
});
|
||||
|
||||
// 计算比率
|
||||
stats.onlineRate = Math.round((onlineCount / stats.total) * 100);
|
||||
stats.wearingRate = Math.round((wearingCount / stats.total) * 100);
|
||||
stats.chargingRate = Math.round((chargingCount / stats.total) * 100);
|
||||
stats.batteryStats.average = Math.round(totalBattery / stats.total);
|
||||
|
||||
return stats;
|
||||
});
|
||||
|
||||
// 设备状态颜色映射
|
||||
const deviceStatusColors = {
|
||||
在线: 'rgba(82, 196, 26, 0.8)', // 绿色
|
||||
离线: 'rgba(140, 140, 140, 0.8)', // 灰色
|
||||
充电中: 'rgba(24, 144, 255, 0.8)', // 蓝色
|
||||
低电量: 'rgba(245, 34, 45, 0.8)', // 红色
|
||||
维护中: 'rgba(250, 173, 20, 0.8)' // 橙色
|
||||
};
|
||||
|
||||
// 佩戴状态颜色映射
|
||||
const wearStatusColors = {
|
||||
佩戴: 'rgba(82, 196, 26, 0.8)', // 绿色
|
||||
未佩戴: 'rgba(245, 34, 45, 0.8)', // 红色
|
||||
未知: 'rgba(140, 140, 140, 0.8)' // 灰色
|
||||
};
|
||||
|
||||
// 充电状态颜色映射
|
||||
const chargingStatusColors = {
|
||||
充电中: 'rgba(24, 144, 255, 0.8)', // 蓝色
|
||||
已充满: 'rgba(82, 196, 26, 0.8)', // 绿色
|
||||
未充电: 'rgba(250, 173, 20, 0.8)' // 橙色
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
handleInitOptions();
|
||||
});
|
||||
@@ -290,6 +437,144 @@ onMounted(() => {
|
||||
@reset="resetSearchParams"
|
||||
@search="getDataByPage"
|
||||
/>
|
||||
|
||||
<!-- 设备统计概览卡片 -->
|
||||
<NCard :bordered="false" class="card-wrapper">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<icon-mdi:devices class="text-lg text-blue-500" />
|
||||
<span class="font-medium">设备统计概览</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<!-- 总设备数 -->
|
||||
<div class="border border-blue-200 rounded-lg from-blue-50 to-blue-100 bg-gradient-to-br p-4 text-center">
|
||||
<div class="text-2xl text-blue-600 font-bold">{{ deviceStatistics.total }}</div>
|
||||
<div class="mt-1 text-sm text-blue-500">总设备数</div>
|
||||
</div>
|
||||
|
||||
<!-- 在线设备 -->
|
||||
<div class="border border-green-200 rounded-lg from-green-50 to-green-100 bg-gradient-to-br p-4 text-center">
|
||||
<div class="text-2xl text-green-600 font-bold">{{ deviceStatistics.onlineRate }}%</div>
|
||||
<div class="mt-1 text-sm text-green-500">在线率</div>
|
||||
</div>
|
||||
|
||||
<!-- 佩戴设备 -->
|
||||
<div class="border border-purple-200 rounded-lg from-purple-50 to-purple-100 bg-gradient-to-br p-4 text-center">
|
||||
<div class="text-2xl text-purple-600 font-bold">{{ deviceStatistics.wearingRate }}%</div>
|
||||
<div class="mt-1 text-sm text-purple-500">佩戴率</div>
|
||||
</div>
|
||||
|
||||
<!-- 低电量设备 -->
|
||||
<div class="border border-red-200 rounded-lg from-red-50 to-red-100 bg-gradient-to-br p-4 text-center">
|
||||
<div class="text-2xl text-red-600 font-bold">{{ deviceStatistics.lowBatteryCount }}</div>
|
||||
<div class="mt-1 text-sm text-red-500">低电量设备</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 电池统计 -->
|
||||
<div class="mt-6 grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<!-- 平均电量 -->
|
||||
<div class="border border-yellow-200 rounded-lg from-yellow-50 to-yellow-100 bg-gradient-to-br p-4 text-center">
|
||||
<div class="text-2xl text-yellow-600 font-bold">{{ deviceStatistics.batteryStats.average }}%</div>
|
||||
<div class="mt-1 text-sm text-yellow-500">平均电量</div>
|
||||
</div>
|
||||
|
||||
<!-- 高电量设备 -->
|
||||
<div class="border border-emerald-200 rounded-lg from-emerald-50 to-emerald-100 bg-gradient-to-br p-4 text-center">
|
||||
<div class="text-2xl text-emerald-600 font-bold">{{ deviceStatistics.batteryStats.high }}</div>
|
||||
<div class="mt-1 text-sm text-emerald-500">高电量设备(≥80%)</div>
|
||||
</div>
|
||||
|
||||
<!-- 中等电量设备 -->
|
||||
<div class="border border-amber-200 rounded-lg from-amber-50 to-amber-100 bg-gradient-to-br p-4 text-center">
|
||||
<div class="text-2xl text-amber-600 font-bold">{{ deviceStatistics.batteryStats.medium }}</div>
|
||||
<div class="mt-1 text-sm text-amber-500">中等电量设备(20%-79%)</div>
|
||||
</div>
|
||||
|
||||
<!-- 充电中设备 -->
|
||||
<div class="border border-cyan-200 rounded-lg from-cyan-50 to-cyan-100 bg-gradient-to-br p-4 text-center">
|
||||
<div class="text-2xl text-cyan-600 font-bold">{{ deviceStatistics.chargingRate }}%</div>
|
||||
<div class="mt-1 text-sm text-cyan-500">充电率</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细统计分布 -->
|
||||
<div class="grid grid-cols-1 mt-6 gap-6 lg:grid-cols-4">
|
||||
<!-- 设备状态分布 -->
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h4 class="mb-3 flex items-center gap-2 text-gray-700 font-medium">
|
||||
<icon-mdi:power class="text-green-500" />
|
||||
设备状态分布
|
||||
</h4>
|
||||
<div class="max-h-48 overflow-y-auto space-y-2">
|
||||
<div v-for="(count, status) in deviceStatistics.byStatus" :key="status" class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-3 w-3 rounded-full" :style="{ backgroundColor: deviceStatusColors[status] || '#ccc' }"></div>
|
||||
<span class="text-sm text-gray-600">{{ status }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-800 font-medium">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 佩戴状态分布 -->
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h4 class="mb-3 flex items-center gap-2 text-gray-700 font-medium">
|
||||
<icon-mdi:watch class="text-purple-500" />
|
||||
佩戴状态分布
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(count, wearStatus) in deviceStatistics.byWearStatus" :key="wearStatus" class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-3 w-3 rounded-full" :style="{ backgroundColor: wearStatusColors[wearStatus] || '#ccc' }"></div>
|
||||
<span class="text-sm text-gray-600">{{ wearStatus }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-800 font-medium">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 充电状态分布 -->
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h4 class="mb-3 flex items-center gap-2 text-gray-700 font-medium">
|
||||
<icon-mdi:battery-charging class="text-blue-500" />
|
||||
充电状态分布
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(count, chargingStatus) in deviceStatistics.byChargingStatus" :key="chargingStatus" class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-3 w-3 rounded-full" :style="{ backgroundColor: chargingStatusColors[chargingStatus] || '#ccc' }"></div>
|
||||
<span class="text-sm text-gray-600">{{ chargingStatus }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-800 font-medium">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备型号分布 -->
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h4 class="mb-3 flex items-center gap-2 text-gray-700 font-medium">
|
||||
<icon-mdi:cellphone class="text-indigo-500" />
|
||||
设备型号分布
|
||||
</h4>
|
||||
<div class="max-h-48 overflow-y-auto space-y-2">
|
||||
<div v-for="(count, model) in deviceStatistics.byModel" :key="model" class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-3 w-3 rounded-full"
|
||||
:style="{ backgroundColor: `hsl(${(Object.keys(deviceStatistics.byModel).indexOf(model) * 137.5) % 360}, 70%, 60%)` }"
|
||||
></div>
|
||||
<span class="text-sm text-gray-600">{{ model }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-800 font-medium">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<NCard :bordered="false" class="sm:flex-1-hidden card-wrapper" content-class="flex-col">
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
@@ -320,3 +605,68 @@ onMounted(() => {
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gap-8px {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 统计卡片样式优化 */
|
||||
.card-wrapper {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #e5e7eb;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f9fafb 100%);
|
||||
}
|
||||
|
||||
/* 统计数字动画 */
|
||||
.text-2xl {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.text-2xl:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 分布卡片样式 */
|
||||
.border.border-gray-200 {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.border.border-gray-200:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 彩色点样式 */
|
||||
.h-3.w-3.rounded-full {
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.8);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.h-3.w-3.rounded-full:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.md\:grid-cols-4 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.lg\:grid-cols-4 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { defineEmits, defineProps } from 'vue';
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { fetchAddAlertRules, fetchUpdateAlertRulesInfo } from '@/service/api/health/alert-rules';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
editRule?: any;
|
||||
}>();
|
||||
@@ -11,27 +13,456 @@ const emit = defineEmits<{
|
||||
success: [];
|
||||
}>();
|
||||
|
||||
const handleSave = () => {
|
||||
emit('success');
|
||||
const message = useMessage();
|
||||
const formRef = ref();
|
||||
const loading = ref(false);
|
||||
const currentStep = ref(0);
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null as any,
|
||||
customerId: null as any,
|
||||
ruleType: 'metric',
|
||||
ruleCategory: 'SINGLE',
|
||||
physicalSign: '',
|
||||
thresholdMin: null as any,
|
||||
thresholdMax: null as any,
|
||||
alertMessage: '',
|
||||
severityLevel: 'moderate',
|
||||
level: 'moderate',
|
||||
isEnabled: true,
|
||||
timeWindowSeconds: 300,
|
||||
cooldownSeconds: 600,
|
||||
priorityLevel: 3,
|
||||
effectiveDays: '1,2,3,4,5,6,7',
|
||||
trendDuration: 1,
|
||||
enabledChannels: ['message'] as string[],
|
||||
conditions: [] as any[]
|
||||
});
|
||||
|
||||
// 生理指标选项
|
||||
const physicalSignOptions = [
|
||||
{ label: '心率', value: 'heart_rate' },
|
||||
{ label: '血氧浓度', value: 'blood_oxygen' },
|
||||
{ label: '体温', value: 'temperature' },
|
||||
{ label: '收缩压', value: 'pressure_high' },
|
||||
{ label: '舒张压', value: 'pressure_low' },
|
||||
{ label: '步数', value: 'step' },
|
||||
{ label: '卡路里', value: 'calorie' },
|
||||
{ label: '距离', value: 'distance' },
|
||||
{ label: '压力指数', value: 'stress' }
|
||||
];
|
||||
|
||||
// 严重程度选项
|
||||
const severityOptions = [
|
||||
{ label: '信息', value: 'info' },
|
||||
{ label: '一般', value: 'minor' },
|
||||
{ label: '重要', value: 'major' },
|
||||
{ label: '紧急', value: 'critical' }
|
||||
];
|
||||
|
||||
// 通知渠道选项
|
||||
const channelOptions = [
|
||||
{ label: '内部消息', value: 'message' },
|
||||
{ label: '微信', value: 'wechat' },
|
||||
{ label: '短信', value: 'sms' },
|
||||
{ label: '邮件', value: 'email' }
|
||||
];
|
||||
|
||||
// 星期选项
|
||||
const dayOptions = [
|
||||
{ label: '周一', value: '1' },
|
||||
{ label: '周二', value: '2' },
|
||||
{ label: '周三', value: '3' },
|
||||
{ label: '周四', value: '4' },
|
||||
{ label: '周五', value: '5' },
|
||||
{ label: '周六', value: '6' },
|
||||
{ label: '周日', value: '7' }
|
||||
];
|
||||
|
||||
// 操作符选项
|
||||
const operatorOptions = [
|
||||
{ label: '大于 >', value: '>' },
|
||||
{ label: '小于 <', value: '<' },
|
||||
{ label: '大于等于 >=', value: '>=' },
|
||||
{ label: '小于等于 <=', value: '<=' },
|
||||
{ label: '等于 ==', value: '==' },
|
||||
{ label: '不等于 !=', value: '!=' }
|
||||
];
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = {
|
||||
ruleCategory: {
|
||||
required: true,
|
||||
message: '请选择规则类型'
|
||||
},
|
||||
physicalSign: {
|
||||
required: true,
|
||||
message: '请选择监测指标',
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
thresholdMin: {
|
||||
required: true,
|
||||
type: 'number',
|
||||
message: '请输入最小阈值',
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
thresholdMax: {
|
||||
required: true,
|
||||
type: 'number',
|
||||
message: '请输入最大阈值',
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
severityLevel: {
|
||||
required: true,
|
||||
message: '请选择严重程度'
|
||||
}
|
||||
};
|
||||
|
||||
// 是否为编辑模式
|
||||
const isEditMode = computed(() => !!props.editRule?.id);
|
||||
|
||||
// 步骤配置
|
||||
const steps = [
|
||||
{ title: '基础信息', description: '配置规则基本信息' },
|
||||
{ title: '条件设置', description: '设置触发条件' },
|
||||
{ title: '高级配置', description: '通知和时间设置' },
|
||||
{ title: '确认保存', description: '确认规则配置' }
|
||||
];
|
||||
|
||||
// 监听编辑规则变化
|
||||
watch(() => props.editRule, (newRule) => {
|
||||
if (newRule) {
|
||||
Object.assign(formData, {
|
||||
...newRule,
|
||||
enabledChannels: newRule.enabledChannels || ['message'],
|
||||
effectiveDays: newRule.effectiveDays || '1,2,3,4,5,6,7'
|
||||
});
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// 监听显示状态
|
||||
watch(() => props.visible, (visible) => {
|
||||
if (visible) {
|
||||
currentStep.value = 0;
|
||||
if (!props.editRule) {
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
customerId: null,
|
||||
ruleType: 'metric',
|
||||
ruleCategory: 'SINGLE',
|
||||
physicalSign: '',
|
||||
thresholdMin: null,
|
||||
thresholdMax: null,
|
||||
alertMessage: '',
|
||||
severityLevel: 'moderate',
|
||||
level: 'moderate',
|
||||
isEnabled: true,
|
||||
timeWindowSeconds: 300,
|
||||
cooldownSeconds: 600,
|
||||
priorityLevel: 3,
|
||||
effectiveDays: '1,2,3,4,5,6,7',
|
||||
trendDuration: 1,
|
||||
enabledChannels: ['message'],
|
||||
conditions: []
|
||||
});
|
||||
};
|
||||
|
||||
// 添加复合条件
|
||||
const addCondition = () => {
|
||||
formData.conditions.push({
|
||||
physical_sign: '',
|
||||
operator: '>',
|
||||
threshold: null,
|
||||
duration_seconds: 60
|
||||
});
|
||||
};
|
||||
|
||||
// 删除复合条件
|
||||
const removeCondition = (index: number) => {
|
||||
formData.conditions.splice(index, 1);
|
||||
};
|
||||
|
||||
// 下一步
|
||||
const nextStep = async () => {
|
||||
if (currentStep.value === 0) {
|
||||
// 验证基础信息
|
||||
try {
|
||||
await formRef.value?.validate();
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStep.value < steps.length - 1) {
|
||||
currentStep.value++;
|
||||
}
|
||||
};
|
||||
|
||||
// 上一步
|
||||
const prevStep = () => {
|
||||
if (currentStep.value > 0) {
|
||||
currentStep.value--;
|
||||
}
|
||||
};
|
||||
|
||||
// 保存规则
|
||||
const handleSave = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 最终验证
|
||||
await formRef.value?.validate();
|
||||
|
||||
// 准备提交数据
|
||||
const submitData = {
|
||||
...formData,
|
||||
level: formData.severityLevel, // 同步严重程度字段
|
||||
};
|
||||
|
||||
// 处理复合规则的条件表达式
|
||||
if (formData.ruleCategory === 'COMPOSITE' && formData.conditions.length > 0) {
|
||||
(submitData as any).conditionExpression = {
|
||||
conditions: formData.conditions,
|
||||
logical_operator: 'AND'
|
||||
};
|
||||
}
|
||||
|
||||
// 调用API
|
||||
if (isEditMode.value) {
|
||||
await fetchUpdateAlertRulesInfo(submitData);
|
||||
message.success('规则更新成功');
|
||||
} else {
|
||||
await fetchAddAlertRules(submitData);
|
||||
message.success('规则创建成功');
|
||||
}
|
||||
|
||||
emit('success');
|
||||
emit('update:visible', false);
|
||||
} catch (error: any) {
|
||||
message.error(`保存失败:${error.message || '未知错误'}`);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭向导
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="alert-rule-wizard">
|
||||
<NCard title="告警规则配置向导">
|
||||
<div class="p-8 text-center">
|
||||
<NIcon size="48" class="mb-4 text-primary">
|
||||
<icon-ic:baseline-construction />
|
||||
</NIcon>
|
||||
<h3 class="mb-2 text-lg font-medium">功能开发中</h3>
|
||||
<p class="mb-4 text-gray-600">告警规则配置向导正在开发中,敬请期待</p>
|
||||
<NSpace justify="center">
|
||||
<NButton @click="$emit('update:visible', false)">关闭</NButton>
|
||||
<NButton type="primary" @click="handleSave">保存</NButton>
|
||||
</NSpace>
|
||||
<!-- 步骤条 -->
|
||||
<div class="steps-container mb-6">
|
||||
<NSteps :current="currentStep" size="small">
|
||||
<NStep v-for="(step, index) in steps" :key="index" :title="step.title" :description="step.description" />
|
||||
</NSteps>
|
||||
</div>
|
||||
|
||||
<!-- 表单内容 -->
|
||||
<NForm ref="formRef" :model="formData" :rules="formRules" label-placement="left" label-width="120px">
|
||||
<!-- 第一步:基础信息 -->
|
||||
<div v-show="currentStep === 0" class="step-content">
|
||||
<NDivider title-placement="left">基础信息</NDivider>
|
||||
|
||||
<NFormItem label="规则类型" path="ruleCategory">
|
||||
<NRadioGroup v-model:value="formData.ruleCategory">
|
||||
<NRadio value="SINGLE">单体征规则</NRadio>
|
||||
<NRadio value="COMPOSITE">复合规则</NRadio>
|
||||
</NRadioGroup>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem v-if="formData.ruleCategory === 'SINGLE'" label="监测指标" path="physicalSign">
|
||||
<NSelect
|
||||
v-model:value="formData.physicalSign"
|
||||
:options="physicalSignOptions"
|
||||
placeholder="请选择监测指标"
|
||||
clearable
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="严重程度" path="severityLevel">
|
||||
<NSelect
|
||||
v-model:value="formData.severityLevel"
|
||||
:options="severityOptions"
|
||||
placeholder="请选择严重程度"
|
||||
@update:value="formData.level = formData.severityLevel"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="优先级">
|
||||
<NRate v-model:value="formData.priorityLevel" :count="5" />
|
||||
<span class="ml-2 text-sm text-gray-500">{{ formData.priorityLevel }} 星</span>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="启用状态">
|
||||
<NSwitch v-model:value="formData.isEnabled">
|
||||
<template #checked>启用</template>
|
||||
<template #unchecked>禁用</template>
|
||||
</NSwitch>
|
||||
</NFormItem>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<!-- 第二步:条件设置 -->
|
||||
<div v-show="currentStep === 1" class="step-content">
|
||||
<NDivider title-placement="left">条件设置</NDivider>
|
||||
|
||||
<!-- 单体征规则 -->
|
||||
<div v-if="formData.ruleCategory === 'SINGLE'">
|
||||
<NFormItem label="最小阈值" path="thresholdMin">
|
||||
<NInputNumber v-model:value="formData.thresholdMin" placeholder="请输入最小阈值" style="width: 100%" />
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="最大阈值" path="thresholdMax">
|
||||
<NInputNumber v-model:value="formData.thresholdMax" placeholder="请输入最大阈值" style="width: 100%" />
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="连续次数">
|
||||
<NInputNumber v-model:value="formData.trendDuration" placeholder="连续触发次数" :min="1" style="width: 100%" />
|
||||
<div class="text-sm text-gray-500 mt-1">连续多少次超出阈值后触发告警</div>
|
||||
</NFormItem>
|
||||
</div>
|
||||
|
||||
<!-- 复合规则 -->
|
||||
<div v-if="formData.ruleCategory === 'COMPOSITE'">
|
||||
<NFormItem label="复合条件">
|
||||
<div class="w-full">
|
||||
<div v-for="(condition, index) in formData.conditions" :key="index" class="condition-item mb-4 p-4 border border-gray-200 rounded">
|
||||
<div class="grid grid-cols-4 gap-4 items-center">
|
||||
<NSelect
|
||||
v-model:value="condition.physical_sign"
|
||||
:options="physicalSignOptions"
|
||||
placeholder="监测指标"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="condition.operator"
|
||||
:options="operatorOptions"
|
||||
placeholder="操作符"
|
||||
/>
|
||||
<NInputNumber
|
||||
v-model:value="condition.threshold"
|
||||
placeholder="阈值"
|
||||
/>
|
||||
<NButton type="error" size="small" @click="removeCondition(index)">删除</NButton>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<NInputNumber
|
||||
v-model:value="condition.duration_seconds"
|
||||
placeholder="持续时间(秒)"
|
||||
:min="1"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-500">持续时间</span>
|
||||
</div>
|
||||
</div>
|
||||
<NButton type="dashed" @click="addCondition" class="w-full">
|
||||
<template #icon>
|
||||
<NIcon><icon-ic:baseline-add /></NIcon>
|
||||
</template>
|
||||
添加条件
|
||||
</NButton>
|
||||
</div>
|
||||
</NFormItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第三步:高级配置 -->
|
||||
<div v-show="currentStep === 2" class="step-content">
|
||||
<NDivider title-placement="left">高级配置</NDivider>
|
||||
|
||||
<NFormItem label="通知渠道">
|
||||
<NCheckboxGroup v-model:value="formData.enabledChannels">
|
||||
<NCheckbox v-for="channel in channelOptions" :key="channel.value" :value="channel.value">
|
||||
{{ channel.label }}
|
||||
</NCheckbox>
|
||||
</NCheckboxGroup>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="生效时间">
|
||||
<NCheckboxGroup v-model:value="formData.effectiveDays" style="flex-wrap: wrap;">
|
||||
<NCheckbox v-for="day in dayOptions" :key="day.value" :value="day.value">
|
||||
{{ day.label }}
|
||||
</NCheckbox>
|
||||
</NCheckboxGroup>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="时间窗口">
|
||||
<NInputNumber v-model:value="formData.timeWindowSeconds" placeholder="时间窗口(秒)" :min="60" style="width: 100%" />
|
||||
<div class="text-sm text-gray-500 mt-1">规则评估的时间窗口,单位:秒</div>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="冷却时间">
|
||||
<NInputNumber v-model:value="formData.cooldownSeconds" placeholder="冷却时间(秒)" :min="60" style="width: 100%" />
|
||||
<div class="text-sm text-gray-500 mt-1">告警触发后的冷却时间,避免重复告警</div>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="告警消息">
|
||||
<NInput
|
||||
v-model:value="formData.alertMessage"
|
||||
type="textarea"
|
||||
placeholder="请输入告警消息模板,支持变量:{device_sn}, {value}, {timestamp}"
|
||||
:rows="3"
|
||||
/>
|
||||
</NFormItem>
|
||||
</div>
|
||||
|
||||
<!-- 第四步:确认保存 -->
|
||||
<div v-show="currentStep === 3" class="step-content">
|
||||
<NDivider title-placement="left">确认配置</NDivider>
|
||||
|
||||
<NDescriptions :column="2" bordered>
|
||||
<NDescriptionsItem label="规则类型">
|
||||
{{ formData.ruleCategory === 'SINGLE' ? '单体征规则' : '复合规则' }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="严重程度">
|
||||
{{ severityOptions.find(item => item.value === formData.severityLevel)?.label }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="优先级">
|
||||
<NRate :value="formData.priorityLevel" readonly :count="5" size="small" />
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="状态">
|
||||
{{ formData.isEnabled ? '启用' : '禁用' }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem v-if="formData.ruleCategory === 'SINGLE'" label="监测指标">
|
||||
{{ physicalSignOptions.find(item => item.value === formData.physicalSign)?.label }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem v-if="formData.ruleCategory === 'SINGLE'" label="阈值范围">
|
||||
{{ formData.thresholdMin }} ~ {{ formData.thresholdMax }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="通知渠道" :span="2">
|
||||
<NSpace>
|
||||
<NTag v-for="channel in formData.enabledChannels" :key="channel" size="small">
|
||||
{{ channelOptions.find(item => item.value === channel)?.label }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
</div>
|
||||
</NForm>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-buttons mt-6 flex justify-between">
|
||||
<div>
|
||||
<NButton v-if="currentStep > 0" @click="prevStep">上一步</NButton>
|
||||
</div>
|
||||
<NSpace>
|
||||
<NButton @click="handleClose">取消</NButton>
|
||||
<NButton v-if="currentStep < steps.length - 1" type="primary" @click="nextStep">下一步</NButton>
|
||||
<NButton v-else type="primary" :loading="loading" @click="handleSave">
|
||||
{{ isEditMode ? '更新规则' : '创建规则' }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -36,19 +36,51 @@ function createDefaultModel(): Api.SystemManage.RolePermission {
|
||||
/** menu permission data */
|
||||
const permissionData = shallowRef<Api.SystemManage.MenuPermission[]>([]);
|
||||
|
||||
// 数据加载状态
|
||||
const dataLoading = shallowRef(false);
|
||||
|
||||
async function getPermissionData() {
|
||||
const { error, data } = await fetchGetMenuPermission();
|
||||
if (!error) {
|
||||
permissionData.value = data;
|
||||
try {
|
||||
dataLoading.value = true;
|
||||
const { error, data } = await fetchGetMenuPermission();
|
||||
if (!error && data) {
|
||||
permissionData.value = data;
|
||||
console.log('按钮权限数据加载完成:', data);
|
||||
} else {
|
||||
console.error('获取按钮权限数据失败:', error);
|
||||
permissionData.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载按钮权限数据异常:', error);
|
||||
permissionData.value = [];
|
||||
} finally {
|
||||
dataLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** init get permissionIds for roleId, belong checks */
|
||||
async function getPermissionIds() {
|
||||
const { error, data } = await fetchGetRolePermissionIds(props.roleId);
|
||||
if (!error) {
|
||||
checks.value = data;
|
||||
getPermissionData();
|
||||
try {
|
||||
dataLoading.value = true;
|
||||
|
||||
// 首先获取角色已有的权限
|
||||
const { error: roleError, data: roleData } = await fetchGetRolePermissionIds(props.roleId);
|
||||
if (!roleError && roleData) {
|
||||
checks.value = roleData;
|
||||
console.log('当前角色已有权限:', roleData);
|
||||
} else {
|
||||
checks.value = [];
|
||||
}
|
||||
|
||||
// 然后获取所有可用的权限数据
|
||||
await getPermissionData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取角色权限失败:', error);
|
||||
checks.value = [];
|
||||
window.$message?.error?.('获取权限数据失败,请重试');
|
||||
} finally {
|
||||
dataLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,11 +98,40 @@ async function handleSubmit() {
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
checks.value = permissionData.value.flatMap(item => item.buttons.map(button => button.id));
|
||||
// 确保permissionData已加载且有数据
|
||||
if (!permissionData.value || permissionData.value.length === 0) {
|
||||
console.warn('权限数据未加载或为空');
|
||||
window.$message?.warning?.('权限数据未加载,请稍后再试');
|
||||
return;
|
||||
}
|
||||
|
||||
const allButtonIds: string[] = [];
|
||||
|
||||
// 遍历所有菜单的按钮权限
|
||||
permissionData.value.forEach(item => {
|
||||
if (item.buttons && Array.isArray(item.buttons)) {
|
||||
item.buttons.forEach(button => {
|
||||
if (button.id) {
|
||||
allButtonIds.push(String(button.id));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log('全选按钮权限ID列表:', allButtonIds);
|
||||
console.log('当前选中权限:', checks.value);
|
||||
|
||||
// 更新选中状态
|
||||
checks.value = [...allButtonIds];
|
||||
|
||||
console.log('全选后选中权限:', checks.value);
|
||||
window.$message?.success?.(`已选中 ${allButtonIds.length} 项按钮权限`);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
checks.value = [];
|
||||
console.log('清空所有选中权限');
|
||||
window.$message?.success?.('已清空所有选中的按钮权限');
|
||||
}
|
||||
|
||||
watch(visible, () => {
|
||||
@@ -98,28 +159,59 @@ watch(visible, () => {
|
||||
<div class="permission-stats">
|
||||
<NSpace>
|
||||
<NTag type="primary" size="small">已选择 {{ checks.length }} 项权限</NTag>
|
||||
<NButton size="small" type="info" quaternary @click="selectAll">全选</NButton>
|
||||
<NButton size="small" type="warning" quaternary @click="clearAll">清空</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
type="info"
|
||||
quaternary
|
||||
:loading="dataLoading"
|
||||
:disabled="dataLoading || !permissionData?.length"
|
||||
@click="selectAll"
|
||||
>
|
||||
全选
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
type="warning"
|
||||
quaternary
|
||||
:disabled="!checks.length"
|
||||
@click="clearAll"
|
||||
>
|
||||
清空
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
<NCheckboxGroup v-model:value="checks" class="permission-container">
|
||||
<NDescriptions label-placement="left" bordered :column="1" class="permission-descriptions">
|
||||
<NDescriptionsItem v-for="item in permissionData" :key="item.menuId" :label="$t(item.i18nKey)" class="menu-permission-item">
|
||||
<div class="button-permission-grid">
|
||||
<div v-for="button in item.buttons" :key="button.id" class="button-permission-card">
|
||||
<NCheckbox :value="button.id" :label="button.name" class="enhanced-checkbox">
|
||||
<template #default>
|
||||
<div class="button-info">
|
||||
<span class="button-name">{{ button.name }}</span>
|
||||
<NTag size="tiny" type="info">{{ button.code }}</NTag>
|
||||
</div>
|
||||
</template>
|
||||
</NCheckbox>
|
||||
<NSpin :show="dataLoading" :description="dataLoading ? '正在加载权限数据...' : ''">
|
||||
<NDescriptions
|
||||
v-if="permissionData && permissionData.length > 0"
|
||||
label-placement="left"
|
||||
bordered
|
||||
:column="1"
|
||||
class="permission-descriptions"
|
||||
>
|
||||
<NDescriptionsItem v-for="item in permissionData" :key="item.menuId" :label="$t(item.i18nKey)" class="menu-permission-item">
|
||||
<div class="button-permission-grid">
|
||||
<div v-for="button in item.buttons" :key="button.id" class="button-permission-card">
|
||||
<NCheckbox :value="button.id" :label="button.name" class="enhanced-checkbox">
|
||||
<template #default>
|
||||
<div class="button-info">
|
||||
<span class="button-name">{{ button.name }}</span>
|
||||
<NTag size="tiny" type="info">{{ button.code }}</NTag>
|
||||
</div>
|
||||
</template>
|
||||
</NCheckbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
|
||||
<NEmpty
|
||||
v-else-if="!dataLoading"
|
||||
description="暂无按钮权限数据"
|
||||
class="empty-state"
|
||||
/>
|
||||
</NSpin>
|
||||
</NCheckboxGroup>
|
||||
|
||||
<template #footer>
|
||||
@@ -232,4 +324,25 @@ watch(visible, () => {
|
||||
.permission-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* 空状态样式 */
|
||||
.empty-state {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 加载状态优化 */
|
||||
.permission-container :deep(.n-spin-content) {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.permission-container :deep(.n-spin-description) {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -154,7 +154,13 @@ onMounted(() => init());
|
||||
|
||||
<template>
|
||||
<div class="h-full-hidden">
|
||||
<NCard :title="$t('page.manage.orgUnits.title')" :bordered="false" size="small" class="h-full sm:flex-1-hidden" content-class="h-full-hidden">
|
||||
<NCard :bordered="false" size="small" class="h-full sm:flex-1-hidden department-tree-card" content-class="h-full-hidden">
|
||||
<template #header>
|
||||
<div class="department-header">
|
||||
<SvgIcon icon="material-symbols:corporate-fare" class="text-18px text-blue-500" />
|
||||
<span class="department-title">部门列表</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<NFlex>
|
||||
<NButton v-if="hasAuth('sys:org:units:add')" ghost type="primary" @click="handleAdd()">
|
||||
@@ -167,7 +173,7 @@ onMounted(() => init());
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</template>
|
||||
<NInput v-model:value="name" clearable :placeholder="$t('common.keywordSearch')" />
|
||||
<NInput v-model:value="name" clearable placeholder="搜索部门名称" />
|
||||
<NTree
|
||||
:pattern="name"
|
||||
:data="orgUnitsTreeData"
|
||||
@@ -187,6 +193,53 @@ onMounted(() => init());
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 部门树卡片样式 */
|
||||
.department-tree-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #e5e7eb;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f9fafb 100%);
|
||||
}
|
||||
|
||||
.department-tree-card :deep(.n-card__header) {
|
||||
padding: 16px 20px 12px 20px;
|
||||
border-bottom: 2px solid #f3f4f6;
|
||||
}
|
||||
|
||||
/* 部门头部样式 */
|
||||
.department-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.department-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* 搜索框样式 */
|
||||
.department-tree-card :deep(.n-input) {
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.department-tree-card :deep(.n-input__input-el) {
|
||||
border: 2px solid #e5e7eb;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.department-tree-card :deep(.n-input:hover .n-input__input-el) {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.department-tree-card :deep(.n-input.n-input--focus .n-input__input-el) {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* 树节点样式优化 */
|
||||
:deep(.n-tree) {
|
||||
.n-tree-node-content__suffix {
|
||||
display: none;
|
||||
@@ -194,5 +247,90 @@ onMounted(() => init());
|
||||
.n-tree-node-content:hover .n-tree-node-content__suffix {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.n-tree-node {
|
||||
border-radius: 6px;
|
||||
margin: 2px 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.n-tree-node:hover {
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
||||
}
|
||||
|
||||
.n-tree-node--selected {
|
||||
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
|
||||
border: 1px solid #3b82f6;
|
||||
}
|
||||
|
||||
.n-tree-node-content {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.n-tree-node-content__text {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.n-tree-node--selected .n-tree-node-content__text {
|
||||
color: #1d4ed8;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* 操作按钮样式 */
|
||||
:deep(.n-button-group .n-button) {
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:deep(.n-button-group .n-button:hover) {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 头部按钮样式 */
|
||||
.department-tree-card :deep(.n-card__header-extra .n-button) {
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.department-tree-card :deep(.n-card__header-extra .n-button:hover) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
/* 刷新按钮特殊样式 */
|
||||
.department-tree-card :deep(.n-card__header-extra .n-button[quaternary]) {
|
||||
color: #6b7280;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.department-tree-card :deep(.n-card__header-extra .n-button[quaternary]:hover) {
|
||||
color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.department-tree-card :deep(.n-card__header) {
|
||||
padding: 12px 16px 8px 16px;
|
||||
}
|
||||
|
||||
.department-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.department-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.n-tree .n-tree-node-content) {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { $t } from '@/locales';
|
||||
import { extractOptionsFromTree } from './shared';
|
||||
import SvgIcon from '@/components/custom/svg-icon.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'UserResponsibilitiesSetting'
|
||||
@@ -33,6 +34,12 @@ const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
// 加载状态
|
||||
const loading = reactive({
|
||||
init: false,
|
||||
submit: false
|
||||
});
|
||||
|
||||
type Model = Api.SystemManage.UserResponsibilities;
|
||||
|
||||
const model: Model = reactive(createDefaultModel());
|
||||
@@ -64,43 +71,46 @@ const orgUnitsPrincipalOptions = computed(() => extractOptionsFromTree(orgUnitsT
|
||||
|
||||
/** init options */
|
||||
async function handleInitOptions() {
|
||||
fetchGetAllRoles()
|
||||
.then(({ error, data }) => {
|
||||
if (!error && data) {
|
||||
roleOptions.value = data;
|
||||
}
|
||||
return fetchGetAllPositions(customerId.value);
|
||||
})
|
||||
.then(({ error, data }) => {
|
||||
if (!error && data) {
|
||||
positionOptions.value = data;
|
||||
}
|
||||
return fetchGetOrgUnitsTree(customerId.value);
|
||||
})
|
||||
.then(({ error, data }) => {
|
||||
if (!error && data) {
|
||||
orgUnitsTree.value = data;
|
||||
}
|
||||
});
|
||||
try {
|
||||
loading.init = true;
|
||||
const roleResult = await fetchGetAllRoles();
|
||||
if (!roleResult.error && roleResult.data) {
|
||||
roleOptions.value = roleResult.data;
|
||||
}
|
||||
|
||||
console.log('positionOptions.value', positionOptions.value);
|
||||
const positionResult = await fetchGetAllPositions(customerId.value);
|
||||
if (!positionResult.error && positionResult.data) {
|
||||
positionOptions.value = positionResult.data;
|
||||
}
|
||||
|
||||
const orgResult = await fetchGetOrgUnitsTree(customerId.value);
|
||||
if (!orgResult.error && orgResult.data) {
|
||||
orgUnitsTree.value = orgResult.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化选项失败:', error);
|
||||
window.$message?.error('加载数据失败,请重试');
|
||||
} finally {
|
||||
loading.init = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** init model */
|
||||
async function handleInitModel() {
|
||||
Object.assign(model, createDefaultModel());
|
||||
|
||||
await handleInitOptions();
|
||||
|
||||
await handleUseResponsibilities();
|
||||
}
|
||||
|
||||
/** get user responsibilities */
|
||||
async function handleUseResponsibilities() {
|
||||
console.log('props.userId', props.userId);
|
||||
const { error, data } = await fetchGetUserResponsibilities(props.userId);
|
||||
if (!error && data) {
|
||||
Object.assign(model, data);
|
||||
try {
|
||||
const { error, data } = await fetchGetUserResponsibilities(props.userId);
|
||||
if (!error && data) {
|
||||
Object.assign(model, data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户权限职责失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,11 +121,19 @@ function handleOrgUnitsPrincipalIdsUpdate(value: string[]) {
|
||||
|
||||
/** submit */
|
||||
async function handleSubmit() {
|
||||
const { error, data } = await fetchSaveUserResponsibilities(model);
|
||||
if (!error && data) {
|
||||
window.$message?.success($t('common.updateSuccess'));
|
||||
emit('submitted');
|
||||
closeModal();
|
||||
try {
|
||||
loading.submit = true;
|
||||
const { error, data } = await fetchSaveUserResponsibilities(model);
|
||||
if (!error && data) {
|
||||
window.$message?.success($t('common.updateSuccess'));
|
||||
emit('submitted');
|
||||
closeModal();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存权限职责失败:', error);
|
||||
window.$message?.error('保存失败,请重试');
|
||||
} finally {
|
||||
loading.submit = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,69 +144,582 @@ function closeModal() {
|
||||
watch(visible, () => {
|
||||
if (visible.value) {
|
||||
handleInitModel();
|
||||
console.log('orgUnitsTree.value', orgUnitsTree.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal v-model:show="visible" preset="card" :segmented="false" class="w-800px">
|
||||
<NGrid :x-gap="8" :y-gap="8">
|
||||
<NFormItemGi span="12 s:8 m:6" :label="$t('page.manage.user.userRole')">
|
||||
<NSelect
|
||||
v-model:value="model.roleIds"
|
||||
multiple
|
||||
filterable
|
||||
:options="roleOptions"
|
||||
:max-tag-count="3"
|
||||
:placeholder="$t('page.manage.user.form.userRole')"
|
||||
/>
|
||||
</NFormItemGi>
|
||||
<NFormItemGi span="12 s:8 m:6" :label="$t('page.manage.user.userPosition')">
|
||||
<NSelect
|
||||
v-model:value="model.positionIds"
|
||||
multiple
|
||||
filterable
|
||||
:options="positionOptions"
|
||||
:max-tag-count="3"
|
||||
:placeholder="$t('page.manage.user.form.userPosition')"
|
||||
/>
|
||||
</NFormItemGi>
|
||||
<NFormItemGi span="24" :label="$t('page.manage.user.userOrgUnits')">
|
||||
<NTreeSelect
|
||||
v-model:value="model.orgUnitsIds"
|
||||
:options="orgUnitsTree"
|
||||
multiple
|
||||
checkable
|
||||
filterable
|
||||
key-field="id"
|
||||
label-field="name"
|
||||
default-expand-all
|
||||
:max-tag-count="7"
|
||||
:on-update-value="handleOrgUnitsPrincipalIdsUpdate"
|
||||
:placeholder="$t('page.manage.user.form.userOrgUnits')"
|
||||
/>
|
||||
</NFormItemGi>
|
||||
<NFormItemGi span="24" :label="$t('page.manage.user.manageOrganization')">
|
||||
<NSelect
|
||||
v-model:value="model.orgUnitsPrincipalIds"
|
||||
multiple
|
||||
filterable
|
||||
:max-tag-count="7"
|
||||
:options="orgUnitsPrincipalOptions"
|
||||
:placeholder="$t('page.manage.user.form.userOrgUnits')"
|
||||
/>
|
||||
</NFormItemGi>
|
||||
</NGrid>
|
||||
<NModal
|
||||
v-model:show="visible"
|
||||
preset="card"
|
||||
:segmented="false"
|
||||
class="user-responsibilities-modal"
|
||||
:style="{ width: '900px', maxWidth: '90vw' }"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<template #header>
|
||||
<div class="modal-header">
|
||||
<div class="header-icon">
|
||||
<SvgIcon icon="material-symbols:admin-panel-settings" class="text-24px text-blue-500" />
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h3 class="header-title">权限职责设置</h3>
|
||||
<p class="header-subtitle">配置用户的角色权限、职位分工和组织管理权限</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="modal-content" v-loading="loading.init">
|
||||
<!-- 角色与职位配置区域 -->
|
||||
<div class="config-section">
|
||||
<div class="section-header">
|
||||
<SvgIcon icon="material-symbols:badge" class="section-icon" />
|
||||
<h4 class="section-title">角色与职位</h4>
|
||||
<span class="section-description">设置用户的系统角色和工作职位</span>
|
||||
</div>
|
||||
|
||||
<NGrid :x-gap="16" :y-gap="16">
|
||||
<NFormItemGi span="12" :label="$t('page.manage.user.userRole')" class="form-item-enhanced">
|
||||
<template #label>
|
||||
<div class="label-with-icon">
|
||||
<SvgIcon icon="material-symbols:person-check" class="label-icon text-blue-500" />
|
||||
<span>{{ $t('page.manage.user.userRole') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<NSelect
|
||||
v-model:value="model.roleIds"
|
||||
multiple
|
||||
filterable
|
||||
:options="roleOptions"
|
||||
:max-tag-count="3"
|
||||
:placeholder="$t('page.manage.user.form.userRole')"
|
||||
:loading="loading.init"
|
||||
class="enhanced-select"
|
||||
size="large"
|
||||
clearable
|
||||
>
|
||||
<template #empty>
|
||||
<div class="empty-state">
|
||||
<SvgIcon icon="material-symbols:search-off" class="text-32px text-gray-400" />
|
||||
<span class="text-gray-500">暂无角色数据</span>
|
||||
</div>
|
||||
</template>
|
||||
</NSelect>
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi span="12" :label="$t('page.manage.user.userPosition')" class="form-item-enhanced">
|
||||
<template #label>
|
||||
<div class="label-with-icon">
|
||||
<SvgIcon icon="material-symbols:work" class="label-icon text-green-500" />
|
||||
<span>{{ $t('page.manage.user.userPosition') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<NSelect
|
||||
v-model:value="model.positionIds"
|
||||
multiple
|
||||
filterable
|
||||
:options="positionOptions"
|
||||
:max-tag-count="3"
|
||||
:placeholder="$t('page.manage.user.form.userPosition')"
|
||||
:loading="loading.init"
|
||||
class="enhanced-select"
|
||||
size="large"
|
||||
clearable
|
||||
>
|
||||
<template #empty>
|
||||
<div class="empty-state">
|
||||
<SvgIcon icon="material-symbols:search-off" class="text-32px text-gray-400" />
|
||||
<span class="text-gray-500">暂无职位数据</span>
|
||||
</div>
|
||||
</template>
|
||||
</NSelect>
|
||||
</NFormItemGi>
|
||||
</NGrid>
|
||||
</div>
|
||||
|
||||
<!-- 组织架构配置区域 -->
|
||||
<div class="config-section">
|
||||
<div class="section-header">
|
||||
<SvgIcon icon="material-symbols:account-tree" class="section-icon" />
|
||||
<h4 class="section-title">组织架构</h4>
|
||||
<span class="section-description">配置用户所属的组织单位和管理权限</span>
|
||||
</div>
|
||||
|
||||
<NGrid :x-gap="16" :y-gap="16">
|
||||
<NFormItemGi span="24" :label="$t('page.manage.user.userOrgUnits')" class="form-item-enhanced">
|
||||
<template #label>
|
||||
<div class="label-with-icon">
|
||||
<SvgIcon icon="material-symbols:corporate-fare" class="label-icon text-purple-500" />
|
||||
<span>{{ $t('page.manage.user.userOrgUnits') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<NTreeSelect
|
||||
v-model:value="model.orgUnitsIds"
|
||||
:options="orgUnitsTree"
|
||||
multiple
|
||||
checkable
|
||||
filterable
|
||||
key-field="id"
|
||||
label-field="name"
|
||||
default-expand-all
|
||||
:max-tag-count="5"
|
||||
:on-update-value="handleOrgUnitsPrincipalIdsUpdate"
|
||||
:placeholder="$t('page.manage.user.form.userOrgUnits')"
|
||||
:loading="loading.init"
|
||||
class="enhanced-tree-select"
|
||||
size="large"
|
||||
clearable
|
||||
>
|
||||
<template #empty>
|
||||
<div class="empty-state">
|
||||
<SvgIcon icon="material-symbols:folder-off" class="text-32px text-gray-400" />
|
||||
<span class="text-gray-500">暂无组织架构数据</span>
|
||||
</div>
|
||||
</template>
|
||||
</NTreeSelect>
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi span="24" :label="$t('page.manage.user.manageOrganization')" class="form-item-enhanced">
|
||||
<template #label>
|
||||
<div class="label-with-icon">
|
||||
<SvgIcon icon="material-symbols:supervisor-account" class="label-icon text-orange-500" />
|
||||
<span>{{ $t('page.manage.user.manageOrganization') }}</span>
|
||||
<NTooltip>
|
||||
<template #trigger>
|
||||
<SvgIcon icon="material-symbols:info" class="text-14px text-gray-400 ml-4px cursor-help" />
|
||||
</template>
|
||||
选择用户可以管理的组织单位,需要先选择所属组织
|
||||
</NTooltip>
|
||||
</div>
|
||||
</template>
|
||||
<NSelect
|
||||
v-model:value="model.orgUnitsPrincipalIds"
|
||||
multiple
|
||||
filterable
|
||||
:max-tag-count="5"
|
||||
:options="orgUnitsPrincipalOptions"
|
||||
:placeholder="$t('page.manage.user.form.userOrgUnits')"
|
||||
:disabled="!model.orgUnitsIds || model.orgUnitsIds.length === 0"
|
||||
class="enhanced-select"
|
||||
size="large"
|
||||
clearable
|
||||
>
|
||||
<template #empty>
|
||||
<div class="empty-state">
|
||||
<SvgIcon icon="material-symbols:manage-accounts-off" class="text-32px text-gray-400" />
|
||||
<span class="text-gray-500">
|
||||
{{ !model.orgUnitsIds || model.orgUnitsIds.length === 0 ? '请先选择所属组织' : '暂无可管理的组织' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</NSelect>
|
||||
</NFormItemGi>
|
||||
</NGrid>
|
||||
</div>
|
||||
|
||||
<!-- 权限预览区域 -->
|
||||
<div class="config-section preview-section">
|
||||
<div class="section-header">
|
||||
<SvgIcon icon="material-symbols:preview" class="section-icon" />
|
||||
<h4 class="section-title">配置预览</h4>
|
||||
<span class="section-description">当前权限配置概览</span>
|
||||
</div>
|
||||
|
||||
<div class="preview-grid">
|
||||
<div class="preview-card">
|
||||
<div class="preview-header">
|
||||
<SvgIcon icon="material-symbols:badge" class="text-blue-500" />
|
||||
<span>角色权限</span>
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
<span class="preview-count">{{ model.roleIds?.length || 0 }}</span>
|
||||
<span class="preview-label">个角色</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-card">
|
||||
<div class="preview-header">
|
||||
<SvgIcon icon="material-symbols:work" class="text-green-500" />
|
||||
<span>职位分工</span>
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
<span class="preview-count">{{ model.positionIds?.length || 0 }}</span>
|
||||
<span class="preview-label">个职位</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-card">
|
||||
<div class="preview-header">
|
||||
<SvgIcon icon="material-symbols:corporate-fare" class="text-purple-500" />
|
||||
<span>所属组织</span>
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
<span class="preview-count">{{ model.orgUnitsIds?.length || 0 }}</span>
|
||||
<span class="preview-label">个部门</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-card">
|
||||
<div class="preview-header">
|
||||
<SvgIcon icon="material-symbols:supervisor-account" class="text-orange-500" />
|
||||
<span>管理权限</span>
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
<span class="preview-count">{{ model.orgUnitsPrincipalIds?.length || 0 }}</span>
|
||||
<span class="preview-label">个部门</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<NButton quaternary @click="closeModal">
|
||||
<div class="modal-footer">
|
||||
<NButton
|
||||
quaternary
|
||||
size="large"
|
||||
@click="closeModal"
|
||||
:disabled="loading.submit"
|
||||
class="cancel-btn"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon icon="material-symbols:close" />
|
||||
</template>
|
||||
{{ $t('common.cancel') }}
|
||||
</NButton>
|
||||
<NButton type="primary" @click="handleSubmit">
|
||||
{{ $t('common.confirm') }}
|
||||
<NButton
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="handleSubmit"
|
||||
:loading="loading.submit"
|
||||
class="submit-btn"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon icon="material-symbols:check" />
|
||||
</template>
|
||||
{{ loading.submit ? '保存中...' : $t('common.confirm') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 弹窗整体样式 */
|
||||
.user-responsibilities-modal {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.user-responsibilities-modal :deep(.n-card) {
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
/* 弹窗头部样式 */
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 内容区域样式 */
|
||||
.modal-content {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* 配置区域样式 */
|
||||
.config-section {
|
||||
margin-bottom: 32px;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f9fafb 100%);
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.config-section:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.config-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 区域头部样式 */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 20px;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
background: #f3f4f6;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 表单项样式 */
|
||||
.form-item-enhanced {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-item-enhanced :deep(.n-form-item-label) {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.label-with-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 增强选择器样式 */
|
||||
.enhanced-select,
|
||||
.enhanced-tree-select {
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.enhanced-select :deep(.n-base-selection),
|
||||
.enhanced-tree-select :deep(.n-base-selection) {
|
||||
border-radius: 8px;
|
||||
border: 2px solid #e5e7eb;
|
||||
background: #ffffff;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.enhanced-select :deep(.n-base-selection:hover),
|
||||
.enhanced-tree-select :deep(.n-base-selection:hover) {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.enhanced-select :deep(.n-base-selection.n-base-selection--focus),
|
||||
.enhanced-tree-select :deep(.n-base-selection.n-base-selection--focus) {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
/* 空状态样式 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 32px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* 预览区域样式 */
|
||||
.preview-section {
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
||||
border: 2px solid #bae6fd;
|
||||
}
|
||||
|
||||
.preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e0f2fe;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.preview-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.preview-count {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* 弹窗底部样式 */
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 0 0 0;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
border-radius: 8px;
|
||||
border: 2px solid #e5e7eb;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
border-color: #6b7280;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.user-responsibilities-modal {
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin-left: 0;
|
||||
align-self: stretch;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
flex-direction: column-reverse;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载状态优化 */
|
||||
.modal-content[v-loading] {
|
||||
position: relative;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.modal-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.modal-content::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.modal-content::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.modal-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user