mirror of
https://gitee.com/pnoker/iot-dc3.git
synced 2026-10-31 02:01:51 +08:00
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:
+1
-1
@@ -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
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+685
-754
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 >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 > {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.',
|
||||
|
||||
@@ -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: '更新会话模型失败。',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
|
||||
@@ -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'}];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user