refactor(web): enforce typed response envelopes and domain unions

Default the R envelope to unknown so bare wrappers surface their data shape, remove the | string tail that voided alarm/attribute unions, add explicit generics to every dashboard/point/info wrapper, and move the alert row types plus RangeKey to a single dashboard source. Also fix mock seeds to match backend enums (PENDING, DINGTALK_BOT).
This commit is contained in:
pnoker
2026-08-24 17:48:41 +08:00
parent 0c288789dd
commit 40ed14593d
31 changed files with 302 additions and 191 deletions
+36 -16
View File
@@ -17,44 +17,64 @@
import {httpGet, httpPost} from '@/api/common';
import {API_DATA_BASE} from '@/config/constant/api';
import type {AlertPageQuery, AlertSource} from '@/config/types/dashboard';
import type {PageResult} from '@/config/types';
import type {
AlertActivityRow,
AlertEventRow,
AlertPageQuery,
AlertSource,
AlertStormRow,
AlertTopSourceRow,
AlertTrendRow,
AlertTypeRow,
AgingBacklog,
ChangeImpact,
CorrelationPair,
FlappingSource,
MttaTrend,
PeerDeviation,
} from '@/config/types/dashboard';
export const alertPage = (body: AlertPageQuery = {}) => httpPost(`${API_DATA_BASE}/dashboard/alert/page`, body);
export const alertPage = (body: AlertPageQuery = {}) =>
httpPost<R<PageResult<AlertEventRow>>>(`${API_DATA_BASE}/dashboard/alert/page`, body);
export const alertConfirm = (source: AlertSource, id: string) =>
httpPost(`${API_DATA_BASE}/dashboard/alert/confirm`, undefined, {params: {source, id}});
httpPost<R<string>>(`${API_DATA_BASE}/dashboard/alert/confirm`, undefined, {params: {source, id}});
export const alertUnconfirm = (source: AlertSource, id: string) =>
httpPost(`${API_DATA_BASE}/dashboard/alert/unconfirm`, undefined, {params: {source, id}});
httpPost<R<string>>(`${API_DATA_BASE}/dashboard/alert/unconfirm`, undefined, {params: {source, id}});
export const alertBulkConfirm = (items: Array<{ source: AlertSource; id: string }>, confirm: boolean) =>
httpPost(`${API_DATA_BASE}/dashboard/alert/bulk_confirm`, {items, confirm});
httpPost<R<string>>(`${API_DATA_BASE}/dashboard/alert/bulk_confirm`, {items, confirm});
export const alertTrend = (days = 30) => httpGet(`${API_DATA_BASE}/dashboard/alert/trend`, {params: {days}});
export const alertTrend = (days = 30) =>
httpGet<R<AlertTrendRow[]>>(`${API_DATA_BASE}/dashboard/alert/trend`, {params: {days}});
export const alertTopSources = (days = 30, limit = 10) =>
httpGet(`${API_DATA_BASE}/dashboard/alert/top_sources`, {params: {days, limit}});
httpGet<R<AlertTopSourceRow[]>>(`${API_DATA_BASE}/dashboard/alert/top_sources`, {params: {days, limit}});
export const alertActivity = (days = 7) => httpGet(`${API_DATA_BASE}/dashboard/alert/activity`, {params: {days}});
export const alertActivity = (days = 7) =>
httpGet<R<AlertActivityRow[]>>(`${API_DATA_BASE}/dashboard/alert/activity`, {params: {days}});
export const alertTypeDistribution = (days = 30) =>
httpGet(`${API_DATA_BASE}/dashboard/alert/type_distribution`, {params: {days}});
httpGet<R<AlertTypeRow[]>>(`${API_DATA_BASE}/dashboard/alert/type_distribution`, {params: {days}});
export const alertStormSources = (hours = 1, minCount = 10, limit = 10) =>
httpGet(`${API_DATA_BASE}/dashboard/alert/storm_sources`, {params: {hours, min_count: minCount, limit}});
httpGet<R<AlertStormRow[]>>(`${API_DATA_BASE}/dashboard/alert/storm_sources`, {params: {hours, min_count: minCount, limit}});
export const alertFlapping = (hours = 6, minCount = 5, limit = 20) =>
httpGet(`${API_DATA_BASE}/dashboard/alert/flapping`, {params: {hours, min_count: minCount, limit}});
httpGet<R<FlappingSource[]>>(`${API_DATA_BASE}/dashboard/alert/flapping`, {params: {hours, min_count: minCount, limit}});
export const alertCorrelation = (hours = 24, windowSec = 30, limit = 15) =>
httpGet(`${API_DATA_BASE}/dashboard/alert/correlation`, {params: {hours, window_sec: windowSec, limit}});
httpGet<R<CorrelationPair[]>>(`${API_DATA_BASE}/dashboard/alert/correlation`, {params: {hours, window_sec: windowSec, limit}});
export const alertPeerDeviation = (days = 7) =>
httpGet(`${API_DATA_BASE}/dashboard/alert/peer_deviation`, {params: {days}});
httpGet<R<PeerDeviation[]>>(`${API_DATA_BASE}/dashboard/alert/peer_deviation`, {params: {days}});
export const alertAging = () => httpGet(`${API_DATA_BASE}/dashboard/alert/aging`);
export const alertAging = () => httpGet<R<AgingBacklog>>(`${API_DATA_BASE}/dashboard/alert/aging`);
export const alertMtta = (days = 30) => httpGet(`${API_DATA_BASE}/dashboard/alert/mtta`, {params: {days}});
export const alertMtta = (days = 30) =>
httpGet<R<MttaTrend[]>>(`${API_DATA_BASE}/dashboard/alert/mtta`, {params: {days}});
export const alertChangeImpact = (days = 30, limit = 30) =>
httpGet(`${API_DATA_BASE}/dashboard/alert/change_impact`, {params: {days, limit}});
httpGet<R<ChangeImpact[]>>(`${API_DATA_BASE}/dashboard/alert/change_impact`, {params: {days, limit}});
+12 -6
View File
@@ -18,10 +18,15 @@
import {httpGet} from '@/api/common';
import {API_DATA_BASE, API_MANAGER_BASE} from '@/config/constant/api';
import type {
AlertActivityRow,
DailyGrowthSummary,
DeviceStats,
DriverStats,
Granularity,
StatsCountBucket,
StatsTimeBucket,
StatsTodaySummary,
StreamRow,
TimeRangeParams,
TopDimension,
} from '@/config/types/dashboard';
@@ -32,23 +37,24 @@ export const statsTimeseries = (params: TimeRangeParams & { granularity?: Granul
httpGet<R<StatsTimeBucket[]>>(`${API_DATA_BASE}/dashboard/stats/timeseries`, {params: timeRangeParams(params)});
export const statsTop = (params: TimeRangeParams & { dimension?: TopDimension; limit?: number } = {}) =>
httpGet(`${API_DATA_BASE}/dashboard/top`, {params: timeRangeParams(params)});
httpGet<R<StatsCountBucket[]>>(`${API_DATA_BASE}/dashboard/top`, {params: timeRangeParams(params)});
export const streamLatest = (size = 20) => httpGet(`${API_DATA_BASE}/dashboard/stream`, {params: {size}});
export const streamLatest = (size = 20) =>
httpGet<R<StreamRow[]>>(`${API_DATA_BASE}/dashboard/stream`, {params: {size}});
export const statsLatency = (params: TimeRangeParams = {rangeKey: '24h'}) =>
httpGet(`${API_DATA_BASE}/dashboard/stats/latency`, {params: timeRangeParams(params)});
httpGet<R<StatsCountBucket[]>>(`${API_DATA_BASE}/dashboard/stats/latency`, {params: timeRangeParams(params)});
export const statsActivity = (params: TimeRangeParams = {rangeKey: '7d'}) =>
httpGet(`${API_DATA_BASE}/dashboard/stats/activity`, {params: timeRangeParams(params)});
httpGet<R<AlertActivityRow[]>>(`${API_DATA_BASE}/dashboard/stats/activity`, {params: timeRangeParams(params)});
export const dailyGrowth = (days = 7) =>
httpGet<R<DailyGrowthSummary>>(`${API_MANAGER_BASE}/dashboard/growth`, {params: {days}});
export const driverStats = () => httpGet(`${API_MANAGER_BASE}/dashboard/driver/stats`);
export const driverStats = () => httpGet<R<DriverStats>>(`${API_MANAGER_BASE}/dashboard/driver/stats`);
export const deviceStats = (topN = 10) =>
httpGet(`${API_MANAGER_BASE}/dashboard/device/stats`, {params: {top_n: topN}});
httpGet<R<DeviceStats>>(`${API_MANAGER_BASE}/dashboard/device/stats`, {params: {top_n: topN}});
const timeRangeParams = <T extends TimeRangeParams>(params: T) => {
const {rangeKey, rangeHours, ...rest} = params;
+8 -6
View File
@@ -17,19 +17,21 @@
import {httpGet} from '@/api/common';
import {API_DATA_BASE} from '@/config/constant/api';
import type {AlertStatsSummary} from '@/config/types/dashboard';
import type {AlertEventRow, AlertStatsSummary, CoverageGap, ProtocolHealth, SilentSource} from '@/config/types/dashboard';
export const alertStats = () => httpGet<R<AlertStatsSummary>>(`${API_DATA_BASE}/dashboard/alert/stats`);
export const alertLatest = (size = 10) => httpGet(`${API_DATA_BASE}/dashboard/alert/latest`, {params: {size}});
export const alertLatest = (size = 10) =>
httpGet<R<AlertEventRow[]>>(`${API_DATA_BASE}/dashboard/alert/latest`, {params: {size}});
export const systemHealth = () => httpGet(`${API_DATA_BASE}/dashboard/system/health`);
export const systemHealth = () => httpGet<R<Record<string, unknown>>>(`${API_DATA_BASE}/dashboard/system/health`);
export const protocolHealth = () => httpGet(`${API_DATA_BASE}/dashboard/protocol/health`);
export const protocolHealth = () => httpGet<R<ProtocolHealth[]>>(`${API_DATA_BASE}/dashboard/protocol/health`);
export const silentSources = (baselineDays = 7, silentMinutes = 15, limit = 50) =>
httpGet(`${API_DATA_BASE}/dashboard/silent/sources`, {
httpGet<R<SilentSource[]>>(`${API_DATA_BASE}/dashboard/silent/sources`, {
params: {baseline_days: baselineDays, silent_minutes: silentMinutes, limit},
});
export const coverageGap = (limit = 100) => httpGet(`${API_DATA_BASE}/dashboard/coverage/gap`, {params: {limit}});
export const coverageGap = (limit = 100) =>
httpGet<R<CoverageGap>>(`${API_DATA_BASE}/dashboard/coverage/gap`, {params: {limit}});
+2 -2
View File
@@ -17,10 +17,10 @@
import {httpGet} from '@/api/common';
import {API_MANAGER_BASE} from '@/config/constant/api';
import type {TopologyMode} from '@/config/types/dashboard';
import type {TopologyMode, TopologyResponse} from '@/config/types/dashboard';
export const topology = (params: { mode?: TopologyMode; rangeKey?: string } = {}) =>
httpGet(`${API_MANAGER_BASE}/dashboard/topology`, {
httpGet<R<TopologyResponse>>(`${API_MANAGER_BASE}/dashboard/topology`, {
params: {
...(params.mode ? {mode: params.mode} : {}),
...(params.rangeKey ? {range_key: params.rangeKey} : {}),
+1 -1
View File
@@ -30,4 +30,4 @@ export const listDriver = <T = R<PageResult<DriverRecord>>>(query: PageQuery) =>
httpPost<T>(`${API_MANAGER_BASE}/driver/list`, query);
export const listDriverStatus = (query: Record<string, unknown>) =>
httpPost(`${API_DATA_BASE}/driver/status/list`, query);
httpPost<R<Record<string, string>>>(`${API_DATA_BASE}/driver/status/list`, query);
+16 -16
View File
@@ -20,57 +20,57 @@ import {API_MANAGER_BASE} from '@/config/constant/api';
import type {CommandInfoForm, DriverInfoForm, EventInfoForm, PointInfoForm} from '@/config/types/manager';
export const addDriverInfo = (driverInfo: DriverInfoForm) =>
httpPost(`${API_MANAGER_BASE}/driver_attribute_config/add`, driverInfo);
httpPost<R<string>>(`${API_MANAGER_BASE}/driver_attribute_config/add`, driverInfo);
export const updateDriverInfo = (driverInfo: DriverInfoForm) =>
httpPost(`${API_MANAGER_BASE}/driver_attribute_config/update`, driverInfo);
httpPost<R<string>>(`${API_MANAGER_BASE}/driver_attribute_config/update`, driverInfo);
export const getDriverInfoByDeviceIdAndAttributeId = (deviceId: string, attributeId: string) =>
httpGet(`${API_MANAGER_BASE}/driver_attribute_config/get_by_device_id_and_attribute_id`, {
httpGet<R<DriverInfoForm>>(`${API_MANAGER_BASE}/driver_attribute_config/get_by_device_id_and_attribute_id`, {
params: {device_id: deviceId, attribute_id: attributeId},
});
export const listDriverInfoByDeviceId = (deviceId: string) =>
httpGet(`${API_MANAGER_BASE}/driver_attribute_config/list_by_device_id`, {params: {device_id: deviceId}});
httpGet<R<DriverInfoForm[]>>(`${API_MANAGER_BASE}/driver_attribute_config/list_by_device_id`, {params: {device_id: deviceId}});
export const addPointInfo = (pointInfo: PointInfoForm) =>
httpPost(`${API_MANAGER_BASE}/point_attribute_config/add`, pointInfo);
httpPost<R<string>>(`${API_MANAGER_BASE}/point_attribute_config/add`, pointInfo);
export const updatePointInfo = (pointInfo: PointInfoForm) =>
httpPost(`${API_MANAGER_BASE}/point_attribute_config/update`, pointInfo);
httpPost<R<string>>(`${API_MANAGER_BASE}/point_attribute_config/update`, pointInfo);
export const listPointInfoByDeviceIdAndPointId = (deviceId: string, pointId: string) =>
httpGet(`${API_MANAGER_BASE}/point_attribute_config/list_by_device_id_and_point_id`, {
httpGet<R<PointInfoForm[]>>(`${API_MANAGER_BASE}/point_attribute_config/list_by_device_id_and_point_id`, {
params: {device_id: deviceId, point_id: pointId},
});
export const listPointInfoByDeviceId = (deviceId: string) =>
httpGet(`${API_MANAGER_BASE}/point_attribute_config/list_by_device_id`, {params: {device_id: deviceId}});
httpGet<R<PointInfoForm[]>>(`${API_MANAGER_BASE}/point_attribute_config/list_by_device_id`, {params: {device_id: deviceId}});
export const addCommandInfo = (commandInfo: CommandInfoForm) =>
httpPost(`${API_MANAGER_BASE}/command_attribute_config/add`, commandInfo);
httpPost<R<string>>(`${API_MANAGER_BASE}/command_attribute_config/add`, commandInfo);
export const updateCommandInfo = (commandInfo: CommandInfoForm) =>
httpPost(`${API_MANAGER_BASE}/command_attribute_config/update`, commandInfo);
httpPost<R<string>>(`${API_MANAGER_BASE}/command_attribute_config/update`, commandInfo);
export const listCommandInfoByDeviceIdAndCommandId = (deviceId: string, commandId: string) =>
httpGet(`${API_MANAGER_BASE}/command_attribute_config/list_by_device_id_and_command_id`, {
httpGet<R<CommandInfoForm[]>>(`${API_MANAGER_BASE}/command_attribute_config/list_by_device_id_and_command_id`, {
params: {device_id: deviceId, command_id: commandId},
});
export const listCommandInfoByDeviceId = (deviceId: string) =>
httpGet(`${API_MANAGER_BASE}/command_attribute_config/list_by_device_id`, {params: {device_id: deviceId}});
httpGet<R<CommandInfoForm[]>>(`${API_MANAGER_BASE}/command_attribute_config/list_by_device_id`, {params: {device_id: deviceId}});
export const addEventInfo = (eventInfo: EventInfoForm) =>
httpPost(`${API_MANAGER_BASE}/event_attribute_config/add`, eventInfo);
httpPost<R<string>>(`${API_MANAGER_BASE}/event_attribute_config/add`, eventInfo);
export const updateEventInfo = (eventInfo: EventInfoForm) =>
httpPost(`${API_MANAGER_BASE}/event_attribute_config/update`, eventInfo);
httpPost<R<string>>(`${API_MANAGER_BASE}/event_attribute_config/update`, eventInfo);
export const listEventInfoByDeviceIdAndEventId = (deviceId: string, eventId: string) =>
httpGet(`${API_MANAGER_BASE}/event_attribute_config/list_by_device_id_and_event_id`, {
httpGet<R<EventInfoForm[]>>(`${API_MANAGER_BASE}/event_attribute_config/list_by_device_id_and_event_id`, {
params: {device_id: deviceId, event_id: eventId},
});
export const listEventInfoByDeviceId = (deviceId: string) =>
httpGet(`${API_MANAGER_BASE}/event_attribute_config/list_by_device_id`, {params: {device_id: deviceId}});
httpGet<R<EventInfoForm[]>>(`${API_MANAGER_BASE}/event_attribute_config/list_by_device_id`, {params: {device_id: deviceId}});
+1 -1
View File
@@ -27,7 +27,7 @@ export const addLocalCredential = crud.add;
export const deleteLocalCredential = crud.delete;
export const resetLocalCredentialPassword = (id: string, password: string) =>
httpPost(`${API_LOCAL_CREDENTIAL_BASE}/reset_password`, undefined, {params: {id, password}});
httpPost<R<string>>(`${API_LOCAL_CREDENTIAL_BASE}/reset_password`, undefined, {params: {id, password}});
export const checkLoginNameAvailable = (name: string) =>
httpGet<R<boolean>>(`${API_LOCAL_CREDENTIAL_BASE}/check`, {params: {name}});
+2 -1
View File
@@ -33,4 +33,5 @@ export const getMenuById = crud.getById;
export const listMenu = crud.list;
export const listMenuTree = (query: PageQuery = {}) => httpPost(`${API_AUTH_BASE}/menu/list_tree`, query);
export const listMenuTree = (query: PageQuery = {}) =>
httpPost<R<MenuRecord[]>>(`${API_AUTH_BASE}/menu/list_tree`, query);
+10 -8
View File
@@ -18,6 +18,7 @@
import {httpGet, httpPost} from '@/api/common';
import {createCrudApi} from '@/api/factory';
import {API_DATA_BASE, API_MANAGER_BASE} from '@/config/constant/api';
import type {PageResult} from '@/config/types';
import type {PointForm, PointRecord} from '@/config/types/manager';
const crud = createCrudApi<PointForm, PointRecord>({base: API_MANAGER_BASE, entity: 'point'});
@@ -35,27 +36,28 @@ export const listPoint = crud.list;
export const listPointByIds = (pointIds: string[]) =>
httpPost<R<Record<string, PointRecord>>>(`${API_MANAGER_BASE}/point/list_by_ids`, pointIds);
export const listPointUnit = (pointIds: string[]) => httpPost(`${API_MANAGER_BASE}/point/unit`, pointIds);
export const listPointUnit = (pointIds: string[]) =>
httpPost<R<Record<string, string>>>(`${API_MANAGER_BASE}/point/unit`, pointIds);
export const listPointByProfileId = (profileId: string) =>
httpGet(`${API_MANAGER_BASE}/point/list_by_profile_id`, {params: {profile_id: profileId}});
httpGet<R<PointRecord[]>>(`${API_MANAGER_BASE}/point/list_by_profile_id`, {params: {profile_id: profileId}});
export const listPointByDeviceId = (deviceId: string) =>
httpGet(`${API_MANAGER_BASE}/point/list_by_device_id`, {params: {device_id: deviceId}});
httpGet<R<PointRecord[]>>(`${API_MANAGER_BASE}/point/list_by_device_id`, {params: {device_id: deviceId}});
export const getPointValueLatest = (pointValue: Record<string, unknown>) =>
httpPost(`${API_DATA_BASE}/point_value/latest`, pointValue);
httpPost<R<PageResult<Record<string, unknown>>>>(`${API_DATA_BASE}/point_value/latest`, pointValue);
export const listPointValue = (pointValue: Record<string, unknown>) =>
httpPost(`${API_DATA_BASE}/point_value/list`, pointValue);
httpPost<R<PageResult<Record<string, unknown>>>>(`${API_DATA_BASE}/point_value/list`, pointValue);
export const listPointValueHistory = (deviceId: string, pointId: string, count = 100) =>
httpGet(`${API_DATA_BASE}/point_value/list_history_by_device_id_and_point_id`, {
httpGet<R<string[]>>(`${API_DATA_BASE}/point_value/list_history_by_device_id_and_point_id`, {
params: {device_id: deviceId, point_id: pointId, count}
});
export const readPointValue = (pointValueReadVO: Record<string, unknown>) =>
httpPost(`${API_DATA_BASE}/point_command/read`, pointValueReadVO);
httpPost<R<string>>(`${API_DATA_BASE}/point_command/read`, pointValueReadVO);
export const writePointValue = (pointValueWriteVO: Record<string, unknown>) =>
httpPost(`${API_DATA_BASE}/point_command/write`, pointValueWriteVO);
httpPost<R<string>>(`${API_DATA_BASE}/point_command/write`, pointValueWriteVO);
+2 -2
View File
@@ -29,10 +29,10 @@ export const deleteServiceAccount = crud.delete;
export const updateServiceAccount = crud.update;
export const enableServiceAccount = (id: string) =>
httpPost(`${API_SERVICE_ACCOUNT_BASE}/enable`, undefined, {params: {id}});
httpPost<R<string>>(`${API_SERVICE_ACCOUNT_BASE}/enable`, undefined, {params: {id}});
export const disableServiceAccount = (id: string) =>
httpPost(`${API_SERVICE_ACCOUNT_BASE}/disable`, undefined, {params: {id}});
httpPost<R<string>>(`${API_SERVICE_ACCOUNT_BASE}/disable`, undefined, {params: {id}});
export const getServiceAccountById = crud.getById;
+1 -1
View File
@@ -19,7 +19,7 @@ import {httpPost} from '@/api/common';
import {API_AUTH_BASE} from '@/config/constant/api';
import type {Login} from '@/config/types';
export const generateSalt = (login: Login) => httpPost(`${API_AUTH_BASE}/token/salt`, login);
export const generateSalt = (login: Login) => httpPost<R<string>>(`${API_AUTH_BASE}/token/salt`, login);
export const generateToken = (login: Login) => httpPost(`${API_AUTH_BASE}/token/generate`, login);
@@ -23,13 +23,14 @@
import type {PropType} from 'vue';
import {computed} from 'vue';
import {useI18n} from 'vue-i18n';
import type {RangeKey} from '@/config/types/dashboard';
/**
* Presets the frontend sends as {@code rangeKey} — kept in sync with
* backend {@link TimeRangeKeyEnum}. The empty-string sentinel is the
* "no filter" choice, rendered only when {@code includeAll} is true.
*/
export type RangeKey = '' | 'today' | '24h' | '7d' | '30d';
export type {RangeKey};
const props = defineProps({
modelValue: {
+10 -24
View File
@@ -22,8 +22,10 @@ import {useRouter} from 'vue-router';
import type {Order, PageQuery} from '@/config/types';
import type {EntityColumnConfig, EntityListConfig, EntityOption} from '@/config/types/entityList';
import {ENUM_TAG_TYPE_MAP} from '@/config/constant/enums';
import {timestampLabel} from '@/utils/dateUtil';
import {prettyJson} from '@/utils/jsonUtil';
import {logger} from '@/utils/log';
import {successMessage} from '@/utils/notificationUtil';
import {cleanSearchParams, resetSearchForm} from '@/utils/searchParamUtil';
@@ -160,7 +162,9 @@ export const useEntityListPage = (rawConfig: EntityListConfig) => {
if (config.value.mode === 'tree') {
state.rows = (res.data as Record<string, any>[]) || [];
} else {
const page = res.data || {};
// Config-driven boundary: the envelope's data shape depends on the
// concrete config.list implementation, so narrow it once here.
const page = (res.data ?? {}) as { records?: Record<string, any>[]; total?: number };
state.rows = page.records || [];
state.page.total = Number(page.total || 0);
}
@@ -269,7 +273,10 @@ export const useEntityListPage = (rawConfig: EntityListConfig) => {
const submit = () => {
const addRequest = config.value.add;
const updateRequest = config.value.update;
if (!addRequest || !updateRequest) return;
if (!addRequest || !updateRequest) {
logger.warn('Entity list action not configured', {add: Boolean(addRequest), update: Boolean(updateRequest)});
return;
}
formRef.value?.validate((valid) => {
if (!valid) return;
let data: Record<string, unknown>;
@@ -317,28 +324,7 @@ export const useEntityListPage = (rawConfig: EntityListConfig) => {
const tagType = (value: unknown) => {
const text = String(value ?? '');
if (
text === 'ENABLE' ||
text === 'SUCCESS' ||
text === 'NORMAL' ||
text === 'AUTO' ||
text === 'LOW' ||
text === 'ACTIVE'
)
return 'success';
if (
text === 'DISABLE' ||
text === 'FAILED' ||
text === 'FAILURE' ||
text === 'ERROR' ||
text === 'DENIED' ||
text === 'FIRING' ||
text === 'HIGH'
)
return 'danger';
if (text === 'PENDING' || text === 'RETRYING' || text === 'RECOVERED' || text === 'MEDIUM' || text === 'SUSPENDED')
return 'warning';
return 'info';
return ENUM_TAG_TYPE_MAP[text] || 'info';
};
const formatCell = (row: Record<string, any>, column: EntityColumnConfig) => {
+1 -1
View File
@@ -18,7 +18,7 @@
/**
* Standard response envelope.
*/
declare type R<T = any> = {
declare type R<T = unknown> = {
ok: boolean;
code: string;
message: string;
+31
View File
@@ -217,3 +217,34 @@ export const MCP_RISK_LEVEL_OPTIONS: EnumOption[] = [
{label: MCP_RISK_LEVELS.MEDIUM, value: MCP_RISK_LEVELS.MEDIUM},
{label: MCP_RISK_LEVELS.HIGH, value: MCP_RISK_LEVELS.HIGH},
];
// Backend: PrincipalTypeEnum
export const PRINCIPAL_TYPE_OPTIONS: EnumOption[] = [
{label: 'USER', value: 'USER'},
{label: 'SERVICE_ACCOUNT', value: 'SERVICE_ACCOUNT'},
{label: 'SYSTEM', value: 'SYSTEM'},
];
/**
* Enum name → el-tag type mapping (kept in sync with backend enums).
*/
export const ENUM_TAG_TYPE_MAP: Record<string, 'primary' | 'success' | 'info' | 'warning' | 'danger'> = {
ENABLE: 'success',
SUCCESS: 'success',
NORMAL: 'success',
AUTO: 'success',
LOW: 'success',
ACTIVE: 'success',
DISABLE: 'danger',
FAILED: 'danger',
FAILURE: 'danger',
ERROR: 'danger',
DENIED: 'danger',
FIRING: 'danger',
HIGH: 'danger',
PENDING: 'warning',
RETRYING: 'warning',
RECOVERED: 'warning',
MEDIUM: 'warning',
SUSPENDED: 'warning',
};
+6 -6
View File
@@ -19,12 +19,12 @@
* Alarm / notification data-domain types.
*/
export type AlarmTargetTypeFlag = 'POINT' | 'DEVICE' | 'DRIVER' | string;
export type NotifyChannelTypeFlag = 'FEISHU_BOT' | 'WEBHOOK' | 'EMAIL' | string;
export type RuleStateFlag = 'NORMAL' | 'FIRING' | 'RECOVERED' | string;
export type NotifyHistoryStatusFlag = 'PENDING' | 'SUCCESS' | 'FAILED' | 'RETRYING' | 'SKIPPED' | string;
export type AutoConfirmFlag = 'AUTO' | 'MANUAL' | string;
export type EnableFlag = 'ENABLE' | 'DISABLE' | string;
export type AlarmTargetTypeFlag = 'POINT' | 'DEVICE' | 'DRIVER';
export type NotifyChannelTypeFlag = 'FEISHU_BOT' | 'WEBHOOK' | 'EMAIL';
export type RuleStateFlag = 'NORMAL' | 'FIRING' | 'RECOVERED';
export type NotifyHistoryStatusFlag = 'PENDING' | 'SUCCESS' | 'FAILED' | 'RETRYING' | 'SKIPPED';
export type AutoConfirmFlag = 'AUTO' | 'MANUAL';
export type EnableFlag = 'ENABLE' | 'DISABLE';
export interface StructuredExt<T = Record<string, unknown>> {
type?: string;
+1 -1
View File
@@ -31,7 +31,7 @@ export interface Attribute {
name: string;
attributeName: string;
attributeCode: string;
attributeTypeFlag?: 'STRING' | 'BYTE' | 'SHORT' | 'INT' | 'LONG' | 'FLOAT' | 'DOUBLE' | 'BOOLEAN' | string;
attributeTypeFlag?: 'STRING' | 'BYTE' | 'SHORT' | 'INT' | 'LONG' | 'FLOAT' | 'DOUBLE' | 'BOOLEAN';
defaultValue?: string;
remark?: string;
attributeExt?: Record<string, unknown>;
+73 -8
View File
@@ -16,14 +16,9 @@
*/
/**
* Dashboard / event-overview payload shapes. Keeping these here (not in
* api/dashboard.ts alongside the fetch functions) follows the project-wide
* convention described in CLAUDE.md: "config/entity/" is the interface-only
* module, always imported with `import type` under verbatimModuleSyntax.
*
* <p>Shared primitives (AlertSource, RangeKey) live here too so every
* card / API wrapper points at the same union instead of re-declaring
* `'device' | 'driver'` inline.</p>
* Dashboard / event-overview payload shapes. Kept here (not inside the API
* wrappers) so every card and `api/dashboard/*` module points at one source
* of truth, always imported with `import type` under verbatimModuleSyntax.
*/
/** Three canonical alarm sources — point-level, device-level, driver-level. */
@@ -215,3 +210,73 @@ export interface DailyGrowthSummary {
pointDailyCounts: number[];
profileDailyCounts: number[];
}
// ---- Alert overview cards (previously declared inline in components) ----
export interface AlertEventRow {
id: string;
source: AlertSource;
sourceId: string;
createTime: string;
message?: string;
}
export interface AlertStormRow {
source: AlertSource;
sourceId: string;
count: number;
}
export interface AlertTypeRow {
type: string;
count: number;
}
export interface AlertActivityRow {
/** 0..6 = Sun..Sat, matching Postgres EXTRACT(DOW). */
dow: number;
hour: number;
count: number;
}
export interface AlertTrendRow {
date: string;
source: string;
count: number;
}
export interface AlertTopSourceRow {
name: string;
count: number;
}
/** Bucket shape shared by statsTop / latency / enable-breakdown endpoints. */
export interface StatsCountBucket {
entityId?: number;
key?: string;
bin?: number;
count: number;
}
export interface StreamRow {
deviceId: string;
pointId: string;
driverId?: string;
// driverName / deviceName / pointName are populated server-side via metadata
// facades, so the feed renders the full tuple without extra lookups.
driverName?: string;
deviceName?: string;
pointName?: string;
}
export interface DriverStats {
byEnable: { key: string; count: number }[];
byType: { key: string; count: number }[];
byService: { key: string; count: number }[];
}
export interface DeviceStats {
byEnable: { key: string; count: number }[];
byProfile: { key: string; count: number }[];
byDriver: { key: string; count: number }[];
}
+6 -4
View File
@@ -88,6 +88,8 @@ export interface EntityRowAction {
key: string;
label: string;
type?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
/** When set, the action renders inside el-popconfirm; onClick runs only after confirmation. */
popconfirmTitle?: string;
onClick: (row: Record<string, any>) => void;
}
@@ -127,10 +129,10 @@ export interface EntityListConfig {
/** Builds the submitted payload instead of using the default field assembly. */
toPayload?: (form: Record<string, any>) => Record<string, unknown>;
list: (query: PageQuery) => Promise<R>;
add?: (payload: Record<string, unknown>) => Promise<R>;
update?: (payload: Record<string, unknown>) => Promise<R>;
remove?: (id: string) => Promise<R>;
list: (query: PageQuery) => Promise<R<unknown>>;
add?: (payload: Record<string, unknown>) => Promise<R<unknown>>;
update?: (payload: Record<string, unknown>) => Promise<R<unknown>>;
remove?: (id: string) => Promise<R<unknown>>;
detail?: { routeName: string }; // Detail route; omit to hide the detail action.
extraActions?: EntityRowAction[];
+7 -6
View File
@@ -19,6 +19,7 @@ import type {
MessageRecord,
NotifyChannelBindRecord,
NotifyChannelRecord,
NotifyChannelTypeFlag,
NotifyHistoryRecord,
NotifyRecord,
RuleRecord,
@@ -40,7 +41,7 @@ const ext = (type: string, content: Record<string, unknown>, version = 1) => ({
interface ChannelDef {
name: string;
code: string;
type: string;
type: NotifyChannelTypeFlag;
credential: string;
content: Record<string, unknown>;
}
@@ -62,7 +63,7 @@ const channelDefs: ChannelDef[] = [
{
name: '钉钉运维群机器人',
code: 'dingtalk-ops',
type: 'DINGTALK_BOT',
type: 'WEBHOOK',
credential: 'dingtalk-ops-bot',
content: {
signEnabled: true,
@@ -193,7 +194,7 @@ const messageDefs: MessageDef[] = [
variables: ['severity', 'device', 'point', 'value', 'unit', 'threshold', 'triggerTime'],
templates: [
{
channelType: 'DINGTALK_BOT',
channelType: 'WEBHOOK',
payloadType: 'MARKDOWN',
template: {
title: '${severity} ${device} 告警',
@@ -335,7 +336,7 @@ const ruleDefs: RuleDef[] = [
interface StateDef {
ruleIdx: number;
entity: string;
state: 'FIRING' | 'PENDING' | 'RECOVERED';
state: 'FIRING' | 'NORMAL' | 'RECOVERED';
fingerprint: string;
triggerCount: number;
first: string;
@@ -376,7 +377,7 @@ const stateDefs: StateDef[] = [
{
ruleIdx: 2,
entity: '5011',
state: 'PENDING',
state: 'NORMAL',
fingerprint: 'fp-0a7b3e5d9c218f64',
triggerCount: 0,
first: '',
@@ -474,7 +475,7 @@ const historyDefs: HistoryDef[] = [
retry: 2,
time: '2026-08-05T09:30:00',
request: {
channelType: 'DINGTALK_BOT',
channelType: 'WEBHOOK',
target: 'https://oapi.dingtalk.com/robot/send?access_token=****',
payloadType: 'MARKDOWN'
},
+50 -38
View File
@@ -16,6 +16,7 @@
*/
import {computed, defineComponent, onBeforeUnmount, onMounted, reactive, watch} from 'vue';
import {ElMessageBox} from 'element-plus';
import type {FormItemRule, FormRules} from 'element-plus';
import {Search} from '@element-plus/icons-vue';
@@ -49,6 +50,7 @@ import {
import type {
Attribute,
CommandInfoForm,
DriverInfoForm,
CommandRecord,
DeviceRecord,
Dictionary,
@@ -718,8 +720,8 @@ export default defineComponent({
listDriverInfoByDeviceId(reactiveData.id)
.then((res) => {
const formData: AttributeFormData = reactiveData.driverFormData;
res.data.forEach((info: { attributeId: string; id: any; configValue: any }) => {
const attributeCode = reactiveData.driverAttributeTable[info.attributeId];
res.data.forEach((info: DriverInfoForm) => {
const attributeCode = reactiveData.driverAttributeTable[info.attributeId ?? ''];
const attribute = reactiveData.driverAttributes.find((item) => item.attributeCode === attributeCode);
if (attribute) {
formData[attributeCode] = createAttributeFormItem(attribute, info.id, info.configValue);
@@ -775,16 +777,14 @@ export default defineComponent({
return listPointInfoByDeviceId(reactiveData.id)
.then((infoRes) => {
(infoRes.data || []).forEach(
(info: { pointId: string; attributeId: string; id: string; configValue: unknown }) => {
const attributeCode = reactiveData.pointAttributeTable[info.attributeId];
const attribute = reactiveData.pointAttributes.find((item) => item.attributeCode === attributeCode);
const row = rowTable[info.pointId];
if (row && attribute) {
row.attributes[attributeCode] = createPointAttributeCell(attribute, info.id, info.configValue);
}
(infoRes.data || []).forEach((info: PointInfoForm) => {
const attributeCode = reactiveData.pointAttributeTable[info.attributeId ?? ''];
const attribute = reactiveData.pointAttributes.find((item) => item.attributeCode === attributeCode);
const row = rowTable[String(info.pointId ?? '')];
if (row && attribute) {
row.attributes[attributeCode] = createPointAttributeCell(attribute, info.id ?? '', info.configValue);
}
);
});
reactiveData.pointInfoData = rows;
reactiveData.oldPointInfoData = clone(rows);
})
@@ -839,16 +839,14 @@ export default defineComponent({
return listCommandInfoByDeviceId(reactiveData.id)
.then((infoRes) => {
(infoRes.data || []).forEach(
(info: { commandId: string; attributeId: string; id: string; configValue: unknown }) => {
const attributeCode = reactiveData.commandAttributeTable[info.attributeId];
const attribute = reactiveData.commandAttributes.find((item) => item.attributeCode === attributeCode);
const row = rowTable[String(info.commandId)];
if (row && attribute) {
row.attributes[attributeCode] = createPointAttributeCell(attribute, info.id, info.configValue);
}
(infoRes.data || []).forEach((info: CommandInfoForm) => {
const attributeCode = reactiveData.commandAttributeTable[info.attributeId ?? ''];
const attribute = reactiveData.commandAttributes.find((item) => item.attributeCode === attributeCode);
const row = rowTable[String(info.commandId ?? '')];
if (row && attribute) {
row.attributes[attributeCode] = createPointAttributeCell(attribute, info.id ?? '', info.configValue);
}
);
});
reactiveData.commandInfoData = rows;
reactiveData.oldCommandInfoData = clone(rows);
})
@@ -900,16 +898,14 @@ export default defineComponent({
return listEventInfoByDeviceId(reactiveData.id)
.then((infoRes) => {
(infoRes.data || []).forEach(
(info: { eventId: string; attributeId: string; id: string; configValue: unknown }) => {
const attributeCode = reactiveData.eventAttributeTable[info.attributeId];
const attribute = reactiveData.eventAttributes.find((item) => item.attributeCode === attributeCode);
const row = rowTable[String(info.eventId)];
if (row && attribute) {
row.attributes[attributeCode] = createPointAttributeCell(attribute, info.id, info.configValue);
}
(infoRes.data || []).forEach((info: EventInfoForm) => {
const attributeCode = reactiveData.eventAttributeTable[info.attributeId ?? ''];
const attribute = reactiveData.eventAttributes.find((item) => item.attributeCode === attributeCode);
const row = rowTable[String(info.eventId ?? '')];
if (row && attribute) {
row.attributes[attributeCode] = createPointAttributeCell(attribute, info.id ?? '', info.configValue);
}
);
});
reactiveData.eventInfoData = rows;
reactiveData.oldEventInfoData = clone(rows);
})
@@ -1095,8 +1091,13 @@ export default defineComponent({
};
try {
const res = cell.id ? await updatePointInfo(payload) : await addPointInfo(payload);
cell.id = String(res?.data?.id || cell.id || '');
// Backend add/update return R<String> (success code) without the new
// id, so there is nothing to read back from res.data.
if (cell.id) {
await updatePointInfo(payload);
} else {
await addPointInfo(payload);
}
cell.originalValue = cell.configValue;
cell.dirty = false;
} catch (error) {
@@ -1212,8 +1213,11 @@ export default defineComponent({
};
try {
const res = cell.id ? await updateCommandInfo(payload) : await addCommandInfo(payload);
cell.id = String(res?.data?.id || cell.id || '');
if (cell.id) {
await updateCommandInfo(payload);
} else {
await addCommandInfo(payload);
}
cell.originalValue = cell.configValue;
cell.dirty = false;
} catch (error) {
@@ -1327,8 +1331,11 @@ export default defineComponent({
};
try {
const res = cell.id ? await updateEventInfo(payload) : await addEventInfo(payload);
cell.id = String(res?.data?.id || cell.id || '');
if (cell.id) {
await updateEventInfo(payload);
} else {
await addEventInfo(payload);
}
cell.originalValue = cell.configValue;
cell.dirty = false;
} catch (error) {
@@ -1473,10 +1480,15 @@ export default defineComponent({
window.removeEventListener('beforeunload', warnBeforeUnload);
});
onBeforeRouteLeave((_to, _from, next) => {
onBeforeRouteLeave(async (_to, _from, next) => {
if (totalDirtyCount.value > 0) {
const leave = window.confirm('You have unsaved changes. Are you sure you want to leave?');
if (!leave) {
try {
await ElMessageBox.confirm(t('device.edit.unsavedConfirm'), t('common.confirm'), {
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
});
} catch {
next(false);
return;
}
@@ -31,7 +31,7 @@ import {Chart} from '@antv/g2';
import {statsActivity} from '@/api/dashboard';
import DashboardCard from '@/components/card/dashboard/DashboardCard.vue';
import type {RangeKey} from '@/components/segmented/RangeSegmented.vue';
import type {RangeKey} from '@/config/types/dashboard';
import RangeSegmented from '@/components/segmented/RangeSegmented.vue';
const {t} = useI18n();
@@ -57,7 +57,7 @@ import {listDriverByIds} from '@/api/driver';
import {listPointByIds} from '@/api/point';
import {listProfileByIds} from '@/api/profile';
import DashboardCard from '@/components/card/dashboard/DashboardCard.vue';
import type {RangeKey} from '@/components/segmented/RangeSegmented.vue';
import type {RangeKey} from '@/config/types/dashboard';
import RangeSegmented from '@/components/segmented/RangeSegmented.vue';
type TabKey = 'deviceStatus' | 'protocol' | 'profile' | 'topDevice' | 'topPoint' | 'topDriver';
@@ -31,7 +31,7 @@ import {Chart} from '@antv/g2';
import {statsLatency} from '@/api/dashboard';
import DashboardCard from '@/components/card/dashboard/DashboardCard.vue';
import type {RangeKey} from '@/components/segmented/RangeSegmented.vue';
import type {RangeKey} from '@/config/types/dashboard';
import RangeSegmented from '@/components/segmented/RangeSegmented.vue';
const {t} = useI18n();
@@ -96,7 +96,7 @@ import type {
TopologyStats,
} from '@/config/types/dashboard';
import DashboardCard from '@/components/card/dashboard/DashboardCard.vue';
import type {RangeKey} from '@/components/segmented/RangeSegmented.vue';
import type {RangeKey} from '@/config/types/dashboard';
import RangeSegmented from '@/components/segmented/RangeSegmented.vue';
const {t, locale} = useI18n();
@@ -30,7 +30,7 @@ import {Chart} from '@antv/g2';
import {statsTimeseries} from '@/api/dashboard';
import DashboardCard from '@/components/card/dashboard/DashboardCard.vue';
import type {RangeKey} from '@/components/segmented/RangeSegmented.vue';
import type {RangeKey} from '@/config/types/dashboard';
import RangeSegmented from '@/components/segmented/RangeSegmented.vue';
const rangeKey = ref<RangeKey>('24h');
@@ -92,10 +92,10 @@ export interface AlarmEntityConfig {
columns: AlarmColumnConfig[];
fields: AlarmFieldConfig[];
defaultForm: () => Record<string, unknown>;
list: (query: PageQuery) => Promise<R>;
add?: (payload: Record<string, unknown>) => Promise<R>;
update?: (payload: Record<string, unknown>) => Promise<R>;
remove?: (id: string) => Promise<R>;
list: (query: PageQuery) => Promise<R<unknown>>;
add?: (payload: Record<string, unknown>) => Promise<R<unknown>>;
update?: (payload: Record<string, unknown>) => Promise<R<unknown>>;
remove?: (id: string) => Promise<R<unknown>>;
}
export const ALARM_DETAIL_ROUTE_MAP: Record<AlarmTabKey, string> = {
@@ -57,13 +57,7 @@ import {alertStormSources} from '@/api/dashboard';
import DashboardCard from '@/components/card/dashboard/DashboardCard.vue';
import {useEntityNames} from '@/composables/useEntityNames';
import {jumpToSourceEvents} from '@/utils/jumpUtil';
import type {AlertSource} from '@/config/types/dashboard';
interface StormRow {
source: AlertSource;
sourceId: string;
count: number;
}
import type {AlertSource, AlertStormRow} from '@/config/types/dashboard';
const props = defineProps({
limit: {type: Number, default: 10},
@@ -94,13 +88,13 @@ const windowKey = ref<WindowKey>('24h');
const window = computed(() => WINDOW_SPECS[windowKey.value]);
const loading = ref(false);
const rows = ref<StormRow[]>([]);
const rows = ref<AlertStormRow[]>([]);
const load = async () => {
loading.value = true;
try {
const {hours, minCount} = window.value;
const res: { data?: StormRow[] } = await alertStormSources(hours, minCount, props.limit);
const res: { data?: AlertStormRow[] } = await alertStormSources(hours, minCount, props.limit);
rows.value = res?.data ?? [];
await resolveBySource(rows.value);
} catch {
@@ -113,9 +107,9 @@ const load = async () => {
watch(windowKey, load);
watch(locale, load);
const nameFor = (r: StormRow) => nameBySource(r.source, r.sourceId);
const nameFor = (r: AlertStormRow) => nameBySource(r.source, r.sourceId);
const onDrillIn = (row: StormRow) => jumpToSourceEvents(router, row.source, row.sourceId);
const onDrillIn = (row: AlertStormRow) => jumpToSourceEvents(router, row.source, row.sourceId);
const sourceTagType = (s: AlertSource) => (s === 'device' ? 'primary' : s === 'driver' ? 'warning' : 'success');
const sourceLabel = (s: AlertSource) => {
@@ -36,16 +36,12 @@ import {Chart} from '@antv/g2';
import {alertTypeDistribution} from '@/api/dashboard';
import DashboardCard from '@/components/card/dashboard/DashboardCard.vue';
import type {AlertTypeRow} from '@/config/types/dashboard';
const {t, locale} = useI18n();
interface TypeRow {
type: string;
count: number;
}
const loading = ref(false);
const rows = ref<TypeRow[]>([]);
const rows = ref<AlertTypeRow[]>([]);
const chartRef = ref<HTMLElement>();
let chart: Chart | undefined;
@@ -58,7 +54,7 @@ const labelFor = (type: string) => {
return translated && translated !== key ? translated : type;
};
const render = (data: TypeRow[]) => {
const render = (data: AlertTypeRow[]) => {
if (!chartRef.value) return;
chart?.destroy();
chart = new Chart({container: chartRef.value, autoFit: true});
@@ -78,7 +74,7 @@ const render = (data: TypeRow[]) => {
const load = async () => {
loading.value = true;
try {
const res: { data?: TypeRow[] } = await alertTypeDistribution(30);
const res: { data?: AlertTypeRow[] } = await alertTypeDistribution(30);
rows.value = res?.data ?? [];
await nextTick();
if (rows.value.length > 0) render(rows.value);
@@ -57,26 +57,18 @@ import {useI18n} from 'vue-i18n';
import {alertPage} from '@/api/dashboard';
import DashboardCard from '@/components/card/dashboard/DashboardCard.vue';
import {useEntityNames} from '@/composables/useEntityNames';
import type {AlertSource} from '@/config/types/dashboard';
interface Row {
id: string;
source: AlertSource;
sourceId: string;
createTime: string;
message?: string;
}
import type {AlertEventRow, AlertSource} from '@/config/types/dashboard';
const {t, locale} = useI18n();
const loading = ref(false);
const rows = ref<Row[]>([]);
const rows = ref<AlertEventRow[]>([]);
const {resolveBySource, nameBySource} = useEntityNames();
const load = async () => {
loading.value = true;
try {
const res: { data?: { records?: Row[] } } = await alertPage({confirmFlag: 0, current: 1, size: 5});
const data: Row[] = res?.data?.records ?? [];
const res: { data?: { records?: AlertEventRow[] } } = await alertPage({confirmFlag: 0, current: 1, size: 5});
const data: AlertEventRow[] = res?.data?.records ?? [];
rows.value = data;
await resolveBySource(data);
} catch {
@@ -86,7 +78,7 @@ const load = async () => {
}
};
const nameFor = (r: Row) => nameBySource(r.source, r.sourceId);
const nameFor = (r: AlertEventRow) => nameBySource(r.source, r.sourceId);
const sourceTagType = (s: AlertSource) => (s === 'device' ? 'primary' : s === 'driver' ? 'warning' : 'success');
const sourceColor = (s: AlertSource) => (s === 'device' ? '#409eff' : s === 'driver' ? '#e6a23c' : '#67c23a');
@@ -133,7 +133,7 @@ export const useAlarmEntityPage = (props: AlarmEntityPageProps) => {
activeConfig.value
.list(query())
.then((res: R) => {
const page = res.data || {};
const page = (res.data ?? {}) as { records?: AlarmEntity[]; total?: number };
state.rows = page.records || [];
state.page.total = Number(page.total || 0);
})