feat(web): unify three-terminal control density and close audit defects

Land the v7 three-terminal UX decision: drop the width/pointer-based
44px touch enlargement so one 32px density holds across desktop,
tablet, and mobile, and rebuild entity pages on a responsive record
list (ResponsiveRecordList + EntityMobileCard + per-entity mobile
summaries). Tighten the nginx security headers (CSP frame-ancestors
'none', X-Frame-Options DENY) to match the static e2e server.

Complete the dc3-web audit remediation, including the remaining LOW
items from docs/dc3-web-low-defects-handoff.md: streaming-safe
message loads in the agentic store, mock menu reparent cycle
rejection, paged-list page clamping, fetch/axios mock request
normalization, per-table attribute catalogs, post-flush alarm
watchers, rowKey-agnostic delete state, localized card text
fallbacks, and e2e-server port fail-fast.

Gate: vue-tsc, eslint, vitest 687 passed, responsive e2e 64 passed.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
pnoker
2026-09-07 19:33:17 +08:00
co-authored by Claude Code
parent 031deb4453
commit 3fb43c4239
197 changed files with 16561 additions and 4989 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ WORKDIR /build
COPY dc3-web/ ./
RUN corepack enable && corepack prepare pnpm@11.24.0 --activate
RUN corepack enable && corepack prepare pnpm@12.3.4 --activate
RUN apk add --no-cache git
RUN pnpm install --frozen-lockfile --ignore-scripts
RUN pnpm build
+1 -1
View File
@@ -27,7 +27,7 @@
<meta content="black" name="apple-mobile-web-app-status-bar-style"/>
<meta content="width=device-width,initial-scale=1.0" name="viewport"/>
<meta
content="default-src 'self'; script-src 'self' https://webapi.amap.com; connect-src 'self' https://*.amap.com; img-src 'self' data: https://*.amap.com; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'"
content="default-src 'self'; script-src 'self' https://webapi.amap.com; connect-src 'self' https://*.amap.com; img-src 'self' data: https://*.amap.com; style-src 'self' 'unsafe-inline'; font-src 'self' data:; base-uri 'self'"
http-equiv="Content-Security-Policy"/>
<meta content="nosniff" http-equiv="X-Content-Type-Options"/>
<meta content="no-referrer" http-equiv="Referrer-Policy"/>
+19 -17
View File
@@ -21,13 +21,14 @@
"engines": {
"node": ">=22.0.0"
},
"packageManager": "pnpm@11.24.0",
"packageManager": "pnpm@12.3.4",
"type": "module",
"scripts": {
"dev": "vite --host --mode dev",
"dev:mock": "vite --host --mode mock",
"dev:prod": "vite --host --mode production",
"serve:e2e": "vite build --mode dev && node scripts/testing/e2e-server.mjs",
"serve:e2e:mock": "vite build --mode mock && node scripts/testing/e2e-server.mjs",
"build": "vite build",
"build:mock": "vite build --mode mock",
"preview": "vite preview",
@@ -45,6 +46,7 @@
"test:ci": "vitest run --coverage",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:responsive": "E2E_SERVER_MODE=mock playwright test tests/e2e/specs/responsive.spec.ts",
"test:e2e:headed": "E2E_HEADLESS=false playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:sweep": "node tests/e2e/browser-sweep.mjs",
@@ -64,35 +66,35 @@
"marked": "^18.0.11",
"nprogress": "^0.2.0",
"pinia": "^4.0.3",
"vue": "^3.5.40",
"vue": "^3.5.42",
"vue-element-plus-x": "^2.0.3",
"vue-i18n": "^11.4.10",
"vue-router": "^5.2.0"
"vue-router": "^5.3.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.62.1",
"@types/node": "^26.3.0",
"@playwright/test": "^1.63.0",
"@types/node": "^26.4.1",
"@types/nprogress": "^0.2.3",
"@typescript-eslint/eslint-plugin": "^8.68.0",
"@typescript-eslint/parser": "^8.68.0",
"@typescript-eslint/eslint-plugin": "^8.69.0",
"@typescript-eslint/parser": "^8.69.0",
"@vitejs/plugin-vue": "^6.0.8",
"@vitest/coverage-v8": "^4.1.11",
"@vitest/coverage-v8": "^5.0.0",
"@vue/eslint-config-typescript": "^14.9.0",
"@vue/test-utils": "^2.4.11",
"eslint": "^10.9.1",
"eslint-plugin-vue": "^10.10.0",
"globals": "^17.11.0",
"happy-dom": "^20.11.0",
"@vue/test-utils": "^2.5.0",
"eslint": "^10.10.0",
"eslint-plugin-vue": "^10.11.0",
"globals": "^17.12.0",
"happy-dom": "^20.14.0",
"jsdom": "^30.0.1",
"lint-staged": "^17.3.0",
"playwright": "^1.62.1",
"sass": "^1.103.1",
"lint-staged": "^17.5.0",
"playwright": "^1.63.0",
"sass": "^1.104.0",
"typescript": "~6.0.3",
"unplugin-auto-import": "^21.1.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.2.2",
"vitest": "^4.1.11",
"vitest": "^5.0.0",
"vue-eslint-parser": "^10.4.1",
"vue-tsc": "^3.3.11"
},
+4 -3
View File
@@ -17,8 +17,9 @@
import {defineConfig, devices} from '@playwright/test';
const baseURL = process.env.E2E_BASE_URL || 'http://localhost:8080';
const baseURL = process.env.E2E_BASE_URL || 'http://127.0.0.1:4174';
const startServer = process.env.E2E_START_SERVER !== '0';
const serverCommand = process.env.E2E_SERVER_MODE === 'mock' ? 'pnpm run serve:e2e:mock' : 'pnpm run serve:e2e';
const workers = Number(process.env.E2E_WORKERS || 1);
export default defineConfig({
@@ -47,9 +48,9 @@ export default defineConfig({
},
webServer: startServer
? {
command: 'pnpm run serve:e2e',
command: serverCommand,
url: baseURL,
reuseExistingServer: !process.env.CI,
reuseExistingServer: false,
timeout: 120_000,
}
: undefined,
+685 -754
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -26,3 +26,4 @@ minimumReleaseAgeExclude:
- axios@1.18.1 || 1.20.0
- globals@17.7.0
- happy-dom@20.11.0
- eslint-plugin-vue@10.11.0
+14 -2
View File
@@ -33,10 +33,19 @@ const env = {
...process.env,
};
const defaultBaseUrl = `http://localhost:${env.APP_CLI_PORT || '8080'}`;
const defaultBaseUrl = 'http://127.0.0.1:4174';
const baseUrl = new URL(env.E2E_BASE_URL || defaultBaseUrl);
const host = env.E2E_HOST || '0.0.0.0';
const port = Number(env.E2E_PORT || baseUrl.port || env.APP_CLI_PORT || 8080);
const portValue = env.E2E_PORT || baseUrl.port;
const port = Number(portValue);
// Fail fast on a missing/invalid port: Number('') === 0 and listen(0) would
// silently bind a random port that Playwright's webServer poll never finds.
if (!portValue || !Number.isInteger(port) || port <= 0) {
console.error(
`Invalid E2E server port "${portValue}". Set E2E_PORT or use an E2E_BASE_URL with an explicit port.`
);
process.exit(1);
}
const apiPrefix = normalizeApiPrefix(env.APP_API_PREFIX || '/api');
const apiTarget = new URL(
env.E2E_API_TARGET || `${env.APP_API_PATH || 'http://localhost'}:${env.APP_API_PORT || '8000'}`
@@ -172,6 +181,9 @@ function serveStatic(req, res, pathname) {
res.writeHead(200, {
'content-length': size,
'content-type': contentType(file),
'content-security-policy': "frame-ancestors 'none'",
'x-content-type-options': 'nosniff',
'referrer-policy': 'no-referrer',
});
if (req.method === 'HEAD') {
+2 -1
View File
@@ -23,11 +23,12 @@ import type {
McpClientRegistrationForm,
McpConnectionForm,
McpConnectionRecord,
McpMetadata,
McpToolRecord,
OAuthClientRecord,
} from '@/config/types/auth';
export const getMcpMetadata = () => httpGet<Record<string, unknown>>(`${API_MCP_BASE}/metadata`);
export const getMcpMetadata = () => httpGet<McpMetadata>(`${API_MCP_BASE}/metadata`);
export const registerMcpClient = (body: McpClientRegistrationForm) =>
httpPost<Record<string, unknown>>(`${API_MCP_BASE}/client/register`, body);
File diff suppressed because it is too large Load Diff
@@ -16,49 +16,52 @@
-->
<template>
<div class="things-card__footer">
<div :aria-busy="busy" class="things-card__footer">
<div class="things-card-footer-operation">
<el-popconfirm
:icon="SwitchButton"
:disabled="busy || !enabled"
:title="disableTitle"
icon-color="#e6a23c"
placement="top"
@confirm="$emit('disable')"
>
<template #reference>
<el-button :disabled="!enabled" link type="primary">
<el-button :disabled="busy || !enabled" :loading="busy && enabled" link type="primary">
{{ t('common.disable') }}
</el-button>
</template>
</el-popconfirm>
<el-popconfirm
:icon="CircleCheck"
:disabled="busy || enabled"
:title="enableTitle"
icon-color="#67c23a"
placement="top"
@confirm="$emit('enable')"
>
<template #reference>
<el-button :disabled="enabled" link type="primary">
<el-button :disabled="busy || enabled" :loading="busy && !enabled" link type="primary">
{{ t('common.enable') }}
</el-button>
</template>
</el-popconfirm>
<el-popconfirm
:icon="CircleClose"
:disabled="busy"
:title="deleteTitle"
icon-color="#f56c6c"
placement="top"
@confirm="$emit('delete')"
>
<template #reference>
<el-button link type="primary">{{ t('common.delete') }}</el-button>
<el-button :disabled="busy" :loading="busy" link type="primary">{{ t('common.delete') }}</el-button>
</template>
</el-popconfirm>
<el-button link type="primary" @click="$emit('edit')">
<el-button :disabled="busy" link type="primary" @click="$emit('edit')">
{{ t('common.edit') }}
</el-button>
<el-button :disabled="detailDisabled" link type="primary" @click="$emit('detail')">
<el-button :disabled="busy || detailDisabled" link type="primary" @click="$emit('detail')">
{{ t('common.detail') }}
</el-button>
</div>
@@ -75,6 +78,7 @@ defineProps({
enableTitle: {type: String, required: true},
deleteTitle: {type: String, required: true},
detailDisabled: {type: Boolean, default: false},
busy: {type: Boolean, default: false},
});
defineEmits(['disable', 'enable', 'delete', 'edit', 'detail']);
@@ -84,15 +88,39 @@ const {t} = useI18n();
<style lang="scss" scoped>
.things-card__footer {
height: 35px;
min-height: var(--dc3-touch-target);
margin-top: 2px;
padding-inline-end: var(--dc3-floating-action-safe-space);
box-sizing: border-box;
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--el-border-color);
}
.things-card-footer-operation {
height: 35px;
min-height: var(--dc3-touch-target);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--dc3-space-1);
:deep(.el-button) {
min-height: var(--dc3-touch-target);
margin: 0;
}
}
@media (max-width: $breakpoint-xs-max) {
.things-card__footer {
justify-content: stretch;
}
.things-card-footer-operation {
width: 100%;
:deep(.el-button) {
flex: 1 1 auto;
}
}
}
</style>
@@ -66,6 +66,7 @@
/>
<el-button
v-if="refreshable"
:aria-label="title ? `${title}: ${t('common.refresh')}` : t('common.refresh')"
:icon="Refresh"
:loading="spinButton"
circle
@@ -77,7 +78,14 @@
</template>
<div v-loading="loadBody" class="dashboard-card__body">
<div v-if="empty" class="dashboard-card__empty">
<div v-if="error" class="dashboard-card__error" role="alert">
<el-alert :closable="false" :title="errorText ?? t('common.loadFailed')" show-icon type="error">
<el-button :loading="loading" link type="danger" @click="emit('refresh')">
{{ retryText ?? t('common.retry') }}
</el-button>
</el-alert>
</div>
<div v-else-if="empty" class="dashboard-card__empty">
<el-empty :description="emptyText" :image-size="emptyImageSize"/>
</div>
<div v-else class="dashboard-card__content">
@@ -93,8 +101,11 @@
<script lang="ts" setup>
import {computed, onUnmounted, useSlots, watch} from 'vue';
import {useI18n} from 'vue-i18n';
import {Refresh} from '@element-plus/icons-vue';
const {t} = useI18n();
export type DashboardCardHeight = number | 'auto';
export type DashboardCardBodyMode = 'plain' | 'chart' | 'scroll';
export type DashboardCardLoadingTarget = 'body' | 'button' | 'none';
@@ -132,6 +143,9 @@ const props = withDefaults(
empty?: boolean;
emptyText?: string;
emptyImageSize?: number;
error?: boolean;
errorText?: string;
retryText?: string;
refreshable?: boolean;
/** If provided, an `el-segmented` is rendered left of the refresh button (interval in ms; 0 = off). */
autoRefresh?: AutoRefreshOption[];
@@ -150,6 +164,7 @@ const props = withDefaults(
empty: false,
emptyText: '',
emptyImageSize: 80,
error: false,
refreshable: true,
autoRefresh: undefined,
interval: 0,
@@ -161,6 +176,7 @@ const emit = defineEmits<{
(e: 'update:interval', value: number): void;
}>();
const slots = useSlots();
const hasFooter = computed(() => !!slots.footer);
@@ -286,7 +302,19 @@ defineExpose({
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
min-width: 0;
max-width: 100%;
flex-shrink: 1;
> * {
min-width: 0;
max-width: 100%;
}
:deep(.el-segmented) {
min-width: 0;
max-width: 100%;
}
}
// ---- body ------------------------------------------------------------
@@ -312,6 +340,10 @@ defineExpose({
padding: 40px 0;
}
.dashboard-card__error {
padding: var(--dc3-space-4);
}
// ---- bodyMode variants ----------------------------------------------
// plain — Element default body padding, no special overflow treatment.
&--plain {
@@ -382,5 +414,28 @@ defineExpose({
margin-bottom: 0;
}
}
@media (max-width: $breakpoint-xs-max) {
.dashboard-card__header {
align-items: flex-start;
flex-wrap: wrap;
}
.dashboard-card__title,
.dashboard-card__tools {
width: 100%;
flex: 1 1 100%;
}
.dashboard-card__tools {
justify-content: flex-end;
flex-wrap: wrap;
:deep(.el-segmented) {
flex: 1 1 auto;
overflow-x: auto;
}
}
}
}
</style>
@@ -20,9 +20,14 @@
<div class="things-card-header-icon">
<img :alt="name" :src="icon"/>
</div>
<div class="things-card-header-name nowrap-name" @click.stop="$emit('copy-id')">
<button
:aria-label="`${name}: ${copyLabel}`"
class="things-card-header-name nowrap-name"
type="button"
@click.stop="$emit('copy-id')"
>
{{ name }}
</div>
</button>
<div :title="statusTitle" class="things-card-header-status">
<slot/>
</div>
@@ -35,6 +40,7 @@ defineProps({
icon: {type: String, required: true},
enabled: {type: Boolean, default: false},
statusTitle: {type: String, default: ''},
copyLabel: {type: String, required: true},
});
defineEmits(['copy-id']);
@@ -60,6 +66,10 @@ defineEmits(['copy-id']);
}
.things-card-header-name {
appearance: none;
padding: 0;
border: 0;
background: transparent;
height: 48px;
line-height: 48px;
font-size: 14px;
@@ -70,11 +80,20 @@ defineEmits(['copy-id']);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
width: 200px;
width: 100%;
max-width: 200px;
text-align: left;
&:hover {
&:hover,
&:focus-visible {
color: var(--el-color-primary);
}
&:focus-visible {
border-radius: var(--dc3-radius-sm);
outline: none;
box-shadow: var(--dc3-focus-ring);
}
}
.things-card-header-status {
@@ -20,6 +20,8 @@
<el-card shadow="never">
<el-form
ref="formRef"
:aria-busy="saving"
:disabled="saving"
:inline="true"
:model="formModel"
:rules="rules"
@@ -35,10 +37,10 @@
<slot name="actions"/>
<span v-if="!hideDefaultActions" aria-hidden="true" class="info-card-footer-divider"/>
<template v-if="!hideDefaultActions">
<el-button :icon="Check" plain type="primary" @click="onSave">
<el-button :disabled="saving" :icon="Check" :loading="saving" plain type="primary" @click="onSave">
{{ t('common.save') }}
</el-button>
<el-button :icon="RefreshLeft" plain @click="onReset">
<el-button :disabled="saving" :icon="RefreshLeft" plain @click="onReset">
{{ t('common.reset') }}
</el-button>
</template>
@@ -71,6 +73,10 @@ defineProps({
type: Boolean,
default: false,
},
saving: {
type: Boolean,
default: false,
},
});
const emit = defineEmits<{
@@ -18,7 +18,7 @@
<template>
<div class="matrix-toolbar">
<el-card shadow="never">
<el-form :inline="true" :model="formModel" class="matrix-toolbar__body">
<el-form :aria-busy="saving" :disabled="saving" :inline="true" :model="formModel" class="matrix-toolbar__body">
<div class="matrix-toolbar-body-form">
<slot :form-data="formModel" name="filters"/>
</div>
@@ -28,7 +28,7 @@
<slot name="actions"/>
<span aria-hidden="true" class="matrix-toolbar-footer-divider"/>
<el-button
:disabled="dirtyCount < 1"
:disabled="saving || dirtyCount < 1"
:icon="Check"
:loading="saving"
plain
@@ -37,9 +37,9 @@
>
{{ t('device.edit.saveAll') }}
</el-button>
<el-popconfirm :title="$t('common.discardConfirm')" @confirm="$emit('discard')">
<el-popconfirm :disabled="saving || dirtyCount < 1" :title="$t('common.discardConfirm')" @confirm="$emit('discard')">
<template #reference>
<el-button :disabled="dirtyCount < 1" :icon="RefreshLeft" plain>
<el-button :disabled="saving || dirtyCount < 1" :icon="RefreshLeft" plain>
{{ t('device.edit.discardChanges') }}
</el-button>
</template>
+66 -4
View File
@@ -16,7 +16,18 @@
-->
<template>
<el-card :class="`stat-card--${tone}`" class="stat-card" shadow="hover" @click="emit('click')">
<el-card
:aria-label="title"
:aria-busy="loading"
:class="`stat-card--${tone}`"
class="stat-card"
role="button"
shadow="hover"
tabindex="0"
@click="emit('click')"
@keydown.enter="activateOnSelf"
@keydown.space.prevent="activateOnSelf"
>
<div class="stat-card__row">
<div class="stat-card__icon">
<el-icon :size="28">
@@ -26,16 +37,18 @@
<div class="stat-card__body">
<div class="stat-card__title">{{ title }}</div>
<div class="stat-card__value">
<span class="stat-card__value-text">{{ formattedValue }}</span>
<span v-if="trend" :class="['stat-card__trend', `stat-card__trend--${trend.direction}`]">
<span v-if="loading" aria-hidden="true" class="stat-card__value-skeleton" />
<span v-else class="stat-card__value-text">{{ error ? '—' : formattedValue }}</span>
<span v-if="!loading && !error && trend" :class="['stat-card__trend', `stat-card__trend--${trend.direction}`]">
<el-icon><component :is="trendIcon"/></el-icon>
{{ trend.label }}
</span>
</div>
<div v-if="subtitle" class="stat-card__subtitle">{{ subtitle }}</div>
<div v-if="!loading && !error && subtitle" class="stat-card__subtitle">{{ subtitle }}</div>
</div>
<el-button
v-if="onRefresh"
:aria-label="refreshLabel"
:icon="Refresh"
:loading="refreshing"
circle
@@ -63,6 +76,7 @@ import {computed, ref} from 'vue';
import {CaretBottom, CaretTop, Minus, Refresh} from '@element-plus/icons-vue';
import MiniAreaChart from '@/components/chart/MiniAreaChart.vue';
import i18n from '@/config/i18n';
interface Trend {
direction: 'up' | 'down' | 'flat';
@@ -77,6 +91,8 @@ const props = defineProps({
tone: {type: String as PropType<'blue' | 'green' | 'orange' | 'purple' | 'red'>, default: 'blue'},
trend: {type: Object as PropType<Trend | null>, default: null},
sparkline: {type: Array as PropType<number[]>, default: () => []},
loading: {type: Boolean, default: false},
error: {type: Boolean, default: false},
/**
* Optional refresh handler. When provided, a small text button appears
* in the top-right of the card; clicking it invokes the handler without
@@ -87,6 +103,15 @@ const props = defineProps({
});
const emit = defineEmits<{ (e: 'click'): void }>();
const refreshLabel = computed(() => `${props.title} - ${i18n.global.t('common.refresh')}`);
// keydown bubbles up from the nested refresh button; only activate the
// card when the event originates from the card element itself (the click
// path is already isolated by the button's @click.stop).
const activateOnSelf = (event: KeyboardEvent) => {
if (event.target !== event.currentTarget) return;
emit('click');
};
const refreshing = ref(false);
const doRefresh = async () => {
@@ -229,6 +254,13 @@ const accentColor = computed(() => TONE_ACCENT[props.tone] || TONE_ACCENT.blue);
opacity: 1;
}
@media (max-width: $breakpoint-xs-max), (pointer: coarse) {
.stat-card__refresh {
right: var(--dc3-floating-action-safe-space);
opacity: 1;
}
}
.stat-card__icon {
width: 44px;
height: 44px;
@@ -267,6 +299,21 @@ const accentColor = computed(() => TONE_ACCENT[props.tone] || TONE_ACCENT.blue);
letter-spacing: -0.025em;
}
.stat-card__value-skeleton {
display: inline-block;
width: 78px;
height: 30px;
border-radius: var(--dc3-radius-sm);
background: linear-gradient(
90deg,
var(--el-fill-color-light) 25%,
var(--el-fill-color-lighter) 37%,
var(--el-fill-color-light) 63%
);
background-size: 400% 100%;
animation: stat-card-skeleton 1.4s ease infinite;
}
.stat-card__trend {
font-size: 12px;
display: inline-flex;
@@ -297,5 +344,20 @@ const accentColor = computed(() => TONE_ACCENT[props.tone] || TONE_ACCENT.blue);
margin-top: auto;
padding-top: 8px;
}
@keyframes stat-card-skeleton {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stat-card__value-skeleton {
animation: none;
}
}
}
</style>
+66 -3
View File
@@ -59,7 +59,7 @@
:page-size="+page.size"
:page-sizes="pageSizes"
:pager-count="isMobile ? 5 : 7"
:size="isMobile ? 'small' : 'default'"
size="default"
:total="+page.total"
background
@size-change="onSizeChange"
@@ -144,12 +144,15 @@ const emit = defineEmits<{
}>();
const {t} = useI18n();
const {isMobile} = useBreakpoint();
const {isMobile, isTablet} = useBreakpoint();
const formRef = ref<FormInstance>();
// Pagination degrades to a compact pager on thumb terminals: totals and
// page-size pickers are desktop affordances (A3).
const paginationLayout = computed(() => (isMobile.value ? 'prev, pager, next' : 'total, prev, pager, next, sizes'));
// Tablet toolbars do not have enough horizontal room for the desktop total,
// seven-page pager, and page-size select. Keep the same compact interaction
// used on phones so every navigation control remains visible and tappable.
const paginationLayout = computed(() => (isMobile.value || isTablet.value ? 'prev, pager, next' : 'total, prev, pager, next, sizes'));
const search = async () => {
const form = unref(formRef);
@@ -212,6 +215,30 @@ defineExpose({search, reset});
// Stretch every input surface to the cell width — shared with
// InfoCard via src/styles/shared-form-widths.scss.
@include form-item-full-width;
// Segmented filters keep every option reachable on narrow terminals.
// Element Plus sizes the group from its labels, so a long enum can
// otherwise be clipped by the card's overflow boundary. The control
// becomes its own thumb-scroll surface while the form cell remains
// fluid.
}
:deep(.el-form-item .el-segmented) {
min-width: 0;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
:deep(.el-form-item .el-segmented__group) {
width: max-content;
min-width: 100%;
}
:deep(.el-form-item .el-segmented__item) {
flex: 0 0 auto;
min-width: max-content;
}
}
@@ -235,15 +262,27 @@ defineExpose({search, reset});
.tool-card-footer-button {
display: flex;
align-items: center;
flex: 1 1 280px;
min-width: 0;
max-width: 100%;
flex-wrap: wrap;
gap: 8px;
}
.tool-card-footer-page {
display: flex;
align-items: center;
flex: 1 1 280px;
min-width: 0;
max-width: 100%;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
:deep(.el-pagination) {
min-width: 0;
max-width: 100%;
}
}
// Vertical divider, reused both between actions / search-reset in the
@@ -266,6 +305,30 @@ defineExpose({search, reset});
.tool-card-footer-button > .tool-card-footer-divider:first-child {
display: none;
}
@media (max-width: $breakpoint-xs-max) {
align-items: stretch;
.tool-card-footer-button,
.tool-card-footer-page {
width: 100%;
flex: 0 1 100%;
justify-content: center;
}
.tool-card-footer-button {
flex-wrap: wrap;
}
.tool-card-footer-page {
:deep(.el-pagination) {
flex: 1 1 100%;
width: 100%;
overflow-x: auto;
justify-content: center;
}
}
}
}
:deep(.el-card) {
+303 -44
View File
@@ -42,7 +42,12 @@
:include-all="field.includeAll"
/>
<search-segmented
v-else-if="field.kind === 'select' && !field.multiple && (field.options?.length ?? 0) <= 3"
v-else-if="
field.kind === 'select' &&
!field.multiple &&
(field.options?.length ?? 0) <= 3 &&
!isCardLayout
"
v-model="searchForm[field.prop]"
:options="field.options || []"
/>
@@ -88,9 +93,63 @@
</template>
</tool-card>
<el-alert
v-if="state.status === 'error'"
:closable="false"
:title="t('common.loadFailed')"
class="entity-list-page__error"
show-icon
type="error"
>
<el-button :loading="state.loading" link type="danger" @click="load">
{{ t('common.retry') }}
</el-button>
</el-alert>
<blank-card>
<template v-if="isCardLayout">
<div v-if="state.loading && state.rows.length === 0" class="entity-list-page__mobile-loading">
<div v-for="item in 3" :key="item" aria-hidden="true" class="entity-list-page__mobile-skeleton">
<span class="entity-list-page__skeleton-line entity-list-page__skeleton-line--title" />
<span class="entity-list-page__skeleton-line" />
<span class="entity-list-page__skeleton-line entity-list-page__skeleton-line--short" />
</div>
</div>
<el-empty
v-else-if="state.status === 'error' && state.rows.length === 0"
:description="t('common.loadFailed')"
/>
<el-empty
v-else-if="!state.loading && state.rows.length === 0"
:description="config.emptyText || t('common.empty')"
/>
<div v-else class="entity-list-page__mobile-cards">
<entity-mobile-card
v-for="entry in mobileRows"
:key="String(getCellValue(entry.row, config.rowKey || 'id'))"
:aria-level="entry.depth + 1"
:can-delete="canDelete"
:can-edit="canEdit"
:config="config"
:format-cell="formatCell"
:get-cell-value="getCellValue"
:action-disabled="actionDisabled"
:action-loading="actionLoading"
:on-action="runAction"
:row="entry.row"
:depth="entry.depth"
:tag-type="tagType"
:removing="isRemoving(String(getCellValue(entry.row, config.rowKey || 'id') ?? ''))"
@delete="removeRow"
@detail="openDetail"
@edit="openEdit"
/>
</div>
</template>
<el-table
v-else
v-loading="state.loading"
:aria-busy="state.loading"
:data="state.rows"
:default-expand-all="config.mode === 'tree' && config.defaultExpandAll"
:row-key="config.mode === 'tree' ? config.rowKey || 'id' : undefined"
@@ -110,7 +169,7 @@
<enable-tag v-if="column.kind === 'enable'" :value="getCellValue(row, column.prop)"/>
<span v-else-if="column.kind === 'color'" class="entity-list-page__color-cell">
<span
:style="{background: getCellValue(row, column.prop) || '#F4F4F5'}"
:style="{background: getCellValue(row, column.prop) || 'var(--el-fill-color-light)'}"
class="entity-list-page__swatch"
/>
{{ formatCell(row, column) }}
@@ -155,38 +214,62 @@
<template v-for="action in config.extraActions" :key="action.key">
<el-popconfirm
v-if="action.popconfirmTitle"
:disabled="actionDisabled(action, row)"
:cancel-button-text="t('common.cancel')"
:confirm-button-text="t('common.confirm')"
:title="action.popconfirmTitle"
@confirm="action.onClick(row)"
@confirm="runAction(action, row)"
>
<template #reference>
<el-button :type="action.type || 'primary'" link>{{ action.label }}</el-button>
<el-button
:disabled="actionDisabled(action, row)"
:loading="actionLoading(action, row)"
:type="action.type || 'primary'"
link
>{{ action.label }}</el-button>
</template>
</el-popconfirm>
<el-button v-else :type="action.type || 'primary'" link @click="action.onClick(row)">
<el-button
v-else
:disabled="actionDisabled(action, row)"
:loading="actionLoading(action, row)"
:type="action.type || 'primary'"
link
@click="runAction(action, row)"
>
{{ action.label }}
</el-button>
</template>
</template>
<el-button v-if="config.editable && canEdit(row)" link type="primary" @click="openEdit(row)">
<el-button
v-if="config.editable && canEdit(row)"
:disabled="isRemoving(String(row.id ?? ''))"
link
type="primary"
@click="openEdit(row)"
>
{{ t('common.edit') }}
</el-button>
<el-popconfirm
v-if="config.editable && canDelete(row)"
:disabled="isRemoving(String(row.id ?? ''))"
:cancel-button-text="t('common.cancel')"
:confirm-button-text="t('common.confirm')"
:title="config.confirmDeleteText || t('common.confirmDelete')"
@confirm="remove(row.id)"
>
<template #reference>
<el-button link type="danger">{{ t('common.delete') }}</el-button>
<el-button :loading="isRemoving(String(row.id ?? ''))" link type="danger">
{{ t('common.delete') }}
</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
<template #empty>
<el-empty :description="config.emptyText || t('common.empty')"/>
<el-empty
:description="state.status === 'error' ? t('common.loadFailed') : config.emptyText || t('common.empty')"
/>
</template>
</el-table>
</blank-card>
@@ -194,15 +277,24 @@
<el-dialog
v-model="formVisible"
:append-to-body="true"
:before-close="requestCloseForm"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
:close-on-press-escape="!state.saving"
:show-close="!state.saving"
:title="dialogTitle"
:width="config.dialogWidth || '720px'"
class="things-dialog things-dialog--wide"
destroy-on-close
draggable
>
<el-alert
v-if="state.saveError"
:closable="false"
:title="t('common.saveFailed')"
class="entity-list-page__form-alert"
show-icon
type="error"
/>
<el-form
:ref="setFormRef"
:model="formModel"
@@ -211,12 +303,17 @@
label-position="top"
>
<el-row :gutter="8">
<el-col v-for="field in config.fields" :key="field.prop" :span="field.span || 12">
<el-col
v-for="field in config.fields"
:key="field.prop"
:sm="field.span || 12"
:xs="24"
>
<el-form-item :label="field.label" :prop="field.prop">
<el-select
v-if="field.kind === 'select'"
v-model="formModel[field.prop]"
:disabled="editing && field.disabledOnEdit"
:disabled="state.saving || (editing && field.disabledOnEdit)"
:placeholder="field.placeholder"
clearable
filterable
@@ -231,7 +328,7 @@
<el-input-number
v-else-if="field.kind === 'number'"
v-model="formModel[field.prop]"
:disabled="editing && field.disabledOnEdit"
:disabled="state.saving || (editing && field.disabledOnEdit)"
:min="0"
:precision="field.precision || 0"
controls-position="right"
@@ -240,13 +337,13 @@
<enable-flag-segmented
v-else-if="field.kind === 'enableFlag'"
v-model="formModel[field.prop]"
:disabled="editing && field.disabledOnEdit"
:disabled="state.saving || (editing && field.disabledOnEdit)"
/>
<el-input
v-else-if="field.kind === 'json' || field.kind === 'textarea'"
v-model="formModel[field.prop]"
:autosize="{minRows: field.rows || 4, maxRows: 18}"
:disabled="editing && field.disabledOnEdit"
:disabled="state.saving || (editing && field.disabledOnEdit)"
:placeholder="field.placeholder"
resize="vertical"
type="textarea"
@@ -254,24 +351,42 @@
<el-color-picker
v-else-if="field.kind === 'color'"
v-model="formModel[field.prop]"
:disabled="editing && field.disabledOnEdit"
:disabled="state.saving || (editing && field.disabledOnEdit)"
show-alpha
/>
<el-tree-select
v-else-if="field.kind === 'treeSelect'"
v-model="formModel[field.prop]"
:check-strictly="field.tree?.checkStrictly"
:data="treeOptionsFor(field)"
:disabled="editing && field.disabledOnEdit"
:node-key="field.tree?.nodeKey || 'id'"
:props="field.tree?.props"
clearable
filterable
/>
<div v-else-if="field.kind === 'treeSelect'" class="entity-list-page__tree-field">
<el-tree-select
v-model="formModel[field.prop]"
:check-strictly="field.tree?.checkStrictly"
:data="treeOptionsFor(field)"
:disabled="state.saving || (editing && field.disabledOnEdit)"
:loading="treeLoading[field.prop]"
:node-key="field.tree?.nodeKey || 'id'"
:props="field.tree?.props"
clearable
filterable
/>
<el-alert
v-if="treeErrors[field.prop]"
:closable="false"
:title="t('common.optionLoadFailed')"
show-icon
type="error"
>
<el-button
:loading="treeLoading[field.prop]"
link
type="danger"
@click="loadTreeOptions(field)"
>
{{ t('common.retry') }}
</el-button>
</el-alert>
</div>
<el-input
v-else
v-model="formModel[field.prop]"
:disabled="editing && field.disabledOnEdit"
:disabled="state.saving || (editing && field.disabledOnEdit)"
:maxlength="field.maxlength"
:placeholder="field.placeholder"
:show-word-limit="!!field.maxlength"
@@ -283,8 +398,8 @@
</el-form>
<template #footer>
<div class="things-dialog-footer">
<el-button @click="formVisible = false">{{ t('common.cancel') }}</el-button>
<el-button plain @click="resetForm">{{ t('common.reset') }}</el-button>
<el-button :disabled="state.saving" @click="requestCloseForm()">{{ t('common.cancel') }}</el-button>
<el-button :disabled="state.saving" plain @click="resetForm">{{ t('common.reset') }}</el-button>
<el-button :loading="state.saving" type="primary" @click="submit">{{ t('common.confirm') }}</el-button>
</div>
</template>
@@ -293,7 +408,7 @@
</template>
<script lang="ts" setup>
import {computed, reactive, watch} from 'vue';
import {computed, onBeforeUnmount, reactive, watch} from 'vue';
import {Plus} from '@element-plus/icons-vue';
import BlankCard from '@/components/card/blank/BlankCard.vue';
@@ -302,10 +417,14 @@ import EnableFlagSegmented from '@/components/segmented/EnableFlagSegmented.vue'
import SearchSegmented from '@/components/segmented/SearchSegmented.vue';
import EnableTag from '@/components/tag/EnableTag.vue';
import {resolveIcon} from '@/config/constant/icons';
import type {EntityListConfig} from '@/config/types/entityList';
import type {EntityFieldConfig, EntityListConfig, EntityRowAction} from '@/config/types/entityList';
import {useEntityListPage} from '@/composables/useEntityListPage';
import {useBreakpoint} from '@/composables/useBreakpoint';
import EntityMobileCard from './EntityMobileCard.vue';
const props = defineProps<{ config: EntityListConfig }>();
const {isMobile, isTablet} = useBreakpoint();
const isCardLayout = computed(() => isMobile.value || isTablet.value);
const {
t,
@@ -329,6 +448,7 @@ const {
openAdd,
openEdit,
openDetail,
requestCloseForm,
resetForm,
submit,
remove,
@@ -336,30 +456,83 @@ const {
tagType,
canEdit,
canDelete,
isRemoving,
} = useEntityListPage(props.config);
type MobileRow = {row: Record<string, any>; depth: number};
const mobileRows = computed<MobileRow[]>(() => {
if (config.value.mode !== 'tree') return state.rows.map((row) => ({row, depth: 0}));
const flattened: MobileRow[] = [];
const visit = (rows: Record<string, any>[], depth: number) => {
rows.forEach((row) => {
flattened.push({row, depth});
if (Array.isArray(row.children) && row.children.length > 0) {
visit(row.children as Record<string, any>[], depth + 1);
}
});
};
visit(state.rows, 0);
return flattened;
});
const runningActions = reactive(new Set<string>());
// treeSelect: raw rows loaded once on dialog open; transform applied reactively via treeOptionsFor
const rawTreeData = reactive<Record<string, any[]>>({});
let treeLoadGen = 0;
const treeLoading = reactive<Record<string, boolean>>({});
const treeErrors = reactive<Record<string, boolean>>({});
let treeDialogSession = 0;
const treeRequestIds: Record<string, number> = {};
watch(formVisible, async (visible) => {
if (!visible) return;
const myGen = ++treeLoadGen;
const loadTreeOptions = async (field: EntityFieldConfig, session = treeDialogSession) => {
if (!field.tree) return;
const requestId = (treeRequestIds[field.prop] || 0) + 1;
treeRequestIds[field.prop] = requestId;
treeLoading[field.prop] = true;
treeErrors[field.prop] = false;
try {
const result = await field.tree.load();
if (session === treeDialogSession && requestId === treeRequestIds[field.prop] && formVisible.value) {
rawTreeData[field.prop] = result as any[];
}
} catch {
if (session === treeDialogSession && requestId === treeRequestIds[field.prop] && formVisible.value) {
treeErrors[field.prop] = true;
}
} finally {
if (session === treeDialogSession && requestId === treeRequestIds[field.prop]) {
treeLoading[field.prop] = false;
}
}
};
watch(formVisible, (visible) => {
const session = ++treeDialogSession;
if (!visible) {
Object.keys(rawTreeData).forEach((key) => delete rawTreeData[key]);
Object.keys(treeLoading).forEach((key) => (treeLoading[key] = false));
Object.keys(treeErrors).forEach((key) => delete treeErrors[key]);
return;
}
for (const field of config.value.fields) {
if (field.kind === 'treeSelect' && field.tree) {
const result = await field.tree.load();
if (myGen === treeLoadGen) {
rawTreeData[field.prop] = result as any[];
}
void loadTreeOptions(field, session);
}
}
});
onBeforeUnmount(() => {
treeDialogSession += 1;
Object.keys(treeRequestIds).forEach((key) => {
treeRequestIds[key] = (treeRequestIds[key] || 0) + 1;
});
runningActions.clear();
});
/** Returns tree options for a treeSelect field, applying transform with the live form model. */
const treeOptionsFor = (field: {
prop: string;
tree?: { transform?: (rows: any[], form: Record<string, any>) => unknown[] };
}) => {
const treeOptionsFor = (field: EntityFieldConfig) => {
const raw = rawTreeData[field.prop] || [];
return field.tree?.transform ? field.tree.transform(raw, formModel) : raw;
};
@@ -368,9 +541,37 @@ const treeOptionsFor = (field: {
const getCellValue = (row: Record<string, any>, prop: string): any =>
prop.split('.').reduce((obj: any, key) => (obj != null ? obj[key] : undefined), row);
const actionKey = (action: EntityRowAction, row: Record<string, any>) =>
`${action.key}:${String(getCellValue(row, config.value.rowKey || 'id'))}`;
const actionLoading = (action: EntityRowAction, row: Record<string, any>) =>
runningActions.has(actionKey(action, row)) || Boolean(action.loading?.(row));
const actionDisabled = (action: EntityRowAction, row: Record<string, any>) =>
isRemoving(String(row.id ?? '')) || actionLoading(action, row) || Boolean(action.disabled?.(row));
const runAction = async (action: EntityRowAction, row: Record<string, any>) => {
const key = actionKey(action, row);
if (runningActions.has(key) || action.disabled?.(row)) return;
runningActions.add(key);
try {
await action.onClick(row);
} catch {
// Request interceptors surface the operation error to the user.
} finally {
runningActions.delete(key);
}
};
// Resolve icon component once per cell to avoid double call in v-if + :is
const cellIcon = (row: Record<string, any>, prop: string) => resolveIcon(getCellValue(row, prop));
const removeRow = (row: Record<string, any>) => {
const rowId = getCellValue(row, config.value.rowKey || 'id');
if (rowId == null) return;
void remove(String(rowId));
};
// Operation column width based on number of visible action buttons
const operationWidth = computed(() => {
if (config.value.operationWidth) return config.value.operationWidth;
@@ -392,11 +593,59 @@ defineExpose({reload: load});
.entity-list-page {
min-width: 0;
&__error {
margin-bottom: var(--dc3-space-3);
:deep(.el-alert__content) {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--dc3-space-2);
}
}
&__table {
margin-top: 1px;
border-radius: 4px;
}
&__mobile-cards {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--dc3-space-3);
}
&__mobile-loading {
display: grid;
gap: var(--dc3-space-3);
}
&__mobile-skeleton {
display: grid;
gap: var(--dc3-space-2);
padding: var(--dc3-space-4);
border: 1px solid var(--dc3-border-base);
border-radius: var(--dc3-radius-lg);
background: var(--dc3-bg-elevated);
}
&__skeleton-line {
display: block;
width: 100%;
height: 12px;
border-radius: var(--dc3-radius-sm);
background: var(--el-fill-color-light);
}
&__skeleton-line--title {
width: 42%;
height: 16px;
}
&__skeleton-line--short {
width: 68%;
}
&__form {
:deep(.el-input),
:deep(.el-select),
@@ -407,6 +656,16 @@ defineExpose({reload: load});
}
}
&__form-alert {
margin-bottom: var(--dc3-space-4);
}
&__tree-field {
display: grid;
gap: var(--dc3-space-2);
width: 100%;
}
&__inline-code {
padding: 2px 5px;
border-radius: 4px;
@@ -0,0 +1,359 @@
<!--
- Copyright 2016-present the IoT DC3 original author or authors.
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<article
:aria-level="depth + 1"
:class="{'entity-mobile-card--nested': depth > 0}"
:style="{'--entity-depth': depth}"
class="entity-mobile-card"
>
<header class="entity-mobile-card__header">
<span v-if="depth > 0" aria-hidden="true" class="entity-mobile-card__branch">↳</span>
<el-button
v-if="canOpenDetail"
:aria-label="`${t('common.detail')}: ${primaryText}`"
class="entity-mobile-card__title-link"
link
type="primary"
@click="openDetail"
>
<span class="entity-mobile-card__title" :title="primaryText">{{ primaryText }}</span>
</el-button>
<div v-else class="entity-mobile-card__title" :title="primaryText">{{ primaryText }}</div>
<enable-tag
v-if="primaryColumn?.kind === 'enable'"
:value="cellPrimitive(primaryColumn.prop)"
/>
<el-tag
v-else-if="primaryColumn?.kind === 'tag'"
:type="tagTypeValue(primaryColumn.prop)"
>
{{ primaryText }}
</el-tag>
</header>
<dl class="entity-mobile-card__fields">
<div v-for="column in detailColumns" :key="column.prop" class="entity-mobile-card__field">
<dt>{{ column.label }}</dt>
<dd :title="formatCell(row, column)">
<enable-tag v-if="column.kind === 'enable'" :value="cellPrimitive(column.prop)" />
<el-tag v-else-if="column.kind === 'tag'" :type="tagTypeValue(column.prop)">
{{ formatCell(row, column) }}
</el-tag>
<el-button
v-else-if="isColumnLinkable(column)"
class="entity-mobile-card__value-link"
link
type="primary"
@click="column.onClick?.(row)"
>
{{ formatCell(row, column) }}
</el-button>
<span v-else-if="column.kind === 'color'" class="entity-mobile-card__color">
<span
:style="{background: colorValue(column)}"
class="entity-mobile-card__swatch"
/>
{{ formatCell(row, column) }}
</span>
<span v-else-if="column.kind === 'icon'" class="entity-mobile-card__icon">
<el-icon v-if="columnIcon(column)"><component :is="columnIcon(column)" /></el-icon>
{{ formatCell(row, column) }}
</span>
<code v-else-if="column.kind === 'code'" class="entity-mobile-card__code">
{{ formatCell(row, column) }}
</code>
<span v-else>{{ formatCell(row, column) }}</span>
</dd>
</div>
</dl>
<footer v-if="hasActions" class="entity-mobile-card__actions">
<template v-for="action in config.extraActions || []" :key="action.key">
<el-popconfirm
v-if="action.popconfirmTitle"
:disabled="actionDisabled(action)"
:cancel-button-text="t('common.cancel')"
:confirm-button-text="t('common.confirm')"
:title="action.popconfirmTitle"
@confirm="onAction(action)"
>
<template #reference>
<el-button
:disabled="actionDisabled(action)"
:loading="actionLoading(action)"
:type="action.type || 'primary'"
link
>{{ action.label }}</el-button>
</template>
</el-popconfirm>
<el-button
v-else
:disabled="actionDisabled(action)"
:loading="actionLoading(action)"
:type="action.type || 'primary'"
link
@click="onAction(action)"
>
{{ action.label }}
</el-button>
</template>
<el-button
v-if="config.editable && canEdit(row)"
:disabled="removing"
link
type="primary"
@click="emit('edit', row)"
>
{{ t('common.edit') }}
</el-button>
<el-popconfirm
v-if="config.editable && canDelete(row)"
:disabled="removing"
:cancel-button-text="t('common.cancel')"
:confirm-button-text="t('common.confirm')"
:title="config.confirmDeleteText || t('common.confirmDelete')"
@confirm="emit('delete', row)"
>
<template #reference>
<el-button :disabled="removing" :loading="removing" link type="danger">{{ t('common.delete') }}</el-button>
</template>
</el-popconfirm>
</footer>
</article>
</template>
<script lang="ts" setup>
import {computed} from 'vue';
import {useI18n} from 'vue-i18n';
import EnableTag from '@/components/tag/EnableTag.vue';
import {resolveIcon} from '@/config/constant/icons';
import type {EntityColumnConfig, EntityListConfig, EntityRowAction} from '@/config/types/entityList';
const props = defineProps<{
config: EntityListConfig;
row: Record<string, any>;
formatCell: (row: Record<string, any>, column: EntityColumnConfig) => string;
getCellValue: (row: Record<string, any>, prop: string) => unknown;
tagType: (value: unknown) => string;
canEdit: (row: Record<string, any>) => boolean;
canDelete: (row: Record<string, any>) => boolean;
onAction: (action: EntityRowAction, row: Record<string, any>) => void;
actionLoading: (action: EntityRowAction, row: Record<string, any>) => boolean;
actionDisabled: (action: EntityRowAction, row: Record<string, any>) => boolean;
removing?: boolean;
depth?: number;
}>();
const depth = computed(() => props.depth || 0);
const emit = defineEmits<{
(event: 'detail', row: Record<string, any>): void;
(event: 'edit', row: Record<string, any>): void;
(event: 'delete', row: Record<string, any>): void;
}>();
const {t} = useI18n();
const mobileColumns = computed(() => props.config.columns.filter((column) => column.mobile !== 'hidden'));
const primaryColumn = computed(
() => mobileColumns.value.find((column) => column.mobile === 'primary') || mobileColumns.value[0]
);
const detailColumns = computed(() => mobileColumns.value.filter((column) => column !== primaryColumn.value));
const primaryText = computed(() =>
primaryColumn.value ? props.formatCell(props.row, primaryColumn.value) : t('common.empty')
);
const canOpenDetail = computed(() => Boolean(props.config.detail));
const hasActions = computed(
() =>
Boolean(props.config.editable || (props.config.extraActions?.length ?? 0) > 0)
);
const actionLoading = (action: EntityRowAction) =>
Boolean(props.actionLoading(action, props.row) || action.loading?.(props.row));
const actionDisabled = (action: EntityRowAction) =>
Boolean(props.actionDisabled(action, props.row) || action.disabled?.(props.row) || actionLoading(action));
const onAction = (action: EntityRowAction) => props.onAction(action, props.row);
const openDetail = () => {
if (canOpenDetail.value) emit('detail', props.row);
};
const cellPrimitive = (prop: string): string | number | boolean | null | undefined => {
const value = props.getCellValue(props.row, prop);
return ['string', 'number', 'boolean'].includes(typeof value) || value == null
? (value as string | number | boolean | null | undefined)
: String(value);
};
const tagTypeValue = (prop: string): 'success' | 'primary' | 'warning' | 'info' | 'danger' => {
const value = props.tagType(props.getCellValue(props.row, prop));
return ['success', 'primary', 'warning', 'info', 'danger'].includes(value)
? (value as 'success' | 'primary' | 'warning' | 'info' | 'danger')
: 'info';
};
const isColumnLinkable = (column: EntityColumnConfig) =>
column.kind === 'link' && Boolean(column.onClick) && (column.linkable ? column.linkable(props.row) : true);
const columnIcon = (column: EntityColumnConfig) =>
column.kind === 'icon' ? resolveIcon(String(props.getCellValue(props.row, column.prop))) : undefined;
const colorValue = (column: EntityColumnConfig) =>
String(props.getCellValue(props.row, column.prop) || 'var(--el-fill-color-light)');
</script>
<style lang="scss" scoped>
.entity-mobile-card {
min-width: 0;
padding: var(--dc3-space-4);
padding-inline-start: calc(var(--dc3-space-4) + var(--entity-depth, 0) * 12px);
border: 1px solid var(--dc3-border-base);
border-radius: var(--dc3-radius-lg);
background: var(--dc3-bg-elevated);
box-shadow: var(--dc3-shadow-sm);
}
.entity-mobile-card__branch {
flex: 0 0 auto;
color: var(--el-color-primary);
font-size: 16px;
line-height: var(--dc3-touch-target);
}
.entity-mobile-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--dc3-space-3);
min-width: 0;
}
.entity-mobile-card__title {
min-width: 0;
width: 100%;
max-width: 100%;
overflow: hidden;
color: var(--dc3-text-primary);
font-size: 15px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.entity-mobile-card__title-link {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: var(--dc3-touch-target);
margin: calc(var(--dc3-space-2) * -1) 0;
padding: 0;
justify-content: flex-start;
overflow: hidden;
> :first-child {
width: 100%;
min-width: 0;
max-width: 100%;
}
.entity-mobile-card__title {
color: var(--el-color-primary);
}
}
.entity-mobile-card__fields {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--dc3-space-2);
margin: var(--dc3-space-3) 0 0;
}
.entity-mobile-card__field {
display: grid;
grid-template-columns: minmax(78px, 34%) minmax(0, 1fr);
gap: var(--dc3-space-3);
align-items: baseline;
min-width: 0;
}
.entity-mobile-card__field dt {
overflow: hidden;
color: var(--dc3-text-muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.entity-mobile-card__field dd {
min-width: 0;
margin: 0;
overflow: hidden;
color: var(--dc3-text-regular);
font-size: 13px;
overflow-wrap: anywhere;
word-break: break-word;
}
.entity-mobile-card__value-link {
max-width: 100%;
min-height: var(--dc3-touch-target);
padding: 0;
justify-content: flex-start;
text-align: left;
white-space: normal;
}
.entity-mobile-card__color,
.entity-mobile-card__icon {
display: inline-flex;
align-items: center;
gap: var(--dc3-space-1);
min-width: 0;
}
.entity-mobile-card__swatch {
width: 14px;
height: 14px;
flex: 0 0 14px;
border: 1px solid var(--dc3-border-base);
border-radius: 50%;
}
.entity-mobile-card__code {
color: inherit;
background: var(--el-fill-color-light);
overflow-wrap: anywhere;
white-space: normal;
}
.entity-mobile-card__actions {
display: flex;
flex-wrap: wrap;
gap: var(--dc3-space-2);
margin-top: var(--dc3-space-4);
padding-top: var(--dc3-space-3);
padding-inline-end: var(--dc3-floating-action-safe-space);
box-sizing: border-box;
border-top: 1px solid var(--dc3-border-base);
:deep(.el-button) {
min-height: var(--dc3-touch-target);
margin: 0;
}
}
</style>
+104 -54
View File
@@ -17,6 +17,7 @@
<template>
<div class="container">
<a class="skip-link" href="#main-content">{{ t('layout.skipToContent') }}</a>
<header class="header">
<div class="header_brand_glass">
<!-- Thumb terminals get a hamburger instead of the horizontal menu
@@ -31,51 +32,53 @@
/>
<brand-lockup :compact="isMobile" />
</div>
<div class="header_actions_glass">
<div v-if="!isMobile" class="header_menu_wrap">
<nav-menu :compact="isTablet" mode="horizontal" />
</div>
<span v-if="!isMobile" class="header_actions_divider" aria-hidden="true" />
<div class="header_utilities">
<app-preferences compact />
<el-tooltip v-if="settingsEntryName" :content="t('layout.settings')" placement="bottom">
<el-button
:aria-label="t('layout.settings')"
:icon="Setting"
circle
class="header_settings_button"
text
@click="handleCommand('settings')"
/>
</el-tooltip>
</div>
<span class="header_actions_divider" aria-hidden="true" />
<div class="header_user">
<el-dropdown
popper-class="user-dropdown-popper"
trigger="click"
:popper-options="{
modifiers: [{ name: 'preventOverflow', options: { padding: 8 } }],
}"
@command="handleCommand"
>
<button :aria-label="t('layout.account')" class="user_trigger" type="button">
<img :src="assetUrl('images/common/avatar.png')" alt="" class="user_avatar" />
</button>
<template #dropdown>
<el-dropdown-menu>
<li class="user_dropdown_identity" role="none">
<span class="user_dropdown_name">{{ currentLogin }}</span>
</li>
<el-dropdown-item :icon="QuestionFilled" command="help">{{
t("layout.about")
}}</el-dropdown-item>
<el-dropdown-item :icon="SwitchButton" command="logout" divided>{{
t("layout.logout")
}}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<div class="header_actions_row">
<div class="header_actions_glass">
<div v-if="!isMobile" class="header_menu_wrap">
<nav-menu :compact="isTablet" mode="horizontal" />
</div>
<span v-if="!isMobile" class="header_actions_divider" aria-hidden="true" />
<div class="header_utilities">
<app-preferences compact />
<el-tooltip v-if="settingsEntryName" :content="t('layout.settings')" placement="bottom">
<el-button
:aria-label="t('layout.settings')"
:icon="Setting"
circle
class="header_settings_button"
text
@click="handleCommand('settings')"
/>
</el-tooltip>
</div>
<span class="header_actions_divider" aria-hidden="true" />
<div class="header_user">
<el-dropdown
popper-class="user-dropdown-popper"
trigger="click"
:popper-options="{
modifiers: [{ name: 'preventOverflow', options: { padding: 8 } }],
}"
@command="handleCommand"
>
<button :aria-label="t('layout.account')" class="user_trigger" type="button">
<img :src="assetUrl('images/common/avatar.png')" alt="" class="user_avatar" />
</button>
<template #dropdown>
<el-dropdown-menu>
<li class="user_dropdown_identity" role="none">
<span class="user_dropdown_name">{{ currentLogin }}</span>
</li>
<el-dropdown-item :icon="QuestionFilled" command="help">{{
t("layout.about")
}}</el-dropdown-item>
<el-dropdown-item :icon="SwitchButton" command="logout" divided>{{
t("layout.logout")
}}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
</header>
@@ -91,7 +94,7 @@
<nav-menu mode="vertical" />
</el-drawer>
<div class="body">
<div class="body-main">
<div id="main-content" class="body-main" tabindex="-1">
<div v-if="breadcrumbItems.length > 1" class="breadcrumb">
<el-breadcrumb separator="/">
<el-breadcrumb-item
@@ -450,6 +453,26 @@ const handleCommand = async (command: string) => {
<style lang="scss" scoped>
@use '@/styles/glass-capsule.scss' as *;
.skip-link {
position: fixed;
z-index: 1000;
top: var(--dc3-space-2);
left: var(--dc3-space-2);
padding: var(--dc3-space-2) var(--dc3-space-3);
border-radius: var(--dc3-radius-md);
background: var(--el-color-primary);
color: var(--el-color-white);
box-shadow: var(--dc3-shadow-md);
transform: translateY(-200%);
transition: transform var(--dc3-duration-fast) var(--dc3-ease-standard);
&:focus {
transform: translateY(0);
outline: none;
box-shadow: var(--dc3-focus-ring), var(--dc3-shadow-md);
}
}
.container {
color: var(--dc3-text-primary);
-moz-osx-font-smoothing: grayscale;
@@ -475,6 +498,12 @@ const handleCommand = async (command: string) => {
-webkit-backdrop-filter: blur(16px) saturate(1.2);
.header_brand_glass,
.header_actions_row {
flex: 0 1 auto;
min-width: 0;
max-width: calc(100% - 228px);
}
.header_actions_glass {
@include glass-capsule(
$extra-bg: color-mix(in srgb, var(--dc3-bg-elevated) 82%, transparent)
@@ -497,9 +526,7 @@ const handleCommand = async (command: string) => {
}
.header_actions_glass {
flex: 0 1 auto;
min-width: 0;
max-width: calc(100% - 228px);
padding: 3px 5px 3px 7px;
}
@@ -589,7 +616,7 @@ const handleCommand = async (command: string) => {
// Tablet keeps navigation visible as accessible icon buttons. Language,
// settings, and account remain first-class actions in the same capsule.
@media (max-width: $breakpoint-sm-max) {
.header_actions_glass {
.header_actions_row {
max-width: calc(100% - 218px);
}
}
@@ -597,23 +624,42 @@ const handleCommand = async (command: string) => {
// Phone keeps exactly two capsules: menu + brand on the left, user on
// the right. The drawer remains the navigation surface.
@media (max-width: $breakpoint-xs-max) {
gap: var(--dc3-space-2);
padding-right: var(--dc3-space-2);
padding-left: var(--dc3-space-2);
flex-wrap: wrap;
align-content: flex-start;
height: 120px;
gap: 4px 8px;
padding: 8px var(--dc3-space-2);
.header_brand_glass {
min-width: 0;
padding-right: var(--dc3-space-3);
height: 44px;
padding: 0 var(--dc3-space-3) 0 4px;
}
.header_menu_toggle {
display: inline-flex;
}
.header_actions_row {
order: 2;
flex: 0 0 100%;
width: 100%;
max-width: none;
display: flex;
justify-content: center;
}
.header_actions_glass {
flex: 0 0 auto;
height: 48px;
box-sizing: border-box;
max-width: none;
padding: 3px 5px;
padding: 2px 5px;
}
.header_brand_glass {
order: 1;
margin-right: auto;
}
.header_settings_button {
@@ -623,6 +669,10 @@ const handleCommand = async (command: string) => {
}
}
@media (max-width: $breakpoint-xs-max) {
--dc3-header-height: 120px;
}
// Mobile navigation drawer content.
.nav-drawer {
:deep(.el-drawer__body) {
@@ -0,0 +1,546 @@
<!--
- Copyright 2016-present the IoT DC3 original author or authors.
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<div class="responsive-record-list">
<el-alert
v-if="status === 'error'"
:closable="false"
:title="errorText || t('common.loadFailed')"
class="responsive-record-list__error"
show-icon
type="error"
>
<el-button :loading="loading" link type="danger" @click="emit('retry')">
{{ t('common.retry') }}
</el-button>
</el-alert>
<component
:is="embedded ? 'div' : BlankCard"
:class="{'responsive-record-list__embedded': embedded}"
>
<template v-if="isCardLayout">
<div
v-if="loading && rows.length === 0"
:aria-label="t('common.loading')"
class="responsive-record-list__skeletons"
role="status"
>
<article v-for="item in 3" :key="item" aria-hidden="true" class="responsive-record-list__skeleton">
<span class="responsive-record-list__skeleton-line responsive-record-list__skeleton-line--title"/>
<span class="responsive-record-list__skeleton-line"/>
<span class="responsive-record-list__skeleton-line responsive-record-list__skeleton-line--short"/>
</article>
</div>
<el-empty
v-else-if="status === 'error' && rows.length === 0"
:description="errorText || t('common.loadFailed')"
/>
<el-empty v-else-if="!loading && rows.length === 0" :description="emptyText || t('common.empty')"/>
<div v-else :aria-busy="loading" class="responsive-record-list__cards">
<article
v-for="row in rows"
:key="rowKeyValue(row)"
:class="{'responsive-record-list__card--selected': isSelected(row)}"
class="responsive-record-list__card"
>
<header v-if="primaryColumn" class="responsive-record-list__card-header">
<el-checkbox
v-if="selectable"
:aria-label="`${selectionLabel || t('common.select')}: ${formattedValue(row, primaryColumn)}`"
:disabled="selectionDisabled?.(row)"
:model-value="isSelected(row)"
class="responsive-record-list__selection"
@change="toggleSelection(row)"
/>
<el-button
v-if="openable"
:aria-label="`${t('common.detail')}: ${formattedValue(row, primaryColumn)}`"
class="responsive-record-list__title-button"
link
type="primary"
@click="emit('open', row)"
>
<span class="responsive-record-list__title">
<slot
:name="`cell-${primaryColumn.key}`"
:column="primaryColumn"
:mobile="true"
:row="row"
:value="cellValue(row, primaryColumn)"
>
<slot
name="cell"
:column="primaryColumn"
:mobile="true"
:row="row"
:value="cellValue(row, primaryColumn)"
>
<responsive-cell :column="primaryColumn" :row="row"/>
</slot>
</slot>
</span>
</el-button>
<span v-else class="responsive-record-list__title">
<slot
:name="`cell-${primaryColumn.key}`"
:column="primaryColumn"
:mobile="true"
:row="row"
:value="cellValue(row, primaryColumn)"
>
<slot
name="cell"
:column="primaryColumn"
:mobile="true"
:row="row"
:value="cellValue(row, primaryColumn)"
>
<responsive-cell :column="primaryColumn" :row="row"/>
</slot>
</slot>
</span>
</header>
<dl v-if="primaryColumn" class="responsive-record-list__details">
<div v-for="column in detailColumns" :key="column.key" class="responsive-record-list__detail">
<dt>{{ column.label }}</dt>
<dd
:class="{'responsive-record-list__detail--code': column.kind === 'code'}"
:title="formattedValue(row, column)"
>
<slot
:name="`cell-${column.key}`"
:column="column"
:mobile="true"
:row="row"
:value="cellValue(row, column)"
>
<slot
name="cell"
:column="column"
:mobile="true"
:row="row"
:value="cellValue(row, column)"
>
<responsive-cell :column="column" :row="row"/>
</slot>
</slot>
</dd>
</div>
</dl>
<footer v-if="hasActions" class="responsive-record-list__actions">
<slot name="actions" :mobile="true" :row="row"/>
</footer>
</article>
</div>
</template>
<el-table
v-else
ref="tableRef"
v-loading="loading"
:aria-busy="loading"
:data="rows"
:row-key="rowKeyForTable"
class="settings-table"
stripe
@selection-change="handleTableSelectionChange"
>
<el-table-column
v-if="selectable"
:selectable="selectionDisabled ? (row: T) => !selectionDisabled!(row) : undefined"
reserve-selection
type="selection"
width="44"
/>
<el-table-column
v-for="column in columns"
:key="column.key"
:fixed="column.fixed"
:label="column.label"
:min-width="column.minWidth"
:show-overflow-tooltip="column.overflow !== false"
:width="column.width"
>
<template #default="{row}">
<slot
:name="`cell-${column.key}`"
:column="column"
:mobile="false"
:row="row"
:value="cellValue(row, column)"
>
<slot
name="cell"
:column="column"
:mobile="false"
:row="row"
:value="cellValue(row, column)"
>
<responsive-cell :column="column" :row="row"/>
</slot>
</slot>
</template>
</el-table-column>
<el-table-column
v-if="hasActions"
:label="t('common.operation')"
:width="operationWidth"
fixed="right"
>
<template #default="{row}">
<slot name="actions" :mobile="false" :row="row"/>
</template>
</el-table-column>
<template #empty>
<el-empty
:description="status === 'error' ? errorText || t('common.loadFailed') : emptyText || t('common.empty')"
/>
</template>
</el-table>
</component>
</div>
</template>
<script lang="ts" setup generic="T extends Record<string, any>">
import {computed, defineComponent, h, nextTick, type PropType, ref, watch} from 'vue';
import {useI18n} from 'vue-i18n';
import {ElCheckbox, ElTag, type TableInstance} from 'element-plus';
import BlankCard from '@/components/card/blank/BlankCard.vue';
import DefaultTag from '@/components/tag/DefaultTag.vue';
import EnableTag from '@/components/tag/EnableTag.vue';
import {useBreakpoint} from '@/composables/useBreakpoint';
import type {ResponsiveListColumn} from '@/config/types';
import {timestampLabel} from '@/utils/dateUtil';
const valueAt = <T extends Record<string, any>>(row: T, prop: string) =>
prop.split('.').reduce((value: any, key) => (value == null ? undefined : value[key]), row);
const formatValue = <T extends Record<string, any>>(row: T, column?: ResponsiveListColumn<T>): string => {
if (!column) return '-';
if (column.formatter) return column.formatter(row);
const value = valueAt(row, column.prop || column.key);
if (column.kind === 'time') return timestampLabel(value);
if (value === undefined || value === null || value === '') return '-';
return String(value);
};
const ResponsiveCell = defineComponent({
name: 'ResponsiveCell',
props: {
column: {type: Object as PropType<ResponsiveListColumn<any>>, required: true},
row: {type: Object as PropType<Record<string, any>>, required: true},
},
setup(cellProps) {
return () => {
const value = valueAt(cellProps.row, cellProps.column.prop || cellProps.column.key);
const text = formatValue(cellProps.row, cellProps.column);
if (cellProps.column.kind === 'enable') return h(EnableTag, {value});
if (cellProps.column.kind === 'default') return h(DefaultTag, {value});
if (cellProps.column.kind === 'tag') {
return h(ElTag, {type: cellProps.column.tagType?.(cellProps.row) || 'info'}, () => text);
}
if (cellProps.column.kind === 'code') return h('code', {class: 'responsive-record-list__code'}, text);
return h('span', text);
};
},
});
const props = withDefaults(
defineProps<{
rows: T[];
columns: ResponsiveListColumn<T>[];
loading?: boolean;
status?: 'idle' | 'loading' | 'success' | 'error';
rowKey?: string | ((row: T) => string | number);
emptyText?: string;
errorText?: string;
openable?: boolean;
operationWidth?: number | string;
embedded?: boolean;
selectable?: boolean;
selectedRows?: T[];
selectionDisabled?: (row: T) => boolean;
selectionLabel?: string;
}>(),
{
loading: false,
status: 'idle',
rowKey: 'id',
emptyText: '',
errorText: '',
openable: false,
operationWidth: 180,
embedded: false,
selectable: false,
selectedRows: () => [],
selectionDisabled: undefined,
selectionLabel: '',
}
);
const emit = defineEmits<{
(event: 'retry'): void;
(event: 'open', row: T): void;
(event: 'selection-change', rows: T[]): void;
}>();
type SlotProps = {
column: ResponsiveListColumn<T>;
mobile: boolean;
row: T;
value: unknown;
};
type ResponsiveRecordListSlots = {
cell?: (props: SlotProps) => unknown;
actions?: (props: {mobile: boolean; row: T}) => unknown;
} & {
[slotName: `cell-${string}`]: ((props: SlotProps) => unknown) | undefined;
};
const slots = defineSlots<ResponsiveRecordListSlots>();
const {t} = useI18n();
const {isMobile, isTablet} = useBreakpoint();
const isCardLayout = computed(() => isMobile.value || isTablet.value);
const tableRef = ref<TableInstance>();
let syncingTableSelection = false;
const mobileColumns = computed(() => props.columns.filter((column) => column.mobile !== 'hidden'));
const primaryColumn = computed(
() => mobileColumns.value.find((column) => column.mobile === 'primary') || mobileColumns.value[0]
);
const detailColumns = computed(() => mobileColumns.value.filter((column) => column !== primaryColumn.value));
const hasActions = computed(() => Boolean(slots.actions));
const cellValue = (row: T, column?: ResponsiveListColumn<T>) =>
column ? valueAt(row, column.prop || column.key) : undefined;
const formattedValue = (row: T, column?: ResponsiveListColumn<T>) => formatValue(row, column);
const rowKeyValue = (row: T) =>
typeof props.rowKey === 'function' ? props.rowKey(row) : String(valueAt(row, props.rowKey));
const rowKeyForTable = (row: T) => String(rowKeyValue(row));
const isSelected = (row: T) =>
props.selectable && props.selectedRows.some((selectedRow) => rowKeyValue(selectedRow) === rowKeyValue(row));
const toggleSelection = (row: T) => {
if (props.selectionDisabled?.(row)) return;
const nextRows = isSelected(row)
? props.selectedRows.filter((selectedRow) => rowKeyValue(selectedRow) !== rowKeyValue(row))
: [...props.selectedRows, row];
emit('selection-change', nextRows);
};
const handleTableSelectionChange = (rows: T[]) => {
if (!syncingTableSelection) emit('selection-change', rows);
};
watch(
() => [props.rows, props.selectedRows] as const,
async () => {
if (!props.selectable || isCardLayout.value) return;
await nextTick();
const table = tableRef.value;
if (!table) return;
syncingTableSelection = true;
table.clearSelection();
const selectedKeys = new Set(props.selectedRows.map(rowKeyValue));
props.rows.forEach((row) => {
if (selectedKeys.has(rowKeyValue(row))) table.toggleRowSelection(row, true);
});
syncingTableSelection = false;
},
{deep: true}
);
</script>
<style lang="scss" scoped>
.responsive-record-list {
min-width: 0;
}
.responsive-record-list__embedded {
box-shadow: none;
background: transparent;
}
.responsive-record-list__error {
margin-bottom: var(--dc3-space-3);
:deep(.el-alert__content) {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--dc3-space-2);
}
}
.responsive-record-list__cards,
.responsive-record-list__skeletons {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--dc3-space-3);
}
.responsive-record-list__card,
.responsive-record-list__skeleton {
min-width: 0;
padding: var(--dc3-space-4);
border: 1px solid var(--dc3-border-base);
border-radius: var(--dc3-radius-lg);
background: var(--dc3-bg-elevated);
box-shadow: var(--dc3-shadow-sm);
}
.responsive-record-list__card--selected {
border-color: var(--el-color-primary-light-5);
background: var(--dc3-bg-interactive);
}
.responsive-record-list__card-header {
display: flex;
align-items: flex-start;
gap: var(--dc3-space-2);
min-width: 0;
}
.responsive-record-list__selection {
min-width: var(--dc3-touch-target);
min-height: var(--dc3-touch-target);
margin: calc(var(--dc3-space-2) * -1) 0;
justify-content: center;
flex: 0 0 var(--dc3-touch-target);
}
.responsive-record-list__title-button {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: var(--dc3-touch-target);
margin: calc(var(--dc3-space-2) * -1) 0;
padding: 0;
justify-content: flex-start;
overflow: hidden;
> :first-child {
width: 100%;
min-width: 0;
max-width: 100%;
}
}
.responsive-record-list__title {
display: block;
min-width: 0;
width: 100%;
max-width: 100%;
overflow: hidden;
color: var(--dc3-text-primary);
font-size: var(--el-font-size-base);
font-weight: var(--el-font-weight-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.responsive-record-list__title-button .responsive-record-list__title {
color: var(--el-color-primary);
}
.responsive-record-list__details {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--dc3-space-2);
margin: var(--dc3-space-3) 0 0;
}
.responsive-record-list__detail {
display: grid;
grid-template-columns: minmax(var(--dc3-list-label-width), 34%) minmax(0, 1fr);
align-items: baseline;
gap: var(--dc3-space-3);
min-width: 0;
}
.responsive-record-list__detail dt,
.responsive-record-list__detail dd {
min-width: 0;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.responsive-record-list__detail dt {
color: var(--dc3-text-muted);
font-size: var(--el-font-size-extra-small);
}
.responsive-record-list__detail dd {
color: var(--dc3-text-regular);
font-size: var(--el-font-size-small);
overflow-wrap: anywhere;
white-space: normal;
}
.responsive-record-list__detail--code {
font-family: var(--el-font-family);
}
.responsive-record-list__actions {
display: flex;
flex-wrap: wrap;
gap: var(--dc3-space-2);
margin-top: var(--dc3-space-4);
padding-top: var(--dc3-space-3);
padding-inline-end: var(--dc3-floating-action-safe-space);
box-sizing: border-box;
border-top: 1px solid var(--dc3-border-base);
:deep(.el-button) {
min-height: var(--dc3-touch-target);
margin: 0;
}
}
.responsive-record-list__skeleton {
display: grid;
gap: var(--dc3-space-2);
}
.responsive-record-list__skeleton-line {
display: block;
width: 100%;
height: var(--dc3-skeleton-line-height);
border-radius: var(--dc3-radius-sm);
background: var(--el-fill-color-light);
}
.responsive-record-list__skeleton-line--title {
width: 42%;
height: var(--dc3-skeleton-title-height);
}
.responsive-record-list__skeleton-line--short {
width: 68%;
}
:deep(.responsive-record-list__code) {
color: inherit;
background: var(--el-fill-color-light);
}
</style>
@@ -79,7 +79,8 @@ function rebuildLogoParticles() {
const particles: LogoParticle[] = [];
const spacing = 15;
// 用同一张规则点阵填充 Logo 的 54 个圆形区域,形成干净、连续的粒子剪影。
// Fill the 54 circular logo regions with one regular grid for a clean,
// continuous particle silhouette.
for (let y = -520; y <= 520; y += spacing) {
for (let x = -560; x <= 540; x += spacing) {
const insideLogo = LOGO_POINTS.some((point) => {
@@ -323,7 +324,7 @@ function draw(now: number) {
context.fillStyle = glow;
context.fillRect(signalX - 32, signalY - 32, 64, 64);
// 最后绘制,避免环境连线和流光穿过 Logo,保持粒子剪影干净完整。
// Draw last so ambient lines and the signal glow never cross the logo.
drawParticleLogo(time);
if (!reducedMotion && inViewport && !document.hidden) animationFrame = requestAnimationFrame(draw);
@@ -20,6 +20,7 @@
:model-value="modelValue"
:options="options"
:size="size"
:disabled="disabled"
class="enable-flag-segmented"
@update:model-value="onChange"
/>
@@ -50,6 +51,10 @@ const props = defineProps({
type: String as PropType<'' | 'default' | 'small' | 'large'>,
default: 'default',
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits<{ (e: 'update:modelValue', value: string | number): void }>();
@@ -89,9 +94,20 @@ const onChange = (value: string | number | boolean) => {
<style lang="scss" scoped>
.enable-flag-segmented {
width: 100%;
min-width: 0;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
:deep(.el-segmented__item) {
flex: 1 1 0;
min-width: max-content;
}
:deep(.el-segmented__group) {
width: max-content;
min-width: 100%;
}
}
</style>
@@ -16,7 +16,9 @@
-->
<template>
<el-segmented :model-value="modelValue" :options="options" :size="size" @update:model-value="onChange"/>
<div class="matrix-status-segmented">
<el-segmented :model-value="modelValue" :options="options" :size="size" @update:model-value="onChange"/>
</div>
</template>
<script lang="ts" setup>
@@ -53,3 +55,39 @@ const onChange = (value: string | number | boolean) => {
emit('update:modelValue', value as MatrixStatus);
};
</script>
<style lang="scss" scoped>
.matrix-status-segmented {
min-width: 0;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
:deep(.el-segmented) {
display: flex;
width: max-content;
min-width: 100%;
}
:deep(.el-segmented__group) {
width: max-content;
min-width: 100%;
}
:deep(.el-segmented__item) {
flex: 0 0 auto;
min-width: max-content;
}
}
@media (max-width: $breakpoint-xs-max) {
.matrix-status-segmented {
scrollbar-width: none;
}
.matrix-status-segmented::-webkit-scrollbar {
display: none;
}
}
</style>
@@ -16,7 +16,24 @@
-->
<template>
<el-segmented :model-value="modelValue" :options="options" :size="size" @update:model-value="onChange"/>
<div :class="['range-segmented', {'range-segmented--select': select}]">
<el-select
v-if="select"
:model-value="modelValue"
class="range-segmented__select"
@update:model-value="onChange"
>
<el-option v-for="option in options" :key="option.value || 'all'" :label="option.label" :value="option.value" />
</el-select>
<el-segmented
v-else
:model-value="modelValue"
:options="options"
:size="size"
class="range-segmented__control"
@update:model-value="onChange"
/>
</div>
</template>
<script lang="ts" setup>
@@ -50,6 +67,11 @@ const props = defineProps({
type: String as PropType<'' | 'default' | 'small' | 'large'>,
default: 'default',
},
/** Use a native select when the range is part of a dense filter toolbar. */
select: {
type: Boolean,
default: false,
},
});
const emit = defineEmits<{ (e: 'update:modelValue', value: RangeKey): void }>();
@@ -73,3 +95,51 @@ const onChange = (value: string | number | boolean) => {
emit('update:modelValue', value as RangeKey);
};
</script>
<style lang="scss" scoped>
.range-segmented {
display: block;
min-width: 0;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
.range-segmented--select {
width: 100%;
flex: 1 1 140px;
}
.range-segmented__select {
width: 100%;
min-width: 120px;
}
.range-segmented__control {
display: flex;
width: max-content;
min-width: 100%;
white-space: nowrap;
:deep(.el-segmented__group) {
width: max-content;
min-width: 100%;
}
:deep(.el-segmented__item) {
flex: 0 0 auto;
min-width: max-content;
}
}
@media (max-width: $breakpoint-xs-max) {
.range-segmented {
scrollbar-width: none;
}
.range-segmented::-webkit-scrollbar {
display: none;
}
}
</style>
@@ -56,9 +56,20 @@ const onChange = (value: string | number | boolean) => {
<style lang="scss" scoped>
.search-segmented {
width: 100%;
min-width: 0;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
:deep(.el-segmented__item) {
flex: 1 1 0;
min-width: max-content;
}
:deep(.el-segmented__group) {
width: max-content;
min-width: 100%;
}
}
</style>
+163 -56
View File
@@ -15,8 +15,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ElMessageBox} from 'element-plus';
import type {FormInstance, FormItemRule, FormRules} from 'element-plus';
import {computed, reactive, ref} from 'vue';
import {computed, getCurrentInstance, onUnmounted, reactive, ref} from 'vue';
import {useI18n} from 'vue-i18n';
import {useRouter} from 'vue-router';
@@ -48,6 +49,9 @@ export const useEntityListPage = (rawConfig: EntityListConfig) => {
formRef.value = (instance || undefined) as FormInstance | undefined;
};
const formModel = reactive<Record<string, any>>({});
const initialFormModel = ref('');
let formSessionId = 0;
let latestSaveId = 0;
const defaultSearchForm = (): Record<string, any> => {
const form: Record<string, any> = {};
@@ -64,6 +68,10 @@ export const useEntityListPage = (rawConfig: EntityListConfig) => {
const state = reactive({
loading: false,
saving: false,
saveError: null as unknown | null,
error: null as unknown | null,
status: 'idle' as 'idle' | 'loading' | 'success' | 'error',
lastUpdated: null as number | null,
rows: [] as Record<string, any>[],
page: {
total: 0,
@@ -75,6 +83,8 @@ export const useEntityListPage = (rawConfig: EntityListConfig) => {
});
let latestLoadId = 0;
const cursorStack: Array<string | undefined> = [undefined];
const removingIds = reactive(new Set<string>());
let disposed = false;
const dialogTitle = computed(() => {
const entity = config.value.title || config.value.name;
@@ -156,29 +166,33 @@ export const useEntityListPage = (rawConfig: EntityListConfig) => {
return out;
};
const loadRelations = async (rows: Record<string, any>[]): Promise<void> => {
if (!config.value.relations || config.value.relations.length === 0) return;
await Promise.all(
config.value.relations.map((r) =>
r.load(rows).then((result) => {
relations[r.key] = result;
})
)
const loadRelations = async (
rows: Record<string, any>[]
): Promise<Array<{key: string; value: Record<string, string>}>> => {
if (!config.value.relations || config.value.relations.length === 0) return [];
return Promise.all(
config.value.relations.map(async (relation) => ({
key: relation.key,
value: await relation.load(rows),
}))
);
};
const load = () => {
if (disposed) return Promise.resolve();
const loadId = ++latestLoadId;
state.loading = true;
config.value
state.status = 'loading';
state.error = null;
return config.value
.list(query())
.then((data: PageResult<Record<string, any>> | CursorPageResult<Record<string, any>> | Record<string, any>[]) => {
.then(async (data: PageResult<Record<string, any>> | CursorPageResult<Record<string, any>> | Record<string, any>[]) => {
if (loadId !== latestLoadId) return;
if (config.value.mode === 'tree') {
state.rows = (data as unknown as Record<string, any>[]) || [];
} else {
const page = data as PageResult<Record<string, any>>;
state.rows = page.items;
state.rows = page.items || [];
if (config.value.pagination === 'cursor') {
const cursorPage = data as CursorPageResult<Record<string, any>>;
state.page.hasNext = cursorPage.hasNext;
@@ -188,14 +202,26 @@ export const useEntityListPage = (rawConfig: EntityListConfig) => {
state.page.hasNext = page.hasNext;
}
}
// Relations resolve after rows arrive so loaders can act on them.
return loadRelations(config.value.mode === 'tree' ? flattenRows(state.rows) : state.rows);
const relationValues = await loadRelations(
config.value.mode === 'tree' ? flattenRows(state.rows) : state.rows
);
if (loadId !== latestLoadId) return;
relationValues.forEach(({key, value}) => {
relations[key] = value;
});
state.status = 'success';
state.lastUpdated = Date.now();
})
.catch(() => {
// handled globally
.catch((error: unknown) => {
if (loadId !== latestLoadId) return;
state.error = error;
state.status = 'error';
})
.finally(() => {
if (loadId === latestLoadId) state.loading = false;
if (loadId === latestLoadId) {
state.loading = false;
if (state.status === 'loading') state.status = 'success';
}
});
};
@@ -260,32 +286,79 @@ export const useEntityListPage = (rawConfig: EntityListConfig) => {
});
};
const captureInitialForm = () => {
initialFormModel.value = JSON.stringify(formModel);
state.saveError = null;
};
const formDirty = computed(() => formVisible.value && JSON.stringify(formModel) !== initialFormModel.value);
const beginFormSession = () => {
formSessionId += 1;
latestSaveId += 1;
state.saving = false;
state.saveError = null;
};
const openAdd = () => {
beginFormSession();
editing.value = false;
assignForm(config.value.defaultForm());
captureInitialForm();
formVisible.value = true;
};
const resetForm = () => {
if (!editing.value) {
assignForm(config.value.defaultForm());
}
assignForm(JSON.parse(initialFormModel.value || '{}') as Record<string, unknown>);
state.saveError = null;
formRef.value?.clearValidate();
};
const openEdit = (row: Record<string, any>) => {
beginFormSession();
editing.value = true;
const value = config.value.defaultForm();
value.id = row.id;
if (row.version) value.version = row.version;
if (row.version !== undefined && row.version !== null) value.version = row.version;
config.value.fields.forEach((field) => {
value[field.prop] = row[field.prop] ?? value[field.prop];
});
Object.assign(value, config.value.fromRow?.(row) || {});
assignForm(value);
captureInitialForm();
formVisible.value = true;
};
const finishCloseForm = (done?: () => void) => {
formSessionId += 1;
state.saveError = null;
if (done) {
done();
return;
}
formVisible.value = false;
};
const requestCloseForm = async (done?: () => void) => {
if (state.saving) return;
const sessionId = formSessionId;
if (!formDirty.value) {
finishCloseForm(done);
return;
}
try {
await ElMessageBox.confirm(t('common.discardConfirm'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning',
});
if (disposed || sessionId !== formSessionId || !formVisible.value) return;
finishCloseForm(done);
} catch {
// Keep the draft open when the user cancels the confirmation.
}
};
const openDetail = (row: Record<string, any>) => {
if (!config.value.detail) return;
router.push({name: config.value.detail.routeName, query: {id: String(row.id)}}).catch(() => {
@@ -297,7 +370,7 @@ export const useEntityListPage = (rawConfig: EntityListConfig) => {
if (config.value.toPayload) return config.value.toPayload(formModel);
const result: Record<string, unknown> = {};
if (formModel.id) result.id = formModel.id;
if (formModel.version) result.version = formModel.version;
if (formModel.version !== undefined && formModel.version !== null) result.version = formModel.version;
config.value.fields.forEach((field) => {
const value = formModel[field.prop];
if (field.kind === 'json') {
@@ -311,50 +384,71 @@ export const useEntityListPage = (rawConfig: EntityListConfig) => {
return result;
};
const submit = () => {
const submit = async () => {
if (state.saving) return;
const addRequest = config.value.add;
const updateRequest = config.value.update;
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>;
try {
data = payload();
} catch {
formRef.value?.validate().catch(() => undefined);
return;
const sessionId = formSessionId;
state.saving = true;
state.saveError = null;
const valid = await formRef.value?.validate().catch(() => false);
if (disposed || sessionId !== formSessionId || !formVisible.value) {
if (sessionId === formSessionId) state.saving = false;
return;
}
if (!valid) {
state.saving = false;
return;
}
let data: Record<string, unknown>;
try {
data = payload();
} catch {
await formRef.value?.validate().catch(() => undefined);
if (sessionId === formSessionId) state.saving = false;
return;
}
const saveId = ++latestSaveId;
try {
await (editing.value ? updateRequest(data) : addRequest(data));
if (saveId !== latestSaveId || sessionId !== formSessionId) return;
successMessage();
captureInitialForm();
state.saving = false;
finishCloseForm();
await load();
} catch (error) {
if (saveId === latestSaveId && sessionId === formSessionId) {
state.saveError = error;
}
state.saving = true;
const request = editing.value ? updateRequest(data) : addRequest(data);
request
.then(() => {
successMessage();
formVisible.value = false;
load();
})
.catch(() => {
// handled globally
})
.finally(() => {
state.saving = false;
});
});
} finally {
if (saveId === latestSaveId && sessionId === formSessionId) {
state.saving = false;
}
}
};
const remove = (id: string) => {
const isRemoving = (id: string) => removingIds.has(String(id));
const remove = async (id: string) => {
const removeRequest = config.value.remove;
if (!removeRequest) return;
removeRequest(id)
.then(() => {
successMessage();
load();
})
.catch(() => {
// handled globally
});
const key = String(id);
if (!removeRequest || removingIds.has(key)) return;
removingIds.add(key);
try {
await removeRequest(id);
if (disposed) return;
successMessage();
await load();
} catch {
// handled globally
} finally {
removingIds.delete(key);
}
};
const optionLabel = (options: EntityOption[] | undefined, value: unknown) => {
@@ -381,6 +475,16 @@ export const useEntityListPage = (rawConfig: EntityListConfig) => {
const canDelete = (row: Record<string, any>) => (config.value.rowDeletable ? config.value.rowDeletable(row) : true);
if (getCurrentInstance()) {
onUnmounted(() => {
disposed = true;
latestLoadId += 1;
latestSaveId += 1;
formSessionId += 1;
removingIds.clear();
});
}
load();
return {
@@ -389,6 +493,7 @@ export const useEntityListPage = (rawConfig: EntityListConfig) => {
state,
searchForm,
formVisible,
formDirty,
editing,
setFormRef,
formModel,
@@ -405,6 +510,7 @@ export const useEntityListPage = (rawConfig: EntityListConfig) => {
cursorPrevious,
openAdd,
openEdit,
requestCloseForm,
openDetail,
resetForm,
submit,
@@ -414,5 +520,6 @@ export const useEntityListPage = (rawConfig: EntityListConfig) => {
optionLabel,
canEdit,
canDelete,
isRemoving,
};
};
+16 -12
View File
@@ -61,13 +61,14 @@ const inflight: Record<EntityKind, Set<string>> = {
point: new Set(),
};
// Track the pending promise per kind so concurrent callers can await
// the same fetch instead of returning immediately with uncached IDs.
const pending: Record<EntityKind, Promise<void> | null> = {
device: null,
driver: null,
profile: null,
point: null,
// Track all pending batches per kind. More than one batch can be in flight
// when callers request disjoint IDs at the same time; a single promise slot
// would be cleared by the first batch and let later callers miss the second.
const pending: Record<EntityKind, Set<Promise<void>>> = {
device: new Set(),
driver: new Set(),
profile: new Set(),
point: new Set(),
};
const fetchers: Record<EntityKind, (ids: string[]) => Promise<BatchResponse>> = {
@@ -106,8 +107,8 @@ async function fetchMissing(kind: EntityKind, rawIds: Array<string>): Promise<vo
// If some IDs are already being fetched by another caller, await the
// pending promise first — it may resolve the IDs we need. Then re-check
// which IDs are still missing so we only fetch the remainder.
if (hasInflight && pending[kind]) {
await pending[kind];
if (hasInflight && pending[kind].size > 0) {
await Promise.all([...pending[kind]]);
// Re-evaluate: the pending fetch may have resolved some of our IDs
const stillMissing: string[] = [];
for (const raw of rawIds) {
@@ -142,9 +143,12 @@ async function fetchMissing(kind: EntityKind, rawIds: Array<string>): Promise<vo
}
})();
pending[kind] = promise;
await promise;
pending[kind] = null;
pending[kind].add(promise);
try {
await promise;
} finally {
pending[kind].delete(promise);
}
}
export type AlertSourceKind = 'point' | 'device' | 'driver';
+9 -4
View File
@@ -16,7 +16,9 @@
*/
// Reactive matchMedia binding (L2, A2). SSR/mock-safe: without window the
// query reports the default until the component mounts.
// query reports the default until the component mounts. In a browser, resolve
// the current value during setup so responsive branches do not flash the
// fallback layout for the first render.
import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue';
@@ -27,9 +29,12 @@ import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue';
* @param initial Initial value before mount (SSR / no-window environments)
*/
export const useMediaQuery = (query: string, initial = false): Ref<boolean> => {
const matches = ref(initial);
let mql: MediaQueryList | undefined;
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
mql = window.matchMedia(query);
}
const matches = ref(mql?.matches ?? initial);
const sync = () => {
if (mql) matches.value = mql.matches;
@@ -40,7 +45,7 @@ export const useMediaQuery = (query: string, initial = false): Ref<boolean> => {
// Test/build environments without matchMedia keep the initial value.
return;
}
mql = window.matchMedia(query);
mql ??= window.matchMedia(query);
sync();
mql.addEventListener('change', sync);
});
+92 -7
View File
@@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {reactive} from 'vue';
import {getCurrentInstance, onUnmounted, reactive} from 'vue';
import type {Order, PageQuery, PageResult, SortSpec} from '@/config/types';
@@ -28,6 +28,12 @@ export interface PagedListPage {
export interface PagedListState<T, Q extends Record<string, any> = Record<string, any>> {
loading: boolean;
/** Last list request error. Kept separate from the global toast so pages can offer retry. */
error: unknown | null;
/** Request lifecycle state used to distinguish an empty result from a failed request. */
status: 'idle' | 'loading' | 'success' | 'error';
/** Timestamp of the last successful response. */
lastUpdated: number | null;
listData: T[];
allData: T[];
query: Partial<Q>;
@@ -54,6 +60,9 @@ export const usePagedList = <T, Q extends Record<string, any> = Record<string, a
) => {
const state = reactive({
loading: false,
error: null as unknown | null,
status: 'idle' as 'idle' | 'loading' | 'success' | 'error',
lastUpdated: null as number | null,
listData: [] as T[],
allData: [] as T[],
query: {} as Partial<Q>,
@@ -66,27 +75,75 @@ export const usePagedList = <T, Q extends Record<string, any> = Record<string, a
},
}) as PagedListState<T, Q>;
let latestLoadId = 0;
let latestOperationId = 0;
// `setAllData` is also used as the apply callback of `loadClientData`.
// Keep track of that synchronous hand-off so applying the newest response
// does not invalidate its own request token and leave `loading` stuck.
let applyingLoadId: number | null = null;
const applyFilters = () => {
const filtered = options.filter ? options.filter([...state.allData], state.query) : [...state.allData];
state.page.total = filtered.length;
// Clamp so a shrunken source (e.g. setAllData while parked on a high page)
// never leaves the slice past the last page showing an empty list.
const maxPage = Math.max(1, Math.ceil(filtered.length / state.page.size));
if (state.page.current > maxPage) state.page.current = maxPage;
const start = (state.page.current - 1) * state.page.size;
state.listData = filtered.slice(start, start + state.page.size);
};
const setAllData = (rows: T[]) => {
if (applyingLoadId === null) {
latestLoadId += 1;
}
state.allData = [...rows];
state.error = null;
state.status = 'success';
state.lastUpdated = Date.now();
state.loading = false;
applyFilters();
};
const loadClientData = async <R>(request: () => Promise<R>, apply: (result: R) => void) => {
const loadId = ++latestLoadId;
state.loading = true;
state.status = 'loading';
state.error = null;
try {
const result = await request();
if (loadId !== latestLoadId) return;
applyingLoadId = loadId;
try {
apply(result);
} finally {
applyingLoadId = null;
}
state.error = null;
state.status = 'success';
state.lastUpdated = Date.now();
} catch (error) {
if (loadId !== latestLoadId) return;
state.error = error;
state.status = 'error';
} finally {
if (loadId === latestLoadId) {
state.loading = false;
}
}
};
const load = async () => {
if (!options.request) {
applyFilters();
state.status = 'success';
state.lastUpdated = Date.now();
return;
}
const loadId = ++latestLoadId;
state.loading = true;
state.status = 'loading';
state.error = null;
try {
const sort: SortSpec[] = state.page.orders.map((order) => ({
field: order.column,
@@ -99,12 +156,19 @@ export const usePagedList = <T, Q extends Record<string, any> = Record<string, a
...state.query,
} as PageQuery & Partial<Q>);
if (loadId !== latestLoadId) return;
state.listData = data.items;
state.page.total = data.total;
} catch {
// handled globally
state.listData = data.items ?? [];
state.page.total = data.total ?? state.listData.length;
state.status = 'success';
state.lastUpdated = Date.now();
} catch (error) {
if (loadId !== latestLoadId) return;
state.error = error;
state.status = 'error';
} finally {
if (loadId === latestLoadId) state.loading = false;
if (loadId === latestLoadId) {
state.loading = false;
if (state.status === 'loading') state.status = 'success';
}
}
};
@@ -170,17 +234,38 @@ export const usePagedList = <T, Q extends Record<string, any> = Record<string, a
};
const withLoading = async (handler: () => Promise<void>) => {
const operationId = ++latestOperationId;
state.loading = true;
state.status = 'loading';
state.error = null;
try {
await handler();
if (operationId !== latestOperationId) return;
state.error = null;
state.status = 'success';
state.lastUpdated = Date.now();
} catch (error) {
if (operationId === latestOperationId) {
state.error = error;
state.status = 'error';
}
throw error;
} finally {
state.loading = false;
if (operationId === latestOperationId) state.loading = false;
}
};
if (getCurrentInstance()) {
onUnmounted(() => {
latestLoadId += 1;
latestOperationId += 1;
});
}
return {
state,
setAllData,
loadClientData,
applyFilters,
load,
search,
+56 -2
View File
@@ -60,11 +60,13 @@ export default {
remove: 'Remove',
confirm: 'Confirm',
cancel: 'Cancel',
close: 'Close',
confirmDelete: 'Are you sure to delete this {name}? This action cannot be undone!',
confirmEnable: 'Are you sure to enable this {name}?',
confirmDisable: 'Are you sure to disable this {name}? This action cannot be undone!',
discardConfirm: 'Discard all unsaved changes? This cannot be undone.',
detail: 'Detail',
select: 'Select',
refresh: 'Refresh',
autoRefresh: 'Auto-refresh',
lastRefreshTime: 'Last refresh',
@@ -85,6 +87,11 @@ export default {
creatorName: 'Created By',
operatorName: 'Modified By',
loading: 'Loading...',
loadFailed: 'Unable to load data.',
saveFailed: 'Unable to save. Your changes are preserved; retry when ready.',
optionLoadFailed: 'Unable to load options.',
detailLoadFailed: 'Unable to load the latest details. The list snapshot remains available.',
retry: 'Retry',
empty: 'No data',
noDescription: 'No description',
operation: 'Operation',
@@ -107,6 +114,7 @@ export default {
byteRange: 'Please enter an integer between {min} and {max}',
positiveIntegerFormat: 'Please enter an integer greater than 0',
entityDevice: 'device',
entityDriver: 'driver',
entityProfile: 'profile',
entityPoint: 'point',
entityGroup: 'group',
@@ -256,6 +264,7 @@ export default {
confirmPasswordPlaceholder: 'Please re-enter the new password',
confirmPasswordMismatch: 'The two passwords do not match',
changePasswordSubmit: 'Submit and log in again',
changePasswordBack: 'Back to login',
changePasswordSuccess: 'Password changed. Please log in with the new password',
changePasswordFailed: 'Failed to change password. Please try again',
},
@@ -293,6 +302,7 @@ export default {
pointsAcrossProfiles: 'across {n} profiles',
vsYesterday: 'vs yesterday',
trendTitle: 'Data Ingestion Trend',
trendEmpty: 'No ingestion data for this range',
liveFeed: {
title: 'Live Point Value Feed',
intervalOff: 'Off',
@@ -314,6 +324,7 @@ export default {
captionProtocol: 'Drivers grouped by service_name (modbus-tcp, mqtt, opc-ua, …)',
captionProfile: 'Devices grouped by Profile — top 10 profiles by device count',
captionTopActive: 'Ranked by write volume over {range} — top 10',
empty: 'No analytics data for this view',
},
ranges: {
today: 'Today',
@@ -340,6 +351,7 @@ export default {
},
latency: {
title: 'Data Latency Distribution',
empty: 'No latency samples for this range',
bin: {
under100ms: '<100ms',
'100to500ms': '100-500ms',
@@ -351,6 +363,7 @@ export default {
},
activity: {
title: 'Hourly Data Activity',
empty: 'No activity data for this range',
count: 'Records',
dow: {sun: 'Sun', mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu', fri: 'Fri', sat: 'Sat'},
},
@@ -370,7 +383,7 @@ export default {
volumeWindow: 'Volume: {range}',
},
sla: {
unackOver24h: 'Unack &gt;24h',
unackOver24h: 'Unack >24h',
silentDevices: 'Silent devices',
},
banner: {
@@ -404,6 +417,7 @@ export default {
themeSystem: 'Follow system theme',
themeDark: 'Dark theme',
navigation: 'Navigation',
skipToContent: 'Skip to main content',
collapseSettings: 'Collapse menu',
expandSettings: 'Expand menu',
},
@@ -502,6 +516,7 @@ export default {
pointSearchPlaceholder: 'Search point name or code',
commandSearchPlaceholder: 'Search command name or code',
eventSearchPlaceholder: 'Search event name or code',
driverAttributeEmpty: 'This driver has no driver attributes',
pointAttributeEmpty: 'This driver has no point attributes',
commandAttributeEmpty: 'This driver has no command attributes',
eventAttributeEmpty: 'This driver has no event attributes',
@@ -526,9 +541,16 @@ export default {
eventSaveFailed: '{count} event attribute configs failed to save',
attributeConfigRequired: 'Enter config value',
attributeConfigLength: 'Config value cannot exceed {max} characters',
matrixScrollRegion: 'Configuration matrix; scroll horizontally to view more columns',
matrixScrollHint: 'Swipe or shift-scroll to view more attribute columns',
attributeNumberFormat: 'Enter a valid number',
attributeIntegerFormat: 'Enter an integer',
attributeRange: 'Range: {min} ~ {max}',
deviceLoadFailed: 'Failed to load device information. Please retry.',
driverLoadFailed: 'Failed to load driver configuration. Please retry.',
pointLoadFailed: 'Failed to load point configuration. Please retry.',
commandLoadFailed: 'Failed to load command configuration. Please retry.',
eventLoadFailed: 'Failed to load event configuration. Please retry.',
},
import: {
title: 'Import Devices',
@@ -662,6 +684,7 @@ export default {
},
card: {
code: 'Command Code',
commandId: 'Command ID',
type: 'Command Type',
callType: 'Call Type',
timeout: 'Timeout (s)',
@@ -708,6 +731,7 @@ export default {
code: 'Event Code',
type: 'Event Type',
level: 'Event Level',
eventId: 'Event ID',
remarkTitle: 'Event description',
},
empty: 'No event data!',
@@ -846,6 +870,7 @@ export default {
saveTime: 'Save Time',
noLatestValue: 'No Value',
noHistory: 'No History',
historyLoadFailed: 'Unable to load history.',
},
edit: {
title: 'Write Point Value',
@@ -943,9 +968,16 @@ export default {
connectionInfo: 'Connection Info',
tenantId: 'Tenant ID',
clientSecret: 'Client Secret',
secretOneTimeTitle: 'Save this client secret now',
secretOneTimeDescription: 'This secret is shown only once. Copy it and store it securely before closing.',
copy: 'Copy',
copyClientId: 'Copy client ID',
copyClientSecret: 'Copy client secret',
connectionName: 'Connection Name',
connectionNameOptional: 'Optional; defaults to the OAuth client name',
clientId: 'Client ID',
principalId: 'Principal ID',
principalOptional: 'Optional; defaults to the current user',
principalType: 'Principal Type',
grantType: 'Grant Type',
expireTime: 'Expire Time',
@@ -962,8 +994,16 @@ export default {
selectedTools: '{count} selected',
empty: 'No MCP data',
saved: 'MCP settings saved',
saveFailed: 'Unable to save. Check your connection and retry; your selection is preserved.',
refreshed: 'Tool catalog refreshed: {count}',
copied: 'MCP config',
clientRequired: 'Please select an OAuth client',
principalRequired: 'Please select a principal',
tenantRequired: 'Please enter a tenant id',
grantsRequired: 'Please select at least one grant type',
redirectUrisRequired: 'Please enter at least one redirect URI',
scopesRequired: 'Please select at least one scope',
serviceAccountRequired: 'Please select a service account principal',
},
event: {
device: 'Device',
@@ -983,12 +1023,16 @@ export default {
subtitleUnconfirmedOfTotal: 'of {total} total · {pct}%',
subtitleTodayUnconfirmed: '{unconfirmed} unhandled',
trendTitle: '30-Day Event Trend',
trendEmpty: 'No event trend data for this period',
topSourcesTitle: 'Top 10 Event Sources',
topSourcesEmpty: 'No event source data for this period',
unconfirmedTitle: 'Recent Unconfirmed Events',
noUnconfirmed: 'No unconfirmed events',
activityTitle: 'Alarm Hour × Day Heatmap',
activityEmpty: 'No alarm activity data for this period',
alarmCount: 'Alarms',
typeDistributionTitle: 'Alarm Type Distribution',
typeDistributionEmpty: 'No alarm type data for this period',
stormTitle: 'Alarm Storm Sources',
stormSubtitle: 'last {hours}h ≥ {min}',
stormEmpty: 'No alarm storm sources',
@@ -1024,7 +1068,7 @@ export default {
correlationSubtitle: '{hours}h window, co-fired within ±{windowSec}s',
correlationEmpty: 'No co-occurring events',
silentTitle: 'Silent Sources',
silentSubtitle: 'Active in last 7d, silent &gt; {min} minutes',
silentSubtitle: 'Active in last 7d, silent > {min} minutes',
silentEmpty: 'No silent sources',
coverageTitle: 'Point Coverage Gap',
coverageSubtitle: 'Gap {missing} / total {total}',
@@ -1244,6 +1288,10 @@ export default {
resetPassword: 'Reset Password',
resetTitle: 'Reset Password',
newPassword: 'New Password',
loginNameRequired: 'Please enter a login name',
principalRequired: 'Please select a principal',
passwordRequired: 'Please enter a password',
passwordMin: 'Password must be at least 6 characters',
addTitle: 'Add Local Credential',
empty: 'No credential data!',
},
@@ -1302,6 +1350,7 @@ export default {
addTitle: 'Add Resource',
editTitle: 'Edit Resource',
detailTitle: 'Resource Detail',
serviceName: 'Service',
resourceNamePlaceholder: 'Enter resource name',
resourceCodePlaceholder: 'Enter resource code',
resourceTypePlaceholder: 'Select resource type',
@@ -1431,6 +1480,7 @@ export default {
headerClose: 'Close',
headerSettings: 'Settings',
resize: 'Resize assistant',
model: 'Select model',
welcomeDescription: 'Ask about devices, drivers, points, events, and platform operations.',
promptDeviceStatus: 'Device Status',
promptDeviceStatusDesc: 'Show abnormal devices and recent events.',
@@ -1468,6 +1518,8 @@ export default {
tokenMemory: 'Memory',
composerPlaceholder: 'Message DC3 Assistant...',
attachFile: 'Attach file',
send: 'Send message',
stop: 'Stop generating',
reasoning: 'Reasoning',
think: 'Think',
fast: 'Fast',
@@ -1489,11 +1541,13 @@ export default {
uploadFailed: 'Upload failed.',
actionConfirmed: 'Action confirmed.',
actionRejected: 'Action rejected.',
actionFailed: 'Action failed. You can retry it.',
requestFailed: 'Request failed.',
failedStream: 'Agentic chat failed.',
failedContext: 'Missing conversation context.',
failedModels: 'Failed to load agentic models, fallback model is used.',
failedSessions: 'Failed to load agentic sessions.',
sessionStreaming: 'This conversation is still generating. Wait until it finishes before deleting it.',
failedSessionDelete: 'Failed to delete server-side agentic session, local session is removed.',
failedSessionUpdate: 'Failed to update agentic session metadata.',
failedSessionModel: 'Failed to update agentic session model.',
+54
View File
@@ -60,11 +60,13 @@ export default {
remove: '移除',
confirm: '确定',
cancel: '取消',
close: '关闭',
confirmDelete: '确定删除此{name}吗?此操作不可撤销!',
confirmEnable: '确定启用此{name}吗?',
confirmDisable: '确定停用此{name}吗?此操作不可撤销!',
discardConfirm: '确定要放弃所有未保存的修改吗?此操作不可撤销。',
detail: '详情',
select: '选择',
refresh: '刷新',
autoRefresh: '自动刷新',
lastRefreshTime: '上次刷新',
@@ -85,6 +87,11 @@ export default {
creatorName: '创建人',
operatorName: '修改人',
loading: '加载中...',
loadFailed: '数据加载失败,请重试。',
saveFailed: '保存失败,当前修改已保留,请稍后重试。',
optionLoadFailed: '选项加载失败。',
detailLoadFailed: '最新详情加载失败,当前仍展示列表快照。',
retry: '重试',
empty: '暂无数据',
noDescription: '无描述信息',
operation: '操作',
@@ -105,6 +112,7 @@ export default {
byteRange: '请输入 {min}~{max} 之间的整数',
positiveIntegerFormat: '请输入大于 0 的整数',
entityDevice: '设备',
entityDriver: '驱动',
entityProfile: '模板',
entityPoint: '位号',
entityGroup: '分组',
@@ -254,6 +262,7 @@ export default {
confirmPasswordPlaceholder: '请再次输入新密码',
confirmPasswordMismatch: '两次输入的密码不一致',
changePasswordSubmit: '提交并重新登录',
changePasswordBack: '返回登录',
changePasswordSuccess: '密码修改成功,请使用新密码登录',
changePasswordFailed: '密码修改失败,请重试',
},
@@ -288,6 +297,7 @@ export default {
pointsAcrossProfiles: '分布于 {n} 个模板',
vsYesterday: '较昨日',
trendTitle: '数据采集趋势',
trendEmpty: '该时间范围暂无采集数据',
liveFeed: {
title: '实时位号数据流',
intervalOff: '关闭',
@@ -308,6 +318,7 @@ export default {
captionProtocol: '驱动按 service_name 聚合(modbus-tcp / mqtt / opc-ua 等)',
captionProfile: '设备按所属 Profile 聚合,取设备数最多的前 10 个模板',
captionTopActive: '{range}内按写入数据量聚合,排名前 10',
empty: '当前视图暂无分析数据',
},
ranges: {
today: '今日',
@@ -334,6 +345,7 @@ export default {
},
latency: {
title: '数据延迟分布',
empty: '该时间范围暂无延迟样本',
bin: {
under100ms: '<100ms',
'100to500ms': '100-500ms',
@@ -345,6 +357,7 @@ export default {
},
activity: {
title: '小时数据活跃度',
empty: '该时间范围暂无活跃数据',
count: '数据条数',
dow: {sun: '周日', mon: '周一', tue: '周二', wed: '周三', thu: '周四', fri: '周五', sat: '周六'},
},
@@ -398,6 +411,7 @@ export default {
themeSystem: '跟随系统主题',
themeDark: '深色主题',
navigation: '导航',
skipToContent: '跳转到主要内容',
collapseSettings: '折叠菜单',
expandSettings: '展开菜单',
},
@@ -496,6 +510,7 @@ export default {
pointSearchPlaceholder: '搜索位号名称或编号',
commandSearchPlaceholder: '搜索指令名称或编号',
eventSearchPlaceholder: '搜索事件名称或编号',
driverAttributeEmpty: '当前驱动没有驱动属性配置项',
pointAttributeEmpty: '当前驱动没有位号属性配置项',
commandAttributeEmpty: '当前驱动没有指令属性配置项',
eventAttributeEmpty: '当前驱动没有事件属性配置项',
@@ -520,9 +535,16 @@ export default {
eventSaveFailed: '{count} 项事件属性配置保存失败',
attributeConfigRequired: '请输入配置值',
attributeConfigLength: '配置值不能超过 {max} 个字符',
matrixScrollRegion: '配置矩阵,可横向滑动查看更多列',
matrixScrollHint: '左右滑动查看其他属性列',
attributeNumberFormat: '请输入正确的数值',
attributeIntegerFormat: '请输入整数',
attributeRange: '范围: {min} ~ {max}',
deviceLoadFailed: '设备信息加载失败,请重试。',
driverLoadFailed: '驱动配置加载失败,请重试。',
pointLoadFailed: '位号配置加载失败,请重试。',
commandLoadFailed: '指令配置加载失败,请重试。',
eventLoadFailed: '事件配置加载失败,请重试。',
},
import: {
title: '导入设备',
@@ -656,6 +678,7 @@ export default {
},
card: {
code: '指令标识',
commandId: '指令 ID',
type: '指令类型',
callType: '调用类型',
timeout: '超时时间(s)',
@@ -702,6 +725,7 @@ export default {
code: '事件标识',
type: '事件类型',
level: '事件等级',
eventId: '事件 ID',
remarkTitle: '事件描述信息',
},
empty: '暂无事件数据!',
@@ -840,6 +864,7 @@ export default {
saveTime: '保存日期',
noLatestValue: '暂无值',
noHistory: '暂无历史',
historyLoadFailed: '历史数据加载失败。',
},
edit: {
title: '写入位号值',
@@ -928,9 +953,16 @@ export default {
connectionInfo: '连接信息',
tenantId: '租户 ID',
clientSecret: 'Client Secret',
secretOneTimeTitle: '请立即保存 Client Secret',
secretOneTimeDescription: '该密钥仅展示一次。关闭前请复制并安全保存,之后将无法再次查看。',
copy: '复制',
copyClientId: '复制 Client ID',
copyClientSecret: '复制 Client Secret',
connectionName: '连接名称',
connectionNameOptional: '可选;留空时使用 OAuth Client 名称',
clientId: 'Client ID',
principalId: 'Principal ID',
principalOptional: '可选;留空时使用当前用户',
principalType: '主体类型',
grantType: '授权类型',
expireTime: '过期时间',
@@ -947,8 +979,16 @@ export default {
selectedTools: '已选 {count} 项',
empty: '暂无 MCP 数据',
saved: 'MCP 配置已保存',
saveFailed: '保存失败,请检查网络后重试。当前选择已保留。',
refreshed: '工具目录已刷新 {count} 项',
copied: 'MCP 配置',
clientRequired: '请选择 OAuth Client',
principalRequired: '请选择主体',
tenantRequired: '请输入租户 ID',
grantsRequired: '请至少选择一种授权类型',
redirectUrisRequired: '请输入至少一个回调地址',
scopesRequired: '请至少选择一个 Scope',
serviceAccountRequired: '请选择服务账号主体',
},
event: {
device: '设备',
@@ -966,12 +1006,16 @@ export default {
subtitleUnconfirmedOfTotal: '累计 {total} 条 · 占 {pct}%',
subtitleTodayUnconfirmed: '未确认 {unconfirmed} 条',
trendTitle: '30天事件趋势',
trendEmpty: '该时间范围暂无事件趋势数据',
topSourcesTitle: '高频事件源 TOP 10',
topSourcesEmpty: '该时间范围暂无事件源数据',
unconfirmedTitle: '最近未确认事件',
noUnconfirmed: '暂无未确认事件',
activityTitle: '告警时段热力图',
activityEmpty: '该时间范围暂无告警活跃数据',
alarmCount: '告警数',
typeDistributionTitle: '告警类型分布',
typeDistributionEmpty: '该时间范围暂无告警类型数据',
stormTitle: '告警风暴源',
stormSubtitle: '最近 {hours} 小时 ≥ {min} 条',
stormEmpty: '暂无告警风暴源',
@@ -1238,6 +1282,10 @@ export default {
resetPassword: '重置密码',
resetTitle: '重置密码',
newPassword: '新密码',
loginNameRequired: '请输入登录名',
principalRequired: '请选择主体',
passwordRequired: '请输入密码',
passwordMin: '密码长度最少为 6 位',
addTitle: '新增本地凭证',
empty: '暂无凭证数据!',
},
@@ -1296,6 +1344,7 @@ export default {
addTitle: '新增资源',
editTitle: '编辑资源',
detailTitle: '资源详情',
serviceName: '所属服务',
resourceNamePlaceholder: '请输入资源名称',
resourceCodePlaceholder: '请输入资源编码',
resourceTypePlaceholder: '请选择资源类型',
@@ -1426,6 +1475,7 @@ export default {
headerClose: '关闭',
headerSettings: '设置',
resize: '调整助手宽度',
model: '选择模型',
welcomeDescription: '询问设备、驱动、位号、事件和平台运维相关问题。',
promptDeviceStatus: '设备状态',
promptDeviceStatusDesc: '查看异常设备和最近事件。',
@@ -1463,6 +1513,8 @@ export default {
tokenMemory: '记忆',
composerPlaceholder: '向 DC3 助手提问...',
attachFile: '添加附件',
send: '发送消息',
stop: '停止生成',
reasoning: '推理',
think: '思考',
fast: '快速',
@@ -1484,11 +1536,13 @@ export default {
uploadFailed: '上传失败。',
actionConfirmed: '操作已确认。',
actionRejected: '操作已拒绝。',
actionFailed: '操作失败,可以重试。',
requestFailed: '请求失败。',
failedStream: 'AI 对话失败。',
failedContext: '缺少对话上下文。',
failedModels: '加载模型列表失败,已使用默认模型。',
failedSessions: '加载会话列表失败。',
sessionStreaming: '当前对话正在生成内容,请等待生成结束后再删除。',
failedSessionDelete: '删除服务端会话失败,本地会话已移除。',
failedSessionUpdate: '更新会话元数据失败。',
failedSessionModel: '更新会话模型失败。',
+6 -229
View File
@@ -18,234 +18,11 @@
import type {RouteRecordRaw} from 'vue-router';
/**
* Lazy-loaded layout component
* Operation routes are defined beside their stable layout parent in
* `views.ts` and `settings.ts`. Keeping this module as an empty route group
* preserves the router assembly contract without registering duplicate
* `/driver`, `/device`, `/profile`, `/point`, or `/settings` parents.
*/
const Layout = () => import('@/components/layout/Layout.vue');
const routes: Array<RouteRecordRaw> = [];
/**
* Operate routes configuration
* Includes detail and edit pages for various entities.
*
* Settings detail routes live under a single /settings Layout wrapper so they
* do not compete with the nested /settings/* list routes defined in
* settings.ts (which resolve through Settings.vue → sidebar). Relative child
* paths (api/detail, label/detail, …) are more specific than the empty-path
* layout child, so Vue Router picks the correct branch.
*/
const routes: Array<RouteRecordRaw> = [
{
path: '/driver',
component: Layout,
children: [
{
name: 'driverDetail',
path: '/driver/detail',
meta: {
title: 'nav.driverDetail',
},
component: () => import('@/views/driver/detail/DriverDetail.vue'),
},
],
},
{
path: '/device',
component: Layout,
children: [
{
name: 'deviceDetail',
path: '/device/detail',
meta: {
title: 'nav.deviceDetail',
},
component: () => import('@/views/device/detail/DeviceDetail.vue'),
},
{
name: 'deviceEdit',
path: '/device/edit',
meta: {
title: 'nav.deviceEdit',
},
component: () => import('@/views/device/edit/DeviceEdit.vue'),
},
],
},
{
path: '/profile',
component: Layout,
children: [
{
name: 'profileDetail',
path: '/profile/detail',
meta: {
title: 'nav.profileDetail',
},
component: () => import('@/views/profile/detail/ProfileDetail.vue'),
},
{
name: 'profileEdit',
path: '/profile/edit',
meta: {
title: 'nav.profileEdit',
},
component: () => import('@/views/profile/edit/ProfileEdit.vue'),
},
],
},
{
path: '/point',
component: Layout,
children: [
{
name: 'pointDetail',
path: '/point/detail',
meta: {
title: 'nav.pointDetail',
},
component: () => import('@/views/point/detail/PointDetail.vue'),
},
],
},
{
path: '/settings',
component: Layout,
children: [
{
name: 'settingsApiDetail',
path: 'api/detail',
meta: {
title: 'nav.settingsApiDetail',
},
component: () => import('@/views/settings/api/detail/ApiDetail.vue'),
},
{
name: 'settingsGroupDetail',
path: 'group/detail',
meta: {
title: 'nav.settingsGroupDetail',
},
component: () => import('@/views/settings/group/detail/GroupDetail.vue'),
},
{
name: 'settingsLabelDetail',
path: 'label/detail',
meta: {
title: 'nav.settingsLabelDetail',
},
component: () => import('@/views/settings/label/detail/LabelDetail.vue'),
},
{
name: 'settingsAlarmRuleDetail',
path: 'alarm/rule/detail',
meta: {
title: 'nav.settingsAlarmRuleDetail',
},
component: () => import('@/views/settings/alarm/detail/AlarmDetail.vue'),
props: {entity: 'rule'},
},
{
name: 'settingsAlarmNotifyDetail',
path: 'alarm/notify/detail',
meta: {
title: 'nav.settingsAlarmNotifyDetail',
},
component: () => import('@/views/settings/alarm/detail/AlarmDetail.vue'),
props: {entity: 'notify'},
},
{
name: 'settingsAlarmMessageDetail',
path: 'alarm/message/detail',
meta: {
title: 'nav.settingsAlarmMessageDetail',
},
component: () => import('@/views/settings/alarm/detail/AlarmDetail.vue'),
props: {entity: 'message'},
},
{
name: 'settingsAlarmChannelDetail',
path: 'alarm/channel/detail',
meta: {
title: 'nav.settingsAlarmChannelDetail',
},
component: () => import('@/views/settings/alarm/detail/AlarmDetail.vue'),
props: {entity: 'channel'},
},
{
name: 'settingsAlarmBindDetail',
path: 'alarm/bind/detail',
meta: {
title: 'nav.settingsAlarmBindDetail',
},
component: () => import('@/views/settings/alarm/detail/AlarmDetail.vue'),
props: {entity: 'bind'},
},
{
name: 'settingsAlarmStateDetail',
path: 'alarm/state/detail',
meta: {
title: 'nav.settingsAlarmStateDetail',
},
component: () => import('@/views/settings/alarm/detail/AlarmDetail.vue'),
props: {entity: 'state'},
},
{
name: 'settingsAlarmHistoryDetail',
path: 'alarm/history/detail',
meta: {
title: 'nav.settingsAlarmHistoryDetail',
},
component: () => import('@/views/settings/alarm/detail/AlarmDetail.vue'),
props: {entity: 'history'},
},
{
name: 'settingsModelConfigDetail',
path: 'model/config/detail',
meta: {
title: 'nav.settingsModelConfigDetail',
},
component: () => import('@/views/settings/agentic/detail/ModelConfigDetail.vue'),
},
{
name: 'settingsModelProviderDetail',
path: 'model/provider/detail',
meta: {
title: 'nav.settingsModelProviderDetail',
},
component: () => import('@/views/settings/agentic/detail/ProviderDetail.vue'),
},
{
name: 'settingsResourceDetail',
path: 'resource/detail',
meta: {
title: 'nav.settingsResourceDetail',
},
component: () => import('@/views/settings/resource/detail/ResourceDetail.vue'),
},
{
name: 'settingsMenuDetail',
path: 'menu/detail',
meta: {
title: 'nav.settingsMenuDetail',
},
component: () => import('@/views/settings/menu/detail/MenuDetail.vue'),
},
{
name: 'settingsUserDetail',
path: 'user/detail',
meta: {
title: 'nav.settingsUserDetail',
},
component: () => import('@/views/settings/user/detail/UserDetail.vue'),
},
{
name: 'settingsRoleDetail',
path: 'role/detail',
meta: {
title: 'nav.settingsRoleDetail',
},
component: () => import('@/views/settings/role/detail/RoleDetail.vue'),
},
],
},
];
export default routes;
export default routes;
+104 -1
View File
@@ -302,7 +302,110 @@ const settingsRouter: RouteRecordRaw = {
},
],
},
{
name: 'settingsApiDetail',
path: 'api/detail',
meta: {title: 'nav.settingsApiDetail'},
component: () => import('@/views/settings/api/detail/ApiDetail.vue'),
},
{
name: 'settingsGroupDetail',
path: 'group/detail',
meta: {title: 'nav.settingsGroupDetail'},
component: () => import('@/views/settings/group/detail/GroupDetail.vue'),
},
{
name: 'settingsLabelDetail',
path: 'label/detail',
meta: {title: 'nav.settingsLabelDetail'},
component: () => import('@/views/settings/label/detail/LabelDetail.vue'),
},
{
name: 'settingsAlarmRuleDetail',
path: 'alarm/rule/detail',
meta: {title: 'nav.settingsAlarmRuleDetail'},
component: () => import('@/views/settings/alarm/detail/AlarmDetail.vue'),
props: {entity: 'rule'},
},
{
name: 'settingsAlarmNotifyDetail',
path: 'alarm/notify/detail',
meta: {title: 'nav.settingsAlarmNotifyDetail'},
component: () => import('@/views/settings/alarm/detail/AlarmDetail.vue'),
props: {entity: 'notify'},
},
{
name: 'settingsAlarmMessageDetail',
path: 'alarm/message/detail',
meta: {title: 'nav.settingsAlarmMessageDetail'},
component: () => import('@/views/settings/alarm/detail/AlarmDetail.vue'),
props: {entity: 'message'},
},
{
name: 'settingsAlarmChannelDetail',
path: 'alarm/channel/detail',
meta: {title: 'nav.settingsAlarmChannelDetail'},
component: () => import('@/views/settings/alarm/detail/AlarmDetail.vue'),
props: {entity: 'channel'},
},
{
name: 'settingsAlarmBindDetail',
path: 'alarm/bind/detail',
meta: {title: 'nav.settingsAlarmBindDetail'},
component: () => import('@/views/settings/alarm/detail/AlarmDetail.vue'),
props: {entity: 'bind'},
},
{
name: 'settingsAlarmStateDetail',
path: 'alarm/state/detail',
meta: {title: 'nav.settingsAlarmStateDetail'},
component: () => import('@/views/settings/alarm/detail/AlarmDetail.vue'),
props: {entity: 'state'},
},
{
name: 'settingsAlarmHistoryDetail',
path: 'alarm/history/detail',
meta: {title: 'nav.settingsAlarmHistoryDetail'},
component: () => import('@/views/settings/alarm/detail/AlarmDetail.vue'),
props: {entity: 'history'},
},
{
name: 'settingsModelConfigDetail',
path: 'model/config/detail',
meta: {title: 'nav.settingsModelConfigDetail'},
component: () => import('@/views/settings/agentic/detail/ModelConfigDetail.vue'),
},
{
name: 'settingsModelProviderDetail',
path: 'model/provider/detail',
meta: {title: 'nav.settingsModelProviderDetail'},
component: () => import('@/views/settings/agentic/detail/ProviderDetail.vue'),
},
{
name: 'settingsResourceDetail',
path: 'resource/detail',
meta: {title: 'nav.settingsResourceDetail'},
component: () => import('@/views/settings/resource/detail/ResourceDetail.vue'),
},
{
name: 'settingsMenuDetail',
path: 'menu/detail',
meta: {title: 'nav.settingsMenuDetail'},
component: () => import('@/views/settings/menu/detail/MenuDetail.vue'),
},
{
name: 'settingsUserDetail',
path: 'user/detail',
meta: {title: 'nav.settingsUserDetail'},
component: () => import('@/views/settings/user/detail/UserDetail.vue'),
},
{
name: 'settingsRoleDetail',
path: 'role/detail',
meta: {title: 'nav.settingsRoleDetail'},
component: () => import('@/views/settings/role/detail/RoleDetail.vue'),
},
],
};
export default settingsRouter;
export default settingsRouter;
+54 -6
View File
@@ -33,7 +33,7 @@ const routes: RouteRecordRaw = {
children: [
{
name: 'home',
path: '/home',
path: 'home',
meta: {
title: 'nav.home',
},
@@ -41,41 +41,89 @@ const routes: RouteRecordRaw = {
},
{
name: 'driver',
path: '/driver',
path: 'driver',
meta: {
icon: 'Promotion',
title: 'nav.driver',
},
component: () => import('@/views/driver/Driver.vue'),
},
{
name: 'driverDetail',
path: 'driver/detail',
meta: {
title: 'nav.driverDetail',
},
component: () => import('@/views/driver/detail/DriverDetail.vue'),
},
{
name: 'profile',
path: '/profile',
path: 'profile',
meta: {
icon: 'List',
title: 'nav.profile',
},
component: () => import('@/views/profile/Profile.vue'),
},
{
name: 'profileDetail',
path: 'profile/detail',
meta: {
title: 'nav.profileDetail',
},
component: () => import('@/views/profile/detail/ProfileDetail.vue'),
},
{
name: 'profileEdit',
path: 'profile/edit',
meta: {
title: 'nav.profileEdit',
},
component: () => import('@/views/profile/edit/ProfileEdit.vue'),
},
{
name: 'device',
path: '/device',
path: 'device',
meta: {
icon: 'Management',
title: 'nav.device',
},
component: () => import('@/views/device/Device.vue'),
},
{
name: 'deviceDetail',
path: 'device/detail',
meta: {
title: 'nav.deviceDetail',
},
component: () => import('@/views/device/detail/DeviceDetail.vue'),
},
{
name: 'deviceEdit',
path: 'device/edit',
meta: {
title: 'nav.deviceEdit',
},
component: () => import('@/views/device/edit/DeviceEdit.vue'),
},
{
name: 'pointValue',
path: '/point_value',
path: 'point_value',
meta: {
icon: 'Histogram',
title: 'page.pointValue',
},
component: () => import('@/views/point/value/PointValue.vue'),
},
{
name: 'pointDetail',
path: 'point/detail',
meta: {
title: 'nav.pointDetail',
},
component: () => import('@/views/point/detail/PointDetail.vue'),
},
],
};
export default routes;
export default routes;
+7 -1
View File
@@ -119,4 +119,10 @@ export interface NotifyHistoryRecord extends AlarmBase {
}
export type AlarmEntity =
RuleRecord | NotifyRecord | MessageRecord | NotifyChannelBindRecord | RuleStateRecord | NotifyHistoryRecord;
| RuleRecord
| NotifyRecord
| MessageRecord
| NotifyChannelRecord
| NotifyChannelBindRecord
| RuleStateRecord
| NotifyHistoryRecord;
+15
View File
@@ -305,6 +305,21 @@ export interface McpConnectionRecord extends McpConnectionForm {
lastUsedTime?: string;
}
export interface McpMetadata {
issuer?: string;
authorization_endpoint?: string;
token_endpoint?: string;
jwks_uri?: string;
revocation_endpoint?: string;
registration_endpoint?: string;
response_types_supported?: string[];
grant_types_supported?: string[];
scopes_supported?: string[];
token_endpoint_auth_methods_supported?: string[];
[key: string]: unknown;
}
export interface McpToolRecord {
id: string;
toolId: string;
+8 -1
View File
@@ -28,6 +28,7 @@ export type Translator = (key: string, params?: Record<string, unknown>) => stri
export type EntityMode = 'page' | 'tree';
export type EntityPagination = 'offset' | 'cursor';
export type EntityMobileRole = 'primary' | 'detail' | 'hidden';
export type EntityFieldKind =
'input' | 'number' | 'select' | 'enableFlag' | 'textarea' | 'json' | 'color' | 'treeSelect';
export type EntityColumnKind = 'text' | 'tag' | 'code' | 'time' | 'enable' | 'color' | 'icon' | 'link';
@@ -81,6 +82,8 @@ export interface EntityColumnConfig {
formatter?: (row: Record<string, any>, ctx: EntityColumnContext) => string;
onClick?: (row: Record<string, any>) => void; // link
linkable?: (row: Record<string, any>) => boolean; // Link columns are clickable by default.
/** Controls which columns remain visible and their prominence on narrow screens. */
mobile?: EntityMobileRole;
}
export interface EntitySearchFieldConfig {
@@ -99,7 +102,11 @@ export interface EntityRowAction {
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;
/** Optional externally managed loading state for long-running row actions. */
loading?: (row: Record<string, any>) => boolean;
/** Optional predicate for disabling an action for a specific row. */
disabled?: (row: Record<string, any>) => boolean;
onClick: (row: Record<string, any>) => void | Promise<void>;
}
/** Toolbar action for operations outside generic CRUD, such as refresh, registration, or connection creation. */
+7
View File
@@ -72,6 +72,12 @@ export type {CommandRecord, CommandForm, CommandParamRecord, CommandParamForm, C
export type {EventRecord, EventForm, EventParamRecord, EventParamForm, EventHistoryRecord} from './event';
export type {EntityPagination} from './entityList';
export type {
ResponsiveListCellKind,
ResponsiveListColumn,
ResponsiveListMobileRole,
ResponsiveListTagType,
} from './responsiveList';
export type {
UserForm,
@@ -99,6 +105,7 @@ export type {
OAuthClientRecord,
McpConnectionForm,
McpConnectionRecord,
McpMetadata,
McpToolRecord,
McpAuditRecord,
IdentityAuditRecord,
@@ -0,0 +1,34 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export type ResponsiveListCellKind = 'text' | 'time' | 'tag' | 'enable' | 'default' | 'code' | 'custom';
export type ResponsiveListMobileRole = 'primary' | 'detail' | 'hidden';
export type ResponsiveListTagType = 'primary' | 'success' | 'info' | 'warning' | 'danger';
export interface ResponsiveListColumn<T extends Record<string, any> = Record<string, any>> {
key: string;
label: string;
prop?: string;
kind?: ResponsiveListCellKind;
width?: number | string;
minWidth?: number | string;
fixed?: boolean | 'left' | 'right';
overflow?: boolean;
mobile?: ResponsiveListMobileRole;
formatter?: (row: T) => string;
tagType?: (row: T) => ResponsiveListTagType;
}
+1 -1
View File
@@ -86,7 +86,7 @@ export function registerCrud(spec: CrudSpec): void {
if (i >= 0) rows()[i] = {...rows()[i], ...ctx.body, operateTime: stamp()};
return responseOf(ctx.config, ok(String(ctx.body?.id ?? '')));
});
on('post', `${baseUrl}/delete`, (ctx) => {
on(['post', 'delete'], `${baseUrl}/delete`, (ctx) => {
const id = ctx.params.id;
const i = rows().findIndex((r) => String(r.id) === String(id));
if (i >= 0) rows().splice(i, 1);
+12 -3
View File
@@ -30,13 +30,16 @@ import {
import {
apis,
attributeConfigs,
attributes,
commandAttributes,
commandParams,
commands,
driverAttributes,
eventAttributes,
eventParams,
events,
groups,
labels,
pointAttributes,
resources,
} from "./seed/manager";
import {
@@ -90,7 +93,10 @@ export interface MockDb {
commandParams: Record<string, unknown>[];
events: Record<string, unknown>[];
eventParams: Record<string, unknown>[];
attributes: Record<string, unknown>[];
driverAttributes: Record<string, unknown>[];
pointAttributes: Record<string, unknown>[];
commandAttributes: Record<string, unknown>[];
eventAttributes: Record<string, unknown>[];
attributeConfigs: Record<string, unknown>[];
alarmRules: Record<string, unknown>[];
alarmNotifies: Record<string, unknown>[];
@@ -135,7 +141,10 @@ export const db: MockDb = {
commandParams: copy(commandParams),
events: copy(events),
eventParams: copy(eventParams),
attributes: copy(attributes),
driverAttributes: copy(driverAttributes),
pointAttributes: copy(pointAttributes),
commandAttributes: copy(commandAttributes),
eventAttributes: copy(eventAttributes),
attributeConfigs: copy(attributeConfigs),
alarmRules: copy(alarmRules),
alarmNotifies: copy(alarmNotifies),
+79
View File
@@ -16,10 +16,14 @@
*/
import type {AgenticVisualizationSpec} from '@/config/types';
import type {InternalAxiosRequestConfig} from 'axios';
import {currentMockLocale} from './locale';
import type {MockDb} from './db';
import {resolve} from './dispatch';
import type {MockCtx} from './types';
const CHAT_URL = '/api/v3/agentic/chat/completions';
const API_PREFIX = '/api/';
const encoder = new TextEncoder();
const sse = (obj: unknown): Uint8Array => encoder.encode(`data: ${JSON.stringify(obj)}\n\n`);
@@ -372,3 +376,78 @@ export function installAgenticFetchMock(db: MockDb): void {
return original(input as RequestInfo, init);
}) as typeof window.fetch;
}
const readRequestBody = async (input: RequestInfo | URL, init?: RequestInit): Promise<unknown> => {
// `fetch(new Request(url, {body}))` carries the body on the request object,
// not in init — read it from there so browser-level fixtures see the same
// parsed body the axios adapter would.
const raw = init?.body ?? (input instanceof Request ? await input.clone().text() : undefined);
if (typeof raw !== 'string') return raw ?? {};
try {
return JSON.parse(raw);
} catch {
return {};
}
};
/** Mirror the axios adapter: repeated query keys arrive as arrays, not the last value only. */
const collectParams = (searchParams: URLSearchParams): Record<string, unknown> => {
const params: Record<string, unknown> = {};
for (const [key, value] of searchParams.entries()) {
const existing = params[key];
if (existing === undefined) {
params[key] = value;
} else if (Array.isArray(existing)) {
existing.push(value);
} else {
params[key] = [existing, value];
}
}
return params;
};
/**
* Route native fetch calls used by browser-level fixtures through the same
* mock handlers as Axios. The production build never imports this module.
*/
export function installApiFetchMock(db: MockDb): void {
const previous = window.fetch.bind(window);
window.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const requestUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const url = new URL(requestUrl, window.location.origin);
if (!url.pathname.startsWith(API_PREFIX) || url.pathname === CHAT_URL) {
return previous(input, init);
}
const method = String(init?.method || (input instanceof Request ? input.method : 'GET')).toLowerCase();
const body = await readRequestBody(input, init);
const params = collectParams(url.searchParams);
const ctx: MockCtx = {
config: {
url: url.pathname.replace(/^\/+/, ''),
method,
data: init?.body,
params,
} as InternalAxiosRequestConfig,
url: url.pathname.replace(/^\/+/, ''),
method,
params,
body,
db,
};
try {
const response = await resolve(ctx)(ctx);
const headers = {'content-type': String(response.headers?.['content-type'] || 'application/json')};
return new Response(response.status === 204 ? null : JSON.stringify(response.data), {
status: response.status,
headers,
});
} catch (error) {
return new Response(JSON.stringify({ok: false, code: 500, message: String(error)}), {
status: 500,
headers: {'content-type': 'application/json'},
});
}
}) as typeof window.fetch;
}
+8 -7
View File
@@ -61,7 +61,7 @@ const cud = (url: string, key: 'agenticModelConfigs' | 'agenticProviders') => {
if (i >= 0) coll[i] = {...coll[i], ...ctx.body, operateTime: stamp()};
return responseOf(ctx.config, ok(String(ctx.body?.id ?? '')));
});
on('post', `${url}/delete`, (ctx) => {
on(['post', 'delete'], `${url}/delete`, (ctx) => {
const coll = ctx.db[key];
const i = coll.findIndex((r) => String(r.id) === String(ctx.params.id));
if (i >= 0) coll.splice(i, 1);
@@ -98,7 +98,7 @@ export function registerAgenticHandlers(): void {
const rows = ctx.db.agenticSessions.map(localizeAgenticRow);
return responseOf(ctx.config, ok(paginate(rows, ctx.body)));
});
on('post', 'api/v3/agentic/session/delete', (ctx) => {
on(['post', 'delete'], 'api/v3/agentic/session/delete', (ctx) => {
const id = ctx.params.conversation_id;
const coll = ctx.db.agenticSessions;
const i = coll.findIndex((r) => String(r.conversationId) === String(id));
@@ -182,10 +182,11 @@ export function registerAgenticHandlers(): void {
ctx.config,
ok({
issuer: 'https://demo.dc3.site',
authorizationEndpoint: '/oauth/authorize',
tokenEndpoint: '/oauth/token',
registrationEndpoint: '/oauth/register',
scopesSupported: ['mcp:tools:list', 'mcp:tools:call'],
authorization_endpoint: 'https://demo.dc3.site/oauth/authorize',
token_endpoint: 'https://demo.dc3.site/oauth/token',
jwks_uri: 'https://demo.dc3.site/oauth/jwks',
registration_endpoint: 'https://demo.dc3.site/oauth/register',
scopes_supported: ['mcp:tools:list', 'mcp:tools:call'],
}),
),
);
@@ -234,7 +235,7 @@ export function registerAgenticHandlers(): void {
});
on('post', 'api/v3/auth/mcp/connection/tools/replace', (ctx) => responseOf(ctx.config, ok(true)));
on('get', 'api/v3/auth/mcp/connection/tools/list', (ctx) =>
responseOf(ctx.config, ok(ctx.db.mcpTools)),
responseOf(ctx.config, ok(ctx.db.mcpTools.slice(0, 3).map((tool) => String(tool.toolId)))),
);
// ── MCP: tool catalog & audit ──
+18 -6
View File
@@ -44,7 +44,15 @@ const configScope = (
export function registerBusinessHandlers(): void {
const registerVersionedCrud = (
baseUrl: string,
collection: 'commands' | 'commandParams' | 'events' | 'eventParams',
collection:
| 'commands'
| 'commandParams'
| 'events'
| 'eventParams'
| 'driverAttributes'
| 'pointAttributes'
| 'commandAttributes'
| 'eventAttributes',
search: string[] = [],
exact: string[] = ['enableFlag'],
) => {
@@ -120,11 +128,15 @@ export function registerBusinessHandlers(): void {
),
);
// ── A5. attribute metadata (shared across every driver) ──
on('get', 'api/v3/manager/driver_attribute/list_by_driver_id', (ctx) => responseOf(ctx.config, ok(db.attributes)));
on('get', 'api/v3/manager/point_attribute/list_by_driver_id', (ctx) => responseOf(ctx.config, ok(db.attributes)));
on('get', 'api/v3/manager/command_attribute/list_by_driver_id', (ctx) => responseOf(ctx.config, ok(db.attributes)));
on('get', 'api/v3/manager/event_attribute/list_by_driver_id', (ctx) => responseOf(ctx.config, ok(db.attributes)));
// ── A5. attribute metadata (one collection per backend table) ──
registerVersionedCrud('api/v3/manager/driver_attribute', 'driverAttributes', ['attributeName', 'attributeCode']);
registerVersionedCrud('api/v3/manager/point_attribute', 'pointAttributes', ['attributeName', 'attributeCode']);
registerVersionedCrud('api/v3/manager/command_attribute', 'commandAttributes', ['attributeName', 'attributeCode']);
registerVersionedCrud('api/v3/manager/event_attribute', 'eventAttributes', ['attributeName', 'attributeCode']);
on('get', 'api/v3/manager/driver_attribute/list_by_driver_id', (ctx) => responseOf(ctx.config, ok(db.driverAttributes)));
on('get', 'api/v3/manager/point_attribute/list_by_driver_id', (ctx) => responseOf(ctx.config, ok(db.pointAttributes)));
on('get', 'api/v3/manager/command_attribute/list_by_driver_id', (ctx) => responseOf(ctx.config, ok(db.commandAttributes)));
on('get', 'api/v3/manager/event_attribute/list_by_driver_id', (ctx) => responseOf(ctx.config, ok(db.eventAttributes)));
// ── A6. attribute_config (driver/point/command/event scopes) ──
on('get', 'api/v3/manager/driver_attribute_config/list_by_device_id', (ctx) =>
+160 -13
View File
@@ -16,30 +16,177 @@
*/
import {on} from '../dispatch';
import {ok, okPage, responseOf} from '../response';
import {matches, paginate} from '../query';
import {newId, stamp} from '../crud';
import {fail, ok, responseOf} from '../response';
import {menuTree} from '../seed/menuTree';
import type {MenuNode} from '@/store/modules/menu';
/** Flatten the tree into a flat list for the Menu settings table. */
const flattenMenu = (nodes: MenuNode[]): MenuNode[] => {
const out: MenuNode[] = [];
const walk = (list: MenuNode[]) => {
for (const node of list) {
out.push(node);
if (node.children?.length) walk(node.children);
}
};
walk(nodes);
type MenuRow = Omit<MenuNode, 'children'> & {
createTime?: string;
operateTime?: string;
remark?: string;
};
/** Flatten the seed tree while preserving the parent relationship. */
const flattenMenu = (nodes: MenuNode[], parentMenuId = '0', level = 1): MenuRow[] => {
const out: MenuRow[] = [];
for (const node of nodes) {
const {children, ...row} = node;
out.push({
...row,
parentMenuId,
menuLevel: row.menuLevel || `C${Math.min(level, 3)}`,
menuTypeFlag: row.menuTypeFlag || (children?.length ? 'TITLE' : 'COMMON'),
enableFlag: row.enableFlag === 'ENABLED' ? 'ENABLE' : row.enableFlag,
createTime: '2026-07-15 09:30:00',
operateTime: '2026-08-01 14:20:00',
});
if (children?.length) out.push(...flattenMenu(children, String(node.id), level + 1));
}
return out;
};
const rows: MenuRow[] = flattenMenu(menuTree);
/** Rebuild the nested response expected by the router and tree selectors. */
const buildTree = (source: MenuRow[]): MenuNode[] => {
const byParent = new Map<string, MenuRow[]>();
for (const row of source) {
const parent = String(row.parentMenuId ?? '0');
const siblings = byParent.get(parent) || [];
siblings.push(row);
byParent.set(parent, siblings);
}
const nest = (row: MenuRow): MenuNode => {
const children = (byParent.get(String(row.id)) || [])
.slice()
.sort((a, b) => Number(a.menuIndex ?? 0) - Number(b.menuIndex ?? 0))
.map(nest);
return children.length ? {...row, children} : {...row};
};
return (byParent.get('0') || [])
.slice()
.sort((a, b) => Number(a.menuIndex ?? 0) - Number(b.menuIndex ?? 0))
.map(nest);
};
const findById = (id: unknown) => rows.find((row) => String(row.id) === String(id));
const descendantsOf = (id: string): Set<string> => {
const descendants = new Set<string>([id]);
let changed = true;
while (changed) {
changed = false;
for (const row of rows) {
if (descendants.has(String(row.parentMenuId)) && !descendants.has(String(row.id))) {
descendants.add(String(row.id));
changed = true;
}
}
}
return descendants;
};
const filterMenuRows = (body: Record<string, unknown> = {}): MenuRow[] => {
const values = (value: unknown) => {
if (Array.isArray(value)) return value.filter((item) => item !== '' && item !== 'ALL').map(String);
if (value === undefined || value === null || value === '' || value === 'ALL') return [];
return [String(value)];
};
const typeFilter = values(body.menuTypeFlag);
const enableFilter = values(body.enableFlag);
const matchesRow = (row: MenuRow) =>
matches(row.menuName, body.menuName) &&
matches(row.menuCode, body.menuCode) &&
(typeFilter.length === 0 || typeFilter.includes(String(row.menuTypeFlag))) &&
(enableFilter.length === 0 || enableFilter.includes(String(row.enableFlag)));
const matched = rows.filter(matchesRow);
if (matched.length === rows.length) return rows;
const byId = new Map(rows.map((row) => [String(row.id), row]));
const included = new Set<string>();
for (const row of matched) {
let current: MenuRow | undefined = row;
const seen = new Set<string>();
while (current && !seen.has(String(current.id))) {
const id = String(current.id);
seen.add(id);
included.add(id);
const parentId: string = String(current.parentMenuId ?? '0');
current = parentId === '0' ? undefined : byId.get(parentId);
}
}
return rows.filter((row) => included.has(String(row.id)));
};
export function registerMenuHandlers(): void {
// Drives the router guard + Layout top nav. menu store takes res when
// it is an array, so we wrap the tree in an R envelope.
on('post', 'api/v3/auth/menu/list_tree', (ctx) => responseOf(ctx.config, ok(menuTree)));
on('post', 'api/v3/auth/menu/list_tree', (ctx) =>
responseOf(ctx.config, ok(buildTree(filterMenuRows(ctx.body || {})))),
);
// Menu management table (settingsMenu route).
on('post', 'api/v3/auth/menu/list', (ctx) =>
responseOf(ctx.config, okPage(flattenMenu(menuTree))),
responseOf(
ctx.config,
ok(paginate(rows, ctx.body, (row) =>
matches(row.menuName, ctx.body?.menuName) &&
matches(row.menuCode, ctx.body?.menuCode) &&
(ctx.body?.menuTypeFlag == null || ctx.body.menuTypeFlag === '' || ctx.body.menuTypeFlag === 'ALL' || String(row.menuTypeFlag) === String(ctx.body.menuTypeFlag)) &&
(ctx.body?.enableFlag == null || ctx.body.enableFlag === '' || ctx.body.enableFlag === 'ALL' || String(row.enableFlag) === String(ctx.body.enableFlag)),
)),
),
);
on('get', 'api/v3/auth/menu/get_by_id', (ctx) =>
responseOf(ctx.config, ok(findById(ctx.params.id) || rows[0] || {})),
);
on('post', 'api/v3/auth/menu/add', (ctx) => {
const parentId = String(ctx.body?.parentMenuId ?? '0');
if (parentId !== '0' && !findById(parentId)) {
return responseOf(ctx.config, fail('R4041', 'Parent menu not found', 404), 404);
}
const row: MenuRow = {
...ctx.body,
id: newId(),
parentMenuId: parentId,
createTime: stamp(),
operateTime: stamp(),
};
rows.push(row);
return responseOf(ctx.config, ok(String(row.id)));
});
on('post', 'api/v3/auth/menu/update', (ctx) => {
const index = rows.findIndex((row) => String(row.id) === String(ctx.body?.id));
const current = index >= 0 ? rows[index] : undefined;
if (!current) return responseOf(ctx.config, fail('R4041', 'Menu not found', 404), 404);
const parentId = String(ctx.body?.parentMenuId ?? current.parentMenuId ?? '0');
if (parentId !== '0' && !findById(parentId)) {
return responseOf(ctx.config, fail('R4041', 'Parent menu not found', 404), 404);
}
// A parent that is the menu itself or one of its descendants detaches the
// whole branch from the root — buildTree would silently drop it.
if (parentId !== '0' && descendantsOf(String(current.id)).has(parentId)) {
return responseOf(ctx.config, fail('R4042', 'Parent menu cannot be the menu itself or its descendant', 400), 400);
}
rows[index] = {...current, ...ctx.body, parentMenuId: parentId, operateTime: stamp()};
return responseOf(ctx.config, ok(String(current.id)));
});
on(['post', 'delete'], 'api/v3/auth/menu/delete', (ctx) => {
const id = String(ctx.params.id ?? ctx.body?.id ?? '');
const ids = descendantsOf(id);
const before = rows.length;
for (let index = rows.length - 1; index >= 0; index -= 1) {
const row = rows[index];
if (row && ids.has(String(row.id))) rows.splice(index, 1);
}
return responseOf(ctx.config, ok(before === rows.length ? '' : id));
});
}
+43 -1
View File
@@ -16,6 +16,7 @@
*/
import {on} from '../dispatch';
import {matches} from '../query';
import {ok, responseOf} from '../response';
import {registerCrud} from '../crud';
import {db} from '../db';
@@ -46,6 +47,47 @@ const buildTree = (
return rows.filter((r) => String(r[parentIdField]) === '0').map(nest);
};
/**
* Apply resource-list filters before rebuilding the tree. The API accepts
* arrays for type/scope filters; matching ancestors are retained so a result
* remains navigable instead of rendering orphaned children.
*/
const filterResources = (rows: Record<string, unknown>[], body: Record<string, unknown> = {}) => {
const values = (key: string): string[] => {
const value = body[key];
if (Array.isArray(value)) return value.map((item) => String(item)).filter(Boolean);
if (value === undefined || value === null || value === '' || value === 'ALL') return [];
return [String(value)];
};
const typeFilter = values('resourceTypeFlags');
const scopeFilter = values('resourceScopeFlags');
const enableFlag = body.enableFlag;
const matchesRow = (row: Record<string, unknown>) =>
matches(row.resourceName, body.resourceName) &&
matches(row.resourceCode, body.resourceCode) &&
(typeFilter.length === 0 || typeFilter.includes(String(row.resourceTypeFlag))) &&
(scopeFilter.length === 0 || scopeFilter.includes(String(row.resourceScopeFlag))) &&
(enableFlag === undefined || enableFlag === null || enableFlag === '' || enableFlag === 'ALL' || String(row.enableFlag) === String(enableFlag));
const matched = rows.filter(matchesRow);
if (matched.length === rows.length) return rows;
const byId = new Map(rows.map((row) => [String(row.id), row]));
const included = new Set<string>();
for (const row of matched) {
let current: Record<string, unknown> | undefined = row;
const seen = new Set<string>();
while (current && !seen.has(String(current.id))) {
const id = String(current.id);
seen.add(id);
included.add(id);
const parentId: string = String(current.parentResourceId ?? '0');
current = parentId === '0' ? undefined : byId.get(parentId);
}
}
return rows.filter((row) => included.has(String(row.id)));
};
export function registerSettingsHandlers(): void {
// ── user ──
registerCrud({
@@ -223,6 +265,6 @@ export function registerSettingsHandlers(): void {
exact: ['resourceTypeFlag', 'resourceScopeFlag', 'enableFlag'],
});
on('post', 'api/v3/auth/resource/list_tree', (ctx) =>
responseOf(ctx.config, ok(buildTree(db.resources, 'id', 'parentResourceId'))),
responseOf(ctx.config, ok(buildTree(filterResources(db.resources, ctx.body), 'id', 'parentResourceId'))),
);
}
+17 -2
View File
@@ -100,13 +100,28 @@ const allRows = (deviceId?: string, pointId?: string): Record<string, unknown>[]
const cursorScope = (deviceId?: unknown, pointId?: unknown): string =>
`${deviceId == null ? '' : String(deviceId)}|${pointId == null ? '' : String(pointId)}`;
const toBase64Url = (value: string): string => {
const bytes = new TextEncoder().encode(value);
let binary = '';
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
};
const fromBase64Url = (value: string): string => {
const base64 = value.replaceAll('-', '+').replaceAll('_', '/');
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
return new TextDecoder().decode(bytes);
};
const encodeCursor = (scope: string, offset: number): string =>
Buffer.from(JSON.stringify({v: 1, scope, offset}), 'utf8').toString('base64url');
toBase64Url(JSON.stringify({v: 1, scope, offset}));
const decodeCursor = (token: unknown, scope: string): number => {
if (typeof token !== 'string' || token.length === 0) return 0;
try {
const decoded = JSON.parse(Buffer.from(token, 'base64url').toString('utf8')) as {
const decoded = JSON.parse(fromBase64Url(token)) as {
v?: number;
scope?: string;
offset?: number;
+2 -1
View File
@@ -29,7 +29,7 @@ import {registerBusinessHandlers} from './handlers/business';
import {registerSettingsHandlers} from './handlers/settings';
import {registerTimeseriesHandlers} from './handlers/timeseries';
import {fallbackHandler} from './handlers/fallback';
import {installAgenticFetchMock} from './fetch';
import {installAgenticFetchMock, installApiFetchMock} from './fetch';
import {db} from './db';
let installed = false;
@@ -59,6 +59,7 @@ export function setupMock(): void {
registerAgenticHandlers();
request.defaults.adapter = createMockAdapter();
installApiFetchMock(db);
installAgenticFetchMock(db);
// No pre-seeded auth: the demo lands on /login so visitors experience the
+5
View File
@@ -133,6 +133,11 @@ const localizePoint = (row: MockRow, locale: 'en' | 'zh'): MockRow => {
const localizeDevice = (row: MockRow, locale: 'en' | 'zh'): MockRow => {
if (locale === 'zh') return {...row};
// Seed rows represent the demo catalogue and may be translated. Runtime
// rows, however, are user data: replacing their names with a profile label
// makes newly created entities impossible to find by the name the user
// entered. Keep those names verbatim in every locale.
if (!sourceRows.device.has(String(row.id))) return {...row};
const profile = sourceRows.profile.get(String(row.profileId ?? ''));
const profileName = profile && profileNames[String(profile.profileCode ?? '')];
if (!profileName) return {...row};
+14 -1
View File
@@ -655,12 +655,25 @@ const attributeSeeds = [
enableFlag: 'ENABLE',
},
];
export const attributes: Attribute[] = attributeSeeds.map((record) => ({
const attributes: Attribute[] = attributeSeeds.map((record) => ({
...record,
version: 0,
attributeTypeFlag: record.attributeTypeFlag as Attribute['attributeTypeFlag'],
}));
// The backend models the four `*_attribute` endpoints as separate tables.
// Split the shared Modbus seed by scope: connection settings belong to the
// driver, point-level flags to the point; command/event attribute configs
// reference the register address, so each gets its own row copy (ids repeat
// across tables exactly as they would in separate backend tables).
const attributesByCode = (codes: string[]): Attribute[] =>
attributes.filter((attribute) => codes.includes(attribute.attributeCode));
export const driverAttributes = attributesByCode(['host', 'port', 'unitId']);
export const pointAttributes = attributesByCode(['address', 'enabled', 'factor']);
export const commandAttributes = attributesByCode(['address']);
export const eventAttributes = attributesByCode(['address']);
// ─── Attribute Config ────────────────────────────────────────────────
// Ids start at 30001. Unifies the four `*_attribute_config` tables:
// - driver: deviceId only
+372 -36
View File
@@ -61,7 +61,10 @@ export const useAgenticStore = defineStore('agentic', () => {
const visible = ref(false);
const bootstrapped = ref(false);
const loading = ref(false);
const sessionsLoading = ref(false);
const sessionsError = ref<unknown | null>(null);
const streaming = ref(false);
const streamingConversationId = ref('');
const sessions = ref<AgenticSession[]>([]);
const models = ref<AgenticModel[]>([]);
const selectedModel = ref('');
@@ -76,6 +79,23 @@ export const useAgenticStore = defineStore('agentic', () => {
const pendingAttachmentIdsByConversation = ref<Record<string, string[]>>({});
const pendingActionsByConversation = ref<Record<string, AgenticAction[]>>({});
const traceEventsByConversation = ref<Record<string, AgenticTraceEvent[]>>({});
const sessionActionLoading = ref<Record<string, boolean>>({});
const actionLoading = ref<Record<string, boolean>>({});
const actionErrors = ref<Record<string, boolean>>({});
const messagesLoadingByConversation = ref<Record<string, boolean>>({});
const messagesErrorByConversation = ref<Record<string, boolean>>({});
const attachmentsLoadingByConversation = ref<Record<string, boolean>>({});
const attachmentsErrorByConversation = ref<Record<string, boolean>>({});
const pendingActionsLoadingByConversation = ref<Record<string, boolean>>({});
const pendingActionsErrorByConversation = ref<Record<string, boolean>>({});
let sessionsRequestToken = 0;
let selectionRequestToken = 0;
let lifecycleToken = 0;
const messagesRequestTokens = new Map<string, number>();
const attachmentsRequestTokens = new Map<string, number>();
const pendingActionsRequestTokens = new Map<string, number>();
const sessionModelRequestTokens = new Map<string, number>();
const sessionPreferenceRequestTokens = new Map<string, number>();
const activeModel = computed(() => {
return models.value.find((model) => model.model === selectedModel.value) || models.value[0] || DEFAULT_MODEL;
@@ -107,6 +127,40 @@ export const useAgenticStore = defineStore('agentic', () => {
return activeConversationId.value ? traceEventsByConversation.value[activeConversationId.value] || [] : [];
});
const currentMessagesLoading = computed(() => Boolean(messagesLoadingByConversation.value[activeConversationId.value]));
const currentMessagesError = computed(() => Boolean(messagesErrorByConversation.value[activeConversationId.value]));
const currentAttachmentsLoading = computed(() =>
Boolean(attachmentsLoadingByConversation.value[activeConversationId.value])
);
const currentAttachmentsError = computed(() => Boolean(attachmentsErrorByConversation.value[activeConversationId.value]));
const currentPendingActionsLoading = computed(() =>
Boolean(pendingActionsLoadingByConversation.value[activeConversationId.value])
);
const currentPendingActionsError = computed(() =>
Boolean(pendingActionsErrorByConversation.value[activeConversationId.value])
);
const setSessionActionLoading = (conversationId: string, busy: boolean) => {
const next = {...sessionActionLoading.value};
if (busy) next[conversationId] = true;
else delete next[conversationId];
sessionActionLoading.value = next;
};
const setActionLoading = (actionId: string, busy: boolean) => {
const next = {...actionLoading.value};
if (busy) next[actionId] = true;
else delete next[actionId];
actionLoading.value = next;
};
const setActionError = (actionId: string, failed: boolean) => {
const next = {...actionErrors.value};
if (failed) next[actionId] = true;
else delete next[actionId];
actionErrors.value = next;
};
const open = async () => {
visible.value = true;
await bootstrap();
@@ -125,6 +179,7 @@ export const useAgenticStore = defineStore('agentic', () => {
};
const bootstrap = async () => {
const currentLifecycle = lifecycleToken;
if (bootstrapped.value) {
const conversationId = ensureActiveSession();
restoreSessionModel(currentSession.value);
@@ -133,13 +188,15 @@ export const useAgenticStore = defineStore('agentic', () => {
loadAttachments(conversationId),
loadPendingActions(conversationId),
]);
return;
return currentLifecycle === lifecycleToken;
}
loading.value = true;
try {
await loadModels();
if (currentLifecycle !== lifecycleToken) return false;
await loadSessions();
if (currentLifecycle !== lifecycleToken) return false;
bootstrapped.value = true;
const conversationId = ensureActiveSession();
restoreSessionModel(currentSession.value);
@@ -148,16 +205,20 @@ export const useAgenticStore = defineStore('agentic', () => {
loadAttachments(conversationId),
loadPendingActions(conversationId),
]);
return currentLifecycle === lifecycleToken;
} finally {
loading.value = false;
if (currentLifecycle === lifecycleToken) loading.value = false;
}
};
const loadModels = async () => {
const currentLifecycle = lifecycleToken;
try {
const response = await listAgenticModels();
if (currentLifecycle !== lifecycleToken) return;
models.value = response?.length ? response : [DEFAULT_MODEL];
} catch (error) {
if (currentLifecycle !== lifecycleToken) return;
models.value = [DEFAULT_MODEL];
warnMessage(i18n.global.t('agentic.failedModels'), 'Agentic', error);
}
@@ -172,19 +233,46 @@ export const useAgenticStore = defineStore('agentic', () => {
};
const loadSessions = async () => {
const currentLifecycle = lifecycleToken;
const requestToken = ++sessionsRequestToken;
sessionsLoading.value = true;
sessionsError.value = null;
try {
const response = await listAgenticSessions({offset: 0, limit: 50});
if (currentLifecycle !== lifecycleToken || requestToken !== sessionsRequestToken) return true;
sessions.value = (response?.items || []).map(normalizeSession);
sessionsError.value = null;
if (activeConversationId.value) {
restoreSessionModel(sessions.value.find((session) => session.conversationId === activeConversationId.value));
}
return true;
} catch (error) {
sessions.value = [];
if (currentLifecycle !== lifecycleToken || requestToken !== sessionsRequestToken) return false;
sessionsError.value = error;
warnMessage(i18n.global.t('agentic.failedSessions'), 'Agentic', error);
return false;
} finally {
if (currentLifecycle === lifecycleToken && requestToken === sessionsRequestToken) sessionsLoading.value = false;
}
};
const retrySessions = async () => {
const currentLifecycle = lifecycleToken;
const loaded = await loadSessions();
if (!loaded || currentLifecycle !== lifecycleToken) return false;
const conversationId = ensureActiveSession();
restoreSessionModel(currentSession.value);
await Promise.all([
loadMessages(conversationId),
loadAttachments(conversationId),
loadPendingActions(conversationId),
]);
return true;
};
const newSession = () => {
if (streaming.value) return false;
selectionRequestToken += 1;
const conversationId = createConversationId();
const model = resolveModelName(selectedModel.value);
selectedModel.value = model;
@@ -206,78 +294,148 @@ export const useAgenticStore = defineStore('agentic', () => {
];
}
persistMessages();
return true;
};
const selectSession = async (conversationId?: string) => {
if (!conversationId) {
return;
if (!conversationId || streaming.value) {
return false;
}
activeConversationId.value = String(conversationId);
const currentLifecycle = lifecycleToken;
const requestToken = ++selectionRequestToken;
const selectedConversationId = String(conversationId);
activeConversationId.value = selectedConversationId;
restoreSessionModel(sessions.value.find((session) => session.conversationId === activeConversationId.value));
if (!messagesByConversation.value[activeConversationId.value]) {
messagesByConversation.value[activeConversationId.value] = [];
persistMessages();
}
await loadMessages(activeConversationId.value);
await Promise.all([loadAttachments(activeConversationId.value), loadPendingActions(activeConversationId.value)]);
await loadMessages(selectedConversationId);
if (currentLifecycle !== lifecycleToken || requestToken !== selectionRequestToken) return false;
await Promise.all([loadAttachments(selectedConversationId), loadPendingActions(selectedConversationId)]);
return currentLifecycle === lifecycleToken && requestToken === selectionRequestToken;
};
const deleteSession = async (conversationId: string) => {
if (!conversationId) {
return;
return false;
}
if (streamingConversationId.value === conversationId) {
warnMessage(i18n.global.t('agentic.sessionStreaming'), 'Agentic');
return false;
}
if (sessionActionLoading.value[conversationId]) {
return false;
}
const currentLifecycle = lifecycleToken;
const session = sessions.value.find((item) => item.conversationId === conversationId);
const persisted = Boolean(session?.createTime || session?.operateTime);
setSessionActionLoading(conversationId, true);
let remoteDeleted = true;
try {
await deleteAgenticSession(conversationId);
if (currentLifecycle !== lifecycleToken) return false;
} catch (error) {
if (currentLifecycle !== lifecycleToken) return false;
warnMessage(i18n.global.t('agentic.failedSessionDelete'), 'Agentic', error);
remoteDeleted = false;
if (persisted) {
setSessionActionLoading(conversationId, false);
return false;
}
} finally {
if (currentLifecycle === lifecycleToken) setSessionActionLoading(conversationId, false);
}
if (currentLifecycle !== lifecycleToken) return false;
delete messagesByConversation.value[conversationId];
delete attachmentsByConversation.value[conversationId];
delete pendingAttachmentIdsByConversation.value[conversationId];
delete pendingActionsByConversation.value[conversationId];
delete traceEventsByConversation.value[conversationId];
delete messagesLoadingByConversation.value[conversationId];
delete messagesErrorByConversation.value[conversationId];
delete attachmentsLoadingByConversation.value[conversationId];
delete attachmentsErrorByConversation.value[conversationId];
delete pendingActionsLoadingByConversation.value[conversationId];
delete pendingActionsErrorByConversation.value[conversationId];
messagesRequestTokens.set(conversationId, (messagesRequestTokens.get(conversationId) || 0) + 1);
attachmentsRequestTokens.set(conversationId, (attachmentsRequestTokens.get(conversationId) || 0) + 1);
pendingActionsRequestTokens.set(conversationId, (pendingActionsRequestTokens.get(conversationId) || 0) + 1);
sessionModelRequestTokens.set(conversationId, (sessionModelRequestTokens.get(conversationId) || 0) + 1);
sessionPreferenceRequestTokens.set(
conversationId,
(sessionPreferenceRequestTokens.get(conversationId) || 0) + 1
);
sessions.value = sessions.value.filter((session) => session.conversationId !== conversationId);
if (activeConversationId.value === conversationId) {
const nextSession = sessions.value[0];
if (nextSession) {
activeConversationId.value = nextSession.conversationId;
await selectSession(nextSession.conversationId);
} else {
newSession();
}
}
persistMessages();
return remoteDeleted || !persisted;
};
const renameSession = async (conversationId: string, title: string) => {
const renameSession = async (
conversationId: string,
title: string,
options: {allowStreaming?: boolean} = {}
) => {
if (
!conversationId ||
(!options.allowStreaming && streamingConversationId.value === conversationId) ||
sessionActionLoading.value[conversationId]
) return false;
const currentLifecycle = lifecycleToken;
const normalizedTitle = normalizeTitle(title);
const previous = sessions.value.find((session) => session.conversationId === conversationId)?.title;
sessions.value = sessions.value.map((session) =>
session.conversationId === conversationId ? {...session, title: normalizedTitle} : session
);
setSessionActionLoading(conversationId, true);
try {
const response = await updateAgenticSession(conversationId, {
title: normalizedTitle,
});
if (currentLifecycle !== lifecycleToken) return false;
sessions.value = sessions.value.map((session) =>
session.conversationId === conversationId ? normalizeSession({...session, ...(response || {})}) : session
session.conversationId === conversationId ? normalizeSession({...session, ...(response || {})}) : session
);
return true;
} catch (error) {
warnMessage(i18n.global.t('agentic.failedSessionUpdate'), 'Agentic', error);
if (currentLifecycle === lifecycleToken && previous !== undefined) {
sessions.value = sessions.value.map((session) =>
session.conversationId === conversationId ? {...session, title: previous} : session
);
}
if (currentLifecycle === lifecycleToken) {
warnMessage(i18n.global.t('agentic.failedSessionUpdate'), 'Agentic', error);
}
return false;
} finally {
if (currentLifecycle === lifecycleToken) setSessionActionLoading(conversationId, false);
}
};
const sendMessage = async (content: string) => {
const sendMessage = async (content: string): Promise<boolean> => {
const text = content.trim();
if (!text || streaming.value) {
return;
return false;
}
const currentLifecycle = lifecycleToken;
const conversationId = ensureActiveSession();
if (!conversationId) {
failMessage('Missing conversation context.', 'Agentic');
return;
return false;
}
const model = resolveModelName(selectedModel.value || sessionModel(currentSession.value));
selectedModel.value = model;
@@ -299,8 +457,10 @@ export const useAgenticStore = defineStore('agentic', () => {
traceEventsByConversation.value[conversationId] = [];
const abortController = new AbortController();
currentAbortController.value = abortController;
streamingConversationId.value = conversationId;
streaming.value = true;
let completed = false;
try {
const request = {
model,
@@ -315,15 +475,27 @@ export const useAgenticStore = defineStore('agentic', () => {
if (activeModel.value.stream) {
await streamAgenticChatCompletion(request, {
signal: abortController.signal,
onEvent: (event) => appendTraceEvent(conversationId, event),
onEvent: (event) => {
if (currentLifecycle === lifecycleToken) appendTraceEvent(conversationId, event);
},
onVisualization: (visualization) =>
currentLifecycle === lifecycleToken &&
appendAssistantVisualization(conversationId, assistantMessage.id, visualization),
onDelta: (delta) => appendAssistantDelta(conversationId, assistantMessage.id, delta),
onReasoning: (reasoning) => appendAssistantReasoning(conversationId, assistantMessage.id, reasoning),
onFinish: (reason) => setAssistantFinishReason(conversationId, assistantMessage.id, reason),
onDelta: (delta) => {
if (currentLifecycle === lifecycleToken) appendAssistantDelta(conversationId, assistantMessage.id, delta);
},
onReasoning: (reasoning) => {
if (currentLifecycle === lifecycleToken) {
appendAssistantReasoning(conversationId, assistantMessage.id, reasoning);
}
},
onFinish: (reason) => {
if (currentLifecycle === lifecycleToken) setAssistantFinishReason(conversationId, assistantMessage.id, reason);
},
});
} else {
const response = await completeAgenticChatCompletion(request, abortController.signal);
if (currentLifecycle !== lifecycleToken) return false;
const responseMessage = response.choices?.[0]?.message;
appendAssistantDelta(conversationId, assistantMessage.id, responseMessage?.content || '');
for (const chart of responseMessage?.contentExt?.charts || responseMessage?.content_ext?.charts || []) {
@@ -334,10 +506,16 @@ export const useAgenticStore = defineStore('agentic', () => {
setAssistantFinishReason(conversationId, assistantMessage.id, finishReason);
}
}
if (currentLifecycle !== lifecycleToken) return false;
markAssistantComplete(conversationId, assistantMessage.id);
await syncSessionAfterMessage(conversationId, text);
if (currentLifecycle !== lifecycleToken) return false;
await Promise.all([loadMessages(conversationId), loadPendingActions(conversationId)]);
if (currentLifecycle !== lifecycleToken) return false;
completed = true;
return true;
} catch (error) {
if (currentLifecycle !== lifecycleToken) return false;
if ((error as Error).name === 'AbortError') {
appendAssistantDelta(conversationId, assistantMessage.id, `\n\n${i18n.global.t('agentic.canceled')}`);
} else {
@@ -346,10 +524,16 @@ export const useAgenticStore = defineStore('agentic', () => {
setAssistantFinishReason(conversationId, assistantMessage.id, 'error');
}
markAssistantComplete(conversationId, assistantMessage.id);
return false;
} finally {
pendingAttachmentIdsByConversation.value[conversationId] = [];
streaming.value = false;
currentAbortController.value = undefined;
if (currentLifecycle === lifecycleToken) {
if (completed) pendingAttachmentIdsByConversation.value[conversationId] = [];
if (streamingConversationId.value === conversationId) {
streaming.value = false;
streamingConversationId.value = '';
}
if (currentAbortController.value === abortController) currentAbortController.value = undefined;
}
}
};
@@ -361,8 +545,19 @@ export const useAgenticStore = defineStore('agentic', () => {
if (!conversationId) {
return;
}
const currentLifecycle = lifecycleToken;
const requestToken = (messagesRequestTokens.get(conversationId) || 0) + 1;
messagesRequestTokens.set(conversationId, requestToken);
messagesLoadingByConversation.value[conversationId] = true;
messagesErrorByConversation.value[conversationId] = false;
try {
const response = await listAgenticMessages(conversationId);
if (currentLifecycle !== lifecycleToken || messagesRequestTokens.get(conversationId) !== requestToken) return;
// While this conversation is streaming, the local optimistic messages are
// newer than this snapshot (its request predates them). Writing it back
// would drop them until the post-stream reload, so skip and let that
// reload — which runs once the server has persisted the turn — land.
if (streamingConversationId.value === conversationId) return;
if (response) {
const previousMessages = messagesByConversation.value[conversationId] || [];
const loadedMessages = response.map((message) => ({
@@ -378,7 +573,13 @@ export const useAgenticStore = defineStore('agentic', () => {
persistMessages();
}
} catch (error) {
if (currentLifecycle !== lifecycleToken || messagesRequestTokens.get(conversationId) !== requestToken) return;
messagesErrorByConversation.value[conversationId] = true;
warnMessage(i18n.global.t('agentic.failedMessages'), 'Agentic', error);
} finally {
if (currentLifecycle === lifecycleToken && messagesRequestTokens.get(conversationId) === requestToken) {
messagesLoadingByConversation.value[conversationId] = false;
}
}
};
@@ -386,18 +587,32 @@ export const useAgenticStore = defineStore('agentic', () => {
if (!conversationId) {
return;
}
const currentLifecycle = lifecycleToken;
const requestToken = (attachmentsRequestTokens.get(conversationId) || 0) + 1;
attachmentsRequestTokens.set(conversationId, requestToken);
attachmentsLoadingByConversation.value[conversationId] = true;
attachmentsErrorByConversation.value[conversationId] = false;
try {
const response = await listAgenticAttachments(conversationId);
if (currentLifecycle !== lifecycleToken || attachmentsRequestTokens.get(conversationId) !== requestToken) return;
attachmentsByConversation.value[conversationId] = response || [];
} catch (error) {
if (currentLifecycle !== lifecycleToken || attachmentsRequestTokens.get(conversationId) !== requestToken) return;
attachmentsErrorByConversation.value[conversationId] = true;
attachmentsByConversation.value[conversationId] = attachmentsByConversation.value[conversationId] || [];
warnMessage(i18n.global.t('agentic.failedAttachments'), 'Agentic', error);
} finally {
if (currentLifecycle === lifecycleToken && attachmentsRequestTokens.get(conversationId) === requestToken) {
attachmentsLoadingByConversation.value[conversationId] = false;
}
}
};
const uploadAttachment = async (file: File) => {
const conversationId = ensureActiveSession();
const currentLifecycle = lifecycleToken;
const response = await uploadAgenticAttachment(conversationId, file);
if (currentLifecycle !== lifecycleToken || activeConversationId.value !== conversationId) return false;
attachmentsByConversation.value[conversationId] = [
response,
...(attachmentsByConversation.value[conversationId] || []),
@@ -406,6 +621,7 @@ export const useAgenticStore = defineStore('agentic', () => {
response.id,
...(pendingAttachmentIdsByConversation.value[conversationId] || []),
];
return true;
};
const removeLocalAttachment = (attachmentId: string) => {
@@ -419,23 +635,67 @@ export const useAgenticStore = defineStore('agentic', () => {
if (!conversationId) {
return;
}
const currentLifecycle = lifecycleToken;
const requestToken = (pendingActionsRequestTokens.get(conversationId) || 0) + 1;
pendingActionsRequestTokens.set(conversationId, requestToken);
pendingActionsLoadingByConversation.value[conversationId] = true;
pendingActionsErrorByConversation.value[conversationId] = false;
try {
const response = await listPendingAgenticActions(conversationId);
if (currentLifecycle !== lifecycleToken || pendingActionsRequestTokens.get(conversationId) !== requestToken) return;
pendingActionsByConversation.value[conversationId] = response?.items || [];
} catch (error) {
if (currentLifecycle !== lifecycleToken || pendingActionsRequestTokens.get(conversationId) !== requestToken) return;
pendingActionsErrorByConversation.value[conversationId] = true;
pendingActionsByConversation.value[conversationId] = [];
warnMessage(i18n.global.t('agentic.failedActions'), 'Agentic', error);
} finally {
if (currentLifecycle === lifecycleToken && pendingActionsRequestTokens.get(conversationId) === requestToken) {
pendingActionsLoadingByConversation.value[conversationId] = false;
}
}
};
const confirmAction = async (actionId: string) => {
await confirmAgenticAction(actionId);
await loadPendingActions(activeConversationId.value);
if (!actionId || actionLoading.value[actionId]) return false;
const currentLifecycle = lifecycleToken;
const conversationId = activeConversationId.value;
setActionError(actionId, false);
setActionLoading(actionId, true);
try {
await confirmAgenticAction(actionId);
if (currentLifecycle !== lifecycleToken) return false;
await loadPendingActions(conversationId);
if (currentLifecycle !== lifecycleToken) return false;
return true;
} catch (error) {
if (currentLifecycle !== lifecycleToken) return false;
setActionError(actionId, true);
throw error;
} finally {
if (currentLifecycle === lifecycleToken) setActionLoading(actionId, false);
}
};
const rejectAction = async (actionId: string) => {
await rejectAgenticAction(actionId);
await loadPendingActions(activeConversationId.value);
if (!actionId || actionLoading.value[actionId]) return false;
const currentLifecycle = lifecycleToken;
const conversationId = activeConversationId.value;
setActionError(actionId, false);
setActionLoading(actionId, true);
try {
await rejectAgenticAction(actionId);
if (currentLifecycle !== lifecycleToken) return false;
await loadPendingActions(conversationId);
if (currentLifecycle !== lifecycleToken) return false;
return true;
} catch (error) {
if (currentLifecycle !== lifecycleToken) return false;
setActionError(actionId, true);
throw error;
} finally {
if (currentLifecycle === lifecycleToken) setActionLoading(actionId, false);
}
};
const ensureActiveSession = () => {
@@ -452,6 +712,7 @@ export const useAgenticStore = defineStore('agentic', () => {
};
const syncSessionAfterMessage = async (conversationId: string, firstUserText: string) => {
const currentLifecycle = lifecycleToken;
const session = sessions.value.find((item) => item.conversationId === conversationId);
// Guard: if session or its title is missing, always generate a new title
// rather than crashing on the non-null assertion in the else branch.
@@ -466,36 +727,46 @@ export const useAgenticStore = defineStore('agentic', () => {
},
...sessions.value,
];
await renameSession(conversationId, title);
await renameSession(conversationId, title, {allowStreaming: true});
return;
}
if (normalizeTitle(session.title || defaultSessionTitle()) !== title) {
await renameSession(conversationId, title);
await renameSession(conversationId, title, {allowStreaming: true});
}
}
if (currentLifecycle !== lifecycleToken) return;
};
const setSelectedModel = async (model: string) => {
const setSelectedModel = async (model: string): Promise<boolean> => {
if (streaming.value) return false;
const currentLifecycle = lifecycleToken;
const nextModel = resolveModelName(String(model || ''));
selectedModel.value = nextModel;
applyModelCapabilities();
const conversationId = activeConversationId.value;
if (!conversationId) {
return;
return true;
}
updateSessionLocally(conversationId, {sessionExt: {model: nextModel}});
const session = sessions.value.find((item) => item.conversationId === conversationId);
if (!session?.createTime && !session?.operateTime) {
return;
return true;
}
const requestToken = (sessionModelRequestTokens.get(conversationId) || 0) + 1;
sessionModelRequestTokens.set(conversationId, requestToken);
try {
const response = await updateAgenticSession(conversationId, {sessionExt: {model: nextModel}});
if (currentLifecycle !== lifecycleToken || sessionModelRequestTokens.get(conversationId) !== requestToken) return false;
sessions.value = sessions.value.map((session) =>
session.conversationId === conversationId ? normalizeSession({...session, ...(response || {})}) : session
);
return true;
} catch (error) {
warnMessage(i18n.global.t('agentic.failedSessionModel'), 'Agentic', error);
if (currentLifecycle === lifecycleToken && sessionModelRequestTokens.get(conversationId) === requestToken) {
warnMessage(i18n.global.t('agentic.failedSessionModel'), 'Agentic', error);
}
return false;
}
};
@@ -522,19 +793,24 @@ export const useAgenticStore = defineStore('agentic', () => {
};
const persistSessionPrefs = async (conversationId: string) => {
if (!conversationId) return;
if (!conversationId || streamingConversationId.value === conversationId) return;
const currentLifecycle = lifecycleToken;
const sessionExt = buildCurrentSessionExt();
updateSessionLocally(conversationId, {sessionExt});
const session = sessions.value.find((item) => item.conversationId === conversationId);
if (!session?.createTime && !session?.operateTime) {
return;
}
const requestToken = (sessionPreferenceRequestTokens.get(conversationId) || 0) + 1;
sessionPreferenceRequestTokens.set(conversationId, requestToken);
try {
await updateAgenticSession(conversationId, {
sessionExt,
});
} catch (error) {
warnMessage(i18n.global.t('agentic.failedSessionPreferences'), 'Agentic', error);
if (currentLifecycle === lifecycleToken && sessionPreferenceRequestTokens.get(conversationId) === requestToken) {
warnMessage(i18n.global.t('agentic.failedSessionPreferences'), 'Agentic', error);
}
}
};
@@ -648,7 +924,10 @@ export const useAgenticStore = defineStore('agentic', () => {
visible,
bootstrapped,
loading,
sessionsLoading,
sessionsError,
streaming,
streamingConversationId,
sessions,
models,
selectedModel,
@@ -661,17 +940,33 @@ export const useAgenticStore = defineStore('agentic', () => {
pendingAttachmentIdsByConversation,
pendingActionsByConversation,
traceEventsByConversation,
sessionActionLoading,
actionLoading,
actionErrors,
messagesLoadingByConversation,
messagesErrorByConversation,
attachmentsLoadingByConversation,
attachmentsErrorByConversation,
pendingActionsLoadingByConversation,
pendingActionsErrorByConversation,
activeModel,
currentMessages,
currentSession,
currentAttachments,
currentPendingActions,
currentTraceEvents,
currentMessagesLoading,
currentMessagesError,
currentAttachmentsLoading,
currentAttachmentsError,
currentPendingActionsLoading,
currentPendingActionsError,
open,
close,
toggle,
bootstrap,
loadSessions,
retrySessions,
newSession,
selectSession,
deleteSession,
@@ -688,10 +983,15 @@ export const useAgenticStore = defineStore('agentic', () => {
confirmAction,
rejectAction,
reset() {
lifecycleToken += 1;
currentAbortController.value?.abort();
visible.value = false;
bootstrapped.value = false;
loading.value = false;
sessionsLoading.value = false;
sessionsError.value = null;
streaming.value = false;
streamingConversationId.value = '';
sessions.value = [];
models.value = [];
selectedModel.value = '';
@@ -705,6 +1005,22 @@ export const useAgenticStore = defineStore('agentic', () => {
pendingAttachmentIdsByConversation.value = {};
pendingActionsByConversation.value = {};
traceEventsByConversation.value = {};
sessionActionLoading.value = {};
actionLoading.value = {};
actionErrors.value = {};
messagesLoadingByConversation.value = {};
messagesErrorByConversation.value = {};
attachmentsLoadingByConversation.value = {};
attachmentsErrorByConversation.value = {};
pendingActionsLoadingByConversation.value = {};
pendingActionsErrorByConversation.value = {};
sessionsRequestToken += 1;
selectionRequestToken += 1;
messagesRequestTokens.clear();
attachmentsRequestTokens.clear();
pendingActionsRequestTokens.clear();
sessionModelRequestTokens.clear();
sessionPreferenceRequestTokens.clear();
try {
localStorage.removeItem(MESSAGE_STORAGE_KEY);
} catch {
@@ -793,6 +1109,26 @@ type RawAgenticSession = AgenticSession & {
session_ext?: RawAgenticSessionExt;
};
const normalizeBoolean = (value: unknown): boolean | undefined => {
if (value === undefined || value === null || value === '') {
return undefined;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
const normalized = String(value).trim().toLowerCase();
if (['true', '1', 'yes', 'y', 'on', 'enable', 'enabled'].includes(normalized)) {
return true;
}
if (['false', '0', 'no', 'n', 'off', 'disable', 'disabled'].includes(normalized)) {
return false;
}
return undefined;
};
const normalizeSession = (session: RawAgenticSession): AgenticSession => {
const {session_ext: _sessionExt, sessionExt, ...rest} = session;
const normalizedExt = normalizeSessionExt(sessionExt || _sessionExt);
@@ -809,7 +1145,7 @@ const normalizeSessionExt = (sessionExt?: RawAgenticSessionExt): AgenticSessionE
}
return {
model: sessionExt.model,
reasoningEnabled: sessionExt.reasoningEnabled ?? sessionExt.reasoning_enabled,
reasoningEnabled: normalizeBoolean(sessionExt.reasoningEnabled ?? sessionExt.reasoning_enabled),
temperature: sessionExt.temperature,
maxTokens: sessionExt.maxTokens ?? sessionExt.max_tokens,
icon: sessionExt.icon,
+31 -14
View File
@@ -52,6 +52,9 @@ interface MenuState {
loading: boolean;
}
let inFlightFetch: Promise<void> | null = null;
let fetchGeneration = 0;
/** Recursively find the first node matching the predicate, depth-first. */
const walk = (nodes: MenuNode[], predicate: (node: MenuNode) => boolean): MenuNode | undefined => {
for (const node of nodes) {
@@ -97,25 +100,39 @@ export const useMenuStore = defineStore('menu', {
actions: {
async fetchTree(force = false) {
if (this.loaded && !force) return;
if (this.loading) return;
if (inFlightFetch) return inFlightFetch;
const generation = fetchGeneration;
this.loading = true;
try {
const res: any = await listMenuTree({});
this.tree = Array.isArray(res) ? res : [];
this.loaded = true;
} catch {
this.tree = [];
// Keep failed loads retryable. The router will still deny non-public
// routes against the empty tree for this navigation, but the next
// navigation can recover if the backend/network comes back.
this.loaded = false;
} finally {
this.loading = false;
}
let request = Promise.resolve();
request = (async () => {
try {
const res: any = await listMenuTree({});
if (generation !== fetchGeneration) return;
this.tree = Array.isArray(res) ? res : [];
this.loaded = true;
} catch {
if (generation !== fetchGeneration) return;
this.tree = [];
// Keep failed loads retryable. The router will still deny non-public
// routes against the empty tree for this navigation, but the next
// navigation can recover if the backend/network comes back.
this.loaded = false;
} finally {
if (inFlightFetch === request) inFlightFetch = null;
if (generation === fetchGeneration) this.loading = false;
}
})();
inFlightFetch = request;
return request;
},
reset() {
fetchGeneration += 1;
inFlightFetch = null;
this.tree = [];
this.loaded = false;
this.loading = false;
},
},
});
+88
View File
@@ -18,6 +18,7 @@
@use '@/styles/things-dialog.scss';
@use '@/styles/things-card.scss';
@use '@/styles/theme.scss';
@use '@/styles/tokens.scss' as *;
// Imported once by main.ts. These rules cannot live in element-variables.scss because
// Vite injects additionalData into every scoped style block and appends scope hashes,
@@ -118,6 +119,56 @@ body {
border-radius: var(--dc3-radius-lg);
}
.entity-page-error {
margin-bottom: var(--dc3-space-3);
}
// Element Plus treats any default-slot content as a long-form description:
// it enlarges the icon/title and stacks the slot below the message. Our
// load-error alerts use that slot only for a Retry link, so the default
// presentation wastes vertical space and separates the action from its
// message. Detect that exact structure and restore a compact action-row
// contract; the link action keeps the shared control density on every
// terminal (A3 v7 note).
.el-alert:has(> .el-alert__content > .el-alert__description > .el-button.is-link:only-child) {
box-sizing: border-box;
min-height: var(--dc3-space-10);
padding-block: 2px;
border-radius: var(--dc3-radius-md);
> .el-alert__icon.is-big {
width: var(--dc3-space-5);
font-size: var(--dc3-space-5);
line-height: var(--dc3-space-5);
}
> .el-alert__content {
display: flex;
align-items: center;
flex: 1 1 auto;
flex-flow: row nowrap;
gap: var(--dc3-space-2);
min-width: 0;
padding: 0;
}
> .el-alert__content > .el-alert__title.with-description {
flex: 1 1 auto;
min-width: 0;
font-size: 14px;
line-height: var(--dc3-space-5);
overflow-wrap: anywhere;
}
> .el-alert__content > .el-alert__description {
display: flex;
align-items: center;
flex: 0 0 auto;
margin: 0;
line-height: 1;
}
}
.json-preview {
max-height: 220px;
margin: 0;
@@ -154,6 +205,43 @@ body {
display: flex;
justify-content: flex-end;
gap: 8px;
.el-button {
min-width: 64px;
margin: 0;
}
}
}
// Element Plus keeps tab headers on one unbreakable row. On touch terminals
// that row must own its horizontal scroll rather than being clipped by the
// fixed workspace viewport; this preserves every tab without creating page-
// level overflow and gives the active tab a predictable swipe target.
@media (max-width: $breakpoint-sm-max) {
.body-main,
.settings-main {
.el-tabs {
min-width: 0;
}
.el-tabs__nav-wrap {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.el-tabs__nav-wrap::-webkit-scrollbar {
display: none;
}
.el-tabs__nav {
min-width: max-content;
}
.el-tabs__item {
padding-inline: var(--dc3-space-3);
}
}
}
+18 -2
View File
@@ -119,6 +119,11 @@
// Touch ergonomics (A3)
--dc3-touch-target: 44px;
--dc3-floating-action-safe-space: calc(var(--dc3-touch-target) + var(--dc3-space-3));
--dc3-z-floating-action: 1000;
--dc3-list-label-width: 78px;
--dc3-skeleton-line-height: 12px;
--dc3-skeleton-title-height: 16px;
color-scheme: light;
}
@@ -179,12 +184,23 @@ html.dark {
// it per device class. things-dialog keeps its own max-height contract;
// this rule only re-shapes width/height on mobile.
@media (max-width: $breakpoint-xs-max) {
// Mobile browsers can expose a wider layout viewport while body scroll is
// locked for a modal. Anchor the teleported overlay to the dynamic visual
// viewport so dialogs stay centred and fully tappable above the browser UI.
.el-overlay.el-modal-dialog,
.el-overlay-dialog {
width: 100dvw;
max-width: 100dvw;
height: 100dvh;
max-height: 100dvh;
}
.el-overlay-dialog .el-dialog:not(.is-fullscreen) {
--el-dialog-width: 100%;
width: calc(100vw - 16px) !important;
width: calc(100dvw - 16px) !important;
margin-top: 8px !important;
margin-bottom: 8px !important;
max-height: calc(100vh - 32px);
max-height: calc(100dvh - 32px);
display: flex;
flex-direction: column;
+109 -2
View File
@@ -15,6 +15,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@use '@/styles/tokens.scss' as *;
// Vertically centre things-dialog + cap its height so tall forms scroll
// inside the dialog rather than bleed past the viewport bottom.
.el-overlay-dialog:has(.things-dialog) {
@@ -106,14 +108,71 @@
}
.param-editor {
margin-top: 12px;
margin-top: var(--dc3-space-3);
}
.param-editor__toolbar {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 8px;
margin-bottom: var(--dc3-space-2);
}
.param-editor__rows {
display: grid;
gap: var(--dc3-space-3);
min-width: 0;
}
.param-editor__row {
min-width: 0;
padding: var(--dc3-space-3);
border: 1px solid var(--dc3-border-base);
border-radius: var(--dc3-radius-md);
background: var(--dc3-bg-elevated);
}
.param-editor__row-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--dc3-space-3);
min-height: var(--dc3-touch-target);
margin-bottom: var(--dc3-space-3);
}
.param-editor__row-title {
min-width: 0;
color: var(--dc3-text-primary);
}
.param-editor__fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--dc3-space-3);
min-width: 0;
}
.param-editor__field {
display: grid;
gap: var(--dc3-space-1);
min-width: 0;
}
.param-editor__field--toggle {
align-content: start;
justify-items: start;
}
.param-editor__label {
color: var(--dc3-text-regular);
font-size: var(--el-font-size-small);
line-height: var(--el-line-height-primary);
}
.param-editor__row .el-input,
.param-editor__row .el-select {
width: 100%;
}
.things-dialog-footer {
@@ -147,3 +206,51 @@
}
}
}
@media (max-width: $breakpoint-xs-max) {
.things-dialog {
&.el-dialog {
width: calc(100% - 16px);
max-width: calc(100% - 16px);
max-height: calc(100dvh - 16px);
}
.el-dialog__body {
padding: var(--dc3-space-4) var(--dc3-space-3) var(--dc3-space-3);
}
.things-form-grid {
grid-template-columns: minmax(0, 1fr);
}
.things-dialog-form-item {
.el-form-item__label {
width: auto;
height: auto;
text-align: left;
}
.el-form-item__label::after {
display: none;
}
}
.param-editor__fields {
grid-template-columns: minmax(0, 1fr);
}
.param-editor__row {
padding: var(--dc3-space-3);
}
.things-dialog-footer {
flex-wrap: wrap;
gap: var(--dc3-space-2);
.el-button {
flex: 1 1 100%;
margin: 0;
}
}
}
}
+45 -12
View File
@@ -16,7 +16,7 @@
*/
import type {Ref} from 'vue';
import {ref} from 'vue';
import {getCurrentInstance, onUnmounted, ref} from 'vue';
/**
* The `loading.value = true; try { ... } catch { /* handled globally *\/ }
@@ -25,10 +25,10 @@ import {ref} from 'vue';
*
* <pre>
* const { loading, run } = useAsyncLoader();
* const load = () => run(async () => {
* const res = await someApi();
* rows.value = res;
* });
* const load = () => run(
* () => someApi(),
* { apply: (rows) => { data.value = rows; } }
* );
* </pre>
*
* Errors are swallowed by default — the axios response interceptor already
@@ -37,21 +37,54 @@ import {ref} from 'vue';
*/
export const useAsyncLoader = (): {
loading: Ref<boolean>;
run: <T>(task: () => Promise<T>, options?: { rethrow?: boolean }) => Promise<T | undefined>;
error: Ref<unknown | null>;
status: Ref<'idle' | 'loading' | 'success' | 'error'>;
run: <T>(
task: () => Promise<T>,
options?: { apply?: (result: T) => void; rethrow?: boolean }
) => Promise<T | undefined>;
/** Invalidates an in-flight task so it cannot commit after its owner closes. */
invalidate: () => void;
} => {
const loading = ref(false);
const error = ref<unknown | null>(null);
const status = ref<'idle' | 'loading' | 'success' | 'error'>('idle');
let latestRunId = 0;
const run = async <T>(task: () => Promise<T>, options?: { rethrow?: boolean }): Promise<T | undefined> => {
const invalidate = () => {
latestRunId += 1;
loading.value = false;
};
// A composable is also used directly by utility tests and non-component
// callers, so only register the lifecycle hook when a Vue instance exists.
if (getCurrentInstance()) onUnmounted(invalidate);
const run = async <T>(
task: () => Promise<T>,
options?: { apply?: (result: T) => void; rethrow?: boolean }
): Promise<T | undefined> => {
const runId = ++latestRunId;
loading.value = true;
error.value = null;
status.value = 'loading';
try {
return await task();
} catch (err) {
if (options?.rethrow) throw err;
const result = await task();
if (runId !== latestRunId) return undefined;
options?.apply?.(result);
status.value = 'success';
return result;
} catch (runError) {
if (runId === latestRunId) {
error.value = runError;
status.value = 'error';
}
if (options?.rethrow) throw runError;
return undefined;
} finally {
loading.value = false;
if (runId === latestRunId) loading.value = false;
}
};
return {loading, run};
return {loading, error, status, run, invalidate};
};
+8
View File
@@ -61,6 +61,14 @@ export function authNameRules(t: Translator, entityName: string): FormItemRule[]
];
}
/** Length and character validation for an optional auth name. */
export function optionalAuthNameRules(t: Translator): FormItemRule[] {
return [
{min: NAME_MIN_LENGTH, max: NAME_MAX_LENGTH, message: t('common.authNameLength'), trigger: 'blur'},
{pattern: AUTH_NAME_PATTERN, message: t('common.authNameFormat'), trigger: 'blur'},
];
}
/** Validation rules for remark fields (max length only). */
export function remarkRules(t: Translator): FormItemRule[] {
return [{max: REMARK_MAX_LENGTH, message: t('common.remarkLength'), trigger: 'blur'}];
+170 -68
View File
@@ -30,6 +30,45 @@
@current-change="currentChange"
/>
<el-alert
v-if="reactiveData.status === 'error'"
:closable="false"
:title="$t('common.loadFailed')"
class="entity-page-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.loading" link type="danger" @click="refresh">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<el-alert
v-if="reactiveData.statusError"
:closable="false"
:title="$t('common.optionLoadFailed')"
class="entity-page-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.statusLoading" link type="danger" @click="retryStatusLookup">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<el-alert
v-if="reactiveData.driverLookupError"
:closable="false"
:title="$t('common.optionLoadFailed')"
class="entity-page-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.driverLookupLoading" link type="danger" @click="retryDriverLookup">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<blank-card>
<el-row>
<template v-if="reactiveData.loading">
@@ -38,14 +77,18 @@
</el-col>
</template>
<template v-else>
<el-col v-if="reactiveData.listData.length < 1">
<el-col v-if="reactiveData.status === 'success' && reactiveData.listData.length < 1">
<el-empty :description="$t('device.empty')"/>
</el-col>
<el-col v-else-if="reactiveData.status === 'error' && reactiveData.listData.length < 1">
<el-empty :description="$t('common.loadFailed')"/>
</el-col>
<el-col v-for="data in reactiveData.listData" :key="data.id" :lg="6" :md="12" :sm="12" :xl="6" :xs="24">
<device-card
:data="data"
:driver="reactiveData.driverTable[data.driverId ?? '']"
:embedded="embedded != ''"
:busy="isActionBusy(data)"
:status="reactiveData.statusTable[data.id]"
@delete="onDelete"
@disable="onDisable"
@@ -62,7 +105,7 @@
</template>
<script lang="ts" setup>
import {computed, onBeforeUnmount, ref, watch} from 'vue';
import {computed, onBeforeUnmount, reactive, ref, watch} from 'vue';
import {useI18n} from 'vue-i18n';
import {
@@ -107,6 +150,10 @@ const props = withDefaults(
const {t} = useI18n();
const OPERATION_POLL_INTERVAL_MS = 1_000;
let importAbortController: AbortController | null = null;
let lookupSequence = 0;
let statusRequestId = 0;
let driverRequestId = 0;
let disposed = false;
const deviceAddFormRef = ref<DialogInstance | null>(null);
const deviceImportFormRef = ref<DialogInstance | null>(null);
@@ -127,9 +174,18 @@ const {
const reactiveData = state as typeof state & {
driverTable: Record<string, Record<string, any>>;
statusTable: Record<string, string>;
statusLoading: boolean;
statusError: unknown | null;
driverLookupLoading: boolean;
driverLookupError: unknown | null;
};
reactiveData.driverTable = {};
reactiveData.statusTable = {};
reactiveData.statusLoading = false;
reactiveData.statusError = null;
reactiveData.driverLookupLoading = false;
reactiveData.driverLookupError = null;
const actionBusy = reactive(new Set<string>());
const baseDeviceQuery = computed(() => {
const q: Record<string, unknown> = {};
@@ -150,15 +206,16 @@ const openAdd = () => {
deviceAddFormRef.value?.show();
};
const onAdd = (form: unknown, done: () => void) => {
const onAdd = (form: unknown, done: (successful?: boolean) => void) => {
addDevice(form as Record<string, unknown>)
.then(() => {
load();
if (disposed) return;
successMessage();
void load();
done(true);
})
.catch(() => {
})
.finally(() => {
done();
if (!disposed) done(false);
});
};
@@ -169,6 +226,7 @@ const openImport = () => {
const importTemplate = (form: unknown, done: (successful: boolean) => void) => {
importDeviceTemplate(form as Record<string, unknown>)
.then((res) => {
if (disposed) return;
const templateResponse = res as {data: Blob; headers: Record<string, string>};
const url = window.URL.createObjectURL(templateResponse.data);
const disposition = templateResponse.headers['content-disposition'] ?? '';
@@ -183,7 +241,7 @@ const importTemplate = (form: unknown, done: (successful: boolean) => void) => {
done(true);
})
.catch(() => {
done(false);
if (!disposed) done(false);
});
};
@@ -237,86 +295,130 @@ const onImport = async (
importAbortController = controller;
try {
const accepted = await importDevice(form as Record<string, unknown>, file, idempotencyKey);
report('PENDING');
const operation = await pollOperation(accepted.statusUri, controller.signal, report);
if (disposed || controller.signal.aborted) return;
const reportIfActive = (status: OperationUiStatus) => {
if (!disposed && !controller.signal.aborted) report(status);
};
reportIfActive('PENDING');
const operation = await pollOperation(accepted.statusUri, controller.signal, reportIfActive);
if (disposed || controller.signal.aborted) return;
if (operation.status === 'SUCCEEDED') {
await load();
return;
}
failMessage(operationErrorDetail(operation));
} catch {
if (!controller.signal.aborted) report('REQUEST_ERROR');
if (!controller.signal.aborted && !disposed) report('REQUEST_ERROR');
}
};
onBeforeUnmount(() => importAbortController?.abort());
onBeforeUnmount(() => {
disposed = true;
importAbortController?.abort();
lookupSequence += 1;
actionBusy.clear();
});
const onDisable = (device: DeviceRecord, done: () => void) => {
updateDevice({...device, enableFlag: 'DISABLE'})
.then(() => {
successMessage();
load();
})
.catch(() => {
})
.finally(() => {
done();
});
const isActionBusy = (device: DeviceRecord) => actionBusy.has(String(device.id));
const runAction = async (device: DeviceRecord, action: () => Promise<unknown>) => {
const id = String(device.id);
if (actionBusy.has(id)) return;
actionBusy.add(id);
try {
await action();
if (disposed) return;
successMessage();
await load();
} catch {
// handled globally
} finally {
actionBusy.delete(id);
}
};
const onEnable = (device: DeviceRecord, done: () => void) => {
updateDevice({...device, enableFlag: 'ENABLE'})
.then(() => {
successMessage();
load();
})
.catch(() => {
})
.finally(() => {
done();
});
};
const onDisable = (device: DeviceRecord) =>
runAction(device, () => updateDevice({...device, enableFlag: 'DISABLE'}));
const onDelete = (device: DeviceRecord, done: () => void) => {
deleteDevice(device.id, device.version)
.then(() => {
successMessage();
load();
})
.catch(() => {
})
.finally(() => {
done();
});
};
const onEnable = (device: DeviceRecord) =>
runAction(device, () => updateDevice({...device, enableFlag: 'ENABLE'}));
const onDelete = (device: DeviceRecord) =>
runAction(device, () => deleteDevice(device.id, device.version));
const refresh = () => load();
const loadStatus = (generation = lookupSequence) => {
const requestId = ++statusRequestId;
reactiveData.statusLoading = true;
reactiveData.statusError = null;
const page = {...reactiveData.page, orders: [...reactiveData.page.orders]};
const query = {...(reactiveData.query as Record<string, unknown>)};
return listDeviceStatus({page, ...query})
.then((res) => {
if (generation !== lookupSequence || requestId !== statusRequestId) return;
reactiveData.statusTable = (res || {}) as Record<string, string>;
})
.catch((error) => {
if (generation !== lookupSequence || requestId !== statusRequestId) return;
reactiveData.statusError = error;
})
.finally(() => {
if (generation === lookupSequence && requestId === statusRequestId) reactiveData.statusLoading = false;
});
};
const loadDriverLookup = (devices = reactiveData.listData, generation = lookupSequence) => {
const requestId = ++driverRequestId;
const driverIds = Array.from(new Set(devices.map((device) => device.driverId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)));
reactiveData.driverLookupError = null;
if (driverIds.length === 0) {
reactiveData.driverLookupLoading = false;
reactiveData.driverTable = {};
return Promise.resolve();
}
reactiveData.driverLookupLoading = true;
return listDriverByIds(driverIds)
.then((res) => {
if (generation !== lookupSequence || requestId !== driverRequestId) return;
reactiveData.driverTable = (res || {}) as Record<string, Record<string, any>>;
})
.catch((error) => {
if (generation !== lookupSequence || requestId !== driverRequestId) return;
reactiveData.driverLookupError = error;
})
.finally(() => {
if (generation === lookupSequence && requestId === driverRequestId) reactiveData.driverLookupLoading = false;
});
};
const retryStatusLookup = () => {
void loadStatus();
};
const retryDriverLookup = () => {
void loadDriverLookup();
};
watch(
() => reactiveData.listData,
(devices) => {
// Load status table
listDeviceStatus({page: reactiveData.page, ...(reactiveData.query as Record<string, unknown>)})
.then((res) => {
reactiveData.statusTable = (res || {}) as Record<string, string>;
})
.catch(() => {
// handled globally
});
const sequence = ++lookupSequence;
reactiveData.driverTable = {};
reactiveData.statusTable = {};
reactiveData.statusError = null;
reactiveData.driverLookupError = null;
void loadStatus(sequence);
void loadDriverLookup(devices, sequence);
}
);
// Load driver lookup table
const driverIds = Array.from(new Set(devices.map((d) => d.driverId).filter((id): id is string => !!id)));
if (driverIds.length === 0) {
reactiveData.driverTable = {};
return;
}
listDriverByIds(driverIds)
.then((res) => {
reactiveData.driverTable = (res || {}) as Record<string, Record<string, any>>;
})
.catch(() => {
// handled globally
});
watch(
() => [props.driverId, props.profileId],
() => {
lookupSequence += 1;
_search(baseDeviceQuery.value);
}
);
+176 -32
View File
@@ -19,17 +19,63 @@
<el-dialog
v-model="reactiveData.formVisible"
:append-to-body="true"
:before-close="requestClose"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
:close-on-press-escape="!reactiveData.submitting"
:show-close="!reactiveData.submitting"
:title="$t('device.add.title')"
class="things-dialog"
destroy-on-close
draggable
@closed="onClosed"
>
<el-form ref="formDataRef" :model="reactiveData.formData" :rules="formRule" label-position="top">
<el-alert
v-if="reactiveData.saveError"
:closable="false"
:title="$t('common.saveFailed')"
class="things-dialog-form-alert"
show-icon
type="error"
/>
<el-alert
v-if="reactiveData.driverError || reactiveData.profileError"
:closable="false"
:title="$t('common.loadFailed')"
class="things-dialog-form-alert"
show-icon
type="error"
>
<el-button
v-if="reactiveData.driverError"
:loading="reactiveData.driverLoading"
link
type="danger"
@click="driverDictionary()"
>
{{ $t('device.add.driver') }} {{ $t('common.retry') }}
</el-button>
<el-button
v-if="reactiveData.profileError"
:loading="reactiveData.profileLoading"
link
type="danger"
@click="profileDictionary()"
>
{{ $t('device.add.profile') }} {{ $t('common.retry') }}
</el-button>
</el-alert>
<el-form
ref="formDataRef"
v-loading="reactiveData.submitting"
:aria-busy="reactiveData.submitting"
:model="reactiveData.formData"
:rules="formRule"
label-position="top"
>
<el-form-item :label="$t('device.add.deviceName')" prop="deviceName">
<el-input
v-model="reactiveData.formData.deviceName"
:disabled="reactiveData.submitting"
:placeholder="$t('device.add.deviceNamePlaceholder')"
clearable
maxlength="32"
@@ -39,6 +85,7 @@
<el-form-item :label="$t('device.add.driver')" prop="driverId">
<el-select
v-model="reactiveData.formData.driverId"
:disabled="reactiveData.submitting"
:loading="reactiveData.driverLoading"
:placeholder="$t('device.add.driverPlaceholder')"
:remote-method="driverDictionary"
@@ -59,6 +106,7 @@
<el-form-item :label="$t('device.add.profile')" prop="profileId">
<el-select
v-model="reactiveData.formData.profileId"
:disabled="reactiveData.submitting"
:loading="reactiveData.profileLoading"
:placeholder="$t('device.add.profilePlaceholder')"
:remote-method="profileDictionary"
@@ -79,6 +127,7 @@
<el-form-item :label="$t('device.add.description')" prop="remark">
<el-input
v-model="reactiveData.formData.remark"
:disabled="reactiveData.submitting"
:placeholder="$t('device.add.descriptionPlaceholder')"
clearable
maxlength="300"
@@ -87,24 +136,28 @@
></el-input>
</el-form-item>
</el-form>
<div class="things-dialog-footer">
<slot name="footer">
<el-button @click="cancel">{{ $t('common.cancel') }}</el-button>
<el-button plain @click="reset">{{ $t('common.reset') }}</el-button>
<el-button type="primary" @click="addThing">{{ $t('common.confirm') }}</el-button>
</slot>
</div>
<template #footer>
<div class="things-dialog-footer">
<slot name="footer">
<el-button :disabled="reactiveData.submitting" @click="cancel">{{ $t('common.cancel') }}</el-button>
<el-button :disabled="reactiveData.submitting" plain @click="reset">{{ $t('common.reset') }}</el-button>
<el-button :loading="reactiveData.submitting" type="primary" @click="addThing">
{{ $t('common.confirm') }}
</el-button>
</slot>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import {reactive, ref, unref} from 'vue';
import {computed, onBeforeUnmount, reactive, ref, unref} from 'vue';
import type {FormInstance, FormRules} from 'element-plus';
import {ElMessageBox} from 'element-plus';
import {useI18n} from 'vue-i18n';
import type {Dictionary} from '@/config/types';
import {successMessage} from '@/utils/notificationUtil';
import {nameRules, remarkRules} from '@/utils/formRuleUtil';
import {listDriverDictionary, listProfileDictionary} from '@/api/dictionary';
@@ -122,25 +175,39 @@ interface DictionaryPage {
type DictionaryResponse = DictionaryPage;
const emit = defineEmits<{
(e: 'add', formData: DeviceAddFormData, done: () => void): void;
(e: 'add', formData: DeviceAddFormData, done: (successful?: boolean) => void): void;
}>();
const {t} = useI18n();
const formDataRef = ref<FormInstance>();
const emptyForm = (): DeviceAddFormData => ({
deviceName: '',
driverId: '',
profileId: '',
remark: '',
});
const reactiveData = reactive({
formData: {
deviceName: '',
driverId: '',
profileId: '',
remark: '',
} as DeviceAddFormData,
formData: emptyForm(),
formVisible: false,
submitting: false,
saveError: false,
driverDictionary: [] as Dictionary[],
driverLoading: false,
driverError: false,
profileDictionary: [] as Dictionary[],
profileLoading: false,
profileError: false,
});
const initialForm = ref<DeviceAddFormData>(emptyForm());
let formSession = 0;
let driverRequest = 0;
let profileRequest = 0;
const isDirty = computed(
() => reactiveData.formVisible && JSON.stringify(reactiveData.formData) !== JSON.stringify(initialForm.value)
);
const formRule = reactive<FormRules>({
deviceName: nameRules(t, t('common.entityDevice')),
@@ -162,18 +229,23 @@ const formRule = reactive<FormRules>({
});
const driverDictionary = async (query = '') => {
if (reactiveData.submitting) return;
const requestId = ++driverRequest;
reactiveData.driverLoading = true;
reactiveData.driverError = false;
try {
const res = await listDriverDictionary<DictionaryResponse>({
offset: 0,
limit: 50,
label: query,
});
reactiveData.driverDictionary = res.items ?? [];
if (requestId === driverRequest && reactiveData.formVisible) {
reactiveData.driverDictionary = res.items ?? [];
}
} catch {
// nothing to do
if (requestId === driverRequest && reactiveData.formVisible) reactiveData.driverError = true;
} finally {
reactiveData.driverLoading = false;
if (requestId === driverRequest) reactiveData.driverLoading = false;
}
};
@@ -184,18 +256,23 @@ const driverDictionaryVisible = (visible: boolean) => {
};
const profileDictionary = async (query = '') => {
if (reactiveData.submitting) return;
const requestId = ++profileRequest;
reactiveData.profileLoading = true;
reactiveData.profileError = false;
try {
const res = await listProfileDictionary<DictionaryResponse>({
offset: 0,
limit: 50,
label: query,
});
reactiveData.profileDictionary = res.items ?? [];
if (requestId === profileRequest && reactiveData.formVisible) {
reactiveData.profileDictionary = res.items ?? [];
}
} catch {
// nothing to do
if (requestId === profileRequest && reactiveData.formVisible) reactiveData.profileError = true;
} finally {
reactiveData.profileLoading = false;
if (requestId === profileRequest) reactiveData.profileLoading = false;
}
};
@@ -206,31 +283,98 @@ const profileDictionaryVisible = (visible: boolean) => {
};
const show = () => {
formSession += 1;
driverRequest += 1;
profileRequest += 1;
reactiveData.formData = emptyForm();
initialForm.value = emptyForm();
reactiveData.driverDictionary = [];
reactiveData.profileDictionary = [];
reactiveData.driverError = false;
reactiveData.profileError = false;
reactiveData.saveError = false;
reactiveData.submitting = false;
reactiveData.formVisible = true;
void driverDictionary();
void profileDictionary();
};
const cancel = () => {
reactiveData.formVisible = false;
void requestClose();
};
const reset = () => {
const form = unref(formDataRef);
form?.resetFields();
reactiveData.formData = {...initialForm.value};
reactiveData.saveError = false;
form?.clearValidate();
};
const onClosed = () => {
formSession += 1;
driverRequest += 1;
profileRequest += 1;
reactiveData.driverLoading = false;
reactiveData.profileLoading = false;
formDataRef.value?.clearValidate();
};
onBeforeUnmount(() => {
formSession += 1;
driverRequest += 1;
profileRequest += 1;
});
const requestClose = async (done?: () => void) => {
if (reactiveData.submitting) return;
const session = formSession;
if (!isDirty.value) {
if (done) done();
else reactiveData.formVisible = false;
return;
}
try {
await ElMessageBox.confirm(t('common.discardConfirm'), t('common.confirm'), {
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
});
if (session !== formSession || !reactiveData.formVisible) return;
if (done) done();
else reactiveData.formVisible = false;
} catch {
// Keep the draft open when the user cancels the confirmation.
}
};
const addThing = async () => {
if (reactiveData.submitting) return;
const form = unref(formDataRef);
if (!form) {
return;
}
const session = formSession;
try {
await form.validate();
emit('add', {...reactiveData.formData}, () => {
cancel();
reset();
successMessage();
});
if (session !== formSession || !reactiveData.formVisible) return;
reactiveData.submitting = true;
reactiveData.saveError = false;
try {
emit('add', {...reactiveData.formData}, (successful = true) => {
if (session !== formSession) return;
reactiveData.submitting = false;
if (!successful) {
reactiveData.saveError = true;
return;
}
initialForm.value = {...reactiveData.formData};
reactiveData.formVisible = false;
});
} catch {
reactiveData.submitting = false;
reactiveData.saveError = true;
}
} catch {
// validation errors are displayed by Element Plus
}
+5 -3
View File
@@ -24,6 +24,7 @@
:icon="icon"
:name="data.deviceName"
:status-title="$t('common.enableFlag')"
:copy-label="$t('device.card.copyDeviceId')"
@copy-id="copy(data.id, $t('device.card.copyDeviceId'))"
>
<el-tag v-if="status === 'ONLINE'" effect="plain" type="success">{{ $t('status.online') }}</el-tag>
@@ -67,6 +68,7 @@
:disable-title="$t('common.confirmDisable', {name: $t('common.entityDevice')})"
:enable-title="$t('common.confirmEnable', {name: $t('common.entityDevice')})"
:enabled="enabled"
:busy="busy"
@delete="emitDelete"
@detail="detail"
@disable="emitToggle('disable')"
@@ -84,7 +86,6 @@ import {Edit, Promotion, Sunset} from '@element-plus/icons-vue';
import router from '@/config/router';
import {copy} from '@/utils/commonUtil';
import {timestamp} from '@/utils/dateUtil';
import {successMessage} from '@/utils/notificationUtil';
import {isEnabledFlag} from '@/utils/thingModelFormatUtil';
import ThingsCardHeader from '@/components/card/header/ThingsCardHeader.vue';
import ThingsCardActions from '@/components/card/actions/ThingsCardActions.vue';
@@ -96,17 +97,18 @@ const props = defineProps({
data: {type: Object as PropType<DeviceRecord>, required: true},
driver: {type: Object as PropType<Record<string, any>>, default: () => ({})},
icon: {type: String, default: 'images/common/device.png'},
busy: {type: Boolean, default: false},
});
const emit = defineEmits(['disable', 'enable', 'delete']);
const enabled = computed(() => isEnabledFlag(props.data.enableFlag));
const emitToggle = (name: 'disable' | 'enable') => {
emit(name, props.data, () => successMessage());
emit(name, props.data);
};
const emitDelete = () => {
emit('delete', props.data, () => successMessage());
emit('delete', props.data);
};
const edit = () => {
+193 -36
View File
@@ -18,18 +18,54 @@
<template>
<div>
<base-card>
<el-tabs v-model="reactiveData.active" @tab-click="changeActive">
<el-alert
v-if="reactiveData.status === 'error'"
:closable="false"
:title="$t('common.loadFailed')"
class="detail-page-alert"
show-icon
type="error"
>
<el-button :loading="reactiveData.loading" link type="danger" @click="device">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<el-empty
v-if="reactiveData.status === 'error' && !reactiveData.data.id"
:description="$t('common.loadFailed')"
/>
<el-skeleton v-else-if="reactiveData.status === 'loading' && !reactiveData.data.id" :rows="6" animated />
<el-tabs
v-else-if="reactiveData.status !== 'error' && reactiveData.data.id"
v-model="reactiveData.active"
v-loading="reactiveData.loading"
@tab-click="changeActive"
>
<el-tab-pane :label="$t('device.detail.deviceInfo')" name="detail">
<detail-card>
<el-descriptions :column="2" border>
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item :label="$t('device.detail.deviceName')"
>{{ reactiveData.data.deviceName }}
</el-descriptions-item>
<el-descriptions-item :label="$t('device.detail.driverName')"
>{{ reactiveData.driver.driverName }}
>
<span v-if="reactiveData.driverLoadError" class="detail-inline-error">
{{ $t('common.loadFailed') }}
<el-button :loading="reactiveData.driverLoading" link size="small" type="danger" @click="retryDriver">
{{ $t('common.retry') }}
</el-button>
</span>
<span v-else>{{ reactiveData.driver.driverName || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item :label="$t('device.detail.profileName')"
>{{ reactiveData.profile.profileName || '-' }}
>
<span v-if="reactiveData.profileLoadError" class="detail-inline-error">
{{ $t('common.loadFailed') }}
<el-button :loading="reactiveData.profileLoading" link size="small" type="danger" @click="retryProfile">
{{ $t('common.retry') }}
</el-button>
</span>
<span v-else>{{ reactiveData.profile.profileName || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item :label="$t('device.detail.profileCode')"
>{{ reactiveData.profile.profileCode || '-' }}
@@ -71,7 +107,7 @@
</template>
<script lang="ts" setup>
import {computed, onMounted, reactive, ref, watch} from 'vue';
import {computed, onBeforeUnmount, onMounted, reactive, ref, watch} from 'vue';
import {useRoute} from 'vue-router';
import router from '@/config/router';
@@ -88,22 +124,27 @@ import CommandList from '@/views/settings/command/CommandList.vue';
import EventList from '@/views/settings/event/definition/EventList.vue';
import {timestamp} from '@/utils/dateUtil';
import type {DeviceRecord, DriverRecord, PointRecord, ProfileRecord} from '@/config/types/manager';
import {useBreakpoint} from '@/composables/useBreakpoint';
const route = useRoute();
const {isMobile} = useBreakpoint();
const pointViewRef = ref<InstanceType<typeof point>>();
const commandViewRef = ref<InstanceType<typeof CommandList>>();
const eventViewRef = ref<InstanceType<typeof EventList>>();
const pointValueViewRef = ref<InstanceType<typeof pointValue>>();
const reactiveData = reactive({
id: route.query.id as string,
id: String(route.query.id ?? ''),
active: (route.query.active as string) || 'detail',
profileLoading: true,
pointLoading: true,
pointValueLoading: true,
loading: true,
status: 'idle' as 'idle' | 'loading' | 'success' | 'error',
data: {} as Partial<DeviceRecord>,
driver: {} as Partial<DriverRecord>,
profile: {} as Partial<ProfileRecord>,
driverLoading: false,
profileLoading: false,
driverLoadError: false,
profileLoadError: false,
profileTable: {} as Record<string, any>,
pointTable: {} as Record<string, any>,
deviceTable: {} as Record<string, any>,
@@ -115,6 +156,10 @@ const reactiveData = reactive({
pointValueDetailData: {} as Record<string, any>,
});
let requestId = 0;
let driverRequestId = 0;
let profileRequestId = 0;
const profileId = computed(() => String(reactiveData.data.profileId || ''));
const pointLength = computed(() => {
@@ -129,36 +174,111 @@ const eventLength = computed(() => {
return eventViewRef.value?.reactiveData?.page?.total || 0;
});
const device = () => {
getDeviceById(reactiveData.id)
const loadDriver = (deviceRequestId: number, deviceId: string, driverId: string) => {
if (!driverId) return;
const relationRequestId = ++driverRequestId;
reactiveData.driverLoading = true;
reactiveData.driverLoadError = false;
void getDriverById(driverId)
.then((res) => {
reactiveData.data = res;
reactiveData.deviceTable[res.id] = res.deviceName;
reactiveData.profile = {};
getDriverById(reactiveData.data.driverId || '')
.then((res) => {
reactiveData.driver = res;
})
.catch(() => {
// nothing to do
});
if (reactiveData.data.profileId) {
getProfileById(String(reactiveData.data.profileId))
.then((res) => {
reactiveData.profile = res || {};
})
.catch(() => {
// nothing to do
});
}
if (
deviceRequestId !== requestId ||
relationRequestId !== driverRequestId ||
deviceId !== String(reactiveData.id || '')
) return;
reactiveData.driver = res || {};
})
.catch(() => {
// nothing to do
if (
deviceRequestId === requestId &&
relationRequestId === driverRequestId &&
deviceId === String(reactiveData.id || '')
) {
reactiveData.driverLoadError = true;
}
})
.finally(() => {
reactiveData.profileLoading = false;
if (deviceRequestId === requestId && relationRequestId === driverRequestId) reactiveData.driverLoading = false;
});
};
const loadProfile = (deviceRequestId: number, deviceId: string, profileIdValue: string) => {
if (!profileIdValue) return;
const relationRequestId = ++profileRequestId;
reactiveData.profileLoading = true;
reactiveData.profileLoadError = false;
void getProfileById(profileIdValue)
.then((res) => {
if (
deviceRequestId !== requestId ||
relationRequestId !== profileRequestId ||
deviceId !== String(reactiveData.id || '')
) return;
reactiveData.profile = res || {};
})
.catch(() => {
if (
deviceRequestId === requestId &&
relationRequestId === profileRequestId &&
deviceId === String(reactiveData.id || '')
) {
reactiveData.profileLoadError = true;
}
})
.finally(() => {
if (deviceRequestId === requestId && relationRequestId === profileRequestId) reactiveData.profileLoading = false;
});
};
const retryDriver = () => {
const driverId = String(reactiveData.data.driverId || '');
if (driverId && reactiveData.data.id) loadDriver(requestId, String(reactiveData.data.id), driverId);
};
const retryProfile = () => {
const profileIdValue = String(reactiveData.data.profileId || '');
if (profileIdValue && reactiveData.data.id) loadProfile(requestId, String(reactiveData.data.id), profileIdValue);
};
const device = () => {
const currentRequestId = ++requestId;
const deviceId = String(reactiveData.id || '');
reactiveData.loading = true;
reactiveData.status = 'loading';
reactiveData.data = {};
reactiveData.driver = {};
reactiveData.profile = {};
driverRequestId += 1;
profileRequestId += 1;
reactiveData.driverLoading = false;
reactiveData.profileLoading = false;
reactiveData.driverLoadError = false;
reactiveData.profileLoadError = false;
if (!deviceId) {
reactiveData.loading = false;
reactiveData.status = 'error';
return Promise.resolve();
}
return getDeviceById(deviceId)
.then((res) => {
if (currentRequestId !== requestId || deviceId !== String(reactiveData.id || '')) return;
reactiveData.data = res || {};
if (!res?.id) {
reactiveData.status = 'error';
return;
}
reactiveData.deviceTable = {[res.id]: res.deviceName};
reactiveData.status = 'success';
const driverId = String(reactiveData.data.driverId || '');
loadDriver(currentRequestId, deviceId, driverId);
loadProfile(currentRequestId, deviceId, String(reactiveData.data.profileId || ''));
})
.catch(() => {
if (currentRequestId === requestId) reactiveData.status = 'error';
})
.finally(() => {
if (currentRequestId === requestId) reactiveData.loading = false;
});
};
@@ -190,9 +310,23 @@ const changeActive = (tab: any) => {
watch(
() => [route.query.id, route.query.active],
([id, active]) => {
const nextId = id as string;
if (nextId && nextId !== reactiveData.id) {
const nextId = String(id ?? '');
if (nextId !== reactiveData.id) {
reactiveData.id = nextId;
reactiveData.data = {};
reactiveData.driver = {};
reactiveData.profile = {};
reactiveData.driverLoadError = false;
reactiveData.profileLoadError = false;
reactiveData.profileTable = {};
reactiveData.pointTable = {};
reactiveData.deviceTable = {};
reactiveData.unitTable = {};
reactiveData.listProfileData = [];
reactiveData.listPointData = [];
reactiveData.listPointValueData = [];
reactiveData.listPointValueHistoryData = {};
reactiveData.pointValueDetailData = {};
device();
}
reactiveData.active = (active as string) || 'detail';
@@ -202,4 +336,27 @@ watch(
onMounted(() => {
device();
});
onBeforeUnmount(() => {
requestId += 1;
driverRequestId += 1;
profileRequestId += 1;
});
</script>
<style lang="scss" scoped>
.detail-page-alert {
margin-bottom: var(--dc3-space-3);
:deep(.el-alert__content) {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--dc3-space-2);
}
}
.detail-inline-error {
color: var(--el-color-danger);
}
</style>
+303 -72
View File
@@ -18,12 +18,46 @@
<template>
<div>
<base-card>
<el-tabs v-model="reactiveData.active" @tab-click="changeActive">
<el-alert
v-if="reactiveData.deviceError"
:closable="false"
:title="$t('device.edit.deviceLoadFailed')"
class="device-edit-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.deviceLoading" link type="danger" @click="device">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<el-empty
v-if="reactiveData.deviceMissing || (reactiveData.deviceError && !reactiveData.deviceFormData.id)"
:description="reactiveData.deviceMissing ? $t('common.description') : $t('device.edit.deviceLoadFailed')"
/>
<el-tabs
v-else
v-model="reactiveData.active"
v-loading="reactiveData.deviceLoading"
@tab-click="changeActive"
>
<!-- Device Config -->
<el-tab-pane :label="$t('device.edit.deviceConfig')" name="deviceConfig">
<el-alert
v-if="reactiveData.deviceSaveError"
:closable="false"
:title="$t('common.saveFailed')"
class="device-edit-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.deviceSaving" link type="danger" @click="deviceSave">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<info-card
:form-model="reactiveData.deviceFormData"
:rules="deviceFormRule"
:saving="reactiveData.deviceSaving"
@reset="deviceReset"
@save="deviceSave"
>
@@ -38,46 +72,74 @@
/>
</el-form-item>
<el-form-item :label="$t('device.edit.driver')" prop="driverId">
<el-select
v-model="reactiveData.deviceFormData.driverId"
:loading="reactiveData.driverLoading"
:placeholder="$t('device.edit.driverPlaceholder')"
:remote-method="driverDictionary"
clearable
filterable
remote
reserve-keyword
@change="changeAttribute"
@visible-change="driverDictionaryVisible"
>
<el-option
v-for="dictionary in reactiveData.driverDictionary"
:key="dictionary.value"
:label="dictionary.label"
:value="dictionary.value"
/>
</el-select>
<div class="dictionary-field">
<el-select
v-model="reactiveData.deviceFormData.driverId"
:loading="reactiveData.driverLoading"
:placeholder="$t('device.edit.driverPlaceholder')"
:remote-method="driverDictionary"
clearable
filterable
remote
reserve-keyword
@change="handleDriverChange"
@visible-change="driverDictionaryVisible"
>
<el-option
v-for="dictionary in reactiveData.driverDictionary"
:key="dictionary.value"
:label="dictionary.label"
:value="dictionary.value"
/>
</el-select>
<el-alert
v-if="reactiveData.driverDictionaryError"
:closable="false"
:title="$t('common.loadFailed')"
class="dictionary-load-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.driverLoading" link type="danger" @click="driverDictionary('')">
{{ $t('common.retry') }}
</el-button>
</el-alert>
</div>
</el-form-item>
<el-form-item :label="$t('device.edit.profile')" prop="profileId">
<el-select
v-model="reactiveData.deviceFormData.profileId"
:loading="reactiveData.profileLoading"
:placeholder="$t('device.edit.profilePlaceholder')"
:remote-method="profileDictionary"
clearable
filterable
remote
reserve-keyword
@change="changeProfile"
@visible-change="profileDictionaryVisible"
>
<el-option
v-for="dictionary in reactiveData.profileDictionary"
:key="dictionary.value"
:label="dictionary.label"
:value="dictionary.value"
/>
</el-select>
<div class="dictionary-field">
<el-select
v-model="reactiveData.deviceFormData.profileId"
:loading="reactiveData.profileLoading"
:placeholder="$t('device.edit.profilePlaceholder')"
:remote-method="profileDictionary"
clearable
filterable
remote
reserve-keyword
@change="handleProfileChange"
@visible-change="profileDictionaryVisible"
>
<el-option
v-for="dictionary in reactiveData.profileDictionary"
:key="dictionary.value"
:label="dictionary.label"
:value="dictionary.value"
/>
</el-select>
<el-alert
v-if="reactiveData.profileDictionaryError"
:closable="false"
:title="$t('common.loadFailed')"
class="dictionary-load-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.profileLoading" link type="danger" @click="profileDictionary('')">
{{ $t('common.retry') }}
</el-button>
</el-alert>
</div>
</el-form-item>
<el-form-item :label="$t('common.enableFlag')" prop="enableFlag">
<enable-flag-segmented v-model="reactiveData.deviceFormData.enableFlag"/>
@@ -105,15 +167,34 @@
@discard="driverInfoReset"
@save="saveDriverMatrix"
/>
<el-empty v-if="!hasDriverAttributes" :description="$t('device.edit.driverAttributeEmpty')"/>
<el-table
v-else
v-loading="reactiveData.loading"
:data="reactiveData.driverAttributes"
class="matrix-table"
row-key="id"
stripe
<el-alert
v-if="reactiveData.matrixErrors.driver"
:closable="false"
:title="$t('device.edit.driverLoadFailed')"
class="matrix-load-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.loading" link type="danger" @click="retryMatrix">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<el-empty v-if="!hasDriverAttributes" :description="$t('device.edit.driverAttributeEmpty')"/>
<div
v-else
:aria-label="$t('device.edit.matrixScrollRegion')"
class="matrix-table-scroll"
role="region"
tabindex="0"
>
<p class="matrix-table-scroll__hint">{{ $t('device.edit.matrixScrollHint') }}</p>
<el-table
v-loading="reactiveData.loading"
:data="reactiveData.driverAttributes"
class="matrix-table"
row-key="id"
stripe
>
<el-table-column :label="$t('device.edit.attributeName')" min-width="140" prop="attributeName"/>
<el-table-column :label="$t('device.edit.attributeType')" width="90">
<template #default="{row}">
@@ -127,6 +208,7 @@
<el-switch
v-if="isBooleanAttribute(attribute)"
:active-value="true"
:disabled="reactiveData.driverSaving"
:inactive-value="false"
:model-value="getDriverCellValue(attribute)"
size="small"
@@ -134,6 +216,7 @@
/>
<el-input-number
v-else-if="isNumberAttribute(attribute)"
:disabled="reactiveData.driverSaving"
:model-value="getDriverCellValue(attribute)"
:placeholder="attributePlaceholder(attribute)"
:precision="attributePrecision(attribute)"
@@ -144,6 +227,7 @@
/>
<el-input
v-else
:disabled="reactiveData.driverSaving"
:model-value="getDriverCellValue(attribute)"
:placeholder="attributePlaceholder(attribute)"
clearable
@@ -159,7 +243,8 @@
</div>
</template>
</el-table-column>
</el-table>
</el-table>
</div>
</el-tab-pane>
<!-- Point Config -->
@@ -189,6 +274,7 @@
v-if="filteredPointInfoData.length > reactiveData.pointPageSize"
v-model:current-page="reactiveData.pointPageCurrent"
v-model:page-size="reactiveData.pointPageSize"
:disabled="reactiveData.pointSaving"
:page-sizes="[10, 20, 50]"
:total="filteredPointInfoData.length"
layout="total, sizes, prev, pager, next"
@@ -197,14 +283,35 @@
</template>
</matrix-toolbar>
<el-empty v-if="!hasPointAttributes" :description="$t('device.edit.pointAttributeEmpty')"/>
<el-table
v-else
v-loading="reactiveData.loading"
:data="paginatedPointInfoData"
:row-class-name="pointMatrixRowClassName"
stripe
<el-alert
v-if="reactiveData.matrixErrors.point"
:closable="false"
:title="$t('device.edit.pointLoadFailed')"
class="matrix-load-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.loading" link type="danger" @click="retryMatrix">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<el-empty v-if="!hasPointAttributes" :description="$t('device.edit.pointAttributeEmpty')"/>
<div
v-else
:aria-label="$t('device.edit.matrixScrollRegion')"
class="matrix-table-scroll"
role="region"
tabindex="0"
>
<p class="matrix-table-scroll__hint">{{ $t('device.edit.matrixScrollHint') }}</p>
<el-table
v-loading="reactiveData.loading"
:data="paginatedPointInfoData"
:row-class-name="pointMatrixRowClassName"
class="matrix-table"
stripe
>
<el-table-column :label="$t('device.edit.pointName')" fixed min-width="160" show-overflow-tooltip>
<template #default="{row}">
<el-tooltip :content="row.pointCode || row.id" :disabled="!row.pointCode" placement="top">
@@ -240,6 +347,7 @@
v-if="isBooleanAttribute(attribute)"
v-model="pointCell(row, attribute).configValue"
:active-value="true"
:disabled="reactiveData.pointSaving || pointCell(row, attribute).saving"
:inactive-value="false"
size="small"
@change="markPointCellDirty(row, attribute)"
@@ -247,6 +355,7 @@
<el-input
v-else-if="isNumberAttribute(attribute)"
v-model="pointCell(row, attribute).configValue"
:disabled="reactiveData.pointSaving || pointCell(row, attribute).saving"
:placeholder="attributePlaceholder(attribute)"
clearable
inputmode="decimal"
@@ -259,6 +368,7 @@
<el-input
v-else
v-model="pointCell(row, attribute).configValue"
:disabled="reactiveData.pointSaving || pointCell(row, attribute).saving"
:placeholder="attributePlaceholder(attribute)"
clearable
maxlength="512"
@@ -276,7 +386,8 @@
</div>
</template>
</el-table-column>
</el-table>
</el-table>
</div>
</el-tab-pane>
<!-- Command Config -->
@@ -306,6 +417,7 @@
v-if="filteredCommandInfoData.length > reactiveData.commandPageSize"
v-model:current-page="reactiveData.commandPageCurrent"
v-model:page-size="reactiveData.commandPageSize"
:disabled="reactiveData.commandSaving"
:page-sizes="[10, 20, 50]"
:total="filteredCommandInfoData.length"
layout="total, sizes, prev, pager, next"
@@ -314,14 +426,35 @@
</template>
</matrix-toolbar>
<el-empty v-if="!hasCommandAttributes" :description="$t('device.edit.commandAttributeEmpty')"/>
<el-table
v-else
v-loading="reactiveData.loading"
:data="paginatedCommandInfoData"
:row-class-name="commandMatrixRowClassName"
stripe
<el-alert
v-if="reactiveData.matrixErrors.command"
:closable="false"
:title="$t('device.edit.commandLoadFailed')"
class="matrix-load-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.loading" link type="danger" @click="retryMatrix">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<el-empty v-if="!hasCommandAttributes" :description="$t('device.edit.commandAttributeEmpty')"/>
<div
v-else
:aria-label="$t('device.edit.matrixScrollRegion')"
class="matrix-table-scroll"
role="region"
tabindex="0"
>
<p class="matrix-table-scroll__hint">{{ $t('device.edit.matrixScrollHint') }}</p>
<el-table
v-loading="reactiveData.loading"
:data="paginatedCommandInfoData"
:row-class-name="commandMatrixRowClassName"
class="matrix-table"
stripe
>
<el-table-column :label="$t('device.edit.commandName')" fixed min-width="160" show-overflow-tooltip>
<template #default="{row}">
<el-tooltip :content="row.commandCode || row.id" :disabled="!row.commandCode" placement="top">
@@ -357,6 +490,7 @@
v-if="isBooleanAttribute(attribute)"
v-model="commandCell(row, attribute).configValue"
:active-value="true"
:disabled="reactiveData.commandSaving || commandCell(row, attribute).saving"
:inactive-value="false"
size="small"
@change="markCommandCellDirty(row, attribute)"
@@ -364,6 +498,7 @@
<el-input
v-else-if="isNumberAttribute(attribute)"
v-model="commandCell(row, attribute).configValue"
:disabled="reactiveData.commandSaving || commandCell(row, attribute).saving"
:placeholder="attributePlaceholder(attribute)"
clearable
inputmode="decimal"
@@ -376,6 +511,7 @@
<el-input
v-else
v-model="commandCell(row, attribute).configValue"
:disabled="reactiveData.commandSaving || commandCell(row, attribute).saving"
:placeholder="attributePlaceholder(attribute)"
clearable
maxlength="512"
@@ -393,7 +529,8 @@
</div>
</template>
</el-table-column>
</el-table>
</el-table>
</div>
</el-tab-pane>
<!-- Event Config -->
@@ -423,6 +560,7 @@
v-if="filteredEventInfoData.length > reactiveData.eventPageSize"
v-model:current-page="reactiveData.eventPageCurrent"
v-model:page-size="reactiveData.eventPageSize"
:disabled="reactiveData.eventSaving"
:page-sizes="[10, 20, 50]"
:total="filteredEventInfoData.length"
layout="total, sizes, prev, pager, next"
@@ -431,14 +569,35 @@
</template>
</matrix-toolbar>
<el-empty v-if="!hasEventAttributes" :description="$t('device.edit.eventAttributeEmpty')"/>
<el-table
v-else
v-loading="reactiveData.loading"
:data="paginatedEventInfoData"
:row-class-name="eventMatrixRowClassName"
stripe
<el-alert
v-if="reactiveData.matrixErrors.event"
:closable="false"
:title="$t('device.edit.eventLoadFailed')"
class="matrix-load-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.loading" link type="danger" @click="retryMatrix">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<el-empty v-if="!hasEventAttributes" :description="$t('device.edit.eventAttributeEmpty')"/>
<div
v-else
:aria-label="$t('device.edit.matrixScrollRegion')"
class="matrix-table-scroll"
role="region"
tabindex="0"
>
<p class="matrix-table-scroll__hint">{{ $t('device.edit.matrixScrollHint') }}</p>
<el-table
v-loading="reactiveData.loading"
:data="paginatedEventInfoData"
:row-class-name="eventMatrixRowClassName"
class="matrix-table"
stripe
>
<el-table-column :label="$t('device.edit.eventName')" fixed min-width="160" show-overflow-tooltip>
<template #default="{row}">
<el-tooltip :content="row.eventCode || row.id" :disabled="!row.eventCode" placement="top">
@@ -474,6 +633,7 @@
v-if="isBooleanAttribute(attribute)"
v-model="eventCell(row, attribute).configValue"
:active-value="true"
:disabled="reactiveData.eventSaving || eventCell(row, attribute).saving"
:inactive-value="false"
size="small"
@change="markEventCellDirty(row, attribute)"
@@ -481,6 +641,7 @@
<el-input
v-else-if="isNumberAttribute(attribute)"
v-model="eventCell(row, attribute).configValue"
:disabled="reactiveData.eventSaving || eventCell(row, attribute).saving"
:placeholder="attributePlaceholder(attribute)"
clearable
inputmode="decimal"
@@ -493,6 +654,7 @@
<el-input
v-else
v-model="eventCell(row, attribute).configValue"
:disabled="reactiveData.eventSaving || eventCell(row, attribute).saving"
:placeholder="attributePlaceholder(attribute)"
clearable
maxlength="512"
@@ -510,7 +672,8 @@
</div>
</template>
</el-table-column>
</el-table>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
</base-card>
@@ -522,6 +685,40 @@
<style lang="scss" scoped>
@use '@/styles/edit-card.scss';
.device-edit-error,
.matrix-load-error {
margin-bottom: var(--dc3-space-3);
:deep(.el-alert__content) {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--dc3-space-2);
}
}
.dictionary-field {
display: grid;
gap: var(--dc3-space-2);
width: 100%;
min-width: 0;
}
.dictionary-load-error {
margin: 0;
:deep(.el-alert__content) {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--dc3-space-2);
}
}
.matrix-load-error {
margin-top: var(--dc3-space-2);
}
.driver-info-card {
margin-top: 12px;
}
@@ -549,6 +746,30 @@
}
}
.matrix-table-scroll {
width: 100%;
max-width: 100%;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-inline: contain;
-webkit-overflow-scrolling: touch;
scrollbar-gutter: stable;
outline: none;
&:focus-visible {
box-shadow: var(--dc3-focus-ring);
border-radius: var(--dc3-radius-sm);
}
}
.matrix-table-scroll__hint {
display: none;
margin: 0 0 var(--dc3-space-2);
color: var(--el-text-color-secondary);
font-size: var(--el-font-size-extra-small);
}
.point-matrix-name {
font-size: 13px;
font-weight: 500;
@@ -563,6 +784,16 @@
font-size: 13px;
}
@media (max-width: $breakpoint-sm-max) {
.matrix-table-scroll__hint {
display: block;
}
.matrix-table-scroll :deep(.el-table) {
min-width: max-content;
}
}
.matrix-cell {
&.is-dirty :deep(.el-input__wrapper) {
box-shadow: 0 0 0 1px var(--el-color-warning) inset;
+428 -117
View File
@@ -21,6 +21,7 @@ import {
onBeforeUnmount,
onMounted,
reactive,
ref,
watch,
} from "vue";
import type { FormItemRule, FormRules } from "element-plus";
@@ -97,6 +98,7 @@ interface AttributeFormItem {
type AttributeFormData = Record<string, AttributeFormItem>;
type PointMatrixStatus = "" | "missing" | "configured" | "dirty" | "error";
type MatrixKey = "driver" | "point" | "command" | "event";
interface PointAttributeCell extends AttributeFormItem {
attributeId: string;
@@ -232,9 +234,7 @@ function createPointAttributeCell(
version?: number,
value?: unknown,
): PointAttributeCell {
const configValue = hasConfigValue(value)
? coerceAttributeValue(attribute, value)
: null;
const configValue = coerceAttributeValue(attribute, value);
return {
id: id || undefined,
version,
@@ -340,6 +340,16 @@ function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}
// The matrix pager is unmounted once rows <= pageSize, so Element Plus no
// longer clamps a stale page number left over from a larger dataset. Clamp at
// the read point instead, so a reload that shrinks the data can never slice an
// empty page and blank the table.
function paginateRows<T>(rows: T[], current: number, size: number): T[] {
const maxPage = Math.max(1, Math.ceil(rows.length / size));
const safeCurrent = Math.min(Math.max(current, 1), maxPage);
return rows.slice((safeCurrent - 1) * size, safeCurrent * size);
}
const DEVICE_EDIT_TABS = [
"deviceConfig",
"driverConfig",
@@ -368,9 +378,20 @@ export default defineComponent({
const { t } = useI18n();
const reactiveData = reactive({
id: route.query.id as string,
id: String(route.query.id ?? ""),
active: resolveDeviceTab(route.query.active),
loading: true,
deviceLoading: true,
deviceSaving: false,
deviceSaveError: false,
deviceError: false,
deviceMissing: false,
matrixErrors: {
driver: false,
point: false,
command: false,
event: false,
} as Record<MatrixKey, boolean>,
oldDeviceFormData: {} as Partial<DeviceRecord>,
deviceFormData: {} as Partial<DeviceRecord>,
driverAttributes: [] as Attribute[],
@@ -407,10 +428,39 @@ export default defineComponent({
eventPageCurrent: 1,
driverDictionary: [] as Dictionary[],
driverLoading: false,
driverDictionaryError: false,
profileDictionary: [] as Dictionary[],
profileLoading: false,
profileDictionaryError: false,
});
const loadedDriverId = ref("");
const loadedProfileId = ref("");
const selectionIntent = ref(0);
let matrixRequestToken = 0;
let driverDictionaryRequestToken = 0;
let profileDictionaryRequestToken = 0;
let deviceRequestToken = 0;
let disposed = false;
type MatrixRequestContext = {
token: number;
driverId: string;
profileId: string;
};
let matrixContext: MatrixRequestContext = {
token: 0,
driverId: "",
profileId: "",
};
const isCurrentMatrixContext = (context: MatrixRequestContext) =>
!disposed &&
context.token === matrixRequestToken &&
matrixContext === context &&
String(reactiveData.deviceFormData.driverId || "") === context.driverId &&
String(reactiveData.deviceFormData.profileId || "") === context.profileId;
const deviceFormRule = reactive<FormRules>({
deviceName: nameRules(t, t("common.entityDevice")),
driverId: [
@@ -473,12 +523,38 @@ export default defineComponent({
}, 0);
});
const driverDirtySet = reactive(new Set<string>());
const driverDirtyCount = computed(() => driverDirtySet.size);
const deviceFormDirty = computed(() => {
const fields: Array<keyof DeviceRecord> = [
"deviceName",
"driverId",
"profileId",
"enableFlag",
"remark",
];
return fields.some(
(field) =>
String(reactiveData.deviceFormData[field] ?? "") !==
String(reactiveData.oldDeviceFormData[field] ?? ""),
);
});
const matrixDirtyCount = computed(
() =>
pointDirtyCount.value +
commandDirtyCount.value +
eventDirtyCount.value +
driverDirtyCount.value,
);
const filteredPointInfoData = computed(() => {
const keyword = reactiveData.pointMatrixKeyword.trim().toLowerCase();
return reactiveData.pointInfoData.filter((row) => {
const matchesKeyword =
!keyword ||
row.pointName.toLowerCase().includes(keyword) ||
String(row.pointName ?? "").toLowerCase().includes(keyword) ||
String(row.pointCode || "")
.toLowerCase()
.includes(keyword);
@@ -499,7 +575,7 @@ export default defineComponent({
return reactiveData.commandInfoData.filter((row) => {
const matchesKeyword =
!keyword ||
row.commandName.toLowerCase().includes(keyword) ||
String(row.commandName ?? "").toLowerCase().includes(keyword) ||
String(row.commandCode || "")
.toLowerCase()
.includes(keyword);
@@ -520,7 +596,7 @@ export default defineComponent({
return reactiveData.eventInfoData.filter((row) => {
const matchesKeyword =
!keyword ||
row.eventName.toLowerCase().includes(keyword) ||
String(row.eventName ?? "").toLowerCase().includes(keyword) ||
String(row.eventCode || "")
.toLowerCase()
.includes(keyword);
@@ -538,26 +614,29 @@ export default defineComponent({
// Client-side pagination helpers — slice the filtered arrays so long
// point / command / event lists don't force the user to scroll forever.
const paginatedPointInfoData = computed(() => {
const filtered = filteredPointInfoData.value;
const start =
(reactiveData.pointPageCurrent - 1) * reactiveData.pointPageSize;
return filtered.slice(start, start + reactiveData.pointPageSize);
});
const paginatedPointInfoData = computed(() =>
paginateRows(
filteredPointInfoData.value,
reactiveData.pointPageCurrent,
reactiveData.pointPageSize,
),
);
const paginatedCommandInfoData = computed(() => {
const filtered = filteredCommandInfoData.value;
const start =
(reactiveData.commandPageCurrent - 1) * reactiveData.commandPageSize;
return filtered.slice(start, start + reactiveData.commandPageSize);
});
const paginatedCommandInfoData = computed(() =>
paginateRows(
filteredCommandInfoData.value,
reactiveData.commandPageCurrent,
reactiveData.commandPageSize,
),
);
const paginatedEventInfoData = computed(() => {
const filtered = filteredEventInfoData.value;
const start =
(reactiveData.eventPageCurrent - 1) * reactiveData.eventPageSize;
return filtered.slice(start, start + reactiveData.eventPageSize);
});
const paginatedEventInfoData = computed(() =>
paginateRows(
filteredEventInfoData.value,
reactiveData.eventPageCurrent,
reactiveData.eventPageSize,
),
);
// Reset pagination to page 1 whenever the search keyword or status filter changes.
watch(
@@ -629,20 +708,37 @@ export default defineComponent({
const attributeInputPlaceholder = (attribute: Attribute): string =>
attributePlaceholder(attribute, t);
const upsertDictionary = (items: Dictionary[], item: Dictionary) => {
const index = items.findIndex((candidate) => candidate.value === item.value);
if (index === -1) {
items.push(item);
} else {
items[index] = item;
}
};
const driverDictionary = (query?: string) => {
const requestToken = ++driverDictionaryRequestToken;
reactiveData.driverLoading = true;
reactiveData.driverDictionaryError = false;
listDriverDictionary({
offset: 0, limit: 50,
label: query || "",
})
.then((res) => {
if (requestToken !== driverDictionaryRequestToken) return;
reactiveData.driverDictionary = res.items;
})
.catch(() => {
// nothing to do
.catch(() => {
if (requestToken === driverDictionaryRequestToken) {
reactiveData.driverDictionaryError = true;
reactiveData.driverDictionary = [];
}
})
.finally(() => {
reactiveData.driverLoading = false;
if (requestToken === driverDictionaryRequestToken) {
reactiveData.driverLoading = false;
}
});
};
@@ -651,19 +747,27 @@ export default defineComponent({
};
const profileDictionary = (query?: string) => {
const requestToken = ++profileDictionaryRequestToken;
reactiveData.profileLoading = true;
reactiveData.profileDictionaryError = false;
listProfileDictionary({
offset: 0, limit: 50,
label: query || "",
})
.then((res) => {
if (requestToken !== profileDictionaryRequestToken) return;
reactiveData.profileDictionary = res.items;
})
.catch(() => {
// nothing to do
.catch(() => {
if (requestToken === profileDictionaryRequestToken) {
reactiveData.profileDictionaryError = true;
reactiveData.profileDictionary = [];
}
})
.finally(() => {
reactiveData.profileLoading = false;
if (requestToken === profileDictionaryRequestToken) {
reactiveData.profileLoading = false;
}
});
};
@@ -671,45 +775,133 @@ export default defineComponent({
if (visible) profileDictionary("");
};
const clearMatrixData = () => {
reactiveData.driverAttributes = [];
reactiveData.driverAttributeTable = {};
reactiveData.driverFormData = {};
reactiveData.oldDriverFormData = {};
reactiveData.pointAttributes = [];
reactiveData.pointAttributeTable = {};
reactiveData.pointInfoData = [];
reactiveData.oldPointInfoData = [];
reactiveData.commandAttributes = [];
reactiveData.commandAttributeTable = {};
reactiveData.commandInfoData = [];
reactiveData.oldCommandInfoData = [];
reactiveData.eventAttributes = [];
reactiveData.eventAttributeTable = {};
reactiveData.eventInfoData = [];
reactiveData.oldEventInfoData = [];
reactiveData.matrixErrors.driver = false;
reactiveData.matrixErrors.point = false;
reactiveData.matrixErrors.command = false;
reactiveData.matrixErrors.event = false;
driverDirtySet.clear();
};
const device = () => {
const requestToken = ++deviceRequestToken;
const deviceId = String(reactiveData.id || "");
driverDictionaryRequestToken += 1;
profileDictionaryRequestToken += 1;
reactiveData.driverLoading = false;
reactiveData.profileLoading = false;
reactiveData.driverDictionary = [];
reactiveData.profileDictionary = [];
reactiveData.driverDictionaryError = false;
reactiveData.profileDictionaryError = false;
reactiveData.deviceLoading = true;
reactiveData.deviceSaveError = false;
reactiveData.deviceError = false;
reactiveData.deviceMissing = false;
reactiveData.deviceFormData = {};
reactiveData.oldDeviceFormData = {};
clearMatrixData();
if (!deviceId) {
reactiveData.deviceLoading = false;
reactiveData.loading = false;
reactiveData.deviceMissing = true;
return;
}
getDeviceById(reactiveData.id)
.then((res) => {
reactiveData.deviceFormData = res;
reactiveData.oldDeviceFormData = { ...res };
if (requestToken !== deviceRequestToken || deviceId !== String(reactiveData.id || "")) return;
const device = res || {};
reactiveData.deviceFormData = device;
reactiveData.oldDeviceFormData = { ...device };
reactiveData.deviceLoading = false;
reactiveData.deviceMissing = !device.id;
if (!device.id) {
reactiveData.deviceError = true;
reactiveData.loading = false;
return;
}
getDriverById(reactiveData.deviceFormData.driverId || "").then(
(res) => {
const driver = res;
reactiveData.driverDictionary.push({
label: driver.driverName,
value: driver.id,
} as Dictionary);
},
);
const driverId = String(reactiveData.deviceFormData.driverId || "");
if (driverId) {
upsertDictionary(reactiveData.driverDictionary, {
label: driverId,
value: driverId,
} as Dictionary);
getDriverById(driverId)
.then((driver) => {
if (requestToken !== deviceRequestToken || !driver) return;
upsertDictionary(reactiveData.driverDictionary, {
label: driver.driverName,
value: driver.id,
} as Dictionary);
})
.catch(() => {
if (requestToken === deviceRequestToken) reactiveData.driverDictionaryError = true;
});
}
if (reactiveData.deviceFormData.profileId) {
getProfileById(String(reactiveData.deviceFormData.profileId)).then(
(res) => {
const profile = res;
if (!profile) return;
reactiveData.profileDictionary.push({
const profileId = String(reactiveData.deviceFormData.profileId);
upsertDictionary(reactiveData.profileDictionary, {
label: profileId,
value: profileId,
} as Dictionary);
getProfileById(profileId)
.then((profile) => {
if (requestToken !== deviceRequestToken || !profile) return;
upsertDictionary(reactiveData.profileDictionary, {
label: profile.profileName,
value: profile.id,
} as Dictionary);
},
);
})
.catch(() => {
if (requestToken === deviceRequestToken) reactiveData.profileDictionaryError = true;
});
}
changeAttribute(reactiveData.deviceFormData.driverId || "");
loadedDriverId.value = String(reactiveData.deviceFormData.driverId || "");
loadedProfileId.value = String(reactiveData.deviceFormData.profileId || "");
changeAttribute(loadedDriverId.value);
})
.catch(() => {
// nothing to do
if (requestToken === deviceRequestToken) {
reactiveData.deviceLoading = false;
reactiveData.loading = false;
reactiveData.deviceError = true;
}
});
};
const changeAttribute = (driverId: string) => {
const context: MatrixRequestContext = {
token: ++matrixRequestToken,
driverId: String(driverId || ""),
profileId: String(reactiveData.deviceFormData.profileId || ""),
};
matrixContext = context;
reactiveData.matrixErrors.driver = false;
reactiveData.matrixErrors.point = false;
reactiveData.matrixErrors.command = false;
reactiveData.matrixErrors.event = false;
if (isNull(driverId)) {
reactiveData.loading = false;
clearMatrixData();
return;
}
reactiveData.loading = true;
@@ -717,6 +909,7 @@ export default defineComponent({
Promise.allSettled([
listDriverAttributeByDriverId(driverId)
.then((res) => {
if (!isCurrentMatrixContext(context)) return;
reactiveData.driverAttributes = res;
reactiveData.driverAttributeTable =
reactiveData.driverAttributes.reduce(
@@ -733,9 +926,11 @@ export default defineComponent({
});
reactiveData.driverFormData = clone(driverFormData);
reactiveData.oldDriverFormData = clone(driverFormData);
driverInfo();
return driverInfo(context);
})
.catch(() => {
if (!isCurrentMatrixContext(context)) return;
reactiveData.matrixErrors.driver = true;
reactiveData.driverAttributes = [];
reactiveData.driverAttributeTable = {};
reactiveData.driverFormData = {};
@@ -744,6 +939,7 @@ export default defineComponent({
listPointAttributeByDriverId(driverId)
.then((res) => {
if (!isCurrentMatrixContext(context)) return;
reactiveData.pointAttributes = res;
reactiveData.pointAttributeTable =
reactiveData.pointAttributes.reduce(
@@ -753,9 +949,11 @@ export default defineComponent({
},
{} as Record<string, any>,
);
pointInfo();
return pointInfo(context);
})
.catch(() => {
if (!isCurrentMatrixContext(context)) return;
reactiveData.matrixErrors.point = true;
reactiveData.pointAttributes = [];
reactiveData.pointAttributeTable = {};
reactiveData.pointInfoData = [];
@@ -764,6 +962,7 @@ export default defineComponent({
listCommandAttributeByDriverId(driverId)
.then((res) => {
if (!isCurrentMatrixContext(context)) return;
reactiveData.commandAttributes = res;
reactiveData.commandAttributeTable =
reactiveData.commandAttributes.reduce(
@@ -773,9 +972,11 @@ export default defineComponent({
},
{} as Record<string, any>,
);
commandInfo();
return commandInfo(context);
})
.catch(() => {
if (!isCurrentMatrixContext(context)) return;
reactiveData.matrixErrors.command = true;
reactiveData.commandAttributes = [];
reactiveData.commandAttributeTable = {};
reactiveData.commandInfoData = [];
@@ -784,6 +985,7 @@ export default defineComponent({
listEventAttributeByDriverId(driverId)
.then((res) => {
if (!isCurrentMatrixContext(context)) return;
reactiveData.eventAttributes = res;
reactiveData.eventAttributeTable =
reactiveData.eventAttributes.reduce(
@@ -793,22 +995,31 @@ export default defineComponent({
},
{} as Record<string, any>,
);
eventInfo();
return eventInfo(context);
})
.catch(() => {
if (!isCurrentMatrixContext(context)) return;
reactiveData.matrixErrors.event = true;
reactiveData.eventAttributes = [];
reactiveData.eventAttributeTable = {};
reactiveData.eventInfoData = [];
reactiveData.oldEventInfoData = [];
}),
]).finally(() => {
reactiveData.loading = false;
if (isCurrentMatrixContext(context)) reactiveData.loading = false;
});
};
const driverInfo = () => {
listDriverInfoByDeviceId(reactiveData.id)
const retryMatrix = async () => {
const dirtyCount = matrixDirtyCount.value + driverDirtyCount.value;
if (!(await confirmDiscardMatrixDraft(dirtyCount))) return;
changeAttribute(String(reactiveData.deviceFormData.driverId || ""));
};
const driverInfo = (context: MatrixRequestContext = matrixContext) => {
return listDriverInfoByDeviceId(reactiveData.id)
.then((res) => {
if (!isCurrentMatrixContext(context)) return;
const formData: AttributeFormData = reactiveData.driverFormData;
res.forEach((info: DriverInfoForm) => {
const attributeCode =
@@ -831,22 +1042,23 @@ export default defineComponent({
driverDirtySet.clear();
})
.catch(() => {
// nothing to do
if (isCurrentMatrixContext(context)) reactiveData.matrixErrors.driver = true;
});
};
const pointInfo = () => {
const profileId = String(reactiveData.deviceFormData.profileId || "");
const pointInfo = (context: MatrixRequestContext = matrixContext) => {
const profileId = context.profileId;
if (isNull(profileId)) {
reactiveData.pointInfoData = [];
reactiveData.oldPointInfoData = [];
reactiveData.loading = false;
if (isCurrentMatrixContext(context)) {
reactiveData.pointInfoData = [];
reactiveData.oldPointInfoData = [];
}
return;
}
reactiveData.loading = true;
listPointByProfileId(profileId)
return listPointByProfileId(profileId)
.then((res) => {
if (!isCurrentMatrixContext(context)) return;
const rows: PointInfoMatrixRow[] = (res || []).map(
(point: PointRecord) => {
const attributes: Record<string, PointAttributeCell> = {};
@@ -878,6 +1090,7 @@ export default defineComponent({
return listPointInfoByDeviceId(reactiveData.id)
.then((infoRes) => {
if (!isCurrentMatrixContext(context)) return;
(infoRes || []).forEach((info: PointInfoForm) => {
const attributeCode =
reactiveData.pointAttributeTable[info.attributeId ?? ""];
@@ -898,29 +1111,33 @@ export default defineComponent({
reactiveData.oldPointInfoData = clone(rows);
})
.catch(() => {
if (!isCurrentMatrixContext(context)) return;
reactiveData.matrixErrors.point = true;
reactiveData.pointInfoData = rows;
reactiveData.oldPointInfoData = clone(rows);
});
})
.catch(() => {
if (!isCurrentMatrixContext(context)) return;
reactiveData.matrixErrors.point = true;
reactiveData.pointInfoData = [];
reactiveData.oldPointInfoData = [];
})
.finally(() => {
reactiveData.loading = false;
});
};
const commandInfo = () => {
const profileId = String(reactiveData.deviceFormData.profileId || "");
const commandInfo = (context: MatrixRequestContext = matrixContext) => {
const profileId = context.profileId;
if (isNull(profileId)) {
reactiveData.commandInfoData = [];
reactiveData.oldCommandInfoData = [];
if (isCurrentMatrixContext(context)) {
reactiveData.commandInfoData = [];
reactiveData.oldCommandInfoData = [];
}
return;
}
listCommandByProfileId(profileId)
return listCommandByProfileId(profileId)
.then((res) => {
if (!isCurrentMatrixContext(context)) return;
const rows: CommandInfoMatrixRow[] = (res || []).map(
(command: CommandRecord) => {
const attributes: Record<string, CommandAttributeCell> = {};
@@ -951,6 +1168,7 @@ export default defineComponent({
return listCommandInfoByDeviceId(reactiveData.id)
.then((infoRes) => {
if (!isCurrentMatrixContext(context)) return;
(infoRes || []).forEach((info: CommandInfoForm) => {
const attributeCode =
reactiveData.commandAttributeTable[info.attributeId ?? ""];
@@ -971,26 +1189,33 @@ export default defineComponent({
reactiveData.oldCommandInfoData = clone(rows);
})
.catch(() => {
if (!isCurrentMatrixContext(context)) return;
reactiveData.matrixErrors.command = true;
reactiveData.commandInfoData = rows;
reactiveData.oldCommandInfoData = clone(rows);
});
})
.catch(() => {
if (!isCurrentMatrixContext(context)) return;
reactiveData.matrixErrors.command = true;
reactiveData.commandInfoData = [];
reactiveData.oldCommandInfoData = [];
});
};
const eventInfo = () => {
const profileId = String(reactiveData.deviceFormData.profileId || "");
const eventInfo = (context: MatrixRequestContext = matrixContext) => {
const profileId = context.profileId;
if (isNull(profileId)) {
reactiveData.eventInfoData = [];
reactiveData.oldEventInfoData = [];
if (isCurrentMatrixContext(context)) {
reactiveData.eventInfoData = [];
reactiveData.oldEventInfoData = [];
}
return;
}
listEventByProfileId(profileId)
return listEventByProfileId(profileId)
.then((res) => {
if (!isCurrentMatrixContext(context)) return;
const rows: EventInfoMatrixRow[] = (res || []).map(
(event: EventRecord) => {
const attributes: Record<string, EventAttributeCell> = {};
@@ -1021,6 +1246,7 @@ export default defineComponent({
return listEventInfoByDeviceId(reactiveData.id)
.then((infoRes) => {
if (!isCurrentMatrixContext(context)) return;
(infoRes || []).forEach((info: EventInfoForm) => {
const attributeCode =
reactiveData.eventAttributeTable[info.attributeId ?? ""];
@@ -1041,23 +1267,33 @@ export default defineComponent({
reactiveData.oldEventInfoData = clone(rows);
})
.catch(() => {
if (!isCurrentMatrixContext(context)) return;
reactiveData.matrixErrors.event = true;
reactiveData.eventInfoData = rows;
reactiveData.oldEventInfoData = clone(rows);
});
})
.catch(() => {
if (!isCurrentMatrixContext(context)) return;
reactiveData.matrixErrors.event = true;
reactiveData.eventInfoData = [];
reactiveData.oldEventInfoData = [];
});
};
const deviceUpdate = async (): Promise<boolean> => {
if (reactiveData.deviceSaving) return false;
reactiveData.deviceSaving = true;
reactiveData.deviceSaveError = false;
try {
const res = await updateDevice(reactiveData.deviceFormData);
reactiveData.oldDeviceFormData = { ...res };
reactiveData.oldDeviceFormData = { ...(res || reactiveData.deviceFormData) };
return true;
} catch {
reactiveData.deviceSaveError = true;
return false;
} finally {
reactiveData.deviceSaving = false;
}
};
@@ -1249,8 +1485,9 @@ export default defineComponent({
reactiveData.pointSaving = true;
let failedCount = 0;
await Promise.all(
dirtyCells.map(async ({ row, cell }) => {
try {
await Promise.all(
dirtyCells.map(async ({ row, cell }) => {
cell.saving = true;
cell.error = "";
@@ -1285,9 +1522,11 @@ export default defineComponent({
} finally {
cell.saving = false;
}
}),
);
reactiveData.pointSaving = false;
}),
);
} finally {
reactiveData.pointSaving = false;
}
if (failedCount > 0) {
failMessage(t("device.edit.pointSaveFailed", { count: failedCount }));
@@ -1405,8 +1644,9 @@ export default defineComponent({
reactiveData.commandSaving = true;
let failedCount = 0;
await Promise.all(
dirtyCells.map(async ({ row, cell }) => {
try {
await Promise.all(
dirtyCells.map(async ({ row, cell }) => {
cell.saving = true;
cell.error = "";
@@ -1441,9 +1681,11 @@ export default defineComponent({
} finally {
cell.saving = false;
}
}),
);
reactiveData.commandSaving = false;
}),
);
} finally {
reactiveData.commandSaving = false;
}
if (failedCount > 0) {
failMessage(t("device.edit.commandSaveFailed", { count: failedCount }));
@@ -1557,8 +1799,9 @@ export default defineComponent({
reactiveData.eventSaving = true;
let failedCount = 0;
await Promise.all(
dirtyCells.map(async ({ row, cell }) => {
try {
await Promise.all(
dirtyCells.map(async ({ row, cell }) => {
cell.saving = true;
cell.error = "";
@@ -1593,9 +1836,11 @@ export default defineComponent({
} finally {
cell.saving = false;
}
}),
);
reactiveData.eventSaving = false;
}),
);
} finally {
reactiveData.eventSaving = false;
}
if (failedCount > 0) {
failMessage(t("device.edit.eventSaveFailed", { count: failedCount }));
@@ -1616,8 +1861,6 @@ export default defineComponent({
}
};
const driverDirtySet = reactive(new Set<string>());
const getDriverCellValue = (attribute: Attribute): any => {
return reactiveData.driverFormData[attribute.attributeCode]?.configValue;
};
@@ -1639,14 +1882,9 @@ export default defineComponent({
return driverDirtySet.has(attribute.attributeCode);
};
const driverDirtyCount = computed(() => driverDirtySet.size);
const totalDirtyCount = computed(
() =>
pointDirtyCount.value +
commandDirtyCount.value +
eventDirtyCount.value +
driverDirtyCount.value,
matrixDirtyCount.value + (deviceFormDirty.value ? 1 : 0),
);
const saveDriverMatrix = async () => {
@@ -1661,6 +1899,7 @@ export default defineComponent({
const deviceReset = () => {
reactiveData.deviceFormData = clone(reactiveData.oldDeviceFormData);
reactiveData.deviceSaveError = false;
};
const driverInfoReset = () => {
@@ -1680,10 +1919,71 @@ export default defineComponent({
reactiveData.eventInfoData = clone(reactiveData.oldEventInfoData);
};
const confirmDiscardMatrixDraft = async (dirtyCount: number): Promise<boolean> => {
if (dirtyCount < 1) return true;
try {
await ElMessageBox.confirm(
t("device.edit.unsavedConfirm"),
t("common.confirm"),
{
type: "warning",
confirmButtonText: t("common.confirm"),
cancelButtonText: t("common.cancel"),
},
);
if (disposed) return false;
return true;
} catch {
return false;
}
};
const handleDriverChange = async (driverId: string) => {
const intent = ++selectionIntent.value;
const selectedDriverId = String(driverId || "");
if (selectedDriverId === loadedDriverId.value) return;
if (!(await confirmDiscardMatrixDraft(matrixDirtyCount.value))) {
if (disposed || intent !== selectionIntent.value) return;
reactiveData.deviceFormData.driverId = loadedDriverId.value;
return;
}
if (disposed || intent !== selectionIntent.value) return;
loadedDriverId.value = selectedDriverId;
driverDirtySet.clear();
changeAttribute(selectedDriverId);
};
const changeProfile = () => {
pointInfo();
commandInfo();
eventInfo();
const context: MatrixRequestContext = {
token: ++matrixRequestToken,
driverId: String(reactiveData.deviceFormData.driverId || ""),
profileId: String(reactiveData.deviceFormData.profileId || ""),
};
matrixContext = context;
loadedProfileId.value = context.profileId;
reactiveData.loading = true;
Promise.allSettled([
pointInfo(context),
commandInfo(context),
eventInfo(context),
]).finally(() => {
if (isCurrentMatrixContext(context)) reactiveData.loading = false;
});
};
const handleProfileChange = async (profileId: string) => {
const intent = ++selectionIntent.value;
const selectedProfileId = String(profileId || "");
if (selectedProfileId === loadedProfileId.value) return;
const profileDirtyCount =
pointDirtyCount.value + commandDirtyCount.value + eventDirtyCount.value;
if (!(await confirmDiscardMatrixDraft(profileDirtyCount))) {
if (disposed || intent !== selectionIntent.value) return;
reactiveData.deviceFormData.profileId = loadedProfileId.value;
return;
}
if (disposed || intent !== selectionIntent.value) return;
changeProfile();
};
const changeActive = (tab: any) => {
@@ -1695,27 +1995,29 @@ export default defineComponent({
watch(
() => [route.query.id, route.query.active],
([id, active]) => {
const nextId = id as string;
const nextId = String(id ?? "");
const nextActive = resolveDeviceTab(active);
if (reactiveData.active !== nextActive) {
reactiveData.active = nextActive;
}
if (nextId && nextId !== reactiveData.id) {
if (nextId !== reactiveData.id) {
reactiveData.id = nextId;
reactiveData.loading = true;
reactiveData.deviceLoading = Boolean(nextId);
reactiveData.deviceError = false;
reactiveData.deviceSaveError = false;
reactiveData.deviceMissing = !nextId;
reactiveData.deviceFormData = {};
reactiveData.oldDeviceFormData = {};
reactiveData.driverFormData = {};
reactiveData.oldDriverFormData = {};
reactiveData.pointInfoData = [];
reactiveData.oldPointInfoData = [];
reactiveData.commandInfoData = [];
reactiveData.oldCommandInfoData = [];
reactiveData.eventInfoData = [];
reactiveData.oldEventInfoData = [];
device();
clearMatrixData();
loadedDriverId.value = "";
loadedProfileId.value = "";
deviceRequestToken += 1;
matrixRequestToken += 1;
if (nextId) device();
else reactiveData.loading = false;
}
},
);
@@ -1732,7 +2034,12 @@ export default defineComponent({
});
onBeforeUnmount(() => {
disposed = true;
window.removeEventListener("beforeunload", warnBeforeUnload);
deviceRequestToken += 1;
driverDictionaryRequestToken += 1;
profileDictionaryRequestToken += 1;
matrixRequestToken += 1;
});
onBeforeRouteLeave(async (_to, _from, next) => {
@@ -1780,6 +2087,8 @@ export default defineComponent({
profileDictionaryVisible,
changeProfile,
changeAttribute,
handleDriverChange,
handleProfileChange,
deviceSave,
driverDirtyCount,
getDriverCellValue,
@@ -1813,6 +2122,8 @@ export default defineComponent({
pointMatrixRowClassName,
commandMatrixRowClassName,
eventMatrixRowClassName,
device,
retryMatrix,
deviceReset,
driverInfoReset,
pointInfoReset,
@@ -19,16 +19,55 @@
<el-dialog
v-model="reactiveData.formVisible"
:append-to-body="true"
:before-close="requestClose"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
:close-on-press-escape="!reactiveData.formLoading"
:show-close="!reactiveData.formLoading"
:title="$t('device.import.title')"
class="things-dialog"
destroy-on-close
draggable
@closed="onClosed"
>
<el-alert
v-if="reactiveData.saveError"
:closable="false"
:title="$t('common.saveFailed')"
class="things-dialog-form-alert"
show-icon
type="error"
/>
<el-alert
v-if="reactiveData.driverError || reactiveData.profileError"
:closable="false"
:title="$t('common.loadFailed')"
class="things-dialog-form-alert"
show-icon
type="error"
>
<el-button
v-if="reactiveData.driverError"
:loading="reactiveData.driverLoading"
link
type="danger"
@click="driverDictionary()"
>
{{ $t('device.import.driver') }} {{ $t('common.retry') }}
</el-button>
<el-button
v-if="reactiveData.profileError"
:loading="reactiveData.profileLoading"
link
type="danger"
@click="profileDictionary()"
>
{{ $t('device.add.profile') }} {{ $t('common.retry') }}
</el-button>
</el-alert>
<el-form
ref="formDataRef"
v-loading="reactiveData.formLoading"
:aria-busy="reactiveData.formLoading"
:model="reactiveData.formData"
:rules="formRule"
label-position="top"
@@ -40,6 +79,7 @@
<el-form-item :label="$t('device.import.driver')" prop="driverId">
<el-select
v-model="reactiveData.formData.driverId"
:disabled="reactiveData.formLoading"
:loading="reactiveData.driverLoading"
:placeholder="$t('device.import.driverPlaceholder')"
:remote-method="driverDictionary"
@@ -60,6 +100,7 @@
<el-form-item :label="$t('device.add.profile')" prop="profileId">
<el-select
v-model="reactiveData.formData.profileId"
:disabled="reactiveData.formLoading"
:loading="reactiveData.profileLoading"
:placeholder="$t('device.add.profilePlaceholder')"
:remote-method="profileDictionary"
@@ -81,11 +122,11 @@
<el-upload
ref="formUploadRef"
:auto-upload="false"
:http-request="uploadRequest"
:limit="1"
:on-change="handleChange"
:on-exceed="handleExceed"
:on-remove="handleRemove"
:disabled="reactiveData.formLoading"
accept=".xlsx"
class="things-dialog-upload"
drag
@@ -104,14 +145,18 @@
show-icon
/>
</el-form>
<div class="things-dialog-footer">
<slot name="footer">
<el-button :disabled="reactiveData.formLoading" @click="cancel">{{ $t('common.cancel') }}</el-button>
<el-button :disabled="reactiveData.formLoading" plain @click="reset">{{ $t('common.reset') }}</el-button>
<el-button :disabled="reactiveData.formLoading" plain type="warning" @click="importTemplate">{{ $t('device.import.template') }}</el-button>
<el-button :disabled="reactiveData.formLoading" type="primary" @click="importThing">{{ $t('common.confirm') }}</el-button>
</slot>
</div>
<template #footer>
<div class="things-dialog-footer">
<slot name="footer">
<el-button :disabled="reactiveData.formLoading" @click="cancel">{{ $t('common.cancel') }}</el-button>
<el-button :disabled="reactiveData.formLoading" plain @click="reset">{{ $t('common.reset') }}</el-button>
<el-button :disabled="reactiveData.formLoading" plain type="warning" @click="importTemplate">{{ $t('device.import.template') }}</el-button>
<el-button :loading="reactiveData.formLoading" type="primary" @click="importThing">
{{ $t('common.confirm') }}
</el-button>
</slot>
</div>
</template>
</el-dialog>
</template>
@@ -123,11 +168,11 @@ import type {
UploadInstance,
UploadProps,
UploadRawFile,
UploadRequestOptions,
} from 'element-plus';
import {genFileId} from 'element-plus';
import {reactive, ref, unref} from 'vue';
import {computed, onBeforeUnmount, reactive, ref, unref} from 'vue';
import {useI18n} from 'vue-i18n';
import {ElMessageBox} from 'element-plus';
import type {Dictionary} from '@/config/types';
@@ -165,11 +210,29 @@ const reactiveData = reactive({
} as DeviceImportFormData,
formVisible: false,
formLoading: false,
saveError: false,
importStatus: null as OperationUiStatus | null,
driverDictionary: [] as Dictionary[],
driverLoading: false,
driverError: false,
profileDictionary: [] as Dictionary[],
profileLoading: false,
profileError: false,
});
let formSession = 0;
let driverRequest = 0;
let profileRequest = 0;
let importSession = 0;
const initialForm = ref<DeviceImportFormData>({driverId: '', profileId: ''});
const fileSignature = (file?: UploadRawFile) => (file ? `${file.name}:${file.size}:${file.lastModified}` : '');
const isDirty = computed(() => {
if (!reactiveData.formVisible) return false;
return (
reactiveData.formData.driverId !== initialForm.value.driverId ||
reactiveData.formData.profileId !== initialForm.value.profileId ||
fileSignature(reactiveData.formData.file) !== fileSignature(initialForm.value.file)
);
});
const formRule = reactive<FormRules>({
@@ -197,18 +260,23 @@ const formRule = reactive<FormRules>({
});
const driverDictionary = async (query = '') => {
if (reactiveData.formLoading) return;
const requestId = ++driverRequest;
reactiveData.driverLoading = true;
reactiveData.driverError = false;
try {
const res = await listDriverDictionary<DictionaryResponse>({
offset: 0,
limit: 50,
label: query,
});
reactiveData.driverDictionary = res.items ?? [];
if (requestId === driverRequest && reactiveData.formVisible) {
reactiveData.driverDictionary = res.items ?? [];
}
} catch {
// nothing to do
if (requestId === driverRequest && reactiveData.formVisible) reactiveData.driverError = true;
} finally {
reactiveData.driverLoading = false;
if (requestId === driverRequest) reactiveData.driverLoading = false;
}
};
@@ -219,18 +287,23 @@ const driverDictionaryVisible = (visible: boolean) => {
};
const profileDictionary = async (query = '') => {
if (reactiveData.formLoading) return;
const requestId = ++profileRequest;
reactiveData.profileLoading = true;
reactiveData.profileError = false;
try {
const res = await listProfileDictionary<DictionaryResponse>({
offset: 0,
limit: 50,
label: query,
});
reactiveData.profileDictionary = res.items ?? [];
if (requestId === profileRequest && reactiveData.formVisible) {
reactiveData.profileDictionary = res.items ?? [];
}
} catch {
// nothing to do
if (requestId === profileRequest && reactiveData.formVisible) reactiveData.profileError = true;
} finally {
reactiveData.profileLoading = false;
if (requestId === profileRequest) reactiveData.profileLoading = false;
}
};
@@ -241,73 +314,150 @@ const profileDictionaryVisible = (visible: boolean) => {
};
const show = () => {
formSession += 1;
importSession += 1;
driverRequest += 1;
profileRequest += 1;
reactiveData.formData = {driverId: '', profileId: ''};
initialForm.value = {driverId: '', profileId: ''};
reactiveData.driverDictionary = [];
reactiveData.profileDictionary = [];
reactiveData.driverError = false;
reactiveData.profileError = false;
reactiveData.saveError = false;
reactiveData.formVisible = true;
reactiveData.formLoading = false;
reactiveData.importStatus = null;
idempotencyKey.value = '';
void driverDictionary();
void profileDictionary();
};
const cancel = () => {
reactiveData.formVisible = false;
reactiveData.formLoading = false;
void requestClose();
};
const reset = () => {
const form = unref(formDataRef);
form?.resetFields();
reactiveData.formData = {...initialForm.value};
form?.clearValidate();
formUploadRef.value?.clearFiles();
reactiveData.formData.file = undefined;
reactiveData.formData.file = initialForm.value.file;
reactiveData.importStatus = null;
reactiveData.saveError = false;
idempotencyKey.value = '';
};
const onClosed = () => {
formSession += 1;
importSession += 1;
driverRequest += 1;
profileRequest += 1;
reactiveData.driverLoading = false;
reactiveData.profileLoading = false;
formDataRef.value?.clearValidate();
};
onBeforeUnmount(() => {
formSession += 1;
importSession += 1;
driverRequest += 1;
profileRequest += 1;
});
const requestClose = async (done?: () => void) => {
if (reactiveData.formLoading) return;
const session = formSession;
if (!isDirty.value) {
if (done) done();
else reactiveData.formVisible = false;
return;
}
try {
await ElMessageBox.confirm(t('common.discardConfirm'), t('common.confirm'), {
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
});
if (session !== formSession || !reactiveData.formVisible) return;
if (done) done();
else reactiveData.formVisible = false;
} catch {
// Keep the draft open when the user cancels the confirmation.
}
};
const importTemplate = async () => {
if (reactiveData.formLoading) return;
const form = unref(formDataRef);
if (!form) {
return;
}
const formSessionId = formSession;
try {
await form.validateField(['driverId', 'profileId']);
if (formSessionId !== formSession || !reactiveData.formVisible) return;
reactiveData.formLoading = true;
reactiveData.saveError = false;
const session = formSessionId;
emit('import-template', {...reactiveData.formData}, (successful) => {
if (session !== formSession) return;
reactiveData.formLoading = false;
if (successful) successMessage(t('device.import.templateSuccess'));
else reactiveData.saveError = true;
});
} catch {
// validation errors are displayed by Element Plus
}
};
const uploadRequest = (param: UploadRequestOptions): Promise<unknown> => {
emit('import', reactiveData.formData, param.file as File, idempotencyKey.value, (status) => {
reactiveData.importStatus = status;
if (status === 'SUCCEEDED') {
reactiveData.formLoading = false;
cancel();
reset();
successMessage(t('device.import.importSuccess'));
} else if (status === 'FAILED' || status === 'CANCELLED' || status === 'EXPIRED') {
reactiveData.formLoading = false;
idempotencyKey.value = '';
} else if (status === 'REQUEST_ERROR') {
reactiveData.formLoading = false;
}
const reportImportStatus = (status: OperationUiStatus) => {
reactiveData.importStatus = status;
if (status === 'SUCCEEDED') {
reactiveData.formLoading = false;
initialForm.value = {...reactiveData.formData};
importSession += 1;
reactiveData.formVisible = false;
successMessage(t('device.import.importSuccess'));
} else if (status === 'FAILED' || status === 'CANCELLED' || status === 'EXPIRED') {
reactiveData.formLoading = false;
idempotencyKey.value = '';
} else if (status === 'REQUEST_ERROR') {
reactiveData.formLoading = false;
reactiveData.saveError = true;
}
};
const startImport = (file: File) => {
const session = ++importSession;
emit('import', reactiveData.formData, file, idempotencyKey.value, (status) => {
if (session !== importSession) return;
reportImportStatus(status);
});
return Promise.resolve();
};
const importThing = async () => {
if (reactiveData.formLoading) return;
const form = unref(formDataRef);
if (!form) {
return;
}
const formSessionId = formSession;
try {
await form.validate();
if (formSessionId !== formSession || !reactiveData.formVisible) return;
const file = reactiveData.formData.file;
if (!file) {
await form.validateField('file');
return;
}
reactiveData.formLoading = true;
reactiveData.saveError = false;
reactiveData.importStatus = 'PENDING';
if (!idempotencyKey.value) idempotencyKey.value = crypto.randomUUID();
formUploadRef.value?.submit();
startImport(file);
} catch {
// validation errors are displayed by Element Plus
}
+71 -22
View File
@@ -36,25 +36,39 @@
/>
</el-form-item>
<el-form-item v-if="embedded !== 'driver'" :label="$t('device.tool.driver')" prop="driverId">
<el-select
v-model="formData.driverId"
:loading="driverLoading"
:placeholder="$t('device.tool.driverPlaceholder')"
:remote-method="driverDictionary"
class="edit-form-special"
clearable
filterable
remote
reserve-keyword
@visible-change="driverDictionaryVisible"
>
<el-option
v-for="dictionary in driverDictionaries"
:key="dictionary.value"
:label="dictionary.label"
:value="dictionary.value"
/>
</el-select>
<div class="tool-dictionary-field">
<el-select
v-model="formData.driverId"
:loading="driverLoading"
:placeholder="$t('device.tool.driverPlaceholder')"
:remote-method="driverDictionary"
class="edit-form-special"
clearable
filterable
remote
reserve-keyword
@visible-change="driverDictionaryVisible"
>
<el-option
v-for="dictionary in driverDictionaries"
:key="dictionary.value"
:label="dictionary.label"
:value="dictionary.value"
/>
</el-select>
<el-alert
v-if="driverError"
:closable="false"
:title="$t('common.optionLoadFailed')"
class="tool-dictionary-error"
show-icon
type="error"
>
<el-button :loading="driverLoading" link type="danger" @click="driverDictionary('')">
{{ $t('common.retry') }}
</el-button>
</el-alert>
</div>
</el-form-item>
<el-form-item :label="$t('common.enableFlag')" prop="enableFlag">
<enable-flag-segmented v-model="formData.enableFlag" include-all/>
@@ -72,7 +86,7 @@
</template>
<script lang="ts" setup>
import {reactive, ref} from 'vue';
import {onBeforeUnmount, reactive, ref} from 'vue';
import {Plus, Upload} from '@element-plus/icons-vue';
import ToolCard from '@/components/card/tool/ToolCard.vue';
import EnableFlagSegmented from '@/components/segmented/EnableFlagSegmented.vue';
@@ -105,34 +119,69 @@ const emit = defineEmits([
const formData = reactive<Record<string, any>>({enableFlag: ''});
const driverDictionaries = ref<Dictionary[]>([]);
const driverLoading = ref(false);
const driverError = ref(false);
let driverRequestId = 0;
const onSearch = (data: Record<string, any>) => {
emit('search', cleanSearchParams(data));
};
const onReset = () => {
driverRequestId += 1;
driverLoading.value = false;
driverError.value = false;
driverDictionaries.value = [];
resetSearchForm(formData, {enableFlag: ''});
emit('reset');
};
const driverDictionary = (query?: string) => {
const requestId = ++driverRequestId;
driverLoading.value = true;
driverError.value = false;
listDriverDictionary({
offset: 0, limit: 50,
label: query || '',
})
.then((res) => {
if (requestId !== driverRequestId) return;
driverDictionaries.value = res.items;
})
.catch(() => {
// nothing to do
if (requestId !== driverRequestId) return;
driverDictionaries.value = [];
driverError.value = true;
})
.finally(() => {
driverLoading.value = false;
if (requestId === driverRequestId) driverLoading.value = false;
});
};
const driverDictionaryVisible = (visible: boolean) => {
if (visible) driverDictionary('');
};
onBeforeUnmount(() => {
driverRequestId += 1;
});
</script>
<style lang="scss" scoped>
.tool-dictionary-field {
display: grid;
gap: var(--dc3-space-2);
width: 100%;
min-width: 0;
}
.tool-dictionary-error {
margin: 0;
:deep(.el-alert__content) {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--dc3-space-2);
}
}
</style>
+84 -16
View File
@@ -28,6 +28,32 @@
>
</driver-tool>
<el-alert
v-if="reactiveData.status === 'error'"
:closable="false"
:title="$t('common.loadFailed')"
class="entity-page-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.loading" link type="danger" @click="refresh">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<el-alert
v-if="statusError"
:closable="false"
:title="$t('common.optionLoadFailed')"
class="entity-page-error"
show-icon
type="error"
>
<el-button :loading="statusLoading" link type="danger" @click="retryStatusLookup">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<blank-card>
<el-row>
<template v-if="reactiveData.loading">
@@ -36,11 +62,14 @@
</el-col>
</template>
<template v-else>
<el-col v-if="reactiveData.listData.length < 1">
<el-col v-if="reactiveData.status === 'success' && reactiveData.listData.length < 1">
<el-empty :description="$t('driver.empty')"/>
</el-col>
<el-col v-else-if="reactiveData.status === 'error' && reactiveData.listData.length < 1">
<el-empty :description="$t('common.loadFailed')"/>
</el-col>
<el-col v-for="data in reactiveData.listData" :key="data.id" :lg="6" :md="12" :sm="12" :xl="6" :xs="24">
<driver-card :data="data" :status-table="statusTable" @delete="onDelete"/>
<driver-card :busy="isActionBusy(data)" :data="data" :status-table="statusTable" @delete="onDelete"/>
</el-col>
</template>
</el-row>
@@ -49,7 +78,7 @@
</template>
<script lang="ts" setup>
import {reactive, watch} from 'vue';
import {onBeforeUnmount, reactive, ref, watch} from 'vue';
import {deleteDriver, listDriver, listDriverStatus} from '@/api/driver';
import {usePagedList} from '@/composables/usePagedList';
@@ -76,6 +105,12 @@ const {
});
const statusTable = reactive<Record<string, string>>({});
let statusSequence = 0;
let statusRequestId = 0;
let disposed = false;
const statusLoading = ref(false);
const statusError = ref<unknown | null>(null);
const actionBusy = reactive(new Set<string>());
const search = (params: Record<string, unknown>) => {
_search({type: 'driver', ...params});
@@ -87,31 +122,64 @@ const reset = () => {
const refresh = () => load();
const onDelete = (driver: DriverRecord, done: () => void) => {
deleteDriver(driver.id, driver.version)
.then(() => {
successMessage();
load();
})
.finally(done);
const isActionBusy = (driver: DriverRecord) => actionBusy.has(String(driver.id));
const onDelete = async (driver: DriverRecord) => {
const id = String(driver.id);
if (actionBusy.has(id)) return;
actionBusy.add(id);
try {
await deleteDriver(driver.id, driver.version);
if (disposed) return;
successMessage();
await load();
} catch {
// handled globally
} finally {
actionBusy.delete(id);
}
};
const loadStatus = () => {
listDriverStatus({page: reactiveData.page, ...(reactiveData.query as Record<string, unknown>)})
const loadStatus = (sequence = statusSequence) => {
const requestId = ++statusRequestId;
statusLoading.value = true;
statusError.value = null;
const page = {...reactiveData.page, orders: [...reactiveData.page.orders]};
const query = {...(reactiveData.query as Record<string, unknown>)};
return listDriverStatus({page, ...query})
.then((res) => {
Object.assign(statusTable, res as Record<string, string>);
if (sequence !== statusSequence || requestId !== statusRequestId) return;
Object.assign(statusTable, (res || {}) as Record<string, string>);
})
.catch(() => {
// handled globally
.catch((error) => {
if (sequence !== statusSequence || requestId !== statusRequestId) return;
statusError.value = error;
})
.finally(() => {
if (sequence === statusSequence && requestId === statusRequestId) statusLoading.value = false;
});
};
const retryStatusLookup = () => {
void loadStatus();
};
watch(
() => reactiveData.listData,
() => {
loadStatus();
const sequence = ++statusSequence;
for (const key of Object.keys(statusTable)) delete statusTable[key];
statusError.value = null;
void loadStatus(sequence);
}
);
onBeforeUnmount(() => {
disposed = true;
statusSequence += 1;
statusRequestId += 1;
actionBusy.clear();
});
load();
</script>
+16 -8
View File
@@ -16,7 +16,7 @@
-->
<template>
<div class="things-card" @click="$emit('select-change', data)">
<div class="things-card">
<el-card shadow="hover">
<div class="things-card-content">
<things-card-header
@@ -24,6 +24,7 @@
:icon="icon"
:name="data.driverName"
:status-title="$t('common.name')"
:copy-label="$t('driver.card.copyDriverId')"
@copy-id="copy(data.id, $t('driver.card.copyDriverId'))"
>
<el-tag :type="statusTagType" effect="plain">{{ $t(statusLabelKey) }}</el-tag>
@@ -63,17 +64,22 @@
</p>
</div>
</div>
<div v-if="!footer" class="things-card__footer">
<div v-if="!footer" :aria-busy="busy" class="things-card__footer">
<div class="things-card-footer-operation">
<el-popconfirm
:disabled="busy"
:title="$t('common.confirmDelete', {name: $t('common.entityDriver')})"
@confirm="emitDelete"
>
<template #reference>
<el-button link type="primary" @click.stop>{{ $t('common.delete') }}</el-button>
<el-button :disabled="busy" :loading="busy" link type="primary" @click.stop>
{{ $t('common.delete') }}
</el-button>
</template>
</el-popconfirm>
<el-button link type="primary" @click.stop="detail">{{ $t('common.detail') }}</el-button>
<el-button :disabled="busy" link type="primary" @click.stop="detail">
{{ $t('common.detail') }}
</el-button>
</div>
</div>
</div>
@@ -89,7 +95,6 @@ import router from '@/config/router';
import {copy} from '@/utils/commonUtil';
import {timestamp} from '@/utils/dateUtil';
import {isEnabledFlag} from '@/utils/thingModelFormatUtil';
import {successMessage} from '@/utils/notificationUtil';
import ThingsCardHeader from '@/components/card/header/ThingsCardHeader.vue';
import type {DriverRecord} from '@/config/types/manager';
@@ -98,9 +103,10 @@ const props = defineProps({
statusTable: {type: Object as PropType<Record<string, string>>, default: () => ({})},
data: {type: Object as PropType<DriverRecord>, required: true},
footer: {type: Boolean, default: false},
busy: {type: Boolean, default: false},
});
const emit = defineEmits(['select-change', 'delete']);
const emit = defineEmits(['delete']);
const enabled = computed(() => isEnabledFlag(props.data.enableFlag));
const status = computed(() => {
@@ -123,7 +129,7 @@ const statusLabelKey = computed(() => {
});
const emitDelete = () => {
emit('delete', props.data, () => successMessage());
emit('delete', props.data);
};
const detail = () => {
@@ -143,9 +149,11 @@ const detail = () => {
.things-card__footer {
height: 35px;
margin-top: 2px;
padding-inline-end: var(--dc3-floating-action-safe-space);
box-sizing: border-box;
display: flex;
justify-content: flex-end;
border-top: 1px solid #dcdfe6;
border-top: 1px solid var(--el-border-color);
.things-card-footer-operation {
height: 35px;
@@ -18,10 +18,32 @@
<template>
<div>
<base-card>
<el-tabs v-model="reactiveData.active" @tab-click="changeActive">
<el-alert
v-if="reactiveData.status === 'error'"
:closable="false"
:title="$t('common.loadFailed')"
class="detail-page-alert"
show-icon
type="error"
>
<el-button :loading="reactiveData.loading" link type="danger" @click="driver">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<el-empty
v-if="reactiveData.status === 'error' && !reactiveData.data.id"
:description="$t('common.loadFailed')"
/>
<el-skeleton v-else-if="!reactiveData.data.id" :rows="6" animated />
<el-tabs
v-else
v-model="reactiveData.active"
v-loading="reactiveData.loading"
@tab-click="changeActive"
>
<el-tab-pane :label="$t('driver.detail.driverInfo')" name="detail">
<detail-card>
<el-descriptions :column="2" border>
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item :label="$t('driver.detail.driverName')"
>{{ reactiveData.data.driverName }}
</el-descriptions-item>
@@ -54,6 +76,17 @@
<script lang="ts" src="./index.ts"/>
<style lang="scss" scoped>
.detail-page-alert {
margin-bottom: var(--dc3-space-3);
:deep(.el-alert__content) {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--dc3-space-2);
}
}
.detail-content {
margin-left: 0 !important;
margin-right: 0 !important;
+49 -6
View File
@@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {computed, defineComponent, reactive, ref} from 'vue';
import {computed, defineComponent, onBeforeUnmount, reactive, ref, watch} from 'vue';
import {useRoute} from 'vue-router';
import router from '@/config/router';
@@ -34,6 +34,7 @@ import deviceCard from '@/views/device/card/DeviceCard.vue';
import pointCard from '@/views/point/card/PointCard.vue';
import {timestamp} from '@/utils/dateUtil';
import {useBreakpoint} from '@/composables/useBreakpoint';
export default defineComponent({
name: 'DriverDetail',
@@ -51,23 +52,46 @@ export default defineComponent({
},
setup() {
const route = useRoute();
const {isMobile} = useBreakpoint();
const deviceViewRef: any = ref<InstanceType<typeof device>>();
const reactiveData = reactive({
id: route.query.id as string,
id: String(route.query.id ?? ''),
active: (route.query.active as string) || 'detail',
loading: true,
status: 'idle' as 'idle' | 'loading' | 'success' | 'error',
data: {} as any,
});
let requestId = 0;
const deviceLength = computed(() => {
return deviceViewRef.value?.reactiveData?.page?.total || 0;
});
const driver = () => {
getDriverById(reactiveData.id).then((res) => {
reactiveData.data = res;
});
const currentRequestId = ++requestId;
const driverId = String(reactiveData.id || '');
reactiveData.loading = true;
reactiveData.status = 'loading';
reactiveData.data = {};
if (!driverId) {
reactiveData.loading = false;
reactiveData.status = 'error';
return Promise.resolve();
}
return getDriverById(driverId)
.then((res) => {
if (currentRequestId !== requestId || driverId !== String(reactiveData.id || '')) return;
reactiveData.data = res || {};
reactiveData.status = reactiveData.data.id ? 'success' : 'error';
})
.catch(() => {
if (currentRequestId === requestId) reactiveData.status = 'error';
})
.finally(() => {
if (currentRequestId === requestId) reactiveData.loading = false;
});
};
const changeActive = (tab: any) => {
@@ -89,17 +113,36 @@ export default defineComponent({
default:
break;
}
router.push({query: {...query, active: tab.props.name}});
router.push({query: {...query, active: tab.props.name}}).catch(() => undefined);
};
watch(
() => [route.query.id, route.query.active],
([id, active]) => {
const nextId = String(id ?? '');
if (nextId !== String(reactiveData.id || '')) {
reactiveData.id = nextId;
reactiveData.data = {};
driver();
}
reactiveData.active = String(active || 'detail');
},
);
onBeforeUnmount(() => {
requestId += 1;
});
driver();
return {
deviceViewRef,
reactiveData,
deviceLength,
driver,
changeActive,
timestamp,
isMobile,
};
},
});
+1 -1
View File
@@ -56,7 +56,7 @@
</el-form-item>
</template>
<template #actions>
<el-button :icon="Plus" type="success" @click="$emit('show-add')">{{ $t('common.add') }}</el-button>
<el-button :disabled="!add" :icon="Plus" type="success" @click="$emit('show-add')">{{ $t('common.add') }}</el-button>
</template>
</tool-card>
</template>
+122 -63
View File
@@ -29,18 +29,33 @@
chip deep-links into the event-overview tab that owns the signal. -->
<sla-badge/>
<el-alert
v-if="hasDataError"
:closable="false"
:title="$t('common.loadFailed')"
class="home__error"
show-icon
type="error"
>
<el-button :loading="loading" link type="danger" @click="refreshAll">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<!-- Row 1: 6 indicator cards, always 6-wide via CSS grid -->
<div class="home__stats">
<stat-card
v-for="c in cards"
:key="c.key"
:icon="c.icon"
:loading="c.loading"
:on-refresh="c.onRefresh"
:sparkline="c.sparkline"
:subtitle="c.subtitle"
:title="c.title"
:tone="c.tone"
:trend="c.trend"
:error="c.error"
:value="c.value"
@click="c.onClick"
/>
@@ -103,7 +118,7 @@
<script lang="ts" setup>
import type {Component} from 'vue';
import {computed, onMounted, reactive} from 'vue';
import {computed, onBeforeUnmount, onMounted, reactive} from 'vue';
import {useI18n} from 'vue-i18n';
import {useRouter} from 'vue-router';
import {Bell, List, Management, Promotion, TrendCharts, Warning} from '@element-plus/icons-vue';
@@ -142,6 +157,8 @@ interface CardModel {
tone: Tone;
trend: { direction: 'up' | 'down' | 'flat'; label: string } | null;
sparkline: number[];
loading: boolean;
error: boolean;
onClick: () => void;
onRefresh: () => Promise<void> | void;
}
@@ -205,87 +222,111 @@ const state = reactive<HomeState>({
alertSparkline: [],
});
type HomeSection = 'totals' | 'today' | 'sparkline' | 'alerts' | 'growth';
const sectionStatus = reactive<Record<HomeSection, 'idle' | 'loading' | 'success' | 'error'>>({
totals: 'idle',
today: 'idle',
sparkline: 'idle',
alerts: 'idle',
growth: 'idle',
});
const loading = computed(() => Object.values(sectionStatus).some((value) => value === 'loading'));
const hasDataError = computed(() => Object.values(sectionStatus).some((value) => value === 'error'));
const sectionPending = (...sections: HomeSection[]) =>
sections.some((section) => sectionStatus[section] === 'idle' || sectionStatus[section] === 'loading');
const sectionFailed = (...sections: HomeSection[]) => sections.some((section) => sectionStatus[section] === 'error');
const cardState = (...sections: HomeSection[]) => {
const error = sectionFailed(...sections);
return {loading: !error && sectionPending(...sections), error};
};
let alive = true;
const sectionTasks = new Map<HomeSection, Promise<void>>();
const runSection = (section: HomeSection, task: () => Promise<void>) => {
if (sectionTasks.has(section)) return sectionTasks.get(section)!;
sectionStatus[section] = 'loading';
const request = task()
.then(() => {
if (alive) sectionStatus[section] = 'success';
})
.catch(() => {
if (alive) sectionStatus[section] = 'error';
})
.finally(() => {
sectionTasks.delete(section);
});
sectionTasks.set(section, request);
return request;
};
const emptyPage = {offset: 0, limit: 1};
const toNumber = (value: number | string | null | undefined) => Number(value) || 0;
const toNumberArray = (values: Array<number | string | null | undefined>) => values.map((value) => toNumber(value));
const getSettledTotal = (result: PromiseSettledResult<ListPageResponse>) =>
result.status === 'fulfilled' ? toNumber(result.value.total) : 0;
const loadTotals = async () => {
const [driverRes, deviceRes, pointRes, profileRes] = await Promise.allSettled([
const [driverRes, deviceRes, pointRes, profileRes] = await Promise.all([
listDriver<ListPageResponse>(emptyPage),
listDevice<ListPageResponse>(emptyPage),
listPoint<ListPageResponse>(emptyPage),
listProfile<ListPageResponse>(emptyPage),
]);
state.driverCount = getSettledTotal(driverRes);
state.deviceCount = getSettledTotal(deviceRes);
state.pointCount = getSettledTotal(pointRes);
state.profileCount = getSettledTotal(profileRes);
if (!alive) return;
state.driverCount = toNumber(driverRes.total);
state.deviceCount = toNumber(deviceRes.total);
state.pointCount = toNumber(pointRes.total);
state.profileCount = toNumber(profileRes.total);
};
const loadToday = async () => {
try {
const res = await statsToday();
const data: StatsTodaySummary = res;
state.todayCount = toNumber(data.today);
state.todayPercentChange = toNumber(data.percentChange);
state.totalCount = toNumber(data.total);
} catch {
// handled globally
}
const res = await statsToday();
if (!alive) return;
const data: StatsTodaySummary = res;
state.todayCount = toNumber(data.today);
state.todayPercentChange = toNumber(data.percentChange);
state.totalCount = toNumber(data.total);
};
const loadSparkline = async () => {
try {
const res = await statsTimeseries({granularity: 'hour', rangeKey: '24h'});
const buckets: StatsTimeBucket[] = res;
state.todaySparkline = buckets.map((bucket) => toNumber(bucket.count));
} catch {
// handled globally
}
const res = await statsTimeseries({granularity: 'hour', rangeKey: '24h'});
if (!alive) return;
const buckets: StatsTimeBucket[] = res;
state.todaySparkline = buckets.map((bucket) => toNumber(bucket.count));
};
const loadAlerts = async () => {
try {
const res = await alertStats();
const data: AlertStatsSummary = res;
state.alertCount = toNumber(data.total);
state.alertUnconfirmed = toNumber(data.unconfirmed);
state.deviceAlertCount = toNumber(data.deviceAlerts);
state.driverAlertCount = toNumber(data.driverAlerts);
state.deviceUnconfirmed = toNumber(data.deviceUnconfirmed);
state.driverUnconfirmed = toNumber(data.driverUnconfirmed);
state.todayDeviceAlarms = toNumber(data.todayDeviceAlarms);
state.todayDriverAlarms = toNumber(data.todayDriverAlarms);
state.todayDeviceUnconfirmed = toNumber(data.todayDeviceUnconfirmed);
state.todayDriverUnconfirmed = toNumber(data.todayDriverUnconfirmed);
state.alertSparkline = toNumberArray(data.sparkline24h ?? []);
} catch {
// handled globally
}
const res = await alertStats();
if (!alive) return;
const data: AlertStatsSummary = res;
state.alertCount = toNumber(data.total);
state.alertUnconfirmed = toNumber(data.unconfirmed);
state.deviceAlertCount = toNumber(data.deviceAlerts);
state.driverAlertCount = toNumber(data.driverAlerts);
state.deviceUnconfirmed = toNumber(data.deviceUnconfirmed);
state.driverUnconfirmed = toNumber(data.driverUnconfirmed);
state.todayDeviceAlarms = toNumber(data.todayDeviceAlarms);
state.todayDriverAlarms = toNumber(data.todayDriverAlarms);
state.todayDeviceUnconfirmed = toNumber(data.todayDeviceUnconfirmed);
state.todayDriverUnconfirmed = toNumber(data.todayDriverUnconfirmed);
state.alertSparkline = toNumberArray(data.sparkline24h ?? []);
};
const loadGrowth = async () => {
try {
const res = await dailyGrowth(7);
const data: DailyGrowthSummary = res;
state.driverSparkline = toNumberArray(data.driverDailyCounts ?? []);
state.deviceSparkline = toNumberArray(data.deviceDailyCounts ?? []);
state.pointSparkline = toNumberArray(data.pointDailyCounts ?? []);
} catch {
// handled globally
}
const res = await dailyGrowth(7);
if (!alive) return;
const data: DailyGrowthSummary = res;
state.driverSparkline = toNumberArray(data.driverDailyCounts ?? []);
state.deviceSparkline = toNumberArray(data.deviceDailyCounts ?? []);
state.pointSparkline = toNumberArray(data.pointDailyCounts ?? []);
};
onMounted(() => {
void loadTotals();
void loadToday();
void loadSparkline();
void loadAlerts();
void loadGrowth();
});
const refreshAll = () => Promise.all([
runSection('totals', loadTotals),
runSection('today', loadToday),
runSection('sparkline', loadSparkline),
runSection('alerts', loadAlerts),
runSection('growth', loadGrowth),
]);
const percentTrend = computed(() => {
const percent = state.todayPercentChange;
@@ -299,14 +340,22 @@ const percentTrend = computed(() => {
});
const refreshDriver = async () => {
await Promise.all([loadTotals(), loadGrowth()]);
await Promise.all([runSection('totals', loadTotals), runSection('growth', loadGrowth)]);
};
const refreshDevice = refreshDriver;
const refreshPoint = refreshDriver;
const refreshData = async () => {
await Promise.all([loadToday(), loadSparkline()]);
await Promise.all([runSection('today', loadToday), runSection('sparkline', loadSparkline)]);
};
const refreshAlert = loadAlerts;
const refreshAlert = () => runSection('alerts', loadAlerts);
onMounted(() => {
void refreshAll();
});
onBeforeUnmount(() => {
alive = false;
});
const sparkTrend = (spark: number[]): { direction: 'up' | 'down' | 'flat'; label: string } | null => {
if (spark.length < 2) {
@@ -330,11 +379,12 @@ const cards = computed<CardModel[]>(() => [
key: 'driver',
title: t('home.driverCount'),
value: state.driverCount,
subtitle: t('home.entityAlarms', {n: state.driverAlertCount}),
subtitle: sectionStatus.alerts === 'success' ? t('home.entityAlarms', {n: state.driverAlertCount}) : '',
icon: Promotion,
tone: 'blue',
trend: sparkTrend(state.driverSparkline),
sparkline: state.driverSparkline,
...cardState('totals'),
onClick: () => router.push({name: 'driver'}),
onRefresh: refreshDriver,
},
@@ -342,11 +392,12 @@ const cards = computed<CardModel[]>(() => [
key: 'device',
title: t('home.deviceCount'),
value: state.deviceCount,
subtitle: t('home.entityAlarms', {n: state.deviceAlertCount}),
subtitle: sectionStatus.alerts === 'success' ? t('home.entityAlarms', {n: state.deviceAlertCount}) : '',
icon: Management,
tone: 'purple',
trend: sparkTrend(state.deviceSparkline),
sparkline: state.deviceSparkline,
...cardState('totals'),
onClick: () => router.push({name: 'device'}),
onRefresh: refreshDevice,
},
@@ -359,6 +410,7 @@ const cards = computed<CardModel[]>(() => [
tone: 'orange',
trend: sparkTrend(state.pointSparkline),
sparkline: state.pointSparkline,
...cardState('totals'),
onClick: () => router.push({name: 'profile'}),
onRefresh: refreshPoint,
},
@@ -366,11 +418,12 @@ const cards = computed<CardModel[]>(() => [
key: 'data',
title: t('home.todayData'),
value: state.todayCount,
subtitle: state.totalCount > 0 ? t('home.todayTotal', {n: state.totalCount}) : '',
subtitle: sectionStatus.today === 'success' && state.totalCount > 0 ? t('home.todayTotal', {n: state.totalCount}) : '',
icon: TrendCharts,
tone: 'green',
trend: percentTrend.value,
sparkline: state.todaySparkline,
...cardState('today'),
onClick: () => router.push({name: 'pointValue'}),
onRefresh: refreshData,
},
@@ -383,6 +436,7 @@ const cards = computed<CardModel[]>(() => [
tone: 'red',
trend: sparkTrend(state.alertSparkline),
sparkline: state.alertSparkline,
...cardState('alerts'),
onClick: () => router.push({name: 'settingsDriverAlarm'}),
onRefresh: refreshAlert,
},
@@ -395,6 +449,7 @@ const cards = computed<CardModel[]>(() => [
tone: 'orange',
trend: sparkTrend(state.alertSparkline),
sparkline: state.alertSparkline,
...cardState('alerts'),
onClick: () => router.push({name: 'settingsDeviceAlarm'}),
onRefresh: refreshAlert,
},
@@ -409,6 +464,10 @@ const cards = computed<CardModel[]>(() => [
.home {
padding: 0;
&__error {
margin-bottom: var(--dc3-space-2);
}
.home__row {
margin-bottom: var(--dc3-space-2);
@@ -16,9 +16,19 @@
-->
<template>
<dashboard-card :loading="loading" :title="$t('home.activity.title')" body-mode="chart" @refresh="load">
<dashboard-card
:empty="status === 'success' && !hasData"
:empty-text="$t('home.activity.empty')"
:error="status === 'error'"
:error-text="$t('common.loadFailed')"
:loading="loading"
:retry-text="$t('common.retry')"
:title="$t('home.activity.title')"
body-mode="chart"
@refresh="load"
>
<template #tools>
<range-segmented v-model="rangeKey" size="small" @update:model-value="load"/>
<range-segmented v-model="rangeKey" size="small"/>
</template>
<div ref="chartRef" class="activity-heatmap__canvas"></div>
</dashboard-card>
@@ -33,14 +43,17 @@ import {statsActivity} from '@/api/dashboard';
import DashboardCard from '@/components/card/dashboard/DashboardCard.vue';
import type {RangeKey} from '@/config/types/dashboard';
import RangeSegmented from '@/components/segmented/RangeSegmented.vue';
import {useAsyncLoader} from '@/utils/asyncLoaderUtil';
const {t} = useI18n();
// A weekday/hour heatmap is most informative over a full week. The previous
// 24h default correctly populated only two day rows, which looked incomplete.
const rangeKey = ref<RangeKey>('7d');
const loading = ref(false);
const {error, loading, run, status} = useAsyncLoader();
const chartRef = ref<HTMLElement>();
let chart: Chart | undefined;
const rows = ref<{dow: number; hour: number; count: number}[]>([]);
const hasData = computed(() => rows.value.length > 0);
// Sunday-first to match Postgres EXTRACT(DOW) (0..6 = Sun..Sat).
const dayLabels = computed(() => [
@@ -88,22 +101,38 @@ const render = (rows: { dow: number; hour: number; count: number }[]) => {
};
const load = async () => {
loading.value = true;
try {
const res: any = await statsActivity({rangeKey: rangeKey.value});
const rows = (res ?? []) as { dow: number; hour: number; count: number }[];
await nextTick();
render(rows);
} catch {
// handled globally
} finally {
loading.value = false;
await run(() => statsActivity({rangeKey: rangeKey.value}), {
apply: (res) => {
const payload = Array.isArray(res) ? res : [];
rows.value = payload.map((row) => ({
dow: Number(row.dow),
hour: Number(row.hour),
count: Number(row.count) || 0,
}));
},
});
if (status.value !== 'success') return;
await nextTick();
if (status.value !== 'success') return;
if (hasData.value) render(rows.value);
else {
chart?.destroy();
chart = undefined;
}
};
onMounted(load);
watch(rangeKey, load);
onUnmounted(() => chart?.destroy());
watch(error, (value) => {
if (value) {
chart?.destroy();
chart = undefined;
}
});
onUnmounted(() => {
chart?.destroy();
chart = undefined;
});
</script>
<style lang="scss" scoped>
+21 -15
View File
@@ -18,9 +18,12 @@
<template>
<dashboard-card
:badge="rows.length || null"
:empty="!loading && rows.length === 0"
:empty="status === 'success' && rows.length === 0"
:empty-text="$t('home.alertList.empty')"
:error="status === 'error'"
:error-text="$t('common.loadFailed')"
:loading="loading"
:retry-text="$t('common.retry')"
:title="$t('home.alertList.title')"
body-mode="scroll"
class="alert-list"
@@ -67,6 +70,7 @@ import {alertLatest, alertStats} from '@/api/dashboard';
import DashboardCard from '@/components/card/dashboard/DashboardCard.vue';
import {useEntityNames} from '@/composables/useEntityNames';
import type {AlertSource} from '@/config/types/dashboard';
import {useAsyncLoader} from '@/utils/asyncLoaderUtil';
interface AlertRow {
id: string;
@@ -85,25 +89,27 @@ const props = defineProps({
const {t, locale} = useI18n();
const loading = ref(false);
const {loading, run, status} = useAsyncLoader();
const rows = ref<AlertRow[]>([]);
const stats = reactive({total: 0, unconfirmed: 0});
const {resolveBySource, nameBySource} = useEntityNames();
const refresh = async () => {
loading.value = true;
try {
const [s, l]: any = await Promise.all([alertStats(), alertLatest(props.size)]);
stats.total = (s?.driverAlerts ?? 0) + (s?.deviceAlerts ?? 0);
stats.unconfirmed = (s?.driverUnconfirmed ?? 0) + (s?.deviceUnconfirmed ?? 0);
const data: AlertRow[] = Array.isArray(l) ? l : l?.items ?? [];
rows.value = data;
await resolveBySource(data);
} catch {
// handled globally
} finally {
loading.value = false;
}
await run(
async () => {
const [summary, latest]: any = await Promise.all([alertStats(), alertLatest(props.size)]);
const data: AlertRow[] = Array.isArray(latest) ? latest : latest?.items ?? [];
await resolveBySource(data);
return {summary, data};
},
{
apply: ({summary, data}) => {
stats.total = (summary?.driverAlerts ?? 0) + (summary?.deviceAlerts ?? 0);
stats.unconfirmed = (summary?.driverUnconfirmed ?? 0) + (summary?.deviceUnconfirmed ?? 0);
rows.value = data;
},
}
);
};
// Group the flat row list by YYYY-MM-DD so the timeline has day headings.
@@ -17,23 +17,26 @@
<template>
<dashboard-card
:empty="!loading && empty"
:empty="status === 'success' && !hasData"
:empty-image-size="80"
:empty-text="$t('home.liveFeed.empty')"
:empty-text="$t('home.tabs.empty')"
:error="status === 'error'"
:error-text="$t('common.loadFailed')"
:loading="loading"
:retry-text="$t('common.retry')"
body-mode="chart"
class="analytics-tabs"
variant="tabs"
@refresh="load"
>
<template #title>
<el-tabs v-model="activeTab" class="analytics-tabs__bar" @tab-change="onTabChange">
<el-tabs v-model="activeTab" class="analytics-tabs__bar">
<el-tab-pane v-for="t in tabs" :key="t.key" :label="t.label" :name="t.key"/>
</el-tabs>
</template>
<template #tools>
<range-segmented v-if="isTopTab" v-model="rangeKey" size="small" @update:model-value="load"/>
<range-segmented v-if="isTopTab" v-model="rangeKey" size="small"/>
</template>
<!-- Caption line — spells out the ranking rule and (for top-N tabs) the
@@ -59,6 +62,7 @@ import {listProfileByIds} from '@/api/profile';
import DashboardCard from '@/components/card/dashboard/DashboardCard.vue';
import type {RangeKey} from '@/config/types/dashboard';
import RangeSegmented from '@/components/segmented/RangeSegmented.vue';
import {useAsyncLoader} from '@/utils/asyncLoaderUtil';
type TabKey = 'deviceStatus' | 'protocol' | 'profile' | 'topDevice' | 'topPoint' | 'topDriver';
type Group = 'structural' | 'top';
@@ -85,14 +89,17 @@ const tabs = computed<{ key: TabKey; label: string }[]>(() => {
const activeTab = ref<TabKey>(props.group === 'top' ? 'topDriver' : 'protocol');
const rangeKey = ref<RangeKey>('24h');
const loading = ref(false);
const {loading, run, status} = useAsyncLoader();
const chartRef = ref<HTMLElement>();
const empty = ref(false);
const chartData = ref<{key: string; count: number}[]>([]);
const hasData = computed(() => chartData.value.length > 0);
const isTopTab = computed(
() => activeTab.value === 'topDevice' || activeTab.value === 'topPoint' || activeTab.value === 'topDriver'
);
const isTopTabKey = (tab: TabKey) => tab === 'topDevice' || tab === 'topPoint' || tab === 'topDriver';
// Caption text for the current tab — structural tabs get a fixed phrase,
// top-N tabs interpolate the active range so "{range} internal write volume"
// stays accurate when the user flips the segmented control.
@@ -119,16 +126,14 @@ const caption = computed(() => {
let chart: Chart | undefined;
const onTabChange = () => load();
const disposeChart = () => {
chart?.destroy();
chart = undefined;
};
const ensureChart = () => {
const createChart = () => {
if (!chartRef.value) return;
if (chart) return;
disposeChart();
chart = new Chart({container: chartRef.value, autoFit: true});
};
@@ -140,9 +145,11 @@ const nameCache: Record<NameKind, Record<string, string>> = {
driver: {},
profile: {},
};
let namesGeneration = 0;
const resolveNames = async (kind: NameKind, ids: string[]) => {
const cache = nameCache[kind];
const generation = namesGeneration;
const missing = ids.filter((id) => id && !cache[id]);
if (missing.length === 0) return;
try {
@@ -154,7 +161,7 @@ const resolveNames = async (kind: NameKind, ids: string[]) => {
const data = res || {};
for (const id of missing) {
const item = data[id];
if (item) {
if (item && generation === namesGeneration) {
cache[id] = item.deviceName || item.pointName || item.driverName || item.profileName || id;
}
}
@@ -165,7 +172,7 @@ const resolveNames = async (kind: NameKind, ids: string[]) => {
// ---- renderers ----------------------------------------------------------
const renderPie = (data: { key: string; count: number }[]) => {
ensureChart();
createChart();
if (!chart) return;
chart.clear();
chart
@@ -182,7 +189,7 @@ const renderPie = (data: { key: string; count: number }[]) => {
};
const renderBar = (data: { key: string; count: number }[]) => {
ensureChart();
createChart();
if (!chart) return;
chart.clear();
chart
@@ -198,7 +205,7 @@ const renderBar = (data: { key: string; count: number }[]) => {
};
const renderHorizontalBar = (data: { key: string; count: number }[]) => {
ensureChart();
createChart();
if (!chart) return;
chart.clear();
chart
@@ -215,95 +222,102 @@ const renderHorizontalBar = (data: { key: string; count: number }[]) => {
};
// ---- loaders ------------------------------------------------------------
const loadDriverOrDevice = async () => {
// deviceStatus / protocol / profile share the same two sources.
const [drv, dev]: any = await Promise.all([driverStats(), deviceStats(10)]);
const driverPayload = drv?.data || {byEnable: [], byType: [], byService: []};
const devicePayload = dev?.data || {byEnable: [], byProfile: [], byDriver: []};
if (activeTab.value === 'deviceStatus') {
const raw = (devicePayload.byEnable || []) as { key: string; count: number }[];
const buckets = raw.map((bucket) => ({
...bucket,
key: bucket.key === 'ENABLED' ? t('common.enable') : bucket.key === 'DISABLED' ? t('common.disable') : bucket.key,
}));
empty.value = buckets.length === 0;
await nextTick();
if (!empty.value) renderPie(buckets);
return;
}
if (activeTab.value === 'protocol') {
// byService keys look like "dc3-driver-modbus-tcp" / "dc3-driver-mqtt";
// strip the prefix so the chart axis reads "modbus-tcp / mqtt / opc-ua"
// without the noise.
const raw = (driverPayload.byService || []) as { key: string; count: number }[];
const buckets = raw.map((b) => ({
key: (b.key || '-').replace(/^dc3-driver-/, ''),
count: b.count,
}));
empty.value = buckets.length === 0;
await nextTick();
if (!empty.value) renderBar(buckets);
return;
}
if (activeTab.value === 'profile') {
const raw = (devicePayload.byProfile || []) as { key: string; count: number }[];
const ids = raw.map((b) => b.key).filter(Boolean);
await resolveNames('profile', ids);
const buckets = raw.map((b) => ({key: nameCache.profile[b.key] || b.key, count: b.count}));
empty.value = buckets.length === 0;
await nextTick();
if (!empty.value) renderHorizontalBar(buckets);
}
type Bucket = {key: string; count: number};
type StructuralPayload = {
driver?: {byService?: Array<{key: string; count: number}>};
device?: {
byEnable?: Array<{key: string; count: number}>;
byProfile?: Array<{key: string; count: number}>;
};
};
const loadTop = async () => {
const dimMap: Record<Exclude<TabKey, 'deviceStatus' | 'protocol' | 'profile'>, 'device' | 'point' | 'driver'> = {
const loadStructural = async (tab: Extract<TabKey, 'deviceStatus' | 'protocol' | 'profile'>): Promise<Bucket[]> => {
// Fetch both summaries once for parity with the existing endpoint contract,
// but derive the result from the tab captured at request start. This keeps a
// late response from being interpreted using a newer tab selection.
const [driverResult, deviceResult] = (await Promise.all([driverStats(), deviceStats(10)])) as [StructuralPayload, any];
const driverPayload = driverResult?.driver ?? (driverResult as any)?.data ?? driverResult ?? {};
const devicePayload = deviceResult?.device ?? (deviceResult as any)?.data ?? deviceResult ?? {};
if (tab === 'deviceStatus') {
return (devicePayload.byEnable ?? []).map((bucket: {key: string; count: number}) => ({
key: bucket.key === 'ENABLED' ? t('common.enable') : bucket.key === 'DISABLED' ? t('common.disable') : bucket.key,
count: Number(bucket.count) || 0,
}));
}
if (tab === 'protocol') {
return (driverPayload.byService ?? []).map((bucket: {key: string; count: number}) => ({
key: (bucket.key || '-').replace(/^dc3-driver-/, ''),
count: Number(bucket.count) || 0,
}));
}
const raw = (devicePayload.byProfile ?? []) as Array<{key: string; count: number}>;
await resolveNames('profile', raw.map((bucket) => bucket.key).filter(Boolean));
return raw.map((bucket) => ({
key: nameCache.profile[bucket.key] || bucket.key,
count: Number(bucket.count) || 0,
}));
};
const loadTop = async (
tab: Extract<TabKey, 'topDevice' | 'topPoint' | 'topDriver'>,
range: RangeKey
): Promise<Bucket[]> => {
const dimMap: Record<typeof tab, 'device' | 'point' | 'driver'> = {
topDevice: 'device',
topPoint: 'point',
topDriver: 'driver',
};
const dim = dimMap[activeTab.value as keyof typeof dimMap];
const res: any = await statsTop({dimension: dim, rangeKey: rangeKey.value, limit: 10});
const rows: { entityId: number; count: number }[] = res || [];
const ids = rows.map((r) => String(r.entityId));
await resolveNames(dim, ids);
const buckets = rows.map((r) => ({
key: nameCache[dim][String(r.entityId)] || String(r.entityId),
count: r.count,
const dim = dimMap[tab];
const result = await statsTop({dimension: dim, rangeKey: range, limit: 10});
const rows = (Array.isArray(result) ? result : []) as Array<{entityId: string | number; count: number}>;
await resolveNames(dim, rows.map((row) => String(row.entityId)));
return rows.map((row) => ({
key: nameCache[dim][String(row.entityId)] || String(row.entityId),
count: Number(row.count) || 0,
}));
empty.value = buckets.length === 0;
await nextTick();
if (!empty.value) renderHorizontalBar(buckets);
};
const renderForTab = (tab: TabKey, buckets: Bucket[]) => {
if (tab === 'deviceStatus') renderPie(buckets);
else if (tab === 'protocol') renderBar(buckets);
else renderHorizontalBar(buckets);
};
const load = async () => {
loading.value = true;
try {
if (isTopTab.value) {
await loadTop();
} else {
await loadDriverOrDevice();
}
} catch {
// handled globally
empty.value = true;
} finally {
loading.value = false;
}
const tab = activeTab.value;
const range = rangeKey.value;
await run(
() =>
isTopTabKey(tab)
? loadTop(tab as Extract<TabKey, 'topDevice' | 'topPoint' | 'topDriver'>, range)
: loadStructural(tab as Extract<TabKey, 'deviceStatus' | 'protocol' | 'profile'>),
{apply: (buckets) => (chartData.value = buckets)}
);
if (status.value !== 'success') return;
await nextTick();
if (status.value !== 'success') return;
if (chartData.value.length > 0) renderForTab(tab, chartData.value);
else disposeChart();
};
onMounted(() => {
// Tab/range changes only need a reload; cached entity names stay valid
// and are invalidated solely when the locale (the display language) flips.
watch([activeTab, rangeKey], () => {
load();
});
watch(locale, () => {
namesGeneration += 1;
for (const names of Object.values(nameCache)) {
for (const id of Object.keys(names)) delete names[id];
}
load();
});
onMounted(load);
onUnmounted(() => disposeChart());
</script>
@@ -16,16 +16,26 @@
-->
<template>
<dashboard-card :loading="loading" :title="$t('home.latency.title')" body-mode="chart" @refresh="load">
<dashboard-card
:empty="status === 'success' && !hasData"
:empty-text="$t('home.latency.empty')"
:error="status === 'error'"
:error-text="$t('common.loadFailed')"
:loading="loading"
:retry-text="$t('common.retry')"
:title="$t('home.latency.title')"
body-mode="chart"
@refresh="load"
>
<template #tools>
<range-segmented v-model="rangeKey" size="small" @update:model-value="load"/>
<range-segmented v-model="rangeKey" size="small"/>
</template>
<div ref="chartRef" class="latency-chart__canvas"></div>
</dashboard-card>
</template>
<script lang="ts" setup>
import {nextTick, onMounted, onUnmounted, ref, watch} from 'vue';
import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue';
import {useI18n} from 'vue-i18n';
import {Chart} from '@antv/g2';
@@ -33,12 +43,15 @@ import {statsLatency} from '@/api/dashboard';
import DashboardCard from '@/components/card/dashboard/DashboardCard.vue';
import type {RangeKey} from '@/config/types/dashboard';
import RangeSegmented from '@/components/segmented/RangeSegmented.vue';
import {useAsyncLoader} from '@/utils/asyncLoaderUtil';
const {t} = useI18n();
const rangeKey = ref<RangeKey>('24h');
const loading = ref(false);
const {error, loading, run, status} = useAsyncLoader();
const chartRef = ref<HTMLElement>();
let chart: Chart | undefined;
const rows = ref<{bin: number; count: number}[]>([]);
const hasData = computed(() => rows.value.length > 0);
// Bin labels line up with the 6-bucket layout in the backend SQL.
const binLabels = () => [
@@ -74,22 +87,34 @@ const render = (rows: { bin: number; count: number }[]) => {
};
const load = async () => {
loading.value = true;
try {
const res: any = await statsLatency({rangeKey: rangeKey.value});
const rows = (res ?? []) as { bin: number; count: number }[];
await nextTick();
render(rows);
} catch {
// handled globally
} finally {
loading.value = false;
await run(() => statsLatency({rangeKey: rangeKey.value}), {
apply: (res) => {
const payload = Array.isArray(res) ? res : [];
rows.value = payload.map((row) => ({bin: Number(row.bin), count: Number(row.count) || 0}));
},
});
if (status.value !== 'success') return;
await nextTick();
if (status.value !== 'success') return;
if (hasData.value) render(rows.value);
else {
chart?.destroy();
chart = undefined;
}
};
onMounted(load);
watch(rangeKey, load);
onUnmounted(() => chart?.destroy());
watch(error, (value) => {
if (value) {
chart?.destroy();
chart = undefined;
}
});
onUnmounted(() => {
chart?.destroy();
chart = undefined;
});
</script>
<style lang="scss" scoped>
@@ -19,13 +19,16 @@
<dashboard-card
v-model:interval="intervalMs"
:auto-refresh="intervalOptions"
:empty="!loading && rows.length === 0"
:empty="status === 'success' && rows.length === 0"
:empty-text="$t('home.liveFeed.empty')"
:error="status === 'error'"
:error-text="$t('common.loadFailed')"
:loading="loading"
:retry-text="$t('common.retry')"
:title="$t('home.liveFeed.title')"
body-mode="scroll"
class="live-feed"
loading-target="none"
loading-target="button"
@refresh="refresh"
>
<el-timeline>
@@ -68,6 +71,7 @@ import {useI18n} from 'vue-i18n';
import {streamLatest} from '@/api/dashboard';
import DashboardCard from '@/components/card/dashboard/DashboardCard.vue';
import {useAsyncLoader} from '@/utils/asyncLoaderUtil';
interface Row {
deviceId: string;
@@ -91,7 +95,7 @@ const props = defineProps({
const {t, locale} = useI18n();
const loading = ref(false);
const {loading, run, status} = useAsyncLoader();
const rows = ref<Row[]>([]);
const lastRefreshed = ref<string>('');
const intervalMs = ref(0);
@@ -104,17 +108,15 @@ const intervalOptions = computed(() => [
]);
const refresh = async () => {
loading.value = true;
try {
const res: any = await streamLatest(props.size);
const data: Row[] = res ?? [];
rows.value = data;
lastRefreshed.value = new Date().toISOString();
} catch {
// handled globally
} finally {
loading.value = false;
}
// Auto-refresh must never stack requests; a manual refresh can still be
// initiated after the current request settles via the shared card button.
if (loading.value) return;
await run(() => streamLatest(props.size), {
apply: (res) => {
rows.value = Array.isArray(res) ? res : [];
lastRefreshed.value = new Date().toISOString();
},
});
};
// Show the name when the server resolved it, otherwise fall back to the
@@ -179,6 +181,9 @@ watch(locale, refresh);
}
.live-feed__line {
width: 100%;
min-width: 0;
max-width: 100%;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
@@ -194,21 +199,34 @@ watch(locale, refresh);
// device=blue "management", point=green "data") so the same entity
// reads the same colour everywhere on the page.
.live-feed__driver {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #9059f6;
font-weight: 500;
}
.live-feed__device {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #409eff;
font-weight: 600;
}
.live-feed__point {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #67c23a;
font-weight: 500;
}
.live-feed__sep {
flex: 0 0 auto;
color: #dcdfe6;
}
@@ -220,6 +238,7 @@ watch(locale, refresh);
}
.live-feed__tag {
flex: 0 0 auto;
font-size: 10px;
font-weight: 600;
padding: 1px 5px;
@@ -250,6 +269,10 @@ watch(locale, refresh);
}
.live-feed__value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'Menlo', monospace;
color: #303133;
font-weight: 500;
+67 -28
View File
@@ -29,14 +29,14 @@
<el-icon class="sla-badge__icon">
<Warning/>
</el-icon>
<div v-if="backlog.over24h > 0" class="sla-badge__chip sla-badge__chip--sla" @click="jumpTo('sla')">
<button v-if="backlog.over24h > 0" class="sla-badge__chip sla-badge__chip--sla" type="button" @click="jumpTo('sla')">
<span class="sla-badge__value">{{ backlog.over24h }}</span>
<span class="sla-badge__label">{{ $t('home.sla.unackOver24h') }}</span>
</div>
<div v-if="silentCount > 0" class="sla-badge__chip sla-badge__chip--avail" @click="jumpTo('availability')">
</button>
<button v-if="silentCount > 0" class="sla-badge__chip sla-badge__chip--avail" type="button" @click="jumpTo('availability')">
<span class="sla-badge__value">{{ silentCount }}</span>
<span class="sla-badge__label">{{ $t('home.sla.silentDevices') }}</span>
</div>
</button>
</div>
</template>
@@ -59,14 +59,24 @@ const visible = computed(() => backlog.over24h > 0 || silentCount.value > 0);
const warn = computed(() => backlog.over24h > 0);
const load = () =>
run(async () => {
const [a, s]: [AgingBacklog, SilentSource[]] = await Promise.all([
alertAging(),
silentSources(7, 15, 200),
]);
Object.assign(backlog, a ?? {under1h: 0, h1to6: 0, h6to24: 0, over24h: 0, total: 0});
silentCount.value = (s ?? []).length;
});
run(
async () => {
const [agingBacklog, silentSourceRows]: [AgingBacklog, SilentSource[]] = await Promise.all([
alertAging(),
silentSources(7, 15, 200),
]);
return {agingBacklog, silentCount: (silentSourceRows ?? []).length};
},
{
apply: (result) => {
Object.assign(
backlog,
result.agingBacklog ?? {under1h: 0, h1to6: 0, h6to24: 0, over24h: 0, total: 0}
);
silentCount.value = result.silentCount;
},
}
);
const jumpTo = (tab: 'sla' | 'availability') => {
router.push({name: 'settingsAlarmOverview', query: {tab}}).catch(() => {
@@ -82,17 +92,17 @@ defineExpose({refresh: load});
display: flex;
align-items: center;
gap: var(--dc3-space-2);
padding: var(--dc3-space-2) var(--dc3-space-4);
padding: var(--dc3-space-1) var(--dc3-space-4);
border-radius: var(--dc3-radius-md);
background: #fdf6ec;
border: 1px solid #faecd8;
color: #e6a23c;
background: var(--el-color-warning-light-9);
border: 1px solid var(--el-color-warning-light-7);
color: var(--el-color-warning);
font-size: 13px;
&--warn {
background: #fef0f0;
border-color: #fde2e2;
color: #f56c6c;
background: var(--el-color-danger-light-9);
border-color: var(--el-color-danger-light-7);
color: var(--el-color-danger);
}
.sla-badge__icon {
@@ -100,26 +110,55 @@ defineExpose({refresh: load});
}
.sla-badge__chip {
position: relative;
isolation: isolate;
appearance: none;
display: inline-flex;
align-items: baseline;
align-items: center;
gap: var(--dc3-space-1);
padding: 2px var(--dc3-space-2);
border-radius: 999px;
background: rgba(255, 255, 255, 0.5);
min-height: var(--dc3-space-8);
padding: 0 var(--dc3-space-2);
border: 0;
border-radius: var(--dc3-radius-full);
background: transparent;
font: inherit;
line-height: 1.2;
white-space: nowrap;
cursor: pointer;
transition: background-color 0.12s ease;
&:hover {
background: #ffffff;
&::before {
position: absolute;
z-index: 0;
inset: 2px 0;
border-radius: inherit;
background: var(--dc3-bg-elevated);
content: '';
pointer-events: none;
transition: background-color var(--dc3-duration-fast) var(--dc3-ease-standard);
}
> * {
position: relative;
z-index: 1;
}
&:hover::before,
&:focus-visible::before {
background: var(--dc3-bg-elevated-strong);
}
&:focus-visible {
outline: none;
box-shadow: var(--dc3-focus-ring);
}
}
.sla-badge__chip--sla {
color: #f56c6c;
color: var(--el-color-danger);
}
.sla-badge__chip--avail {
color: #e6a23c;
color: var(--el-color-warning);
}
.sla-badge__value {
@@ -17,8 +17,10 @@
<template>
<dashboard-card
:empty="!loading && !hasData"
:empty="status === 'success' && !hasData"
:empty-text="$t('home.topology.empty')"
:error="status === 'error'"
:error-text="$t('common.loadFailed')"
:height="480"
:loading="loading"
:title="$t('home.topology.title')"
@@ -70,42 +72,34 @@
scoped flex sizing and overlays cleanly. -->
<el-dialog
v-model="othersDialog.visible"
:close-on-click-modal="false"
:title="othersDialog.title"
append-to-body
class="things-dialog things-dialog--wide"
destroy-on-close
width="560px"
>
<el-table :data="othersDialog.children" height="420" size="small">
<!-- @vue-generic {TopologyHiddenChild} -->
<el-table-column
:label="$t('home.topology.colType')"
prop="type"
width="110"
>
<template #default="{ row }">
<el-tag :type="tagTypeFor(row.type)" size="small">{{
layerLabel(row.type)
}}</el-tag>
</template>
</el-table-column>
<el-table-column
:label="$t('home.topology.colName')"
prop="name"
show-overflow-tooltip
/>
<!-- @vue-generic {TopologyHiddenChild} -->
<el-table-column :label="$t('common.operation')" width="110">
<template #default="{ row }">
<el-button link size="small" type="primary" @click="onChildJump(row)">
{{ $t("common.detail") }}
</el-button>
</template>
</el-table-column>
</el-table>
<responsive-record-list
:columns="hiddenChildColumns"
:empty-text="$t('home.topology.empty')"
:rows="othersDialog.children"
embedded
row-key="id"
>
<template #cell-type="{row}">
<el-tag :type="tagTypeFor(row.type)" size="small">{{ layerLabel(row.type) }}</el-tag>
</template>
<template #actions="{row}">
<el-button link type="primary" @click="onChildJump(row)">
{{ $t("common.detail") }}
</el-button>
</template>
</responsive-record-list>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { Chart } from "@antv/g2";
@@ -122,13 +116,14 @@ import type {
} from "@/config/types/dashboard";
import DashboardCard from "@/components/card/dashboard/DashboardCard.vue";
import RangeSegmented from "@/components/segmented/RangeSegmented.vue";
import ResponsiveRecordList from "@/components/list/ResponsiveRecordList.vue";
import { useAsyncLoader } from "@/utils/asyncLoaderUtil";
const { t, locale } = useI18n();
const router = useRouter();
const loading = ref(false);
const chartRef = ref<HTMLElement>();
const data = ref<TopologyResponse | null>(null);
const data = shallowRef<TopologyResponse | null>(null);
const lastRefreshed = ref<string>("");
// Cardinality = "who is wired to what" (structural count).
@@ -152,6 +147,23 @@ const othersDialog = reactive<{
children: [],
});
let chart: Chart | undefined;
const { loading, status, run } = useAsyncLoader();
const hiddenChildColumns = computed(() => [
{
key: "name",
label: t("home.topology.colName"),
minWidth: 200,
mobile: "primary" as const,
},
{
key: "type",
label: t("home.topology.colType"),
width: 110,
kind: "custom" as const,
mobile: "detail" as const,
},
]);
const hasData = computed(() => (data.value?.nodes.length ?? 0) > 0);
const stats = computed<TopologyStats>(
@@ -356,38 +368,54 @@ const render = (payload: TopologyResponse) => {
chart.render();
};
// ---- loader ------------------------------------------------------------
const load = async () => {
loading.value = true;
const destroyChart = () => {
if (!chart) return;
try {
// Only send rangeKey when it's actually meaningful (volume mode).
// Cardinality doesn't look at it, and omitting keeps the server
// cache key tight (one entry per tenant instead of one per range).
const params =
mode.value === "volume"
? { mode: mode.value, rangeKey: rangeKey.value }
: { mode: mode.value };
const res: TopologyResponse = await topology(params);
const payload = res ?? {
nodes: [],
links: [],
stats: { driverCount: 0, deviceCount: 0, profileCount: 0, pointCount: 0 },
};
data.value = payload;
lastRefreshed.value = new Date().toISOString();
if (payload.nodes.length > 0) {
// Wait a tick so the card body has its final box before G2 measures.
await new Promise((r) => setTimeout(r, 0));
render(payload);
}
} catch {
// handled globally
chart.destroy();
} finally {
loading.value = false;
chart = undefined;
}
};
// ---- loader ------------------------------------------------------------
const emptyPayload = (): TopologyResponse => ({
nodes: [],
links: [],
stats: { driverCount: 0, deviceCount: 0, profileCount: 0, pointCount: 0 },
});
const load = async () => {
// Only send rangeKey when it's actually meaningful (volume mode).
// Cardinality doesn't look at it, and omitting keeps the server cache key
// tight (one entry per tenant instead of one per range).
const params =
mode.value === "volume"
? { mode: mode.value, rangeKey: rangeKey.value }
: { mode: mode.value };
const payload = await run(() => topology(params), {
apply: (result) => {
const next = result ?? emptyPayload();
data.value = next;
lastRefreshed.value = new Date().toISOString();
if (next.nodes.length === 0) destroyChart();
},
});
if (!payload || payload.nodes.length === 0) return;
// Wait a tick so the card body has its final box before G2 measures. The
// identity check prevents a slower render callback from painting stale data
// after a newer mode/range request has already completed.
await nextTick();
if (data.value === payload && status.value === "success") render(payload);
};
watch(status, (value) => {
if (value === "error") {
data.value = null;
destroyChart();
}
});
// Flipping mode or rangeKey triggers a reload. rangeKey only matters
// when mode=volume, but we watch it regardless — switching modes first
// then nudging the range should not need a manual refresh click.
@@ -441,7 +469,7 @@ const onChildJump = (child: TopologyHiddenChild) => {
};
onMounted(load);
onUnmounted(() => chart?.destroy());
onUnmounted(destroyChart);
</script>
<style lang="scss" scoped>
@@ -16,27 +16,40 @@
-->
<template>
<dashboard-card :loading="loading" :title="$t('home.trendTitle')" body-mode="chart" @refresh="load">
<dashboard-card
:empty="status === 'success' && !hasData"
:empty-text="$t('home.trendEmpty')"
:error="status === 'error'"
:error-text="$t('common.loadFailed')"
:loading="loading"
:retry-text="$t('common.retry')"
:title="$t('home.trendTitle')"
body-mode="chart"
@refresh="load"
>
<template #tools>
<range-segmented v-model="rangeKey" size="small" @update:model-value="load"/>
<range-segmented v-model="rangeKey" size="small"/>
</template>
<div ref="chartRef" class="trend-chart__canvas"></div>
</dashboard-card>
</template>
<script lang="ts" setup>
import {nextTick, onMounted, onUnmounted, ref, watch} from 'vue';
import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue';
import {Chart} from '@antv/g2';
import {statsTimeseries} from '@/api/dashboard';
import DashboardCard from '@/components/card/dashboard/DashboardCard.vue';
import type {RangeKey} from '@/config/types/dashboard';
import RangeSegmented from '@/components/segmented/RangeSegmented.vue';
import {useAsyncLoader} from '@/utils/asyncLoaderUtil';
const rangeKey = ref<RangeKey>('24h');
const loading = ref(false);
const {error, loading, run, status} = useAsyncLoader();
const chartRef = ref<HTMLElement>();
let chart: Chart | undefined;
const points = ref<{bucket: string; count: number}[]>([]);
const hasData = computed(() => points.value.length > 0);
const ensureChart = () => {
if (!chartRef.value) return;
@@ -80,25 +93,42 @@ const granularityFor = (key: RangeKey): 'hour' | 'day' => {
};
const load = async () => {
loading.value = true;
try {
const res: any = await statsTimeseries({
granularity: granularityFor(rangeKey.value),
rangeKey: rangeKey.value,
});
const points = (res ?? []).map((p: any) => ({bucket: p.bucket, count: Number(p.count) || 0}));
await nextTick();
render(points);
} catch {
// handled globally
} finally {
loading.value = false;
const requestRange = rangeKey.value;
await run(
() =>
statsTimeseries({
granularity: granularityFor(requestRange),
rangeKey: requestRange,
}),
{
apply: (res) => {
const payload = Array.isArray(res) ? res : [];
points.value = payload.map((point) => ({bucket: String(point.bucket), count: Number(point.count) || 0}));
},
}
);
if (status.value !== 'success') return;
await nextTick();
if (status.value !== 'success') return;
if (hasData.value) render(points.value);
else {
chart?.destroy();
chart = undefined;
}
};
onMounted(load);
watch(rangeKey, load);
onUnmounted(() => chart?.destroy());
watch(error, (value) => {
if (value) {
chart?.destroy();
chart = undefined;
}
});
onUnmounted(() => {
chart?.destroy();
chart = undefined;
});
</script>
<style lang="scss" scoped>
+41 -5
View File
@@ -58,6 +58,7 @@
<el-form-item prop="tenant">
<el-input
v-model="reactiveData.formData.tenant"
:disabled="loading"
:placeholder="t('login.tenantPlaceholder')"
:prefix-icon="Box"
auto-complete="off"
@@ -67,6 +68,7 @@
<el-form-item prop="name">
<el-input
v-model="reactiveData.formData.name"
:disabled="loading"
:placeholder="t('login.usernamePlaceholder')"
:prefix-icon="User"
auto-complete="off"
@@ -76,6 +78,7 @@
<el-form-item prop="password">
<el-input
v-model="reactiveData.formData.password"
:disabled="loading"
:placeholder="t('login.passwordPlaceholder')"
:prefix-icon="Lock"
:type="reactiveData.passwordType"
@@ -85,6 +88,7 @@
<template #append>
<el-button
:aria-label="t('login.togglePassword')"
:disabled="loading"
:icon="reactiveData.isHide"
@click="showPassword"
/>
@@ -111,12 +115,30 @@
<span class="login-footer-legal">{{ copyright }}</span>
</footer>
<el-dialog v-model="changePasswordVisible" :title="t('login.changePasswordTitle')" width="420px">
<el-dialog
v-model="changePasswordVisible"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
:title="t('login.changePasswordTitle')"
class="things-dialog"
destroy-on-close
width="420px"
@closed="resetChangePassword"
>
<el-alert :closable="false" :title="changePasswordHint" class="mb-4" show-icon type="warning"/>
<el-form ref="changePasswordRef" :model="changePasswordData" :rules="changePasswordRule" label-width="0">
<el-form
ref="changePasswordRef"
v-loading="changePasswordLoading"
:aria-busy="changePasswordLoading"
:model="changePasswordData"
:rules="changePasswordRule"
label-width="0"
>
<el-form-item prop="newPassword">
<el-input
v-model="changePasswordData.newPassword"
:disabled="changePasswordLoading"
:placeholder="t('login.newPasswordPlaceholder')"
show-password
type="password"
@@ -125,6 +147,7 @@
<el-form-item prop="confirmPassword">
<el-input
v-model="changePasswordData.confirmPassword"
:disabled="changePasswordLoading"
:placeholder="t('login.confirmPasswordPlaceholder')"
show-password
type="password"
@@ -132,7 +155,10 @@
</el-form-item>
</el-form>
<template #footer>
<el-button :loading="changePasswordLoading" type="primary" @click="handleChangePassword">
<el-button :disabled="changePasswordLoading" @click="changePasswordVisible = false">
{{ t('login.changePasswordBack') }}
</el-button>
<el-button :disabled="changePasswordLoading" :loading="changePasswordLoading" type="primary" @click="handleChangePassword">
{{ t('login.changePasswordSubmit') }}
</el-button>
</template>
@@ -148,7 +174,7 @@ import {useI18n} from 'vue-i18n';
import {useAuthStore} from '@/store';
import {PASSWORD_CHANGE_CODES} from '@/config/constant/axios';
import {failMessage, successMessage} from '@/utils/notificationUtil';
import {successMessage} from '@/utils/notificationUtil';
import AppPreferences from '@/components/layout/AppPreferences.vue';
import BrandLockup from '@/components/brand/BrandLockup.vue';
@@ -204,6 +230,7 @@ const showPassword = () => {
};
const handleLogin = async () => {
if (loading.value) return;
const form = unref(formDataRef);
if (!form) {
return;
@@ -264,7 +291,15 @@ const openChangePassword = (code: string) => {
changePasswordVisible.value = true;
};
const resetChangePassword = () => {
changePasswordLoading.value = false;
changePasswordData.newPassword = '';
changePasswordData.confirmPassword = '';
changePasswordRef.value?.clearValidate();
};
const handleChangePassword = async () => {
if (changePasswordLoading.value) return;
const form = unref(changePasswordRef);
if (!form) {
return;
@@ -287,7 +322,8 @@ const handleChangePassword = async () => {
reactiveData.formData.password = changePasswordData.newPassword;
await handleLogin();
} catch {
failMessage(t('login.changePasswordFailed'));
// Failure details are already reported by the axios response
// interceptor; a second toast here would duplicate it.
} finally {
changePasswordLoading.value = false;
}
+132 -64
View File
@@ -33,6 +33,32 @@
@current-change="currentChange"
/>
<el-alert
v-if="reactiveData.status === 'error'"
:closable="false"
:title="$t('common.loadFailed')"
class="entity-page-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.loading" link type="danger" @click="refresh">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<el-alert
v-if="reactiveData.profileLookupError"
:closable="false"
:title="$t('common.optionLoadFailed')"
class="entity-page-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.profileLookupLoading" link type="danger" @click="retryProfileLookup">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<blank-card>
<el-row>
<template v-if="reactiveData.loading">
@@ -49,9 +75,12 @@
</el-col>
</template>
<template v-else>
<el-col v-if="reactiveData.listData.length < 1">
<el-col v-if="reactiveData.status === 'success' && reactiveData.listData.length < 1">
<el-empty :description="$t('point.empty')" />
</el-col>
<el-col v-else-if="reactiveData.status === 'error' && reactiveData.listData.length < 1">
<el-empty :description="$t('common.loadFailed')" />
</el-col>
<el-col
v-for="data in reactiveData.listData"
:key="data.id"
@@ -65,6 +94,7 @@
:data="data"
:embedded="embedded === 'profile' || embedded === 'device'"
:profile="reactiveData.profileTable[data.profileId ?? '']"
:busy="isActionBusy(data)"
@delete="onDelete"
@detail="openDetail"
@disable="onDisable"
@@ -80,8 +110,11 @@
<el-drawer
v-model="reactiveData.detailVisible"
:close-on-click-modal="false"
:close-on-press-escape="true"
:title="$t('point.detail.pointInfo')"
size="520px"
:size="isMobile ? '100%' : '520px'"
destroy-on-close
>
<el-descriptions v-if="reactiveData.detailRecord" :column="1" border>
<el-descriptions-item :label="$t('point.detail.pointName')">
@@ -129,10 +162,11 @@
</template>
<script lang="ts" setup>
import { computed, ref, watch } from "vue";
import { computed, onBeforeUnmount, reactive, ref, watch } from "vue";
import { addPoint, deletePoint, listPoint, updatePoint } from "@/api/point";
import { listProfileByIds } from "@/api/profile";
import { useBreakpoint } from "@/composables/useBreakpoint";
import { usePagedList } from "@/composables/usePagedList";
import { successMessage } from "@/utils/notificationUtil";
import { isNull } from "@/utils/validationUtil";
@@ -168,6 +202,8 @@ const props = withDefaults(
},
);
const { isMobile } = useBreakpoint();
const emit = defineEmits<{
(e: "pre-handle"): void;
(e: "next-handle"): void;
@@ -192,10 +228,18 @@ const reactiveData = state as typeof state & {
detailVisible: boolean;
detailRecord: PointRecord | null;
profileTable: Record<string, Record<string, any>>;
profileLookupLoading: boolean;
profileLookupError: unknown | null;
};
reactiveData.detailVisible = false;
reactiveData.detailRecord = null;
reactiveData.profileTable = {};
reactiveData.profileLookupLoading = false;
reactiveData.profileLookupError = null;
let profileLookupSequence = 0;
let profileLookupRequestId = 0;
let disposed = false;
const actionBusy = reactive(new Set<string>());
const basePointQuery = computed(() => {
const q: Record<string, unknown> = {};
@@ -225,96 +269,120 @@ const openDetail = (row: PointRecord) => {
reactiveData.detailVisible = true;
};
const onAdd = (form: unknown, done: () => void) => {
const onAdd = (form: unknown, done: (successful?: boolean) => void) => {
addPoint(form as Record<string, unknown>)
.then(() => {
if (disposed) return;
successMessage();
load();
void load();
done(true);
})
.catch(() => {})
.finally(() => {
done();
.catch(() => {
if (!disposed) done(false);
});
};
const onUpdate = (form: unknown, done: () => void) => {
const onUpdate = (form: unknown, done: (successful?: boolean) => void) => {
updatePoint(form as Record<string, unknown>)
.then(() => {
if (disposed) return;
successMessage();
load();
void load();
done(true);
})
.catch(() => {})
.finally(() => {
done();
.catch(() => {
if (!disposed) done(false);
});
};
const onDisable = (point: PointRecord, done: () => void) => {
updatePoint({...point, enableFlag: 'DISABLE'})
.then(() => {
successMessage();
load();
})
.catch(() => {})
.finally(() => {
done();
});
const isActionBusy = (point: PointRecord) => actionBusy.has(String(point.id));
const runAction = async (point: PointRecord, action: () => Promise<unknown>) => {
const id = String(point.id);
if (actionBusy.has(id)) return;
actionBusy.add(id);
try {
await action();
if (disposed) return;
successMessage();
await load();
} catch {
// handled globally
} finally {
actionBusy.delete(id);
}
};
const onEnable = (point: PointRecord, done: () => void) => {
updatePoint({...point, enableFlag: 'ENABLE'})
.then(() => {
successMessage();
load();
})
.catch(() => {})
.finally(() => {
done();
});
};
const onDisable = (point: PointRecord) =>
runAction(point, () => updatePoint({...point, enableFlag: 'DISABLE'}));
const onDelete = (point: PointRecord, done: () => void) => {
deletePoint(point.id, point.version)
.then(() => {
successMessage();
load();
})
.catch(() => {})
.finally(() => {
done();
});
};
const onEnable = (point: PointRecord) =>
runAction(point, () => updatePoint({...point, enableFlag: 'ENABLE'}));
const onDelete = (point: PointRecord) =>
runAction(point, () => deletePoint(point.id, point.version));
const refresh = () => load();
const loadProfileLookup = (points = reactiveData.listData, generation = profileLookupSequence) => {
const requestId = ++profileLookupRequestId;
const profileIds = Array.from(new Set(points.map((point) => point.profileId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)));
reactiveData.profileLookupError = null;
if (profileIds.length === 0) {
reactiveData.profileLookupLoading = false;
reactiveData.profileTable = {};
return Promise.resolve();
}
reactiveData.profileLookupLoading = true;
return listProfileByIds(profileIds)
.then((res) => {
if (generation !== profileLookupSequence || requestId !== profileLookupRequestId) return;
reactiveData.profileTable = (res || {}) as Record<string, Record<string, any>>;
})
.catch((error) => {
if (generation !== profileLookupSequence || requestId !== profileLookupRequestId) return;
reactiveData.profileLookupError = error;
})
.finally(() => {
if (generation === profileLookupSequence && requestId === profileLookupRequestId) {
reactiveData.profileLookupLoading = false;
}
});
};
const retryProfileLookup = () => {
void loadProfileLookup();
};
const preHandle = () => emit("pre-handle");
const nextHandle = () => emit("next-handle");
watch(
() => reactiveData.listData,
(points) => {
const profileIds = Array.from(
new Set(
points.map((p) => p.profileId).filter((id): id is string => !!id),
),
);
if (profileIds.length === 0) {
reactiveData.profileTable = {};
return;
}
listProfileByIds(profileIds)
.then((res) => {
reactiveData.profileTable = (res || {}) as Record<
string,
Record<string, any>
>;
})
.catch(() => {
// handled globally
});
const sequence = ++profileLookupSequence;
reactiveData.profileTable = {};
reactiveData.profileLookupError = null;
void loadProfileLookup(points, sequence);
},
);
watch(
() => [props.profileId, props.deviceId],
() => {
profileLookupSequence += 1;
_search(basePointQuery.value);
},
);
onBeforeUnmount(() => {
disposed = true;
profileLookupSequence += 1;
profileLookupRequestId += 1;
actionBusy.clear();
});
defineExpose({ reactiveData, refresh });
load();
+121 -39
View File
@@ -19,18 +19,36 @@
<el-dialog
v-model="reactiveData.visible"
:append-to-body="true"
:before-close="requestClose"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
:close-on-press-escape="!reactiveData.submitting"
:show-close="!reactiveData.submitting"
:title="isEdit ? $t('point.edit.title') : $t('point.add.title')"
class="things-dialog"
destroy-on-close
draggable
@closed="reset"
@closed="onClosed"
>
<el-form ref="formRef" :model="reactiveData.formData" :rules="rules" label-position="top">
<el-alert
v-if="reactiveData.saveError"
:closable="false"
:title="$t('common.saveFailed')"
class="things-dialog-form-alert"
show-icon
type="error"
/>
<el-form
ref="formRef"
v-loading="reactiveData.submitting"
:aria-busy="reactiveData.submitting"
:model="reactiveData.formData"
:rules="rules"
label-position="top"
>
<el-form-item :label="$t('point.add.pointName')" prop="pointName">
<el-input
v-model="reactiveData.formData.pointName"
:disabled="reactiveData.submitting"
:placeholder="$t('point.add.pointNamePlaceholder')"
clearable
maxlength="32"
@@ -40,6 +58,7 @@
<el-form-item :label="$t('point.add.dataType')" prop="pointTypeFlag">
<el-select
v-model="reactiveData.formData.pointTypeFlag"
:disabled="reactiveData.submitting"
:placeholder="$t('point.add.dataTypeRequired')"
clearable
>
@@ -54,18 +73,24 @@
</el-select>
</el-form-item>
<el-form-item :label="$t('point.add.rwType')" prop="rwFlag">
<el-select v-model="reactiveData.formData.rwFlag" :placeholder="$t('point.add.rwTypeRequired')" clearable>
<el-select
v-model="reactiveData.formData.rwFlag"
:disabled="reactiveData.submitting"
:placeholder="$t('point.add.rwTypeRequired')"
clearable
>
<el-option :label="$t('status.readOnly')" value="READ_ONLY"/>
<el-option :label="$t('status.writeOnly')" value="WRITE_ONLY"/>
<el-option :label="$t('status.readWrite')" value="READ_WRITE"/>
</el-select>
</el-form-item>
<el-form-item :label="$t('common.enableFlag')" prop="enableFlag">
<enable-flag-segmented v-model="reactiveData.formData.enableFlag"/>
<enable-flag-segmented v-model="reactiveData.formData.enableFlag" :disabled="reactiveData.submitting"/>
</el-form-item>
<el-form-item :label="$t('point.add.accuracy')" prop="valueDecimal">
<el-input-number
v-model="reactiveData.formData.valueDecimal"
:disabled="reactiveData.submitting"
:max="127"
:min="0"
:placeholder="$t('point.add.accuracyPlaceholder')"
@@ -76,6 +101,7 @@
<el-form-item :label="$t('point.add.unit')" prop="unit">
<el-input
v-model="reactiveData.formData.unit"
:disabled="reactiveData.submitting"
:placeholder="$t('point.add.unitPlaceholder')"
clearable
maxlength="32"
@@ -84,16 +110,23 @@
<el-form-item :label="$t('point.add.baseValue')" prop="baseValue">
<el-input
v-model="reactiveData.formData.baseValue"
:disabled="reactiveData.submitting"
:placeholder="$t('point.add.baseValuePlaceholder')"
clearable
/>
</el-form-item>
<el-form-item :label="$t('point.add.ratio')" prop="multiple">
<el-input v-model="reactiveData.formData.multiple" :placeholder="$t('point.add.ratioPlaceholder')" clearable/>
<el-input
v-model="reactiveData.formData.multiple"
:disabled="reactiveData.submitting"
:placeholder="$t('point.add.ratioPlaceholder')"
clearable
/>
</el-form-item>
<el-form-item :label="$t('point.add.description')" prop="remark">
<el-input
v-model="reactiveData.formData.remark"
:disabled="reactiveData.submitting"
:placeholder="$t('point.add.descriptionPlaceholder')"
clearable
maxlength="300"
@@ -102,19 +135,22 @@
/>
</el-form-item>
</el-form>
<div class="things-dialog-footer">
<el-button @click="cancel">{{ $t('common.cancel') }}</el-button>
<el-button plain @click="formReset">{{ $t('common.reset') }}</el-button>
<el-button :loading="reactiveData.submitting" type="primary" @click="submit">
{{ $t('common.confirm') }}
</el-button>
</div>
<template #footer>
<div class="things-dialog-footer">
<el-button :disabled="reactiveData.submitting" @click="cancel">{{ $t('common.cancel') }}</el-button>
<el-button :disabled="reactiveData.submitting" plain @click="formReset">{{ $t('common.reset') }}</el-button>
<el-button :loading="reactiveData.submitting" type="primary" @click="submit">
{{ $t('common.confirm') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import {reactive, ref, unref} from 'vue';
import {computed, onBeforeUnmount, reactive, ref, unref} from 'vue';
import type {FormInstance, FormRules} from 'element-plus';
import {ElMessageBox} from 'element-plus';
import {useI18n} from 'vue-i18n';
import {byteRules, decimalRules, nameRules, remarkRules, requiredSelectRule} from '@/utils/formRuleUtil';
@@ -147,6 +183,7 @@ const formRef = ref<FormInstance>();
const isEdit = ref(false);
const originalData = ref<PointRecord | null>(null);
let formSession = 0;
const emptyForm = (profileId: string): PointFormData => ({
pointTypeFlag: 'FLOAT',
@@ -159,12 +196,19 @@ const emptyForm = (profileId: string): PointFormData => ({
profileId,
});
const initialData = ref<PointFormData>(emptyForm(''));
const reactiveData = reactive({
visible: false,
submitting: false,
saveError: false,
formData: emptyForm('') as PointFormData,
});
const isDirty = computed(
() => reactiveData.visible && JSON.stringify(reactiveData.formData) !== JSON.stringify(initialData.value)
);
const rules = reactive<FormRules>({
pointName: nameRules(t, t('common.entityPoint')),
pointTypeFlag: requiredSelectRule(t('point.add.dataTypeRequired')),
@@ -177,13 +221,18 @@ const rules = reactive<FormRules>({
});
const show = (profileId: string) => {
formSession += 1;
isEdit.value = false;
originalData.value = null;
reactiveData.formData = emptyForm(profileId);
initialData.value = {...reactiveData.formData};
reactiveData.submitting = false;
reactiveData.saveError = false;
reactiveData.visible = true;
};
const showEdit = (row: PointRecord) => {
formSession += 1;
isEdit.value = true;
originalData.value = {...row};
reactiveData.formData = {
@@ -198,59 +247,92 @@ const showEdit = (row: PointRecord) => {
profileId: row.profileId,
remark: row.remark,
} as PointFormData;
initialData.value = {...reactiveData.formData};
reactiveData.submitting = false;
reactiveData.saveError = false;
reactiveData.visible = true;
};
const cancel = () => {
reactiveData.visible = false;
void requestClose();
};
const reset = () => {
const form = unref(formRef);
form?.resetFields();
formReset();
};
const onClosed = () => {
formSession += 1;
reactiveData.submitting = false;
formRef.value?.clearValidate();
};
onBeforeUnmount(() => {
formSession += 1;
});
const requestClose = async (done?: () => void) => {
if (reactiveData.submitting) return;
const session = formSession;
if (!isDirty.value) {
if (done) done();
else reactiveData.visible = false;
return;
}
try {
await ElMessageBox.confirm(t('common.discardConfirm'), t('common.confirm'), {
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
});
if (session !== formSession || !reactiveData.visible) return;
if (done) done();
else reactiveData.visible = false;
} catch {
// Keep the draft open when the user cancels the confirmation.
}
};
const formReset = () => {
if (isEdit.value && originalData.value) {
const row = originalData.value;
reactiveData.formData = {
pointName: row.pointName,
pointTypeFlag: row.pointTypeFlag,
rwFlag: row.rwFlag,
baseValue: row.baseValue,
multiple: row.multiple,
valueDecimal: row.valueDecimal,
unit: row.unit || '',
enableFlag: row.enableFlag,
profileId: row.profileId,
remark: row.remark,
} as PointFormData;
}
reactiveData.formData = {...initialData.value};
reactiveData.saveError = false;
const form = unref(formRef);
form?.clearValidate();
};
const submit = async () => {
if (reactiveData.submitting) return;
const form = unref(formRef);
if (!form) return;
const session = formSession;
try {
await form.validate();
if (session !== formSession || !reactiveData.visible) return;
const data = {...reactiveData.formData};
const done: DoneCallback = (close = true) => {
if (session !== formSession) return;
reactiveData.submitting = false;
if (close) {
reactiveData.visible = false;
initialData.value = {...reactiveData.formData};
} else {
reactiveData.saveError = true;
}
reactiveData.submitting = false;
};
reactiveData.submitting = true;
if (isEdit.value) {
Object.assign(data, {id: originalData.value?.id});
emit('update', data, done);
} else {
emit('add', data, done);
reactiveData.saveError = false;
try {
if (isEdit.value) {
Object.assign(data, {id: originalData.value?.id});
emit('update', data, done);
} else {
emit('add', data, done);
}
} catch {
reactiveData.submitting = false;
reactiveData.saveError = true;
}
} catch {
// validation errors are displayed by Element Plus
+5 -3
View File
@@ -24,6 +24,7 @@
:icon="icon"
:name="data.pointName"
:status-title="$t('common.name')"
:copy-label="$t('point.card.copyPointId')"
@copy-id="copy(data.id, $t('point.card.copyPointId'))"
/>
<div class="things-card__body">
@@ -105,6 +106,7 @@
:disable-title="$t('common.confirmDisable', {name: $t('common.entityPoint')})"
:enable-title="$t('common.confirmEnable', {name: $t('common.entityPoint')})"
:enabled="enabled"
:busy="busy"
@delete="emitDelete"
@detail="emit('detail', data)"
@disable="emitToggle('disable')"
@@ -121,7 +123,6 @@ import {computed, type PropType} from 'vue';
import {Edit, List, Location, Sunset} from '@element-plus/icons-vue';
import {copy} from '@/utils/commonUtil';
import {timestamp} from '@/utils/dateUtil';
import {successMessage} from '@/utils/notificationUtil';
import {pointTypeKey, rwFlagKey} from '@/utils/pointFormatUtil';
import {isEnabledFlag} from '@/utils/thingModelFormatUtil';
import ThingsCardHeader from '@/components/card/header/ThingsCardHeader.vue';
@@ -133,17 +134,18 @@ const props = defineProps({
data: {type: Object as PropType<PointRecord>, required: true},
profile: {type: Object as PropType<Record<string, any>>, default: () => ({})},
icon: {type: String, default: 'images/common/point.png'},
busy: {type: Boolean, default: false},
});
const emit = defineEmits(['disable', 'enable', 'delete', 'edit', 'detail']);
const enabled = computed(() => isEnabledFlag(props.data.enableFlag));
const emitToggle = (name: 'disable' | 'enable') => {
emit(name, props.data, () => successMessage());
emit(name, props.data);
};
const emitDelete = () => {
emit('delete', props.data, () => successMessage());
emit('delete', props.data);
};
</script>
+58 -3
View File
@@ -18,10 +18,32 @@
<template>
<div>
<base-card>
<el-tabs v-model="reactiveData.active" @tab-click="changeActive">
<el-alert
v-if="reactiveData.status === 'error'"
:closable="false"
:title="$t('common.loadFailed')"
class="detail-page-alert"
show-icon
type="error"
>
<el-button :loading="reactiveData.loading" link type="danger" @click="reload">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<el-empty
v-if="reactiveData.status === 'error' && !reactiveData.data.id"
:description="$t('common.loadFailed')"
/>
<el-skeleton v-else-if="!reactiveData.data.id" :rows="6" animated />
<el-tabs
v-else
v-model="reactiveData.active"
v-loading="reactiveData.loading"
@tab-click="changeActive"
>
<el-tab-pane :label="$t('point.detail.pointInfo')" name="detail">
<detail-card>
<el-descriptions :column="2" border>
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item :label="$t('point.detail.pointName')"
>{{ reactiveData.data.pointName }}
</el-descriptions-item>
@@ -39,7 +61,27 @@
</detail-card>
</el-tab-pane>
<el-tab-pane :label="$t('point.detail.relatedDevices')" name="device">
<el-row>
<el-alert
v-if="reactiveData.relationStatus === 'error'"
:closable="false"
:title="$t('common.loadFailed')"
class="detail-page-alert"
show-icon
type="error"
>
<el-button :loading="reactiveData.relationLoading" link type="danger" @click="device">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<el-empty
v-if="reactiveData.relationStatus === 'error' && reactiveData.listDeviceData.length === 0"
:description="$t('common.loadFailed')"
/>
<el-empty
v-else-if="reactiveData.relationStatus === 'success' && reactiveData.listDeviceData.length === 0"
:description="$t('common.empty')"
/>
<el-row v-else v-loading="reactiveData.relationLoading" :aria-busy="reactiveData.relationLoading">
<el-col
v-for="data in reactiveData.listDeviceData"
:key="data.id"
@@ -64,3 +106,16 @@
</template>
<script lang="ts" src="./index.ts"/>
<style lang="scss" scoped>
.detail-page-alert {
margin-bottom: var(--dc3-space-3);
:deep(.el-alert__content) {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--dc3-space-2);
}
}
</style>
+100 -30
View File
@@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {defineComponent, reactive} from 'vue';
import {defineComponent, onBeforeUnmount, reactive, watch} from 'vue';
import {useRoute} from 'vue-router';
import router from '@/config/router';
@@ -30,6 +30,7 @@ import deviceCard from '@/views/device/card/DeviceCard.vue';
import pointCard from '@/views/point/card/PointCard.vue';
import {timestamp} from '@/utils/dateUtil';
import {useBreakpoint} from '@/composables/useBreakpoint';
export default defineComponent({
components: {
@@ -40,63 +41,105 @@ export default defineComponent({
},
setup() {
const route = useRoute();
const {isMobile} = useBreakpoint();
const reactiveData = reactive({
id: route.query.id as string,
id: String(route.query.id ?? ''),
active: (route.query.active as string) || 'detail',
loading: true,
status: 'idle' as 'idle' | 'loading' | 'success' | 'error',
relationLoading: true,
relationStatus: 'idle' as 'idle' | 'loading' | 'success' | 'error',
driverTable: {} as Record<string, any>,
statusTable: {} as Record<string, any>,
data: {} as any,
listDeviceData: [] as any[],
});
let pointRequestId = 0;
let relationRequestId = 0;
const point = () => {
getPointById(reactiveData.id)
const currentRequestId = ++pointRequestId;
const pointId = String(reactiveData.id || '');
reactiveData.loading = true;
reactiveData.status = 'loading';
reactiveData.data = {};
if (!pointId) {
reactiveData.loading = false;
reactiveData.status = 'error';
return Promise.resolve();
}
return getPointById(pointId)
.then((res) => {
reactiveData.data = res;
if (currentRequestId !== pointRequestId || pointId !== String(reactiveData.id || '')) return;
reactiveData.data = res || {};
reactiveData.status = reactiveData.data.id ? 'success' : 'error';
})
.catch(() => {
// nothing to do
if (currentRequestId === pointRequestId) reactiveData.status = 'error';
})
.finally(() => {
if (currentRequestId === pointRequestId) reactiveData.loading = false;
});
};
const device = () => {
getDeviceStatisticsByPointId(reactiveData.id)
const currentRequestId = ++relationRequestId;
const pointId = String(reactiveData.id || '');
reactiveData.relationLoading = true;
reactiveData.relationStatus = 'loading';
reactiveData.listDeviceData = [];
reactiveData.driverTable = {};
reactiveData.statusTable = {};
if (!pointId) {
reactiveData.relationLoading = false;
reactiveData.relationStatus = 'error';
return Promise.resolve();
}
return getDeviceStatisticsByPointId(pointId)
.then((res) => {
if (currentRequestId !== relationRequestId || pointId !== String(reactiveData.id || '')) return;
reactiveData.listDeviceData = res?.devices || [];
// driver
const driverIds = Array.from(new Set(reactiveData.listDeviceData.map((device) => device.driverId))).filter(
Boolean
);
if (driverIds.length > 0) {
listDriverByIds(driverIds)
.then((res) => {
reactiveData.driverTable = res;
})
.catch(() => {
// nothing to do
});
Promise.all(driverIds.map((driverId) => listDeviceStatusByDriverId(driverId)))
.then((resList) => {
reactiveData.statusTable = resList.reduce<Record<string, any>>((pre, cur) => {
return {...pre, ...(cur || {})};
}, {});
})
.catch(() => {
// nothing to do
});
} else {
reactiveData.driverTable = {};
reactiveData.statusTable = {};
if (driverIds.length === 0) return;
return Promise.allSettled([
listDriverByIds(driverIds),
Promise.all(driverIds.map((driverId) => listDeviceStatusByDriverId(driverId))),
]).then(([drivers, statuses]) => {
if (currentRequestId !== relationRequestId) return;
let partialFailure = false;
if (drivers.status === 'fulfilled') reactiveData.driverTable = drivers.value || {};
else partialFailure = true;
if (statuses.status === 'fulfilled') {
reactiveData.statusTable = statuses.value.reduce<Record<string, any>>((pre, cur) => ({
...pre,
...(cur || {}),
}), {});
} else partialFailure = true;
reactiveData.relationStatus = partialFailure ? 'error' : 'success';
});
})
.then(() => {
if (currentRequestId === relationRequestId && reactiveData.relationStatus === 'loading') {
reactiveData.relationStatus = 'success';
}
})
.catch(() => {
// nothing to do
if (currentRequestId === relationRequestId) reactiveData.relationStatus = 'error';
})
.finally(() => {
if (currentRequestId === relationRequestId) reactiveData.relationLoading = false;
});
};
const reload = () => {
load();
};
const deviceName = () => {
return reactiveData.listDeviceData.map((device) => device.deviceName).join(', ');
};
@@ -108,16 +151,43 @@ export default defineComponent({
});
};
point();
device();
const load = () => {
void point();
void device();
};
watch(
() => [route.query.id, route.query.active],
([id, active]) => {
const nextId = String(id ?? '');
if (nextId !== String(reactiveData.id || '')) {
reactiveData.id = nextId;
reactiveData.data = {};
reactiveData.listDeviceData = [];
reactiveData.driverTable = {};
reactiveData.statusTable = {};
load();
}
reactiveData.active = String(active || 'detail');
},
);
onBeforeUnmount(() => {
pointRequestId += 1;
relationRequestId += 1;
});
load();
return {
reactiveData,
point,
device,
reload,
deviceName,
changeActive,
timestamp,
isMobile,
};
},
});
+17 -3
View File
@@ -16,7 +16,15 @@
-->
<template>
<div class="things-card cursor-pointer" @click="$emit('select', data)">
<div
:aria-label="String(data.pointName || data.attributeName || '')"
class="things-card cursor-pointer"
role="button"
tabindex="0"
@click="$emit('select', data)"
@keydown.enter.prevent="$emit('select', data)"
@keydown.space.prevent="$emit('select', data)"
>
<el-card :shadow="data.shadow">
<div class="things-card-content">
<div :class="['things-card__header', isConfig ? 'header-enable' : 'header-disable']">
@@ -86,6 +94,12 @@ const isSelect = computed(() =>
.cursor-pointer {
cursor: pointer;
&:focus-visible {
border-radius: var(--dc3-radius-xl);
outline: none;
box-shadow: var(--dc3-focus-ring);
}
}
.things-card__header {
@@ -96,8 +110,8 @@ const isSelect = computed(() =>
.things-card-header-icon {
width: 48px;
height: 48px;
margin-right: 12px;
border-radius: 4px;
margin-right: var(--dc3-space-3);
border-radius: var(--dc3-radius-sm);
overflow: hidden;
img {
+73 -24
View File
@@ -40,25 +40,39 @@
:label="$t('point.tool.profile')"
prop="profileId"
>
<el-select
v-model="formData.profileId"
:loading="profileLoading"
:placeholder="$t('point.tool.profilePlaceholder')"
:remote-method="profileDictionary"
class="edit-form-special"
clearable
filterable
remote
reserve-keyword
@visible-change="profileDictionaryVisible"
>
<el-option
v-for="dictionary in profileDictionaries"
:key="dictionary.value"
:label="dictionary.label"
:value="dictionary.value"
/>
</el-select>
<div class="tool-dictionary-field">
<el-select
v-model="formData.profileId"
:loading="profileLoading"
:placeholder="$t('point.tool.profilePlaceholder')"
:remote-method="profileDictionary"
class="edit-form-special"
clearable
filterable
remote
reserve-keyword
@visible-change="profileDictionaryVisible"
>
<el-option
v-for="dictionary in profileDictionaries"
:key="dictionary.value"
:label="dictionary.label"
:value="dictionary.value"
/>
</el-select>
<el-alert
v-if="profileError"
:closable="false"
:title="$t('common.optionLoadFailed')"
class="tool-dictionary-error"
show-icon
type="error"
>
<el-button :loading="profileLoading" link type="danger" @click="profileDictionary('')">
{{ $t('common.retry') }}
</el-button>
</el-alert>
</div>
</el-form-item>
<el-form-item :label="$t('common.enableFlag')" prop="enableFlag">
<enable-flag-segmented v-model="formData.enableFlag" include-all/>
@@ -83,7 +97,7 @@
</template>
<script lang="ts" setup>
import {reactive, ref} from 'vue';
import {onBeforeUnmount, reactive, ref} from 'vue';
import {Back, Check, Plus, RefreshLeft, Search} from '@element-plus/icons-vue';
import ToolCard from '@/components/card/tool/ToolCard.vue';
import EnableFlagSegmented from '@/components/segmented/EnableFlagSegmented.vue';
@@ -91,7 +105,7 @@ import type {Dictionary} from '@/config/types';
import {listProfileDictionary} from '@/api/dictionary';
import {cleanSearchParams, resetSearchForm} from '@/utils/searchParamUtil';
defineProps({
const props = defineProps({
embedded: {
type: String,
default: '',
@@ -129,27 +143,38 @@ const onSearch = (data: Record<string, any>) => {
};
const onReset = () => {
profileRequestId += 1;
profileLoading.value = false;
profileError.value = false;
profileDictionaries.value = [];
resetSearchForm(formData, {enableFlag: ''});
emit('reset');
};
const profileDictionaries = ref<Dictionary[]>([]);
const profileLoading = ref(false);
const profileError = ref(false);
let profileRequestId = 0;
const profileDictionary = (query?: string) => {
const requestId = ++profileRequestId;
profileLoading.value = true;
profileError.value = false;
listProfileDictionary({
offset: 0, limit: 50,
label: query || '',
})
.then((res) => {
if (requestId !== profileRequestId) return;
profileDictionaries.value = res.items;
})
.catch(() => {
// nothing to do
if (requestId !== profileRequestId) return;
profileDictionaries.value = [];
profileError.value = true;
})
.finally(() => {
profileLoading.value = false;
if (requestId === profileRequestId) profileLoading.value = false;
});
};
@@ -157,5 +182,29 @@ const profileDictionaryVisible = (visible: boolean) => {
if (visible) profileDictionary('');
};
profileDictionary();
if (props.embedded !== 'profile' && props.embedded !== 'edit') profileDictionary();
onBeforeUnmount(() => {
profileRequestId += 1;
});
</script>
<style lang="scss" scoped>
.tool-dictionary-field {
display: grid;
gap: var(--dc3-space-2);
width: 100%;
min-width: 0;
}
.tool-dictionary-error {
margin: 0;
:deep(.el-alert__content) {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--dc3-space-2);
}
}
</style>
+299 -107
View File
@@ -32,27 +32,88 @@
@cursor-next="cursorNext"
></point-value-tool>
<el-alert
v-if="reactiveData.status === 'error'"
:closable="false"
:title="$t('common.loadFailed')"
class="entity-page-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.loading" link type="danger" @click="refresh">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<el-alert
v-if="reactiveData.deviceLookupError"
:closable="false"
:title="$t('common.optionLoadFailed')"
class="entity-page-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.deviceLookupLoading" link type="danger" @click="retryDeviceLookup">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<el-alert
v-if="reactiveData.pointLookupError"
:closable="false"
:title="$t('common.optionLoadFailed')"
class="entity-page-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.pointLookupLoading" link type="danger" @click="retryPointLookup">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<el-alert
v-if="reactiveData.unitLookupError"
:closable="false"
:title="$t('common.optionLoadFailed')"
class="entity-page-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.unitLookupLoading" link type="danger" @click="retryUnitLookup">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<blank-card>
<el-row>
<el-col v-for="data in 12" :key="data" :lg="6" :md="12" :sm="12" :xl="6" :xs="24">
<skeleton-card :footer="true" :loading="reactiveData.loading"></skeleton-card>
</el-col>
<el-col v-if="hasData">
<template v-if="reactiveData.loading">
<el-col v-for="data in 12" :key="data" :lg="6" :md="12" :sm="12" :xl="6" :xs="24">
<skeleton-card :footer="true" :loading="true"></skeleton-card>
</el-col>
</template>
<template v-else-if="hasData">
<el-col>
<el-empty
:description="embedded == 'device' ? $t('pointValue.empty.noDevice') : $t('pointValue.empty.noData')"
></el-empty>
</el-col>
<el-col v-for="data in reactiveData.listData" :key="data.id" :lg="6" :md="12" :sm="12" :xl="6" :xs="24">
<point-value-card
:data="data"
:device="reactiveData.deviceTable[data.deviceId]"
:embedded="embedded"
:point="reactiveData.pointTable[data.pointId]"
:unit="reactiveData.unitTable[data.pointId]"
@detail-thing="openDetail"
@write-thing="openWrite"
></point-value-card>
</el-col>
</el-col>
</template>
<template v-else-if="reactiveData.status === 'error' && reactiveData.listData.length === 0">
<el-col>
<el-empty :description="$t('common.loadFailed')"></el-empty>
</el-col>
</template>
<template v-else>
<el-col v-for="data in reactiveData.listData" :key="data.id" :lg="6" :md="12" :sm="12" :xl="6" :xs="24">
<point-value-card
:data="data"
:device="reactiveData.deviceTable[data.deviceId]"
:embedded="embedded"
:point="reactiveData.pointTable[data.pointId]"
:unit="reactiveData.unitTable[data.pointId]"
@detail-thing="openDetail"
@write-thing="openWrite"
></point-value-card>
</el-col>
</template>
</el-row>
</blank-card>
@@ -62,7 +123,7 @@
</template>
<script lang="ts" setup>
import {computed, onMounted, reactive, ref} from 'vue';
import {computed, onBeforeUnmount, onMounted, reactive, ref, watch} from 'vue';
import {getPointValueLatest, listPointByIds, listPointUnit, listPointValue, writePointValue} from '@/api/point';
import {listDeviceByIds} from '@/api/device';
@@ -74,6 +135,7 @@ import pointValueEditForm from './edit/PointValueEditForm.vue';
import pointValueDetail from './detail/PointValueDetail.vue';
import {isNull} from '@/utils/validationUtil';
import {successMessage} from '@/utils/notificationUtil';
const props = defineProps({
embedded: {
@@ -92,9 +154,18 @@ const props = defineProps({
const reactiveData = reactive({
loading: true,
status: 'idle' as 'idle' | 'loading' | 'success' | 'error',
error: null as unknown | null,
lastUpdated: null as number | null,
deviceTable: {} as Record<string, any>,
pointTable: {} as Record<string, any>,
unitTable: {} as Record<string, any>,
deviceLookupLoading: false,
deviceLookupError: null as unknown | null,
pointLookupLoading: false,
pointLookupError: null as unknown | null,
unitLookupLoading: false,
unitLookupError: null as unknown | null,
listData: [] as any[],
detailData: {} as Record<string, unknown>,
query: {},
@@ -107,102 +178,202 @@ const reactiveData = reactive({
cursorStack: [undefined] as Array<string | undefined>,
});
type PointValueListResponse = {
items?: Array<Record<string, any>>;
total?: number;
hasNext?: boolean;
nextCursor?: string | null;
};
let listRequestSequence = 0;
let lookupSequence = 0;
let deviceLookupRequestId = 0;
let pointLookupRequestId = 0;
let unitLookupRequestId = 0;
let disposed = false;
const editRef = ref<InstanceType<typeof pointValueEditForm>>();
const detailRef = ref<InstanceType<typeof pointValueDetail>>();
const hasData = computed(() => {
return !reactiveData.loading && reactiveData.listData?.length < 1;
return reactiveData.status === 'success' && !reactiveData.loading && reactiveData.listData.length === 0;
});
const list = () => {
if (!isNull(props.deviceId)) {
reactiveData.query = {
...reactiveData.query,
deviceId: props.deviceId,
};
}
const clearLookupTables = () => {
reactiveData.deviceTable = {};
reactiveData.pointTable = {};
reactiveData.unitTable = {};
reactiveData.deviceLookupLoading = false;
reactiveData.deviceLookupError = null;
reactiveData.pointLookupLoading = false;
reactiveData.pointLookupError = null;
reactiveData.unitLookupLoading = false;
reactiveData.unitLookupError = null;
};
if (props.embedded == 'device') {
getPointValueLatest({
offset: (reactiveData.page.current - 1) * reactiveData.page.size,
limit: reactiveData.page.size,
...reactiveData.query,
})
.then((res) => {
loadPointValueList(res);
reactiveData.page.hasNext = res.hasNext ?? false;
const listQuery = () => {
const query = {...(reactiveData.query as Record<string, unknown>)};
if (!isNull(props.deviceId)) query.deviceId = props.deviceId;
else delete query.deviceId;
return query;
};
const list = async () => {
const requestId = ++listRequestSequence;
const generation = ++lookupSequence;
clearLookupTables();
reactiveData.loading = true;
reactiveData.status = 'loading';
reactiveData.error = null;
const query = listQuery();
try {
const response: PointValueListResponse = props.embedded === 'device'
? await getPointValueLatest({
offset: (reactiveData.page.current - 1) * reactiveData.page.size,
limit: reactiveData.page.size,
...query,
})
.catch(() => {
// nothing to do
})
.finally(() => {
reactiveData.loading = false;
});
} else {
listPointValue({
limit: reactiveData.page.size,
cursor: reactiveData.cursorStack[reactiveData.page.current - 1],
...reactiveData.query,
})
.then((res) => {
loadPointValueList(res);
reactiveData.page.hasNext = res.hasNext;
reactiveData.cursorStack[reactiveData.page.current] = res.nextCursor ?? undefined;
})
.catch(() => {
// nothing to do
})
.finally(() => {
reactiveData.loading = false;
: await listPointValue({
limit: reactiveData.page.size,
cursor: reactiveData.cursorStack[reactiveData.page.current - 1],
...query,
});
if (requestId !== listRequestSequence) return;
loadPointValueList(response, requestId, generation);
reactiveData.page.hasNext = props.embedded === 'device' ? Boolean(response.hasNext) : Boolean(response.hasNext);
if (props.embedded !== 'device') {
reactiveData.cursorStack[reactiveData.page.current] = response.nextCursor ?? undefined;
}
reactiveData.status = 'success';
reactiveData.lastUpdated = Date.now();
} catch (error) {
if (requestId !== listRequestSequence) return;
reactiveData.error = error;
reactiveData.status = 'error';
} finally {
if (requestId === listRequestSequence) reactiveData.loading = false;
}
};
const loadPointValueList = (res: any) => {
reactiveData.listData = (res.items || []).map((record: any) => {
record.hasLatestValue = record.hasLatestValue !== false;
if (!record.hasLatestValue || !record.createTime || !record.operateTime) {
record.interval = null;
return record;
const loadPointValueList = (res: PointValueListResponse, requestId: number, generation: number) => {
const rows = (Array.isArray(res.items) ? res.items : []).map((record) => {
const nextRecord: Record<string, any> = {...record, hasLatestValue: record.hasLatestValue !== false};
if (!nextRecord.hasLatestValue || !nextRecord.createTime || !nextRecord.operateTime) {
nextRecord.interval = null;
return nextRecord;
}
const tempDate1 = new Date(record.createTime);
const tempDate2 = new Date(record.operateTime);
record.interval = tempDate2.getTime() - tempDate1.getTime();
return record;
const createTime = new Date(nextRecord.createTime);
const operateTime = new Date(nextRecord.operateTime);
nextRecord.interval = operateTime.getTime() - createTime.getTime();
return nextRecord;
});
reactiveData.page.total = res.total ?? 0;
reactiveData.listData = rows;
reactiveData.page.total = res.total ?? rows.length;
void loadDeviceLookup(rows, requestId, generation);
void loadPointLookup(rows, requestId, generation);
void loadUnitLookup(rows, requestId, generation);
};
// device
const deviceIds = Array.from(new Set(reactiveData.listData.map((pointValue) => pointValue.deviceId)));
if (deviceIds.length > 0) {
listDeviceByIds(deviceIds)
.then((res) => {
reactiveData.deviceTable = res;
})
.catch(() => {
// nothing to do
});
const hasCurrentLookup = (requestId: number, generation: number, sequence: number) =>
requestId === listRequestSequence && generation === lookupSequence && sequence > 0;
const loadDeviceLookup = (
rows = reactiveData.listData,
requestId = listRequestSequence,
generation = lookupSequence,
) => {
const sequence = ++deviceLookupRequestId;
const deviceIds = Array.from(new Set(rows.map((pointValue) => pointValue.deviceId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)));
reactiveData.deviceLookupError = null;
if (deviceIds.length === 0) {
reactiveData.deviceLookupLoading = false;
reactiveData.deviceTable = {};
return Promise.resolve();
}
reactiveData.deviceLookupLoading = true;
return listDeviceByIds(deviceIds)
.then((res) => {
if (!hasCurrentLookup(requestId, generation, sequence)) return;
reactiveData.deviceTable = (res || {}) as Record<string, any>;
})
.catch((error) => {
if (!hasCurrentLookup(requestId, generation, sequence)) return;
reactiveData.deviceLookupError = error;
})
.finally(() => {
if (hasCurrentLookup(requestId, generation, sequence)) reactiveData.deviceLookupLoading = false;
});
};
// point & unit
const pointIds = Array.from(new Set(reactiveData.listData.map((pointValue) => pointValue.pointId)));
if (pointIds.length > 0) {
listPointByIds(pointIds)
.then((res) => {
reactiveData.pointTable = res;
})
.catch(() => {
// nothing to do
});
listPointUnit(pointIds)
.then((res) => {
reactiveData.unitTable = res;
})
.catch(() => {
// nothing to do
});
const loadPointLookup = (
rows = reactiveData.listData,
requestId = listRequestSequence,
generation = lookupSequence,
) => {
const sequence = ++pointLookupRequestId;
const pointIds = Array.from(new Set(rows.map((pointValue) => pointValue.pointId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)));
reactiveData.pointLookupError = null;
if (pointIds.length === 0) {
reactiveData.pointLookupLoading = false;
reactiveData.pointTable = {};
return Promise.resolve();
}
reactiveData.pointLookupLoading = true;
return listPointByIds(pointIds)
.then((res) => {
if (!hasCurrentLookup(requestId, generation, sequence)) return;
reactiveData.pointTable = (res || {}) as Record<string, any>;
})
.catch((error) => {
if (!hasCurrentLookup(requestId, generation, sequence)) return;
reactiveData.pointLookupError = error;
})
.finally(() => {
if (hasCurrentLookup(requestId, generation, sequence)) reactiveData.pointLookupLoading = false;
});
};
const loadUnitLookup = (
rows = reactiveData.listData,
requestId = listRequestSequence,
generation = lookupSequence,
) => {
const sequence = ++unitLookupRequestId;
const pointIds = Array.from(new Set(rows.map((pointValue) => pointValue.pointId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)));
reactiveData.unitLookupError = null;
if (pointIds.length === 0) {
reactiveData.unitLookupLoading = false;
reactiveData.unitTable = {};
return Promise.resolve();
}
reactiveData.unitLookupLoading = true;
return listPointUnit(pointIds)
.then((res) => {
if (!hasCurrentLookup(requestId, generation, sequence)) return;
reactiveData.unitTable = (res || {}) as Record<string, any>;
})
.catch((error) => {
if (!hasCurrentLookup(requestId, generation, sequence)) return;
reactiveData.unitLookupError = error;
})
.finally(() => {
if (hasCurrentLookup(requestId, generation, sequence)) reactiveData.unitLookupLoading = false;
});
};
const retryDeviceLookup = () => {
void loadDeviceLookup();
};
const retryPointLookup = () => {
void loadPointLookup();
};
const retryUnitLookup = () => {
void loadUnitLookup();
};
const search = (params: any) => {
@@ -213,18 +384,18 @@ const search = (params: any) => {
reactiveData.query = cleaned;
reactiveData.page.current = 1;
reactiveData.cursorStack = [undefined];
list();
void list();
};
const reset = () => {
reactiveData.query = {};
reactiveData.query = !isNull(props.deviceId) ? {deviceId: props.deviceId} : {};
reactiveData.page.current = 1;
reactiveData.cursorStack = [undefined];
list();
void list();
};
const refresh = () => {
list();
void list();
};
const openWrite = (row: Record<string, unknown>) => {
@@ -234,18 +405,20 @@ const openWrite = (row: Record<string, unknown>) => {
});
};
const writeValue = (formData: Record<string, unknown>, done: () => void) => {
const writeValue = (formData: Record<string, unknown>, done: (successful?: boolean) => void) => {
writePointValue({
deviceId: formData.deviceId,
pointId: formData.pointId,
value: String(formData.value ?? ''),
})
.then(() => {
if (disposed) return;
successMessage();
refresh();
done();
done(true);
})
.catch(() => {
// handled globally
if (!disposed) done(false);
});
};
@@ -265,29 +438,48 @@ const sizeChange = (size: number) => {
reactiveData.page.size = size;
reactiveData.page.current = 1;
reactiveData.cursorStack = [undefined];
list();
void list();
};
const currentChange = (current: number) => {
if (reactiveData.loading || current < 1) return;
if (props.embedded !== 'device' && current > reactiveData.page.current + 1) return;
reactiveData.page.current = current;
list();
void list();
};
const cursorPrevious = () => {
if (reactiveData.page.current <= 1) return;
if (reactiveData.loading) return;
reactiveData.page.current -= 1;
list();
void list();
};
const cursorNext = () => {
if (!reactiveData.page.hasNext) return;
if (reactiveData.loading) return;
reactiveData.page.current += 1;
list();
void list();
};
watch(
() => [props.embedded, props.deviceId],
() => {
reactiveData.query = !isNull(props.deviceId) ? {deviceId: props.deviceId} : {};
reactiveData.page.current = 1;
reactiveData.cursorStack = [undefined];
void list();
}
);
onMounted(() => {
list();
void list();
});
onBeforeUnmount(() => {
disposed = true;
listRequestSequence += 1;
lookupSequence += 1;
});
defineExpose({
@@ -28,13 +28,13 @@
class="things-card__header"
>
<div class="things-card-header-icon">
<img :alt="data.pointName" :src="icon"/>
<img :alt="data?.pointName || point?.pointName || ''" :src="icon"/>
</div>
<div
class="things-card-header-name nowrap-name"
@click="copy(data.pointId, $t('pointValue.card.pointValueId'))"
@click="copy(data?.pointId, $t('pointValue.card.pointValueId'))"
>
{{ point.pointName }}
{{ point?.pointName || data?.pointName || '-' }}
</div>
<div :title="$t('pointValue.card.rwType')" class="things-card-header-status">
<el-tag v-if="!hasLatestValue" effect="plain" type="info">{{ $t('pointValue.card.noLatestValue') }}</el-tag>
@@ -48,11 +48,20 @@
<div class="things-card-body-content-column">
<div class="things-card-body-content-value">
<span
:class="{'value-missing': !hasLatestValue}"
:class="{
'value-fresh': delayOk,
'value-missing': !hasLatestValue,
'value-stale': delaySlow,
}"
:aria-label="$t('pointValue.card.processedValue')"
:title="$t('pointValue.card.processedValue')"
class="nowrap-item value"
role="button"
tabindex="0"
@click="copyValue(data)"
>{{ data.calValue }} {{ hasLatestValue ? unit : '' }}</span
@keydown.enter="copyValue(data)"
@keydown.space.prevent="copyValue(data)"
>{{ data?.calValue ?? '--' }} {{ hasLatestValue ? unit : '' }}</span
>
</div>
<ul>
@@ -60,13 +69,13 @@
<el-icon>
<Sunrise/>
</el-icon>
{{ $t('pointValue.card.rawValue') }}: {{ data.rawValue }}
{{ $t('pointValue.card.rawValue') }}: {{ data?.rawValue ?? '--' }}
</li>
<li v-if="embedded == ''" class="nowrap-item value-point">
<el-icon>
<Management/>
</el-icon>
{{ $t('pointValue.card.device') }}: {{ device.deviceName }}
{{ $t('pointValue.card.device') }}: {{ device?.deviceName || '-' }}
</li>
<li class="nowrap-item">
<el-icon>
@@ -78,20 +87,39 @@
<el-icon>
<Edit/>
</el-icon>
{{ $t('pointValue.card.collectTime') }}: {{ displayTime(data.createTime) }}
{{ $t('pointValue.card.collectTime') }}: {{ displayTime(data?.createTime) }}
</li>
<li class="nowrap-item">
<el-icon>
<Sunset/>
</el-icon>
{{ $t('pointValue.card.saveTime') }}: {{ displayTime(data.operateTime) }}
{{ $t('pointValue.card.saveTime') }}: {{ displayTime(data?.operateTime) }}
</li>
</ul>
</div>
</div>
<div v-if="embedded != ''" class="things-card-body-content-time">
<div
v-if="historyLoading"
aria-live="polite"
class="point-value-history-loading"
>
{{ $t('common.loading') }}
</div>
<el-alert
v-else-if="historyError"
:closable="false"
:title="$t('pointValue.card.historyLoadFailed')"
class="point-value-history-error"
show-icon
type="error"
>
<el-button :loading="historyLoading" link type="danger" @click="history">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<mini-area-chart
v-if="hasLatestValue"
v-else-if="hasLatestValue && historyData.length > 0"
:data="historyData"
:height="80"
:tooltip-unit="unit"
@@ -117,7 +145,7 @@
</template>
<script lang="ts" setup>
import {computed, onMounted, ref, watch} from 'vue';
import {computed, onBeforeUnmount, onMounted, ref, watch} from 'vue';
import {Edit, Management, Sunrise, Sunset, Timer} from '@element-plus/icons-vue';
import {useI18n} from 'vue-i18n';
@@ -167,11 +195,11 @@ defineEmits(['write-thing', 'detail-thing']);
const copyValue = (data: any) => {
const content = {
deviceId: data.deviceId,
pointId: data.pointId,
calValue: data.calValue,
rawValue: data.rawValue,
hasLatestValue: data.hasLatestValue,
deviceId: data?.deviceId,
pointId: data?.pointId,
calValue: data?.calValue,
rawValue: data?.rawValue,
hasLatestValue: data?.hasLatestValue,
};
copy(JSON.stringify(content, null, 2), t('pointValue.card.pointValueId'));
};
@@ -200,29 +228,48 @@ const displayTime = (value: string | null | undefined) => {
};
// Numeric series fed into MiniAreaChart. BOOL points are coerced to 0/1,
// STRING points render as an empty chart (and the embedded timeline area
// collapses to the fallback spacer).
// STRING points render the explicit no-history fallback instead of feeding
// non-numeric values into the chart.
const historyData = ref<number[]>([]);
const historyLoading = ref(false);
const historyError = ref(false);
let historyRequestId = 0;
const history = () => {
if (!hasLatestValue.value) {
const requestId = ++historyRequestId;
const deviceId = String(props.data?.deviceId ?? '');
const pointId = String(props.data?.pointId ?? '');
if (!hasLatestValue.value || !deviceId || !pointId) {
historyData.value = [];
historyLoading.value = false;
historyError.value = false;
return;
}
listPointValueHistory(props.data.deviceId, props.data.pointId, undefined, 100)
historyLoading.value = true;
historyError.value = false;
listPointValueHistory(deviceId, pointId, undefined, 100)
.then((res) => {
const pointValueType = (props.point.pointTypeFlag || '').toLowerCase();
const values = res.items.map((item) => String(item.value ?? ''));
if (requestId !== historyRequestId) return;
const pointValueType = String(props.point?.pointTypeFlag || '').toLowerCase();
const values = (res?.items || []).map((item) => String(item.value ?? ''));
if (pointValueType === 'string') {
historyData.value = [];
} else if (pointValueType === 'boolean') {
historyData.value = values.reverse().map((value) => (value === 'true' ? 1 : 0));
} else {
historyData.value = values.reverse().map((value) => +value);
historyData.value = values
.reverse()
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
}
})
.catch(() => {
// handled globally
if (requestId !== historyRequestId) return;
historyData.value = [];
historyError.value = true;
})
.finally(() => {
if (requestId === historyRequestId) historyLoading.value = false;
});
};
@@ -241,6 +288,10 @@ onMounted(() => {
history();
}
});
onBeforeUnmount(() => {
historyRequestId += 1;
});
</script>
<style lang="scss" scoped>
@@ -294,6 +345,26 @@ onMounted(() => {
}
}
.point-value-history-loading,
.point-value-empty-chart {
display: grid;
min-height: 80px;
place-items: center;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.point-value-history-error {
min-height: 80px;
:deep(.el-alert__content) {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--dc3-space-2);
}
}
.header-enable {
border-bottom: 1px solid var(--el-color-success-light-5);
}
@@ -321,14 +392,22 @@ onMounted(() => {
.value {
font-weight: bold;
font-size: xx-large;
animation: hue 1s ease-in;
font-size: 28px;
font-variant-numeric: tabular-nums;
color: var(--value-tone, var(--el-text-color-primary));
cursor: pointer;
}
.value-fresh {
--value-tone: var(--el-color-success);
}
.value-stale {
--value-tone: var(--el-color-warning);
}
.value-missing {
color: var(--el-text-color-secondary);
animation: none;
--value-tone: var(--el-text-color-secondary);
}
.value-point {
@@ -352,6 +431,8 @@ onMounted(() => {
.things-card__footer {
height: 35px;
margin-top: 2px;
padding-inline-end: var(--dc3-floating-action-safe-space);
box-sizing: border-box;
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--el-border-color);
@@ -362,15 +443,4 @@ onMounted(() => {
}
}
@keyframes hue {
0% {
color: var(--el-color-primary);
}
50% {
color: var(--el-color-primary-light-9);
}
100% {
color: var(--el-color-primary);
}
}
</style>
@@ -20,11 +20,13 @@
v-model="detailVisible"
:close-on-click-modal="false"
:close-on-press-escape="true"
:size="isMobile ? '100%' : isTablet ? '520px' : '40%'"
:title="$t('pointValue.detail.title')"
class="point-value-detail"
destroy-on-close
direction="rtl"
size="40%"
>
<pre class="json-preview">{{ formatJson(detailData) }}</pre>
<pre aria-live="polite" class="json-preview">{{ formatJson(detailData) }}</pre>
</el-drawer>
</template>
@@ -32,6 +34,7 @@
import type {PropType} from 'vue';
import {ref} from 'vue';
import {prettyJson} from '@/utils/jsonUtil';
import {useBreakpoint} from '@/composables/useBreakpoint';
defineProps({
detailData: {
@@ -41,6 +44,7 @@ defineProps({
});
const detailVisible = ref(false);
const {isMobile, isTablet} = useBreakpoint();
const formatJson = (value: unknown) => prettyJson(value);
const show = () => {
detailVisible.value = true;
@@ -19,17 +19,36 @@
<el-dialog
v-model="reactiveData.formVisible"
:append-to-body="true"
:before-close="requestClose"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
:close-on-press-escape="!reactiveData.submitting"
:show-close="!reactiveData.submitting"
:title="$t('pointValue.edit.title')"
class="things-dialog"
destroy-on-close
draggable
@closed="onClosed"
>
<el-form ref="formDataRef" :model="reactiveData.formData" :rules="formRule" label-position="top">
<el-alert
v-if="reactiveData.saveError"
:closable="false"
:title="$t('common.saveFailed')"
class="things-dialog-form-alert"
show-icon
type="error"
/>
<el-form
ref="formDataRef"
v-loading="reactiveData.submitting"
:aria-busy="reactiveData.submitting"
:model="reactiveData.formData"
:rules="formRule"
label-position="top"
>
<el-form-item :label="$t('pointValue.edit.pointValue')" prop="value">
<el-input
v-model="reactiveData.formData.value"
:disabled="reactiveData.submitting"
:placeholder="$t('pointValue.edit.pointValuePlaceholder')"
clearable
></el-input>
@@ -37,6 +56,7 @@
<el-form-item :label="$t('pointValue.edit.description')" prop="remark">
<el-input
v-model="reactiveData.formData.remark"
:disabled="reactiveData.submitting"
:placeholder="$t('pointValue.edit.descriptionPlaceholder')"
clearable
maxlength="300"
@@ -45,23 +65,27 @@
></el-input>
</el-form-item>
</el-form>
<div class="things-dialog-footer">
<slot name="footer">
<el-button @click="cancel">{{ $t('common.cancel') }}</el-button>
<el-button plain @click="reset">{{ $t('common.reset') }}</el-button>
<el-button type="primary" @click="updateThing">{{ $t('common.confirm') }}</el-button>
</slot>
</div>
<template #footer>
<div class="things-dialog-footer">
<slot name="footer">
<el-button :disabled="reactiveData.submitting" @click="cancel">{{ $t('common.cancel') }}</el-button>
<el-button :disabled="reactiveData.submitting" plain @click="reset">{{ $t('common.reset') }}</el-button>
<el-button :loading="reactiveData.submitting" type="primary" @click="updateThing">
{{ $t('common.confirm') }}
</el-button>
</slot>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import type {PropType} from 'vue';
import {reactive, ref, unref} from 'vue';
import {computed, onBeforeUnmount, reactive, ref, unref} from 'vue';
import type {FormInstance, FormRules} from 'element-plus';
import {ElMessageBox} from 'element-plus';
import {useI18n} from 'vue-i18n';
import {successMessage} from '@/utils/notificationUtil';
type PointValueFormData = Record<string, unknown> & { value?: string | number; remark?: string };
@@ -73,7 +97,7 @@ const props = defineProps({
});
const emit = defineEmits<{
(e: 'update-thing', formData: PointValueFormData, done: () => void): void;
(e: 'update-thing', formData: PointValueFormData, done: (successful?: boolean) => void): void;
}>();
const {t} = useI18n();
@@ -81,8 +105,16 @@ const formDataRef = ref<FormInstance>();
const reactiveData = reactive({
formVisible: false,
submitting: false,
saveError: false,
formData: {} as PointValueFormData,
});
const initialData = ref<PointValueFormData>({});
let formSession = 0;
const isDirty = computed(
() => reactiveData.formVisible && JSON.stringify(reactiveData.formData) !== JSON.stringify(initialData.value)
);
const formRule = reactive<FormRules>({
value: [
@@ -104,35 +136,88 @@ const formRule = reactive<FormRules>({
const syncFormData = (value = props.formData) => {
reactiveData.formData = {...value};
initialData.value = {...reactiveData.formData};
};
const show = (value?: PointValueFormData) => {
formSession += 1;
syncFormData(value);
reactiveData.submitting = false;
reactiveData.saveError = false;
reactiveData.formVisible = true;
};
const cancel = () => {
reactiveData.formVisible = false;
void requestClose();
};
const reset = () => {
const form = unref(formDataRef);
form?.resetFields();
reactiveData.formData = {...initialData.value};
reactiveData.saveError = false;
form?.clearValidate();
};
const onClosed = () => {
formSession += 1;
reactiveData.submitting = false;
formDataRef.value?.clearValidate();
};
onBeforeUnmount(() => {
formSession += 1;
});
const requestClose = async (done?: () => void) => {
if (reactiveData.submitting) return;
const session = formSession;
if (!isDirty.value) {
if (done) done();
else reactiveData.formVisible = false;
return;
}
try {
await ElMessageBox.confirm(t('common.discardConfirm'), t('common.confirm'), {
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
});
if (session !== formSession || !reactiveData.formVisible) return;
if (done) done();
else reactiveData.formVisible = false;
} catch {
// Keep the draft open when the user cancels the confirmation.
}
};
const updateThing = async () => {
if (reactiveData.submitting) return;
const form = unref(formDataRef);
if (!form) {
return;
}
const session = formSession;
try {
await form.validate();
emit('update-thing', {...reactiveData.formData}, () => {
cancel();
reset();
successMessage();
});
if (session !== formSession || !reactiveData.formVisible) return;
reactiveData.submitting = true;
reactiveData.saveError = false;
try {
emit('update-thing', {...reactiveData.formData}, (successful = true) => {
if (session !== formSession) return;
reactiveData.submitting = false;
if (!successful) {
reactiveData.saveError = true;
return;
}
initialData.value = {...reactiveData.formData};
reactiveData.formVisible = false;
});
} catch {
reactiveData.submitting = false;
reactiveData.saveError = true;
}
} catch {
// validation errors are displayed by Element Plus
}
@@ -33,46 +33,74 @@
>
<template #filters>
<el-form-item v-if="embedded === ''" :label="$t('pointValue.tool.device')" prop="deviceId">
<el-select
v-model="formData.deviceId"
:loading="deviceLoading"
:placeholder="$t('pointValue.tool.devicePlaceholder')"
:remote-method="deviceDictionary"
class="edit-form-special"
clearable
filterable
remote
reserve-keyword
@visible-change="deviceDictionaryVisible"
>
<el-option
v-for="dictionary in deviceDictionaries"
:key="dictionary.value"
:label="dictionary.label"
:value="dictionary.value"
/>
</el-select>
<div class="tool-dictionary-field">
<el-select
v-model="formData.deviceId"
:loading="deviceLoading"
:placeholder="$t('pointValue.tool.devicePlaceholder')"
:remote-method="deviceDictionary"
class="edit-form-special"
clearable
filterable
remote
reserve-keyword
@visible-change="deviceDictionaryVisible"
>
<el-option
v-for="dictionary in deviceDictionaries"
:key="dictionary.value"
:label="dictionary.label"
:value="dictionary.value"
/>
</el-select>
<el-alert
v-if="deviceError"
:closable="false"
:title="$t('common.optionLoadFailed')"
class="tool-dictionary-error"
show-icon
type="error"
>
<el-button :loading="deviceLoading" link type="danger" @click="deviceDictionary('')">
{{ $t('common.retry') }}
</el-button>
</el-alert>
</div>
</el-form-item>
<el-form-item v-if="embedded === ''" :label="$t('pointValue.tool.point')" prop="pointId">
<el-select
v-model="formData.pointId"
:loading="pointLoading"
:placeholder="$t('pointValue.tool.pointPlaceholder')"
:remote-method="pointDictionary"
class="edit-form-special"
clearable
filterable
remote
reserve-keyword
@visible-change="pointDictionaryVisible"
>
<el-option
v-for="dictionary in pointDictionaries"
:key="dictionary.value"
:label="dictionary.label"
:value="dictionary.value"
/>
</el-select>
<div class="tool-dictionary-field">
<el-select
v-model="formData.pointId"
:loading="pointLoading"
:placeholder="$t('pointValue.tool.pointPlaceholder')"
:remote-method="pointDictionary"
class="edit-form-special"
clearable
filterable
remote
reserve-keyword
@visible-change="pointDictionaryVisible"
>
<el-option
v-for="dictionary in pointDictionaries"
:key="dictionary.value"
:label="dictionary.label"
:value="dictionary.value"
/>
</el-select>
<el-alert
v-if="pointError"
:closable="false"
:title="$t('common.optionLoadFailed')"
class="tool-dictionary-error"
show-icon
type="error"
>
<el-button :loading="pointLoading" link type="danger" @click="pointDictionary('')">
{{ $t('common.retry') }}
</el-button>
</el-alert>
</div>
</el-form-item>
<el-form-item v-if="embedded === 'device'" :label="$t('pointValue.tool.pointName')" prop="pointName">
<el-input
@@ -86,7 +114,7 @@
<enable-flag-segmented v-model="formData.enableFlag" include-all/>
</el-form-item>
<el-form-item :label="$t('settings.event.timeRange')" prop="rangeKey">
<range-segmented v-model="formData.rangeKey" include-all/>
<range-segmented v-model="formData.rangeKey" include-all select/>
</el-form-item>
</template>
<template #actions>
@@ -98,7 +126,7 @@
</template>
<script lang="ts" setup>
import {reactive, ref} from 'vue';
import {onBeforeUnmount, reactive, ref, watch} from 'vue';
import {Plus} from '@element-plus/icons-vue';
import ToolCard from '@/components/card/tool/ToolCard.vue';
import EnableFlagSegmented from '@/components/segmented/EnableFlagSegmented.vue';
@@ -126,50 +154,73 @@ const emit = defineEmits(['search', 'reset', 'refresh', 'size-change', 'current-
const formData = reactive<Record<string, any>>({enableFlag: '', rangeKey: ''});
const deviceDictionaries = ref<Dictionary[]>([]);
const deviceLoading = ref(false);
const deviceError = ref(false);
const pointDictionaries = ref<Dictionary[]>([]);
const pointLoading = ref(false);
const pointError = ref(false);
let deviceRequestId = 0;
let pointRequestId = 0;
const onSearch = (data: Record<string, any>) => {
emit('search', cleanSearchParams(data));
};
const onReset = () => {
deviceRequestId += 1;
pointRequestId += 1;
deviceLoading.value = false;
pointLoading.value = false;
deviceError.value = false;
pointError.value = false;
deviceDictionaries.value = [];
pointDictionaries.value = [];
resetSearchForm(formData, {enableFlag: '', rangeKey: ''});
emit('reset');
};
const deviceDictionary = (query?: string) => {
const requestId = ++deviceRequestId;
deviceLoading.value = true;
deviceError.value = false;
listDeviceDictionary({
offset: 0, limit: 50,
label: query || '',
})
.then((res) => {
if (requestId !== deviceRequestId) return;
deviceDictionaries.value = res.items;
})
.catch(() => {
// nothing to do
if (requestId !== deviceRequestId) return;
deviceDictionaries.value = [];
deviceError.value = true;
})
.finally(() => {
deviceLoading.value = false;
if (requestId === deviceRequestId) deviceLoading.value = false;
});
};
const pointDictionary = (query?: string) => {
const requestId = ++pointRequestId;
const parentId = formData.deviceId || undefined;
pointLoading.value = true;
pointError.value = false;
listPointDictionary({
offset: 0, limit: 50,
label: query || '',
parentId: formData.deviceId,
parentId,
})
.then((res) => {
if (requestId !== pointRequestId || (formData.deviceId || undefined) !== parentId) return;
pointDictionaries.value = res.items;
})
.catch(() => {
// nothing to do
if (requestId !== pointRequestId || (formData.deviceId || undefined) !== parentId) return;
pointDictionaries.value = [];
pointError.value = true;
})
.finally(() => {
pointLoading.value = false;
if (requestId === pointRequestId) pointLoading.value = false;
});
};
@@ -180,4 +231,40 @@ const deviceDictionaryVisible = (visible: boolean) => {
const pointDictionaryVisible = (visible: boolean) => {
if (visible) pointDictionary('');
};
watch(
() => formData.deviceId,
() => {
formData.pointId = '';
pointRequestId += 1;
pointDictionaries.value = [];
pointLoading.value = false;
pointError.value = false;
},
);
onBeforeUnmount(() => {
deviceRequestId += 1;
pointRequestId += 1;
});
</script>
<style lang="scss" scoped>
.tool-dictionary-field {
display: grid;
gap: var(--dc3-space-2);
width: 100%;
min-width: 0;
}
.tool-dictionary-error {
margin: 0;
:deep(.el-alert__content) {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--dc3-space-2);
}
}
</style>
+61 -42
View File
@@ -29,6 +29,19 @@
@current-change="currentChange"
></profile-tool>
<el-alert
v-if="reactiveData.status === 'error'"
:closable="false"
:title="$t('common.loadFailed')"
class="entity-page-error"
show-icon
type="error"
>
<el-button :loading="reactiveData.loading" link type="danger" @click="refresh">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<blank-card>
<el-row>
<template v-if="reactiveData.loading">
@@ -37,13 +50,17 @@
</el-col>
</template>
<template v-else>
<el-col v-if="reactiveData.listData.length < 1">
<el-col v-if="reactiveData.status === 'success' && reactiveData.listData.length < 1">
<el-empty :description="$t('profile.empty')"/>
</el-col>
<el-col v-else-if="reactiveData.status === 'error' && reactiveData.listData.length < 1">
<el-empty :description="$t('common.loadFailed')"/>
</el-col>
<el-col v-for="data in reactiveData.listData" :key="data.id" :lg="6" :md="12" :sm="12" :xl="6" :xs="24">
<profile-card
:data="data"
:embedded="embedded != ''"
:busy="isActionBusy(data)"
@disable-thing="disableThing"
@enable-thing="enableThing"
@delete-thing="deleteThing"
@@ -58,7 +75,7 @@
</template>
<script lang="ts" setup>
import {computed, ref} from 'vue';
import {computed, onBeforeUnmount, reactive, ref, watch} from 'vue';
import {addProfile, deleteProfile, listProfile, updateProfile} from '@/api/profile';
import {usePagedList} from '@/composables/usePagedList';
@@ -87,6 +104,8 @@ const props = withDefaults(
);
const profileAddFormRef = ref<DialogInstance | null>(null);
const actionBusy = reactive(new Set<string>());
let disposed = false;
const {
state: reactiveData,
@@ -119,60 +138,60 @@ const showAdd = () => {
profileAddFormRef.value?.show();
};
const addThing = (form: unknown, done: () => void) => {
const addThing = (form: unknown, done: (successful?: boolean) => void) => {
addProfile(form as Record<string, unknown>)
.then(() => {
if (disposed) return;
successMessage();
load();
void load();
done(true);
})
.catch(() => {
})
.finally(() => {
done();
if (!disposed) done(false);
});
};
const disableThing = (profile: ProfileRecord, done: () => void) => {
updateProfile({...profile, enableFlag: 'DISABLE'})
.then(() => {
successMessage();
load();
})
.catch(() => {
})
.finally(() => {
done();
});
const isActionBusy = (profile: ProfileRecord) => actionBusy.has(String(profile.id));
const runAction = async (profile: ProfileRecord, action: () => Promise<unknown>) => {
const id = String(profile.id);
if (actionBusy.has(id)) return;
actionBusy.add(id);
try {
await action();
if (disposed) return;
successMessage();
await load();
} catch {
// handled globally
} finally {
actionBusy.delete(id);
}
};
const enableThing = (profile: ProfileRecord, done: () => void) => {
updateProfile({...profile, enableFlag: 'ENABLE'})
.then(() => {
successMessage();
load();
})
.catch(() => {
})
.finally(() => {
done();
});
};
const disableThing = (profile: ProfileRecord) =>
runAction(profile, () => updateProfile({...profile, enableFlag: 'DISABLE'}));
const deleteThing = (profile: ProfileRecord, done: () => void) => {
deleteProfile(profile.id, profile.version)
.then(() => {
successMessage();
load();
})
.catch(() => {
})
.finally(() => {
done();
});
};
const enableThing = (profile: ProfileRecord) =>
runAction(profile, () => updateProfile({...profile, enableFlag: 'ENABLE'}));
const deleteThing = (profile: ProfileRecord) =>
runAction(profile, () => deleteProfile(profile.id, profile.version));
const refresh = () => load();
watch(
() => props.deviceId,
() => {
_search(baseProfileQuery.value);
}
);
onBeforeUnmount(() => {
disposed = true;
actionBusy.clear();
});
defineExpose({
reactiveData,
refresh,
+107 -21
View File
@@ -19,17 +19,36 @@
<el-dialog
v-model="reactiveData.formVisible"
:append-to-body="true"
:before-close="requestClose"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
:close-on-press-escape="!reactiveData.submitting"
:show-close="!reactiveData.submitting"
:title="$t('profile.add.title')"
class="things-dialog"
destroy-on-close
draggable
@closed="onClosed"
>
<el-form ref="formDataRef" :model="reactiveData.formData" :rules="formRule" label-position="top">
<el-alert
v-if="reactiveData.saveError"
:closable="false"
:title="$t('common.saveFailed')"
class="things-dialog-form-alert"
show-icon
type="error"
/>
<el-form
ref="formDataRef"
v-loading="reactiveData.submitting"
:aria-busy="reactiveData.submitting"
:model="reactiveData.formData"
:rules="formRule"
label-position="top"
>
<el-form-item :label="$t('profile.add.profileName')" prop="profileName">
<el-input
v-model="reactiveData.formData.profileName"
:disabled="reactiveData.submitting"
:placeholder="$t('profile.add.profileNamePlaceholder')"
clearable
maxlength="32"
@@ -39,6 +58,7 @@
<el-form-item :label="$t('profile.add.description')" prop="remark">
<el-input
v-model="reactiveData.formData.remark"
:disabled="reactiveData.submitting"
:placeholder="$t('profile.add.descriptionPlaceholder')"
clearable
maxlength="300"
@@ -47,22 +67,26 @@
></el-input>
</el-form-item>
</el-form>
<div class="things-dialog-footer">
<slot name="footer">
<el-button @click="cancel">{{ $t('common.cancel') }}</el-button>
<el-button plain @click="reset">{{ $t('common.reset') }}</el-button>
<el-button type="primary" @click="addThing">{{ $t('common.confirm') }}</el-button>
</slot>
</div>
<template #footer>
<div class="things-dialog-footer">
<slot name="footer">
<el-button :disabled="reactiveData.submitting" @click="cancel">{{ $t('common.cancel') }}</el-button>
<el-button :disabled="reactiveData.submitting" plain @click="reset">{{ $t('common.reset') }}</el-button>
<el-button :loading="reactiveData.submitting" type="primary" @click="addThing">
{{ $t('common.confirm') }}
</el-button>
</slot>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import {reactive, ref, unref} from 'vue';
import {computed, onBeforeUnmount, reactive, ref, unref} from 'vue';
import type {FormInstance, FormRules} from 'element-plus';
import {ElMessageBox} from 'element-plus';
import {useI18n} from 'vue-i18n';
import {successMessage} from '@/utils/notificationUtil';
import {nameRules, remarkRules} from '@/utils/formRuleUtil';
interface ProfileAddFormData {
@@ -71,48 +95,110 @@ interface ProfileAddFormData {
}
const emit = defineEmits<{
(e: 'add-thing', formData: ProfileAddFormData, done: () => void): void;
(e: 'add-thing', formData: ProfileAddFormData, done: (successful?: boolean) => void): void;
}>();
const {t} = useI18n();
const formDataRef = ref<FormInstance>();
const emptyForm = (): ProfileAddFormData => ({profileName: '', remark: ''});
const formRule = reactive<FormRules>({
profileName: nameRules(t, t('common.entityProfile')),
remark: remarkRules(t),
});
const reactiveData = reactive({
formData: {} as ProfileAddFormData,
formData: emptyForm(),
formVisible: false,
submitting: false,
saveError: false,
});
const initialForm = ref<ProfileAddFormData>(emptyForm());
let formSession = 0;
const isDirty = computed(
() => reactiveData.formVisible && JSON.stringify(reactiveData.formData) !== JSON.stringify(initialForm.value)
);
const show = () => {
formSession += 1;
reactiveData.formData = emptyForm();
initialForm.value = emptyForm();
reactiveData.submitting = false;
reactiveData.saveError = false;
reactiveData.formVisible = true;
};
const cancel = () => {
reactiveData.formVisible = false;
void requestClose();
};
const reset = () => {
const form = unref(formDataRef);
form?.resetFields();
reactiveData.formData = {...initialForm.value};
reactiveData.saveError = false;
form?.clearValidate();
};
const onClosed = () => {
formSession += 1;
formDataRef.value?.clearValidate();
};
onBeforeUnmount(() => {
formSession += 1;
});
const requestClose = async (done?: () => void) => {
if (reactiveData.submitting) return;
const session = formSession;
if (!isDirty.value) {
if (done) done();
else reactiveData.formVisible = false;
return;
}
try {
await ElMessageBox.confirm(t('common.discardConfirm'), t('common.confirm'), {
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
});
if (session !== formSession || !reactiveData.formVisible) return;
if (done) done();
else reactiveData.formVisible = false;
} catch {
// Keep the draft open when the user cancels the confirmation.
}
};
const addThing = async () => {
if (reactiveData.submitting) return;
const form = unref(formDataRef);
if (!form) {
return;
}
const session = formSession;
try {
await form.validate();
emit('add-thing', {...reactiveData.formData}, () => {
cancel();
reset();
successMessage();
});
if (session !== formSession || !reactiveData.formVisible) return;
reactiveData.submitting = true;
reactiveData.saveError = false;
try {
emit('add-thing', {...reactiveData.formData}, (successful = true) => {
if (session !== formSession) return;
reactiveData.submitting = false;
if (!successful) {
reactiveData.saveError = true;
return;
}
initialForm.value = {...reactiveData.formData};
reactiveData.formVisible = false;
});
} catch {
reactiveData.submitting = false;
reactiveData.saveError = true;
}
} catch {
// validation errors are displayed by Element Plus
}
@@ -24,6 +24,7 @@
:icon="icon"
:name="data.profileName"
:status-title="$t('common.name')"
:copy-label="$t('profile.card.profileId')"
@copy-id="copy(data.id, $t('profile.card.profileId'))"
/>
<div class="things-card__body">
@@ -55,6 +56,7 @@
:disable-title="$t('common.confirmDisable', {name: $t('common.entityProfile')})"
:enable-title="$t('common.confirmEnable', {name: $t('common.entityProfile')})"
:enabled="enabled"
:busy="busy"
@delete="emitAction('delete-thing')"
@detail="detail"
@disable="emitAction('disable-thing')"
@@ -72,7 +74,6 @@ import {Edit, Sunset} from '@element-plus/icons-vue';
import router from '@/config/router';
import {copy} from '@/utils/commonUtil';
import {timestamp} from '@/utils/dateUtil';
import {successMessage} from '@/utils/notificationUtil';
import {isEnabledFlag} from '@/utils/thingModelFormatUtil';
import ThingsCardHeader from '@/components/card/header/ThingsCardHeader.vue';
import ThingsCardActions from '@/components/card/actions/ThingsCardActions.vue';
@@ -82,13 +83,14 @@ const props = defineProps({
embedded: {type: Boolean, default: false},
data: {type: Object as PropType<ProfileRecord>, required: true},
icon: {type: String, default: 'images/common/profile.png'},
busy: {type: Boolean, default: false},
});
const emit = defineEmits(['disable-thing', 'enable-thing', 'delete-thing']);
const enabled = computed(() => isEnabledFlag(props.data.enableFlag));
const emitAction = (name: 'disable-thing' | 'enable-thing' | 'delete-thing') => {
emit(name, props.data, () => successMessage());
emit(name, props.data);
};
const edit = () => {
@@ -18,10 +18,32 @@
<template>
<div>
<base-card>
<el-tabs :model-value="String(reactiveData.active ?? 'detail')" @tab-click="changeActive">
<el-alert
v-if="reactiveData.status === 'error'"
:closable="false"
:title="$t('common.loadFailed')"
class="detail-page-alert"
show-icon
type="error"
>
<el-button :loading="reactiveData.loading" link type="danger" @click="profile">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<el-empty
v-if="reactiveData.status === 'error' && !reactiveData.data.id"
:description="$t('common.loadFailed')"
/>
<el-skeleton v-else-if="!reactiveData.data.id" :rows="6" animated />
<el-tabs
v-else
v-loading="reactiveData.loading"
:model-value="String(reactiveData.active ?? 'detail')"
@tab-click="changeActive"
>
<el-tab-pane :label="$t('profile.detail.profileInfo')" name="detail">
<detail-card>
<el-descriptions :column="2" border>
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item :label="$t('profile.detail.profileName')"
>{{ reactiveData.data.profileName }}
</el-descriptions-item>
@@ -64,3 +86,16 @@
</template>
<script lang="ts" src="./index.ts"/>
<style lang="scss" scoped>
.detail-page-alert {
margin-bottom: var(--dc3-space-3);
:deep(.el-alert__content) {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--dc3-space-2);
}
}
</style>
+39 -10
View File
@@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {computed, defineComponent, reactive, ref, watch} from 'vue';
import {computed, defineComponent, onBeforeUnmount, reactive, ref, watch} from 'vue';
import router from '@/config/router';
import {useRoute} from 'vue-router';
@@ -34,6 +34,7 @@ import EventList from '@/views/settings/event/definition/EventList.vue';
import {timestamp} from '@/utils/dateUtil';
import type {DeviceRecord, DriverRecord, PointRecord, ProfileRecord} from '@/config/types/manager';
import {useBreakpoint} from '@/composables/useBreakpoint';
export default defineComponent({
components: {
@@ -49,6 +50,7 @@ export default defineComponent({
},
setup() {
const route = useRoute();
const {isMobile} = useBreakpoint();
const pointViewRef: any = ref<InstanceType<typeof point>>();
const deviceViewRef: any = ref<InstanceType<typeof device>>();
@@ -56,10 +58,10 @@ export default defineComponent({
const eventViewRef: any = ref<InstanceType<typeof EventList>>();
const reactiveData = reactive({
id: route.query.id as string,
id: String(route.query.id ?? ''),
active: (route.query.active as string) || 'detail',
deviceLoading: true,
pointLoading: true,
loading: true,
status: 'idle' as 'idle' | 'loading' | 'success' | 'error',
driverTable: {} as DriverRecord,
profileTable: {} as ProfileRecord,
statusTable: {} as Record<string, unknown>,
@@ -67,6 +69,7 @@ export default defineComponent({
listDeviceData: [] as DeviceRecord[],
listPointData: [] as PointRecord[],
});
let requestId = 0;
const pointLength = computed(() => {
return pointViewRef.value?.reactiveData?.page?.total || 0;
@@ -85,15 +88,34 @@ export default defineComponent({
});
const profile = () => {
getProfileById(reactiveData.id).then((res) => {
reactiveData.data = res;
});
const currentRequestId = ++requestId;
const profileId = String(reactiveData.id || '');
reactiveData.loading = true;
reactiveData.status = 'loading';
reactiveData.data = {} as ProfileRecord;
if (!profileId) {
reactiveData.loading = false;
reactiveData.status = 'error';
return Promise.resolve();
}
return getProfileById(profileId)
.then((res) => {
if (currentRequestId !== requestId || profileId !== String(reactiveData.id || '')) return;
reactiveData.data = res || ({} as ProfileRecord);
reactiveData.status = reactiveData.data.id ? 'success' : 'error';
})
.catch(() => {
if (currentRequestId === requestId) reactiveData.status = 'error';
})
.finally(() => {
if (currentRequestId === requestId) reactiveData.loading = false;
});
};
const changeActive = (tab: any) => {
reactiveData.active = tab.props.name;
const query = route.query;
router.push({query: {...query, active: tab.props.name}});
router.push({query: {...query, active: tab.props.name}}).catch(() => undefined);
switch (tab.props.name) {
case 'device':
deviceViewRef.value?.refresh();
@@ -115,9 +137,10 @@ export default defineComponent({
watch(
() => [route.query.id, route.query.active],
([id, active]) => {
const nextId = id as string;
if (nextId && nextId !== reactiveData.id) {
const nextId = String(id ?? '');
if (nextId !== reactiveData.id) {
reactiveData.id = nextId;
reactiveData.data = {} as ProfileRecord;
profile();
}
reactiveData.active = (active as string) || 'detail';
@@ -126,18 +149,24 @@ export default defineComponent({
profile();
onBeforeUnmount(() => {
requestId += 1;
});
return {
pointViewRef,
deviceViewRef,
commandViewRef,
eventViewRef,
reactiveData,
profile,
pointLength,
deviceLength,
commandLength,
eventLength,
changeActive,
timestamp,
isMobile,
};
},
});
+41 -2
View File
@@ -17,12 +17,26 @@
<template>
<div>
<base-card>
<el-tabs v-model="reactiveData.active" @tab-click="changeActive">
<base-card v-loading="reactiveData.loading">
<el-alert
v-if="reactiveData.status === 'error'"
:closable="false"
:title="$t('common.loadFailed')"
class="profile-edit__error"
show-icon
type="error"
>
<el-button :loading="reactiveData.loading" link type="danger" @click="profile()">
{{ $t('common.retry') }}
</el-button>
</el-alert>
<el-empty v-else-if="!reactiveData.loading && !reactiveData.profileFormData.id" :description="$t('common.empty')" />
<el-tabs v-else v-model="reactiveData.active" class="profile-edit__tabs" @tab-click="changeActive">
<el-tab-pane :label="$t('profile.edit.profileConfig')" name="profileConfig">
<info-card
:form-model="reactiveData.profileFormData"
:rules="formRule"
:saving="reactiveData.saving"
@reset="profileReset"
@save="profileSave"
>
@@ -70,4 +84,29 @@
<style lang="scss" scoped>
@use '@/styles/edit-card.scss';
.profile-edit__error {
margin-bottom: var(--dc3-space-3);
}
.profile-edit__tabs {
min-width: 0;
:deep(.el-tabs__nav-wrap) {
overflow-x: auto;
}
:deep(.el-tabs__nav) {
min-width: max-content;
}
}
@media (max-width: $breakpoint-xs-max) {
.profile-edit__tabs {
:deep(.el-tabs__item) {
min-height: var(--dc3-touch-target);
padding: 0 var(--dc3-space-3);
}
}
}
</style>
+131 -18
View File
@@ -15,13 +15,14 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {defineComponent, reactive, watch} from 'vue';
import {computed, defineComponent, onBeforeUnmount, onMounted, reactive, watch} from 'vue';
import type {FormRules} from 'element-plus';
import {ElMessageBox} from 'element-plus';
import {nameRules, remarkRules} from '@/utils/formRuleUtil';
import {useI18n} from 'vue-i18n';
import router from '@/config/router';
import {useRoute} from 'vue-router';
import {onBeforeRouteLeave, useRoute} from 'vue-router';
import {getProfileById, updateProfile} from '@/api/profile';
@@ -31,6 +32,8 @@ import EnableFlagSegmented from '@/components/segmented/EnableFlagSegmented.vue'
import point from '@/views/point/Point.vue';
import CommandList from '@/views/settings/command/CommandList.vue';
import EventList from '@/views/settings/event/definition/EventList.vue';
import type {ProfileRecord} from '@/config/types';
import {failMessage, successMessage} from '@/utils/notificationUtil';
const PROFILE_EDIT_TABS = ['profileConfig', 'pointConfig', 'commandConfig', 'eventConfig'] as const;
@@ -47,10 +50,31 @@ export default defineComponent({
const {t} = useI18n();
const reactiveData = reactive({
id: route.query.id,
id: String(route.query.id || ''),
active: resolveTab(route.query.active),
oldProfileFormData: {},
profileFormData: {} as any,
loading: false,
status: 'idle' as 'idle' | 'loading' | 'success' | 'error',
loadError: null as unknown | null,
saving: false,
});
let requestToken = 0;
let saveToken = 0;
let routeChangeToken = 0;
let disposed = false;
const clone = <T,>(value: T): T => {
if (value == null) return value;
return JSON.parse(JSON.stringify(value)) as T;
};
const profileDirty = computed(() => {
const fields: Array<keyof ProfileRecord> = ['profileName', 'enableFlag', 'remark'];
return fields.some(
(field) => String(reactiveData.profileFormData[field] ?? '') !== String((reactiveData.oldProfileFormData as any)[field] ?? ''),
);
});
const formRule = reactive<FormRules>({
@@ -64,52 +88,139 @@ export default defineComponent({
remark: remarkRules(t),
});
const profile = () => {
getProfileById(reactiveData.id as string).then((res) => {
reactiveData.profileFormData = res;
reactiveData.oldProfileFormData = {...res};
});
const profile = async (id = reactiveData.id) => {
const requestedId = String(id || '');
if (!requestedId) {
reactiveData.status = 'idle';
reactiveData.loading = false;
reactiveData.profileFormData = {};
reactiveData.oldProfileFormData = {};
return;
}
const token = ++requestToken;
reactiveData.loading = true;
reactiveData.status = 'loading';
reactiveData.loadError = null;
try {
const res = await getProfileById(requestedId);
if (disposed || token !== requestToken || requestedId !== reactiveData.id) return;
const profileData = clone(res || {});
reactiveData.profileFormData = profileData;
reactiveData.oldProfileFormData = clone(profileData);
reactiveData.status = 'success';
} catch (error) {
if (disposed || token !== requestToken || requestedId !== reactiveData.id) return;
reactiveData.loadError = error;
reactiveData.status = 'error';
} finally {
if (!disposed && token === requestToken) reactiveData.loading = false;
}
};
const profileSave = async () => {
if (reactiveData.saving || reactiveData.loading || !reactiveData.id || !profileDirty.value) return;
const token = ++saveToken;
const requestedId = reactiveData.id;
reactiveData.saving = true;
try {
const res = await updateProfile(reactiveData.profileFormData);
reactiveData.oldProfileFormData = {...res};
} catch {
// API error
if (disposed || token !== saveToken || requestedId !== reactiveData.id) return;
const profileData = clone(res || reactiveData.profileFormData);
reactiveData.profileFormData = profileData;
reactiveData.oldProfileFormData = clone(profileData);
successMessage();
} catch (error) {
if (!disposed && token === saveToken && requestedId === reactiveData.id) {
failMessage(undefined, undefined, error);
}
} finally {
if (!disposed && token === saveToken) reactiveData.saving = false;
}
};
const profileReset = () => {
reactiveData.profileFormData = {...reactiveData.oldProfileFormData};
if (reactiveData.saving) return;
reactiveData.profileFormData = clone(reactiveData.oldProfileFormData);
};
const changeActive = (tab: any) => {
reactiveData.active = tab.props.name;
const query = route.query;
router.push({query: {...query, active: tab.props.name}});
router.push({query: {...query, active: tab.props.name}}).catch(() => {
// handled globally
});
};
const confirmDiscard = async () => {
if (!profileDirty.value) return true;
try {
await ElMessageBox.confirm(
t('common.discardConfirm'),
t('common.confirm'),
{
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
},
);
return true;
} catch {
return false;
}
};
watch(
() => [route.query.id, route.query.active],
([id, active]) => {
const nextId = id as string;
async ([id, active]) => {
const changeToken = ++routeChangeToken;
const nextId = String(id || '');
const nextActive = resolveTab(active);
if (reactiveData.active !== nextActive) {
reactiveData.active = nextActive;
}
if (nextId && nextId !== reactiveData.id) {
if (nextId !== reactiveData.id) {
if (!(await confirmDiscard())) {
if (disposed || changeToken !== routeChangeToken) return;
await router.replace({query: {...route.query, id: reactiveData.id, active: reactiveData.active}}).catch(() => {
// handled globally
});
return;
}
if (disposed || changeToken !== routeChangeToken) return;
reactiveData.id = nextId;
reactiveData.profileFormData = {};
reactiveData.oldProfileFormData = {};
profile();
void profile(nextId);
}
}
);
profile();
const warnBeforeUnload = (event: BeforeUnloadEvent) => {
if (!profileDirty.value || reactiveData.saving) return;
event.preventDefault();
event.returnValue = '';
};
onMounted(() => window.addEventListener('beforeunload', warnBeforeUnload));
onBeforeUnmount(() => {
disposed = true;
requestToken += 1;
saveToken += 1;
routeChangeToken += 1;
window.removeEventListener('beforeunload', warnBeforeUnload);
});
onBeforeRouteLeave(async (_to, _from, next) => {
if (reactiveData.saving || (await confirmDiscard())) {
next();
} else {
next(false);
}
});
void profile();
return {
reactiveData,
@@ -117,6 +228,8 @@ export default defineComponent({
profileSave,
profileReset,
changeActive,
profile,
profileDirty,
};
},
});
+9 -7
View File
@@ -29,6 +29,7 @@
<el-card class="settings-aside-card" shadow="never">
<div class="settings-aside-toolbar">
<el-button
v-if="!isTablet"
:aria-label="asideCollapseLabel"
:icon="appStore.settingsCollapsed ? Expand : Fold"
circle
@@ -37,7 +38,7 @@
/>
</div>
<el-scrollbar>
<settings-sidebar-menu :collapsed="appStore.settingsCollapsed" />
<settings-sidebar-menu :collapsed="sidebarCollapsed" />
</el-scrollbar>
</el-card>
</el-aside>
@@ -79,16 +80,17 @@ import { useAppStore } from "@/store";
import SettingsSidebarMenu from "@/views/settings/components/SettingsSidebarMenu.vue";
const { t } = useI18n();
const { isMobile } = useBreakpoint();
const { isMobile, isTablet } = useBreakpoint();
const appStore = useAppStore();
const asideDrawerVisible = ref(false);
// Desktop aside width follows the L2 layout tokens; collapsed rail is
// icon-only (64px) on tablet.
const asideWidth = computed(() =>
appStore.settingsCollapsed ? "64px" : "220px",
);
// Tablet is an icon rail by contract; only desktop honours the persisted
// collapse preference. This keeps the content canvas usable at 768–1199px
// without making the preference unexpectedly alter the tablet information
// hierarchy.
const sidebarCollapsed = computed(() => isTablet.value || appStore.settingsCollapsed);
const asideWidth = computed(() => (sidebarCollapsed.value ? "64px" : "220px"));
const asideCollapseLabel = computed(() =>
t(

Some files were not shown because too many files have changed in this diff Show More