diff --git a/apps/studio/src/__tests__/integration/e2e-platform-data.test.ts b/apps/studio/src/__tests__/integration/e2e-platform-data.test.ts index 8448e371..21686efd 100644 --- a/apps/studio/src/__tests__/integration/e2e-platform-data.test.ts +++ b/apps/studio/src/__tests__/integration/e2e-platform-data.test.ts @@ -12,6 +12,7 @@ import { PlatformFieldAdapter } from '@thingsvis/kernel'; type PlatformWriteMessage = { type: 'tv:platform-write'; + requestId: string; payload: { dataSourceId: string; data: Record; @@ -240,14 +241,23 @@ describe('E2E-04: 反向写回 (tv:platform-write)', () => { capturedMessages.push(msg); }); - await adapter.write({ switch: true }); + const writePromise = adapter.write({ switch: true }); expect(capturedMessages).toHaveLength(1); const msg = capturedMessages[0] as PlatformWriteMessage; expect(msg.type).toBe('tv:platform-write'); + expect(msg.requestId).toEqual(expect.any(String)); expect(msg.payload.dataSourceId).toBe('__platform__'); expect(msg.payload.data).toEqual({ switch: true }); + simulateHostMessage({ + type: 'tv:platform-write-result', + requestId: msg.requestId, + success: true, + echo: { accepted: true }, + }); + await expect(writePromise).resolves.toEqual({ success: true, echo: { accepted: true } }); + spy.mockRestore(); await adapter.disconnect(); }); @@ -272,18 +282,103 @@ describe('E2E-04: 反向写回 (tv:platform-write)', () => { capturedMessages.push(msg); }); - await adapter.write(false); + const writePromise = adapter.write(false); expect(capturedMessages).toHaveLength(1); const msg = capturedMessages[0] as PlatformWriteMessage & { payload: PlatformWriteMessage['payload'] & { deviceId?: string }; }; expect(msg.type).toBe('tv:platform-write'); + expect(msg.requestId).toEqual(expect.any(String)); expect(msg.payload.dataSourceId).toBe('__platform_device-1__'); expect(msg.payload.deviceId).toBe('device-1'); expect(msg.payload.data).toEqual({ switch: false }); + simulateHostMessage({ + type: 'tv:platform-write-result', + requestId: msg.requestId, + success: true, + }); + await expect(writePromise).resolves.toMatchObject({ success: true }); + + spy.mockRestore(); + await adapter.disconnect(); + }); + + it('E2E-04c: write() resolves failure when host returns an error', async () => { + const adapter = new PlatformFieldAdapter(); + await adapter.connect({ + id: '__platform__', + type: 'PLATFORM_FIELD', + name: 'Platform', + config: { source: 'platform', fieldMappings: {}, bufferSize: 0 }, + }); + + const capturedMessages: unknown[] = []; + const spy = vi.spyOn(window, 'postMessage').mockImplementation((msg) => { + capturedMessages.push(msg); + }); + + const writePromise = adapter.write({ switch: true }); + const msg = capturedMessages[0] as PlatformWriteMessage; + simulateHostMessage({ + type: 'tv:platform-write-result', + requestId: msg.requestId, + success: false, + error: 'permission denied', + }); + + await expect(writePromise).resolves.toMatchObject({ + success: false, + error: 'permission denied', + }); + spy.mockRestore(); await adapter.disconnect(); }); + + it('E2E-04d: write() times out when host does not return a result', async () => { + vi.useFakeTimers(); + const adapter = new PlatformFieldAdapter(); + await adapter.connect({ + id: '__platform__', + type: 'PLATFORM_FIELD', + name: 'Platform', + config: { source: 'platform', fieldMappings: {}, bufferSize: 0 }, + }); + + const spy = vi.spyOn(window, 'postMessage').mockImplementation(() => {}); + const writePromise = adapter.write({ switch: true }); + + await vi.advanceTimersByTimeAsync(5000); + await expect(writePromise).resolves.toMatchObject({ + success: false, + error: 'Platform write timed out after 5s', + }); + + spy.mockRestore(); + await adapter.disconnect(); + vi.useRealTimers(); + }); + + it('E2E-04e: disconnect() clears pending write requests', async () => { + const adapter = new PlatformFieldAdapter(); + await adapter.connect({ + id: '__platform__', + type: 'PLATFORM_FIELD', + name: 'Platform', + config: { source: 'platform', fieldMappings: {}, bufferSize: 0 }, + }); + + const spy = vi.spyOn(window, 'postMessage').mockImplementation(() => {}); + const writePromise = adapter.write({ switch: true }); + + await adapter.disconnect(); + await expect(writePromise).resolves.toMatchObject({ + success: false, + error: 'PlatformFieldAdapter disconnected before write result', + }); + + spy.mockRestore(); + }); }); diff --git a/apps/studio/src/components/CanvasView.tsx b/apps/studio/src/components/CanvasView.tsx index 33f312db..1ac8a489 100644 --- a/apps/studio/src/components/CanvasView.tsx +++ b/apps/studio/src/components/CanvasView.tsx @@ -567,6 +567,8 @@ const CanvasView = forwardRef< ...resolveInitialWidgetProps({ schema: moduleDefs?.schema, standaloneDefaults: moduleDefs?.standaloneDefaults, + previewDefaults: moduleDefs?.previewDefaults, + sampleData: moduleDefs?.sampleData, fallbackDefaults: entry?.defaultProps, }), ...(snippetEntry?.props && typeof snippetEntry.props === 'object' @@ -729,6 +731,8 @@ const CanvasView = forwardRef< props: resolveInitialWidgetProps({ schema: moduleDefs?.schema, standaloneDefaults: moduleDefs?.standaloneDefaults, + previewDefaults: moduleDefs?.previewDefaults, + sampleData: moduleDefs?.sampleData, fallbackDefaults: moduleDefs?.defaultProps, }), grid: { diff --git a/apps/studio/src/components/Modals/DataSourceDialog.tsx b/apps/studio/src/components/Modals/DataSourceDialog.tsx index ac92556a..7f15dfed 100644 --- a/apps/studio/src/components/Modals/DataSourceDialog.tsx +++ b/apps/studio/src/components/Modals/DataSourceDialog.tsx @@ -25,6 +25,12 @@ import { TransformationEditor } from '../DataSourceConfig/TransformationEditor'; import CodeMirror from '@uiw/react-codemirror'; import { json } from '@codemirror/lang-json'; import { useTranslation } from 'react-i18next'; +import { resolveEditorServiceConfig } from '@/lib/embedded/service-config'; +import { + listEmbeddedProviderDataSourceIds, + resolveEmbeddedProviderCatalog, +} from '@/lib/embedded/embedded-data-source-registry'; +import { resolveControlText } from '@/lib/i18n/controlText'; // Default configurations for new data sources const DEFAULT_REST_CONFIG: RESTConfig = { @@ -53,7 +59,7 @@ interface DataSourceDialogProps { } export function DataSourceDialog({ open, onOpenChange, store }: DataSourceDialogProps) { - const { t } = useTranslation('editor'); + const { t, i18n } = useTranslation('editor'); const { states } = useDataSourceRegistry(store); const [selectedId, setSelectedId] = useState(null); const [isAdding, setIsAdding] = useState(false); @@ -73,6 +79,36 @@ export function DataSourceDialog({ open, onOpenChange, store }: DataSourceDialog transformation: '', }); const jsonExtensions = useMemo(() => [json()], []); + const serviceConfig = useMemo(() => resolveEditorServiceConfig(), []); + const providerDataSourceNameMap = useMemo(() => { + const catalog = resolveEmbeddedProviderCatalog(serviceConfig.provider); + if (!catalog) return new Map(); + + const locale = i18n.resolvedLanguage ?? i18n.language; + return new Map( + catalog.dataSources.map((source) => [ + source.id, + resolveControlText(source.label, locale, t as any), + ]), + ); + }, [i18n.language, i18n.resolvedLanguage, serviceConfig.provider, t]); + const protectedDataSourceIds = useMemo(() => { + if (serviceConfig.mode !== 'embedded') return new Set(); + + const groups = + serviceConfig.context === 'dashboard' + ? ['dashboard'] + : serviceConfig.context === 'device-template' + ? ['dashboard', 'current-device', 'current-device-history'] + : undefined; + + return new Set( + listEmbeddedProviderDataSourceIds( + serviceConfig.provider, + groups ? { groups: groups as any } : undefined, + ), + ); + }, [serviceConfig.context, serviceConfig.mode, serviceConfig.provider]); const syncStaticJsonTextFromConfig = (configValue: unknown) => { try { @@ -84,6 +120,12 @@ export function DataSourceDialog({ open, onOpenChange, store }: DataSourceDialog } }; + const getDisplayName = (dataSourceId: string): string => { + const config = dataSourceManager.getConfig(dataSourceId); + if (config?.name && config.name !== dataSourceId) return config.name; + return providerDataSourceNameMap.get(dataSourceId) ?? dataSourceId; + }; + // 验证数据源 ID 格式(只允许字母、数字和下划线) const isValidDataSourceId = (id: string) => /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(id); @@ -143,6 +185,7 @@ export function DataSourceDialog({ open, onOpenChange, store }: DataSourceDialog const deleteSource = async (e: React.MouseEvent, id: string) => { e.stopPropagation(); + if (protectedDataSourceIds.has(id)) return; await dataSourceManager.unregisterDataSource(id); if (selectedId === id) setSelectedId(null); }; @@ -199,14 +242,27 @@ export function DataSourceDialog({ open, onOpenChange, store }: DataSourceDialog
- {ds.id} +
+ + {getDisplayName(ds.id)} + + {getDisplayName(ds.id) !== ds.id ? ( + + {ds.id} + + ) : null} +
- + {!protectedDataSourceIds.has(ds.id) && ( + + )} ))} {Object.keys(states).length === 0 && ( diff --git a/apps/studio/src/components/RightPanel/ControlFieldRow.tsx b/apps/studio/src/components/RightPanel/ControlFieldRow.tsx index 1f4a20a3..1922a598 100644 --- a/apps/studio/src/components/RightPanel/ControlFieldRow.tsx +++ b/apps/studio/src/components/RightPanel/ControlFieldRow.tsx @@ -42,6 +42,149 @@ function allowedModes(field: ControlField): BindingMode[] { return normalized.length ? normalized : ['static']; } +const DEFAULT_WRITE_EVENT_BY_COMPONENT: Record = { + 'interaction/basic-switch': 'change', + 'interaction/basic-slider': 'change', + 'interaction/basic-select': 'change', + 'interaction/basic-input': 'submit', +}; + +const AUTO_WRITE_MARKER = 'field-binding'; + +type EventHandlerLike = { + event?: unknown; + actions?: unknown[]; + [key: string]: unknown; +}; + +type ActionLike = { + type?: unknown; + dataSourceId?: unknown; + payload?: unknown; + __thingsvisAutoWrite?: unknown; + [key: string]: unknown; +}; + +function isDefaultWriteBindingTarget( + componentType: string | undefined, + targetProp: string, +): boolean { + return ( + targetProp === 'value' && + Boolean(componentType && DEFAULT_WRITE_EVENT_BY_COMPONENT[componentType]) + ); +} + +function getRootFieldPath(fieldPath: string): string | null { + if (!fieldPath || fieldPath === '(root)') return null; + return fieldPath.split(/[.[\]]/).filter(Boolean)[0] ?? null; +} + +function createDefaultWriteAction(dataSourceId: string, fieldId: string): ActionLike { + return { + type: 'callWrite', + dataSourceId, + payload: `({ ${JSON.stringify(fieldId)}: payload })`, + __thingsvisAutoWrite: AUTO_WRITE_MARKER, + }; +} + +function isAutoWriteAction(action: unknown): action is ActionLike { + return ( + Boolean(action) && + typeof action === 'object' && + (action as ActionLike).type === 'callWrite' && + (action as ActionLike).__thingsvisAutoWrite === AUTO_WRITE_MARKER + ); +} + +function ensureDefaultWriteEvent( + events: EventHandlerLike[] | undefined, + eventName: string, + dataSourceId: string, + fieldId: string, +): EventHandlerLike[] | null { + const sourceEvents = Array.isArray(events) ? events : []; + const defaultAction = createDefaultWriteAction(dataSourceId, fieldId); + let found = false; + let changed = false; + + const nextEvents = sourceEvents.map((handler) => { + if (handler.event !== eventName) return handler; + found = true; + + const actions = Array.isArray(handler.actions) ? handler.actions : []; + const hasAutoAction = actions.some(isAutoWriteAction); + const hasManualActions = actions.some((action) => !isAutoWriteAction(action)); + + if (hasManualActions && !hasAutoAction) return handler; + + const nextActions = [...actions.filter((action) => !isAutoWriteAction(action)), defaultAction]; + const currentAuto = actions.find(isAutoWriteAction) as ActionLike | undefined; + changed = + changed || + !hasAutoAction || + currentAuto?.dataSourceId !== defaultAction.dataSourceId || + currentAuto?.payload !== defaultAction.payload || + nextActions.length !== actions.length; + + return { + ...handler, + actions: nextActions, + }; + }); + + if (!found) { + return [...sourceEvents, { event: eventName, actions: [defaultAction] }]; + } + + return changed ? nextEvents : null; +} + +function removeDefaultWriteEvent( + events: EventHandlerLike[] | undefined, + eventName: string, +): EventHandlerLike[] | null { + const sourceEvents = Array.isArray(events) ? events : []; + let changed = false; + + const nextEvents = sourceEvents + .map((handler) => { + if (handler.event !== eventName) return handler; + const actions = Array.isArray(handler.actions) ? handler.actions : []; + const nextActions = actions.filter((action) => !isAutoWriteAction(action)); + if (nextActions.length === actions.length) return handler; + changed = true; + return { ...handler, actions: nextActions }; + }) + .filter((handler) => { + if (handler.event !== eventName) return true; + return Array.isArray(handler.actions) && handler.actions.length > 0; + }); + + return changed ? nextEvents : null; +} + +function buildDefaultWriteEventsForSelection( + componentType: string | undefined, + targetProp: string, + selection: FieldPickerValue | null, + events: EventHandlerLike[] | undefined, +): EventHandlerLike[] | null { + if (!isDefaultWriteBindingTarget(componentType, targetProp)) return null; + const eventName = componentType ? DEFAULT_WRITE_EVENT_BY_COMPONENT[componentType] : undefined; + if (!eventName) return null; + + if (!selection?.dataSourceId || !selection.fieldPath) { + return removeDefaultWriteEvent(events, eventName); + } + + const fieldId = getRootFieldPath(selection.fieldPath); + if (!fieldId) return null; + + return ensureDefaultWriteEvent(events, eventName, selection.dataSourceId, fieldId); +} + export function ControlFieldRow({ kernelStore, nodeId, @@ -170,6 +313,21 @@ export function ControlFieldRow({ } }; + useEffect(() => { + const currentEvents = ( + (kernelStore.getState() as KernelState).nodesById[nodeId]?.schemaRef as + | { events?: EventHandlerLike[] } + | undefined + )?.events; + const nextEvents = buildDefaultWriteEventsForSelection( + componentType, + field.path, + fieldSelection, + currentEvents, + ); + if (nextEvents) updateNode({ events: nextEvents }); + }, [componentType, field.path, fieldSelection, kernelStore, nodeId, updateNode]); + return (
@@ -430,8 +588,22 @@ export function ControlFieldRow({ { setFieldSelection(next); + const currentEvents = ( + (kernelStore.getState() as KernelState).nodesById[nodeId]?.schemaRef as + | { events?: EventHandlerLike[] } + | undefined + )?.events; + const nextEvents = buildDefaultWriteEventsForSelection( + componentType, + field.path, + next, + currentEvents, + ); + if (next?.dataSourceId && next.fieldPath) { const expression = makeFieldBindingExpression(next); const selection = parseFieldBindingExpression(expression); @@ -444,12 +616,14 @@ export function ControlFieldRow({ ...(next.transform ? { transform: next.transform } : {}), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any), + ...(nextEvents ? { events: nextEvents } : {}), }); return; } updateNode({ data: removeBinding(bindings, field.path), + ...(nextEvents ? { events: nextEvents } : {}), }); }} /> diff --git a/apps/studio/src/components/RightPanel/DataSourceSelector.tsx b/apps/studio/src/components/RightPanel/DataSourceSelector.tsx index 27ad93be..7e67b8de 100644 --- a/apps/studio/src/components/RightPanel/DataSourceSelector.tsx +++ b/apps/studio/src/components/RightPanel/DataSourceSelector.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Database, Zap } from 'lucide-react'; +import { Database } from 'lucide-react'; import { resolveEditorServiceConfig } from '@/lib/embedded/service-config'; import { Select, @@ -19,12 +19,11 @@ interface DataSourceSelectorProps { } function isHostDataSourceId(id: string) { - return id === '__platform__' || /^__platform_.+__$/.test(id); + return /^__platform_.+__$/.test(id); } export function DataSourceSelector({ dataSources, - platformFields = [], value = '', onChange, placeholder, @@ -32,13 +31,15 @@ export function DataSourceSelector({ const { t } = useTranslation('editor'); const isEmbeddedMode = useMemo(() => resolveEditorServiceConfig().mode === 'embedded', []); const visibleDataSourceIds = useMemo( - () => Object.keys(dataSources).filter((id) => isEmbeddedMode || !isHostDataSourceId(id)), + () => + Object.keys(dataSources).filter( + (id) => id !== '__platform__' && (isEmbeddedMode || !isHostDataSourceId(id)), + ), [dataSources, isEmbeddedMode], ); const hasDataSources = visibleDataSourceIds.length > 0; - const hasPlatformFields = isEmbeddedMode && platformFields.length > 0; - if (!hasDataSources && !hasPlatformFields) { + if (!hasDataSources) { return (
{t('dataSourceSelector.noDataSources')} @@ -78,31 +79,6 @@ export function DataSourceSelector({ ))} )} - - {/* Platform Fields Section */} - {hasPlatformFields && ( - <> -
- - {t('dataSourceSelector.platformFields')} -
- {platformFields.map((field) => ( - -
-
- {field.name} - platform.{field.id} -
- - ))} - - )} ); diff --git a/apps/studio/src/components/RightPanel/FieldPicker.tsx b/apps/studio/src/components/RightPanel/FieldPicker.tsx index 0f64934c..f0d9e29e 100644 --- a/apps/studio/src/components/RightPanel/FieldPicker.tsx +++ b/apps/studio/src/components/RightPanel/FieldPicker.tsx @@ -5,9 +5,11 @@ import type { KernelStore } from '@thingsvis/kernel'; import { useDataSourceRegistry } from '@thingsvis/ui'; import { DEFAULT_PLATFORM_FIELD_CONFIG } from '@thingsvis/schema'; import { dataSourceManager } from '@/lib/store'; -import { usePlatformFieldStore } from '@/lib/stores/platformFieldStore'; import { usePlatformDeviceStore } from '@/lib/stores/platformDeviceStore'; +import { usePlatformFieldStore } from '@/lib/stores/platformFieldStore'; import { resolveEditorServiceConfig } from '@/lib/embedded/service-config'; +import { resolveEmbeddedProviderCatalog } from '@/lib/embedded/embedded-data-source-registry'; +import { resolveControlText } from '@/lib/i18n/controlText'; import { Dialog, DialogContent, @@ -25,12 +27,40 @@ export type FieldPickerValue = { transform?: string; }; -type SourceGroup = 'platform' | 'device' | 'custom'; +type SourceGroup = 'device' | 'deviceStatus' | 'deviceHistory' | 'platform' | 'custom'; + +const TEMPLATE_DEVICE_ID = '__template__'; +const HISTORY_FIELD_SUFFIX = '__history'; + +type PlatformStatField = { + id: string; + name: string; + type: FieldPathInfo['type']; +}; + +type PlatformStatSource = { + id: string; + name: string; + group: 'dashboard' | 'current-device' | 'current-device-history'; + url: string; + params?: Record; + fields: PlatformStatField[]; + transformation: string; +}; + +type RuntimeDeviceField = { + id: string; + name: string; + alias: string; + type: FieldPathInfo['type']; +}; type Props = { kernelStore: KernelStore; value: FieldPickerValue | null; onChange: (next: FieldPickerValue | null) => void; + targetKind?: string; + writableOnly?: boolean; maxDepth?: number; maxNodes?: number; }; @@ -76,6 +106,54 @@ function getRequestedFieldId(fieldPath: string): string | null { return fieldPath.split(/[.[\]]/).filter(Boolean)[0] ?? null; } +function isHistoryFieldPath(fieldPath: string): boolean { + return fieldPath.endsWith(HISTORY_FIELD_SUFFIX); +} + +function isTemplateDeviceSource(device: { deviceId?: string } | undefined): boolean { + return device?.deviceId === TEMPLATE_DEVICE_ID; +} + +function isTelemetryField(field: unknown): boolean { + if (!field || typeof field !== 'object') return false; + const dataType = (field as { dataType?: unknown }).dataType; + return dataType === undefined || dataType === 'telemetry'; +} + +function isFieldTypeCompatible(type: FieldPathInfo['type'], targetKind?: string): boolean { + if (!targetKind || type === 'unknown') return true; + + if (targetKind === 'boolean') return type === 'boolean'; + if (targetKind === 'number' || targetKind === 'slider' || targetKind === 'rangeSlider') { + return type === 'number'; + } + if (targetKind === 'string' || targetKind === 'textarea' || targetKind === 'select') { + return type === 'string' || type === 'number' || type === 'boolean'; + } + if (targetKind === 'color') return type === 'string'; + + return true; +} + +function getStaticFieldId(field: unknown): string { + if (!field || typeof field !== 'object') return ''; + const id = (field as { id?: unknown }).id; + return typeof id === 'string' ? id : ''; +} + +function getStaticFieldType(field: unknown): FieldPathInfo['type'] { + if (!field || typeof field !== 'object') return 'unknown'; + const type = (field as { type?: unknown }).type; + return type === 'string' || + type === 'number' || + type === 'boolean' || + type === 'object' || + type === 'array' || + type === 'unknown' + ? type + : 'string'; +} + function ensurePlatformDeviceDataSource(device: { deviceId: string; deviceName?: string }): void { const dataSourceId = getDeviceDataSourceId(device.deviceId); const existing = dataSourceManager.getAllConfigs().some((config) => config.id === dataSourceId); @@ -85,7 +163,7 @@ function ensurePlatformDeviceDataSource(device: { deviceId: string; deviceName?: 0, ...dataSourceManager .getAllConfigs() - .filter((config) => config.id === '__platform__') + .filter((config) => parseDeviceDataSourceId(config.id) !== null) .map((config) => { const bufferSize = (config.config as { bufferSize?: unknown } | undefined)?.bufferSize; return typeof bufferSize === 'number' && Number.isFinite(bufferSize) ? bufferSize : 0; @@ -106,32 +184,124 @@ function ensurePlatformDeviceDataSource(device: { deviceId: string; deviceName?: }); } -export function FieldPicker({ kernelStore, value, onChange, maxDepth, maxNodes }: Props) { - const { t } = useTranslation('editor'); +function ensurePlatformStatDataSource(source: PlatformStatSource): void { + const existing = dataSourceManager.getAllConfigs().some((config) => config.id === source.id); + if (existing) return; + + void dataSourceManager + .registerDataSource( + { + id: source.id, + name: source.name, + type: 'REST', + config: { + url: source.url, + method: 'GET', + headers: { + 'x-token': '{{ var.platformToken }}', + }, + params: source.params ?? {}, + pollingInterval: 60, + timeout: 30, + auth: { type: 'none' }, + }, + transformation: source.transformation, + }, + false, + ) + .catch((error) => { + console.error( + '[FieldPicker] Failed to register platform statistics source:', + source.id, + error, + ); + }); +} + +export function FieldPicker({ + kernelStore, + value, + onChange, + targetKind, + writableOnly = false, + maxDepth, + maxNodes, +}: Props) { + const { t, i18n } = useTranslation('editor'); const { states } = useDataSourceRegistry(kernelStore); const dataSourceIds = useMemo(() => Object.keys(states).sort(), [states]); + const locale = i18n.resolvedLanguage ?? i18n.language; + const serviceConfig = useMemo(() => resolveEditorServiceConfig(), []); + const isEmbeddedMode = serviceConfig.mode === 'embedded'; + const providerCatalog = useMemo( + () => (isEmbeddedMode ? resolveEmbeddedProviderCatalog(serviceConfig.provider) : undefined), + [isEmbeddedMode, serviceConfig.provider], + ); + const platformSources = useMemo( + () => + (providerCatalog?.dataSources ?? []).map((source) => ({ + id: source.id, + name: resolveControlText(source.label, locale, t), + group: source.group, + url: source.url, + params: source.params, + transformation: source.transformation, + fields: source.fields.map((field) => ({ + id: field.id, + name: resolveControlText(field.label, locale, t), + type: field.type as FieldPathInfo['type'], + })), + })), + [locale, providerCatalog, t], + ); + const platformSourceIds = useMemo( + () => new Set(platformSources.map((source) => source.id)), + [platformSources], + ); + const runtimeDeviceFields = useMemo( + () => + (providerCatalog?.runtimeDeviceFields ?? []).map((field) => ({ + id: field.id, + name: resolveControlText(field.label, locale, t), + alias: resolveControlText(field.alias ?? field.label, locale, t), + type: field.type as FieldPathInfo['type'], + })), + [locale, providerCatalog, t], + ); + const runtimeDeviceFieldIds = useMemo( + () => new Set(runtimeDeviceFields.map((field) => field.id)), + [runtimeDeviceFields], + ); + const platformSourcesByGroup = useMemo( + () => ({ + dashboard: platformSources.filter((source) => source.group === 'dashboard'), + currentDevice: platformSources.filter((source) => source.group === 'current-device'), + currentDeviceHistory: platformSources.filter( + (source) => source.group === 'current-device-history', + ), + }), + [platformSources], + ); + const visiblePlatformSources = useMemo( + () => (writableOnly ? [] : platformSources.filter((source) => source.group === 'dashboard')), + [platformSources, writableOnly], + ); - // 🆕 平台字段(嵌入模式) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const platformFields = usePlatformFieldStore((s: any) => s.fields ?? []); const platformDeviceGroups = usePlatformDeviceStore((s) => s.groups ?? []); - const loadedGroupIds = usePlatformDeviceStore((s) => s.loadedGroupIds ?? []); + const loadedPlatformGroupIds = usePlatformDeviceStore((s) => s.loadedGroupIds ?? []); const platformDevices = usePlatformDeviceStore((s) => s.devices ?? []); - const hasPlatformFields = platformFields.length > 0; + const platformFields = usePlatformFieldStore((s) => s.fields ?? []); const [transformDialogOpen, setTransformDialogOpen] = useState(false); /** Draft code while the dialog is open — only committed on Apply */ const [draftCode, setDraftCode] = useState(''); + const [deviceSearchText, setDeviceSearchText] = useState(''); const selectedDataSourceId = value?.dataSourceId || ''; const selectedFieldPath = value?.fieldPath || ''; const selectedTransform = value?.transform || ''; - const isEmbeddedMode = useMemo(() => resolveEditorServiceConfig().mode === 'embedded', []); - const [embeddedSourceGroup, setEmbeddedSourceGroup] = useState(() => { - if (!isEmbeddedMode) return 'custom'; - return hasPlatformFields ? 'platform' : 'device'; - }); - const [selectedDeviceGroupIdState, setSelectedDeviceGroupIdState] = useState(''); + const safeOnChange = useCallback((next: FieldPickerValue | null) => onChange(next), [onChange]); + const [embeddedSourceGroup, setEmbeddedSourceGroup] = useState('device'); const deviceSources = useMemo(() => { const fromStore = platformDevices.map((device) => ({ @@ -144,7 +314,26 @@ export function FieldPicker({ kernelStore, value, onChange, maxDepth, maxNodes } fields: device.fields ?? [], })); - const knownDeviceIds = new Set(fromStore.map((item) => item.dataSourceId)); + const templateDevice = + serviceConfig.context === 'device-template' && + platformFields.length > 0 && + !fromStore.some((device) => isTemplateDeviceSource(device)) + ? [ + { + deviceId: TEMPLATE_DEVICE_ID, + label: t('binding.templateFields', '物模型字段'), + groupId: TEMPLATE_DEVICE_ID, + groupName: t('binding.templateFields', '物模型字段'), + templateId: undefined, + dataSourceId: getDeviceDataSourceId(TEMPLATE_DEVICE_ID), + fields: platformFields, + }, + ] + : []; + + const knownDeviceIds = new Set( + [...fromStore, ...templateDevice].map((item) => item.dataSourceId), + ); const inferred = dataSourceIds .filter((id) => parseDeviceDataSourceId(id)) .filter((id) => !knownDeviceIds.has(id)) @@ -161,185 +350,304 @@ export function FieldPicker({ kernelStore, value, onChange, maxDepth, maxNodes } }; }); - return [...fromStore, ...inferred].sort((a, b) => a.label.localeCompare(b.label)); - }, [platformDevices, dataSourceIds, t]); + return [...templateDevice, ...fromStore, ...inferred].sort((a, b) => + a.label.localeCompare(b.label), + ); + }, [dataSourceIds, platformDevices, platformFields, serviceConfig.context, t]); const customDataSourceIds = useMemo( () => - dataSourceIds.filter((id) => id !== '__platform__' && parseDeviceDataSourceId(id) === null), - [dataSourceIds], + dataSourceIds.filter( + (id) => + id !== '__platform__' && + parseDeviceDataSourceId(id) === null && + !platformSourceIds.has(id), + ), + [dataSourceIds, platformSourceIds], ); - const hasDeviceCatalog = platformDeviceGroups.length > 0 || deviceSources.length > 0; - const deviceGroupOptions = useMemo(() => { - if (platformDeviceGroups.length > 0) return platformDeviceGroups; - - const deduped = new Map(); - deviceSources.forEach((device) => { - if (!deduped.has(device.groupId)) { - deduped.set(device.groupId, { - groupId: device.groupId, - groupName: device.groupName, - }); - } - }); - return Array.from(deduped.values()); - }, [deviceSources, platformDeviceGroups]); + const hasPlatformStatsCatalog = isEmbeddedMode && visiblePlatformSources.length > 0; + const hasDeviceCatalog = deviceSources.length > 0; + const hasTemplateFieldCatalog = + isEmbeddedMode && + (serviceConfig.context === 'device-template' || + (deviceSources.length === 1 && isTemplateDeviceSource(deviceSources[0]))); + const deviceSourceLabel = hasTemplateFieldCatalog + ? t('binding.templateFields', '物模型字段') + : t('binding.deviceData', '当前设备字段'); + const hasDeviceStatusCatalog = isEmbeddedMode && !writableOnly && runtimeDeviceFields.length > 0; + const hasDeviceHistoryCatalog = isEmbeddedMode && !writableOnly && deviceSources.length > 0; useEffect(() => { if (!isEmbeddedMode) return; - if (selectedDataSourceId === '__platform__') { - setEmbeddedSourceGroup('platform'); - return; - } - if (selectedDataSourceId && parseDeviceDataSourceId(selectedDataSourceId)) { - setEmbeddedSourceGroup('device'); + const fieldRoot = getRequestedFieldId(selectedFieldPath) ?? ''; + if (isHistoryFieldPath(fieldRoot)) { + setEmbeddedSourceGroup('deviceHistory'); + } else if (runtimeDeviceFieldIds.has(fieldRoot)) { + setEmbeddedSourceGroup('deviceStatus'); + } else { + setEmbeddedSourceGroup('device'); + } const selectedDevice = deviceSources.find( (device) => device.dataSourceId === selectedDataSourceId, ); - if (selectedDevice?.groupId) { - setSelectedDeviceGroupIdState(selectedDevice.groupId); - } + if (selectedDevice?.label) setDeviceSearchText(''); return; } - if (selectedDataSourceId) { - setEmbeddedSourceGroup('custom'); + if (selectedDataSourceId && platformSourceIds.has(selectedDataSourceId)) { + setEmbeddedSourceGroup('platform'); return; } - setEmbeddedSourceGroup(hasPlatformFields ? 'platform' : hasDeviceCatalog ? 'device' : 'custom'); - }, [deviceSources, hasDeviceCatalog, hasPlatformFields, isEmbeddedMode, selectedDataSourceId]); - - useEffect(() => { - if (!isEmbeddedMode || embeddedSourceGroup !== 'device') return; - - if (platformDeviceGroups.length > 0) { - if ( - !selectedDeviceGroupIdState || - !deviceGroupOptions.some((group) => group.groupId === selectedDeviceGroupIdState) - ) { - setSelectedDeviceGroupIdState(String(deviceGroupOptions[0]?.groupId || '')); - } + if (selectedDataSourceId) { + setEmbeddedSourceGroup('custom'); return; } - if (!selectedDeviceGroupIdState && deviceSources[0]?.groupId) { - setSelectedDeviceGroupIdState(deviceSources[0].groupId); - } + setEmbeddedSourceGroup( + hasDeviceCatalog ? 'device' : hasPlatformStatsCatalog ? 'platform' : 'custom', + ); }, [ - deviceGroupOptions, deviceSources, - embeddedSourceGroup, + hasDeviceCatalog, + hasPlatformStatsCatalog, isEmbeddedMode, - platformDeviceGroups.length, - selectedDeviceGroupIdState, + platformSourceIds, + runtimeDeviceFieldIds, + selectedDataSourceId, + selectedFieldPath, ]); + useEffect(() => { + if (!isEmbeddedMode || embeddedSourceGroup !== 'platform') return; + visiblePlatformSources.forEach(ensurePlatformStatDataSource); + }, [embeddedSourceGroup, isEmbeddedMode, visiblePlatformSources]); + const selectedGroup = isEmbeddedMode ? embeddedSourceGroup : 'custom'; - const selectedDeviceFromValue = - selectedGroup === 'device' - ? deviceSources.find((device) => device.dataSourceId === selectedDataSourceId) - : undefined; - const selectedDeviceGroupId = - selectedGroup === 'device' - ? selectedDeviceFromValue?.groupId || - selectedDeviceGroupIdState || - String(deviceGroupOptions[0]?.groupId || deviceSources[0]?.groupId || '') - : ''; - const selectedDeviceGroupName = - selectedGroup === 'device' - ? deviceGroupOptions.find((group) => group.groupId === selectedDeviceGroupId)?.groupName || - selectedDeviceFromValue?.groupName || - '' - : ''; - - const visibleDeviceSources = useMemo( - () => - selectedGroup === 'device' - ? deviceSources.filter((device) => device.groupId === selectedDeviceGroupId) - : [], - [deviceSources, selectedDeviceGroupId, selectedGroup], - ); + const isDeviceScopedGroup = + selectedGroup === 'device' || + selectedGroup === 'deviceStatus' || + selectedGroup === 'deviceHistory'; + const visibleDeviceSources = useMemo(() => { + if (!isDeviceScopedGroup) return []; + const keyword = deviceSearchText.trim().toLowerCase(); + if (!keyword) return deviceSources; + return deviceSources.filter((device) => + [device.label, device.deviceId, device.templateId] + .filter(Boolean) + .some((value) => String(value).toLowerCase().includes(keyword)), + ); + }, [deviceSearchText, deviceSources, isDeviceScopedGroup]); + + const selectedDeviceSource = isDeviceScopedGroup + ? deviceSources.find((device) => device.dataSourceId === selectedDataSourceId) || + visibleDeviceSources[0] || + deviceSources[0] + : undefined; + const isTemplateDeviceSelection = + hasTemplateFieldCatalog && isDeviceScopedGroup && isTemplateDeviceSource(selectedDeviceSource); + const pendingPlatformGroupIds = useMemo(() => { + if (!isEmbeddedMode || !isDeviceScopedGroup || isTemplateDeviceSelection) return []; + if (window.parent === window) return []; + const loadedGroupIds = new Set(loadedPlatformGroupIds); + return platformDeviceGroups + .map((group) => group.groupId) + .filter((groupId) => groupId && !loadedGroupIds.has(groupId)); + }, [ + isDeviceScopedGroup, + isEmbeddedMode, + isTemplateDeviceSelection, + loadedPlatformGroupIds, + platformDeviceGroups, + ]); + const isPlatformDeviceListLoading = + isEmbeddedMode && + isDeviceScopedGroup && + !isTemplateDeviceSelection && + pendingPlatformGroupIds.length > 0; - const selectedDeviceSource = - selectedGroup === 'device' - ? visibleDeviceSources.find((device) => device.dataSourceId === selectedDataSourceId) || - visibleDeviceSources[0] - : undefined; + useEffect(() => { + if (!isPlatformDeviceListLoading) return; + + const pendingGroupIds = new Set(pendingPlatformGroupIds); + const handleMessage = (event: MessageEvent) => { + const data = event.data as + | { type?: string; payload?: { groupId?: string; devices?: unknown[] } } + | undefined; + if (data?.type !== 'tv:devices-by-group') return; + + const payload = data.payload; + if (!payload?.groupId || !pendingGroupIds.has(payload.groupId)) return; + if (!Array.isArray(payload.devices)) return; + + usePlatformDeviceStore.getState().setDevicesForGroup(payload.groupId, payload.devices as any); + }; + + window.addEventListener('message', handleMessage); + pendingPlatformGroupIds.forEach((groupId) => { + window.parent.postMessage( + { + type: 'thingsvis:requestDevicesByGroup', + payload: { groupId }, + }, + '*', + ); + }); + + return () => { + window.removeEventListener('message', handleMessage); + }; + }, [isPlatformDeviceListLoading, pendingPlatformGroupIds]); + + useEffect(() => { + if (!isDeviceScopedGroup || !selectedDeviceSource?.deviceId) return; + ensurePlatformDeviceDataSource({ + deviceId: selectedDeviceSource.deviceId, + deviceName: selectedDeviceSource.label, + }); + }, [isDeviceScopedGroup, selectedDeviceSource]); + + const selectedDeviceBaseFields = useMemo(() => { + if (!isDeviceScopedGroup) return []; + const fields = Array.isArray(selectedDeviceSource?.fields) + ? [...selectedDeviceSource.fields] + : []; + const existingIds = new Set( + fields.map((field: any) => (typeof field?.id === 'string' ? field.id : '')).filter(Boolean), + ); + runtimeDeviceFields.forEach((field) => { + if (!existingIds.has(field.id)) { + fields.push(field as any); + } + }); + return fields; + }, [isDeviceScopedGroup, runtimeDeviceFields, selectedDeviceSource]); const selectedDeviceFields = useMemo(() => { if (selectedGroup !== 'device') return []; - return selectedDeviceSource?.fields ?? []; - }, [selectedDeviceSource, selectedGroup]); + return selectedDeviceBaseFields.filter((field: any) => { + const fieldId = getStaticFieldId(field); + if (runtimeDeviceFieldIds.has(fieldId)) return false; + return isFieldTypeCompatible(getStaticFieldType(field), targetKind); + }); + }, [runtimeDeviceFieldIds, selectedDeviceBaseFields, selectedGroup, targetKind]); + + const selectedDeviceStatusFields = useMemo(() => { + if (selectedGroup !== 'deviceStatus') return []; + return selectedDeviceBaseFields.filter((field: any) => { + const fieldId = getStaticFieldId(field); + if (!runtimeDeviceFieldIds.has(fieldId)) return false; + return isFieldTypeCompatible(getStaticFieldType(field), targetKind); + }); + }, [runtimeDeviceFieldIds, selectedDeviceBaseFields, selectedGroup, targetKind]); + + const selectedDeviceHistoryFields = useMemo(() => { + if (selectedGroup !== 'deviceHistory') return []; + return selectedDeviceBaseFields + .filter((field: any) => { + const fieldId = getStaticFieldId(field); + if (!fieldId || runtimeDeviceFieldIds.has(fieldId)) return false; + if (!isTelemetryField(field)) return false; + return getStaticFieldType(field) === 'number'; + }) + .map((field: any) => { + const fieldId = getStaticFieldId(field); + const rawLabel = + typeof field?.alias === 'string' && field.alias + ? field.alias + : typeof field?.name === 'string' && field.name + ? field.name + : fieldId; + return { + id: `${fieldId}${HISTORY_FIELD_SUFFIX}`, + name: `${rawLabel} ${t('binding.historySeriesSuffix', '历史趋势')}`, + type: 'array' as FieldPathInfo['type'], + }; + }); + }, [runtimeDeviceFieldIds, selectedDeviceBaseFields, selectedGroup, t]); + + const selectedPlatformSource = + selectedGroup === 'platform' + ? visiblePlatformSources.find((source) => source.id === selectedDataSourceId) || + visiblePlatformSources[0] + : undefined; + + const selectedPlatformFields = useMemo(() => { + if (selectedGroup !== 'platform') return []; + return (selectedPlatformSource?.fields ?? []).filter((field) => + isFieldTypeCompatible(field.type, targetKind), + ); + }, [selectedPlatformSource, selectedGroup, targetKind]); + const hasStaticFieldOptions = + (selectedGroup === 'device' && selectedDeviceFields.length > 0) || + (selectedGroup === 'deviceStatus' && selectedDeviceStatusFields.length > 0) || + (selectedGroup === 'deviceHistory' && selectedDeviceHistoryFields.length > 0) || + (selectedGroup === 'platform' && selectedPlatformFields.length > 0); + const fieldDisplayNameByPath = useMemo(() => { + const labels = new Map(); + if (selectedGroup === 'device' || selectedGroup === 'deviceStatus') { + const fields = selectedGroup === 'device' ? selectedDeviceFields : selectedDeviceStatusFields; + fields.forEach((field: any) => { + const id = typeof field?.id === 'string' ? field.id : ''; + const label = + typeof field?.alias === 'string' && field.alias + ? field.alias + : typeof field?.name === 'string' && field.name + ? field.name + : ''; + if (id && label && label !== id) labels.set(id, label); + }); + } + if (selectedGroup === 'deviceHistory') { + selectedDeviceHistoryFields.forEach((field) => { + if (field.name && field.name !== field.id) labels.set(field.id, field.name); + }); + } + if (selectedGroup === 'platform') { + selectedPlatformFields.forEach((field) => { + if (field.name && field.name !== field.id) labels.set(field.id, field.name); + }); + } + return labels; + }, [ + selectedDeviceFields, + selectedDeviceHistoryFields, + selectedDeviceStatusFields, + selectedGroup, + selectedPlatformFields, + ]); const effectiveDataSourceId = !isEmbeddedMode ? customDataSourceIds.includes(selectedDataSourceId) ? selectedDataSourceId : (customDataSourceIds[0] ?? '') - : selectedGroup === 'platform' - ? hasPlatformFields - ? '__platform__' - : '' - : selectedGroup === 'device' - ? (selectedDeviceSource?.dataSourceId ?? '') + : isDeviceScopedGroup + ? (selectedDeviceSource?.dataSourceId ?? '') + : selectedGroup === 'platform' + ? (selectedPlatformSource?.id ?? '') : selectedDataSourceId || customDataSourceIds[0] || ''; - const isPlatformSource = effectiveDataSourceId === '__platform__'; - const dsState = effectiveDataSourceId && !isPlatformSource ? states[effectiveDataSourceId] : null; + const dsState = effectiveDataSourceId ? states[effectiveDataSourceId] : null; const snapshot = dsState?.data ?? null; const dsStatus = dsState?.status ?? 'disconnected'; // fieldSchema — available even when DS is offline (cached from last connection) const fieldSchema = (dsState as any)?.fieldSchema ?? null; const isOffline = snapshot === null && fieldSchema !== null; - // Live data snapshot emitted by PlatformFieldAdapter (populated whenever the host pushes tv:platform-data). - // Used to traverse JSON sub-paths and expose __history buffer keys in the field picker. - const platformSnapshot = isPlatformSource - ? ((states['__platform__'] as any)?.data ?? null) - : null; - // Derive field paths for the active data source. - // Platform source: prefer live adapter snapshot to expose JSON sub-paths + __history keys; - // fall back to static field definitions with optional jsonSchema sub-path hints. - // Non-platform: prefer cached fieldSchema (offline-friendly); fall back to live snapshot traversal. + // Prefer cached fieldSchema (offline-friendly); fall back to static device fields or live snapshot traversal. const { paths, pathInfos, truncated } = useMemo(() => { - if (isPlatformSource) { - const staticInfos: FieldPathInfo[] = []; - ( - platformFields as Array<{ id: string; type?: string; jsonSchema?: Record }> - ).forEach((f) => { - staticInfos.push({ path: f.id, type: (f.type ?? 'string') as FieldPathInfo['type'] }); - if (f.jsonSchema) { - Object.entries(f.jsonSchema).forEach(([subPath, subType]) => { - staticInfos.push({ - path: `${f.id}.${subPath}`, - type: subType as FieldPathInfo['type'], - }); - }); - } - }); + const finalize = (infos: FieldPathInfo[], isTruncated = false) => { + const filtered = infos.filter((info) => isFieldTypeCompatible(info.type, targetKind)); + return { + paths: filtered.map((info) => info.path), + pathInfos: filtered, + truncated: isTruncated, + }; + }; - if (platformSnapshot && typeof platformSnapshot === 'object') { - const snapshotResult = listFieldPaths(platformSnapshot, { - maxDepth: maxDepth ?? 5, - maxNodes: maxNodes ?? 200, - }); - const merged = new Map(); - snapshotResult.pathInfos.forEach((info) => merged.set(info.path, info)); - staticInfos.forEach((info) => { - if (!merged.has(info.path)) merged.set(info.path, info); - }); - const mergedInfos = Array.from(merged.values()); - return { - paths: mergedInfos.map((info) => info.path), - pathInfos: mergedInfos, - truncated: snapshotResult.truncated, - }; - } - return { paths: staticInfos.map((i) => i.path), pathInfos: staticInfos, truncated: false }; - } - if (fieldSchema && fieldSchema.length > 0) { + if (selectedGroup === 'custom' && Array.isArray(fieldSchema) && fieldSchema.length > 0) { const infos: FieldPathInfo[] = fieldSchema.map((e: any) => ({ path: e.path, type: (e.type === 'array' @@ -352,7 +660,7 @@ export function FieldPicker({ kernelStore, value, onChange, maxDepth, maxNodes } ? 'object' : 'string') as FieldPathInfo['type'], })); - return { paths: infos.map((i) => i.path), pathInfos: infos, truncated: false }; + return finalize(infos); } if (selectedGroup === 'device' && selectedDeviceFields.length > 0) { const staticInfos: FieldPathInfo[] = []; @@ -370,38 +678,59 @@ export function FieldPicker({ kernelStore, value, onChange, maxDepth, maxNodes } }); } }); - return { paths: staticInfos.map((i) => i.path), pathInfos: staticInfos, truncated: false }; + return finalize(staticInfos); + } + if (selectedGroup === 'deviceStatus' && selectedDeviceStatusFields.length > 0) { + const staticInfos: FieldPathInfo[] = selectedDeviceStatusFields.map((field: any) => ({ + path: field.id, + type: (field.type ?? 'string') as FieldPathInfo['type'], + })); + return finalize(staticInfos); + } + if (selectedGroup === 'deviceHistory' && selectedDeviceHistoryFields.length > 0) { + const staticInfos: FieldPathInfo[] = selectedDeviceHistoryFields.map((field) => ({ + path: field.id, + type: field.type, + })); + return finalize(staticInfos); } - return listFieldPaths(snapshot, { + if (selectedGroup === 'platform' && selectedPlatformFields.length > 0) { + const staticInfos: FieldPathInfo[] = selectedPlatformFields.map((field) => ({ + path: field.id, + type: field.type as FieldPathInfo['type'], + })); + return finalize(staticInfos); + } + const live = listFieldPaths(snapshot, { maxDepth: maxDepth ?? 5, maxNodes: maxNodes ?? 200, }); + return finalize(live.pathInfos, live.truncated); }, [ fieldSchema, snapshot, maxDepth, maxNodes, - isPlatformSource, selectedGroup, selectedDeviceFields, - platformFields, - platformSnapshot, + selectedDeviceHistoryFields, + selectedDeviceStatusFields, + selectedPlatformFields, + targetKind, ]); // 🆕 当前值预览 const rawPreviewValue = useMemo(() => { if (!selectedFieldPath) return undefined; - const activeSnapshot = isPlatformSource ? platformSnapshot : snapshot; - if (!activeSnapshot) return undefined; - return resolveFieldPath(activeSnapshot, selectedFieldPath); - }, [snapshot, platformSnapshot, selectedFieldPath, isPlatformSource]); + if (!snapshot) return undefined; + return resolveFieldPath(snapshot, selectedFieldPath); + }, [snapshot, selectedFieldPath]); const previewDisplay = useMemo(() => { if (rawPreviewValue === undefined) return null; if (selectedTransform.trim()) { // Pass full DS snapshot as `data` so the preview matches runtime behaviour - const activeSnapshot = isPlatformSource ? platformSnapshot : snapshot; - const { ok, result } = applyTransform(selectedTransform, rawPreviewValue, activeSnapshot); + const { ok, result } = applyTransform(selectedTransform, rawPreviewValue, snapshot); return { raw: formatPreview(rawPreviewValue), transformed: ok ? formatPreview(result) : '⚠ transform error', @@ -409,9 +738,7 @@ export function FieldPicker({ kernelStore, value, onChange, maxDepth, maxNodes } }; } return { raw: formatPreview(rawPreviewValue), transformed: null, hasTransform: false }; - }, [rawPreviewValue, selectedTransform, snapshot, platformSnapshot, isPlatformSource]); - - const safeOnChange = useCallback((next: FieldPickerValue | null) => onChange(next), [onChange]); + }, [rawPreviewValue, selectedTransform, snapshot]); const requestFieldPreview = useCallback((dataSourceId: string, fieldPath: string) => { const fieldId = getRequestedFieldId(fieldPath); @@ -431,47 +758,7 @@ export function FieldPicker({ kernelStore, value, onChange, maxDepth, maxNodes } }, []); useEffect(() => { - if (selectedGroup !== 'device') return; - if (!selectedDeviceGroupId || loadedGroupIds.includes(selectedDeviceGroupId)) return; - if (window.parent === window) return; - - const handleMessage = (event: MessageEvent) => { - const data = event.data as - | { type?: string; payload?: { groupId?: string; devices?: unknown[] } } - | undefined; - if (data?.type !== 'tv:devices-by-group') return; - const payload = data.payload; - if (payload?.groupId !== selectedDeviceGroupId || !Array.isArray(payload.devices)) return; - - const devices = payload.devices as Array<{ deviceId?: string; deviceName?: string }>; - usePlatformDeviceStore.getState().setDevicesForGroup(selectedDeviceGroupId, devices as any); - devices.forEach((device) => { - if (!device?.deviceId) return; - ensurePlatformDeviceDataSource({ - deviceId: device.deviceId, - deviceName: device.deviceName, - }); - }); - }; - - window.addEventListener('message', handleMessage); - window.parent.postMessage( - { - type: 'thingsvis:requestDevicesByGroup', - payload: { - groupId: selectedDeviceGroupId, - }, - }, - '*', - ); - - return () => { - window.removeEventListener('message', handleMessage); - }; - }, [loadedGroupIds, selectedDeviceGroupId, selectedGroup]); - - useEffect(() => { - if (selectedGroup !== 'device') return; + if (!isDeviceScopedGroup) return; if (!selectedDeviceSource?.deviceId || !selectedDeviceSource?.templateId) return; if ((selectedDeviceSource.fields?.length ?? 0) > 0) return; if (window.parent === window) return; @@ -504,7 +791,7 @@ export function FieldPicker({ kernelStore, value, onChange, maxDepth, maxNodes } return () => { window.removeEventListener('message', handleMessage); }; - }, [selectedGroup, selectedDeviceSource]); + }, [isDeviceScopedGroup, selectedDeviceSource]); const handleTransformChange = (code: string) => { if (!effectiveDataSourceId || !selectedFieldPath) return; @@ -530,22 +817,22 @@ export function FieldPicker({ kernelStore, value, onChange, maxDepth, maxNodes } const nextGroup = e.target.value as SourceGroup; setEmbeddedSourceGroup(nextGroup); - if (nextGroup === 'platform') { + if ( + nextGroup === 'device' || + nextGroup === 'deviceStatus' || + nextGroup === 'deviceHistory' + ) { + const nextDevice = deviceSources[0]; safeOnChange( - hasPlatformFields ? { dataSourceId: '__platform__', fieldPath: '' } : null, + nextDevice ? { dataSourceId: nextDevice.dataSourceId, fieldPath: '' } : null, ); return; } - if (nextGroup === 'device') { - const nextGroupId = - selectedDeviceGroupIdState || - String(deviceGroupOptions[0]?.groupId || deviceSources[0]?.groupId || ''); - setSelectedDeviceGroupIdState(nextGroupId); - const nextDevice = deviceSources.find((device) => device.groupId === nextGroupId); - safeOnChange( - nextDevice ? { dataSourceId: nextDevice.dataSourceId, fieldPath: '' } : null, - ); + if (nextGroup === 'platform') { + const nextSource = visiblePlatformSources[0]; + if (nextSource) ensurePlatformStatDataSource(nextSource); + safeOnChange(nextSource ? { dataSourceId: nextSource.id, fieldPath: '' } : null); return; } @@ -554,65 +841,84 @@ export function FieldPicker({ kernelStore, value, onChange, maxDepth, maxNodes } }} className="w-full h-8 px-3 text-sm rounded-md border border-input bg-background focus:outline-none focus:ring-1 focus:ring-inset focus:ring-ring focus:ring-inset " > - {hasPlatformFields && ( - + {hasDeviceCatalog && } + {hasDeviceStatusCatalog && ( + + )} + {hasDeviceHistoryCatalog && ( + + )} + {hasPlatformStatsCatalog && ( + )} - {hasDeviceCatalog && } {customDataSourceIds.length > 0 && ( - + )}
-
- - -
+ {selectedGroup !== 'device' && + selectedGroup !== 'deviceStatus' && + selectedGroup !== 'deviceHistory' && ( +
+ + +
+ )} ) : (
setDeviceSearchText(e.target.value)} + placeholder={t('binding.searchDevice', '搜索设备名称或ID')} + className="w-full h-8 px-3 text-sm rounded-md border border-input bg-background focus:outline-none focus:ring-1 focus:ring-inset focus:ring-ring focus:ring-inset" + /> - {dsStatus === 'loading' && !isPlatformSource && !fieldSchema && ( + {dsStatus === 'loading' && !fieldSchema && !hasStaticFieldOptions && (

{t('common.loadingData', 'Loading data...')}

@@ -725,22 +1044,14 @@ export function FieldPicker({ kernelStore, value, onChange, maxDepth, maxNodes } {dsState.error}

)} - {dsStatus === 'connected' && - paths.length === 0 && - snapshot === null && - !isPlatformSource && ( -

- {t('binding.noDataHint', 'No data available. Check config or wait for data.')} -

- )} - {truncated && ( + {dsStatus === 'connected' && paths.length === 0 && snapshot === null && (

- {t('binding.fieldTruncated', 'Field list truncated (depth/size limit).')} + {t('binding.noDataHint', 'No data available. Check config or wait for data.')}

)} - {isPlatformSource && platformFields.length > 0 && selectedFieldPath && ( + {truncated && (

- 💡 {t('binding.externalProvided', 'Platform field provided by host app')} + {t('binding.fieldTruncated', 'Field list truncated (depth/size limit).')}

)}
diff --git a/apps/studio/src/components/RightPanel/PlatformFieldPicker.tsx b/apps/studio/src/components/RightPanel/PlatformFieldPicker.tsx index aa36acb4..969224cc 100644 --- a/apps/studio/src/components/RightPanel/PlatformFieldPicker.tsx +++ b/apps/studio/src/components/RightPanel/PlatformFieldPicker.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Card } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; -import { Database, ChevronRight, Info } from 'lucide-react'; +import { Database, ChevronRight } from 'lucide-react'; import type { PlatformField } from '@/lib/embedded/service-config'; interface PlatformFieldPickerProps { @@ -84,12 +84,6 @@ export function PlatformFieldPicker({ platformFields, onSelectField }: PlatformF
- {/* Tip */} -
- -

{t('platformPicker.bindingTip')}

-
- {/* Field Groups */} {Object.entries(groupedFields).map(([dataType, fields]) => { if (fields.length === 0) return null; diff --git a/apps/studio/src/components/RightPanel/PropsPanel.tsx b/apps/studio/src/components/RightPanel/PropsPanel.tsx index f9afae9e..87090e47 100644 --- a/apps/studio/src/components/RightPanel/PropsPanel.tsx +++ b/apps/studio/src/components/RightPanel/PropsPanel.tsx @@ -40,7 +40,7 @@ import { BaseStylePanel } from './BaseStylePanel'; import { preserveFreePipeLocalRouteOnResize } from '../../../../../packages/widgets/industrial/pipe/src/routeWorld'; function isHostDataSourceId(id: string): boolean { - return id === '__platform__' || /^__platform_.+__$/.test(id); + return /^__platform_.+__$/.test(id); } function hasPipeEndpointBinding(props: Record | null | undefined): boolean { diff --git a/apps/studio/src/components/RightPanel/bindingStorage.ts b/apps/studio/src/components/RightPanel/bindingStorage.ts index b5102b88..8e9c89d7 100644 --- a/apps/studio/src/components/RightPanel/bindingStorage.ts +++ b/apps/studio/src/components/RightPanel/bindingStorage.ts @@ -17,7 +17,7 @@ export function isValidExpression(expression: string): boolean { export function parseFieldBindingExpression(expression: string): FieldBindingSelection | null { const trimmed = expression.trim(); - // Check for regular (and __platform__) data source expression: {{ ds..data. }} + // Check for regular data source expression: {{ ds..data. }} const match = FIELD_BINDING_EXPR_RE.exec(trimmed); if (!match) return null; const dataSourceId = match[1]; diff --git a/apps/studio/src/embed/message-router.ts b/apps/studio/src/embed/message-router.ts index 37b565bb..ca2d47d2 100644 --- a/apps/studio/src/embed/message-router.ts +++ b/apps/studio/src/embed/message-router.ts @@ -233,8 +233,6 @@ export interface EmbedInitPayload { platformDevices?: unknown[]; platformFields?: unknown[]; platformBufferSize?: number; - platformFieldScope?: string; - roleScope?: string; data?: { meta?: { id?: string; name?: string; thumbnail?: string }; canvas?: { @@ -289,7 +287,6 @@ export interface ProcessedEmbedData { platformDeviceGroups: unknown[]; platformDevices: unknown[]; platformBufferSize: number; - platformFieldScope?: string; } function normalizeEmbedCanvasMode(mode: unknown): 'fixed' | 'infinite' | 'grid' { @@ -411,9 +408,6 @@ export function processEmbedInitPayload( typeof p.platformBufferSize === 'number' && Number.isFinite(p.platformBufferSize) ? Math.max(0, Math.trunc(p.platformBufferSize)) : 0, - platformFieldScope: - (typeof p.platformFieldScope === 'string' ? p.platformFieldScope : undefined) || - (typeof p.roleScope === 'string' ? p.roleScope : undefined), }; } @@ -463,7 +457,6 @@ export function initEmbedModeFromUrl(isAuthenticated: boolean): void { // Load platform fields from URL-injected service config (backward compat) const serviceConfig = resolveEditorServiceConfig(); - platformFieldStore.setScope(serviceConfig.platformFieldScope ?? 'all'); if (serviceConfig.platformFields && serviceConfig.platformFields.length > 0) { platformFieldStore.setFields(serviceConfig.platformFields as never); } diff --git a/apps/studio/src/embed/platformDeviceCompat.test.ts b/apps/studio/src/embed/platformDeviceCompat.test.ts index 1673f374..d07af307 100644 --- a/apps/studio/src/embed/platformDeviceCompat.test.ts +++ b/apps/studio/src/embed/platformDeviceCompat.test.ts @@ -2,12 +2,8 @@ import { describe, expect, it } from 'vitest'; import type { DataSource } from '@thingsvis/schema'; import { applyPlatformBufferSize, - adoptLegacyPlatformDataSources, - findLegacyPlatformDataSourceIdsForAdoption, getResolvedPlatformBufferSize, getPlatformDeviceDataSourceId, - hasPlatformDataSourceBoundToDevice, - inferSinglePlatformDeviceId, normalizePlatformBufferSize, } from './platformDeviceCompat'; @@ -26,62 +22,6 @@ function createPlatformDataSource(id: string, config: Record = } describe('platformDeviceCompat', () => { - it('finds only legacy platform datasource ids for runtime adoption', () => { - const dataSources: DataSource[] = [ - createPlatformDataSource('__platform__'), - createPlatformDataSource('legacy-platform'), - createPlatformDataSource(getPlatformDeviceDataSourceId('dev-1'), { deviceId: 'dev-1' }), - { - id: 'rest-1', - name: 'rest-1', - type: 'REST', - config: { url: 'https://example.com' }, - } as unknown as DataSource, - ]; - - expect(findLegacyPlatformDataSourceIdsForAdoption(dataSources)).toEqual(['legacy-platform']); - }); - - it('adopts legacy datasource configs to the active runtime device', () => { - const dataSources: DataSource[] = [ - createPlatformDataSource('__platform__'), - createPlatformDataSource('legacy-platform', { deviceId: 'tpl-123' }), - createPlatformDataSource(getPlatformDeviceDataSourceId('dev-9'), { deviceId: 'dev-9' }), - ]; - - const adopted = adoptLegacyPlatformDataSources(dataSources, 'dev-1'); - - expect((adopted[0]!.config as any).deviceId).toBeUndefined(); - expect((adopted[1]!.config as any).deviceId).toBe('dev-1'); - expect((adopted[2]!.config as any).deviceId).toBe('dev-9'); - }); - - it('detects whether a datasource is already bound to the runtime device', () => { - const dataSources: DataSource[] = [ - createPlatformDataSource('__platform__'), - createPlatformDataSource('legacy-platform', { deviceId: 'dev-1' }), - ]; - - expect(hasPlatformDataSourceBoundToDevice(dataSources, 'dev-1')).toBe(true); - expect(hasPlatformDataSourceBoundToDevice(dataSources, 'dev-2')).toBe(false); - }); - - it('infers a single runtime device id from legacy platform datasources', () => { - expect( - inferSinglePlatformDeviceId([ - createPlatformDataSource('__platform___template____', { deviceId: 'dev-1' }), - createPlatformDataSource('legacy-platform', { deviceId: 'dev-1' }), - ]), - ).toBe('dev-1'); - - expect( - inferSinglePlatformDeviceId([ - createPlatformDataSource('__platform___template____', { deviceId: 'dev-1' }), - createPlatformDataSource('legacy-platform', { deviceId: 'dev-2' }), - ]), - ).toBeNull(); - }); - it('normalizes and inherits platform buffer size from top-level init payload', () => { expect(normalizePlatformBufferSize(-10)).toBe(0); expect(normalizePlatformBufferSize(12.8)).toBe(12); @@ -89,7 +29,7 @@ describe('platformDeviceCompat', () => { expect( getResolvedPlatformBufferSize( [ - createPlatformDataSource('__platform__', { bufferSize: 20 }), + createPlatformDataSource(getPlatformDeviceDataSourceId('dev-2'), { bufferSize: 20 }), createPlatformDataSource(getPlatformDeviceDataSourceId('dev-1'), { bufferSize: 5 }), ], 8, @@ -99,7 +39,7 @@ describe('platformDeviceCompat', () => { expect( getResolvedPlatformBufferSize( [ - createPlatformDataSource('__platform__', { bufferSize: 0 }), + createPlatformDataSource(getPlatformDeviceDataSourceId('dev-2'), { bufferSize: 0 }), createPlatformDataSource(getPlatformDeviceDataSourceId('dev-1'), { bufferSize: 0 }), ], 30, @@ -110,7 +50,7 @@ describe('platformDeviceCompat', () => { it('applies inherited buffer size to all platform datasources', () => { const updated = applyPlatformBufferSize( [ - createPlatformDataSource('__platform__', { bufferSize: 0 }), + createPlatformDataSource(getPlatformDeviceDataSourceId('dev-2'), { bufferSize: 0 }), createPlatformDataSource(getPlatformDeviceDataSourceId('dev-1'), { bufferSize: 12 }), { id: 'rest-1', diff --git a/apps/studio/src/embed/platformDeviceCompat.ts b/apps/studio/src/embed/platformDeviceCompat.ts index d2dfd58f..c7f81b90 100644 --- a/apps/studio/src/embed/platformDeviceCompat.ts +++ b/apps/studio/src/embed/platformDeviceCompat.ts @@ -1,7 +1,5 @@ import type { DataSource, PlatformFieldConfig } from '@thingsvis/schema'; -const GLOBAL_PLATFORM_DATA_SOURCE_ID = '__platform__'; - function normalizeDataSourceType(type: unknown): string { return typeof type === 'string' ? type.toUpperCase() : ''; } @@ -57,58 +55,3 @@ export function getPlatformDeviceDataSourceId(deviceId: string): string { export function isCanonicalPlatformDeviceDataSourceId(dataSourceId: string): boolean { return /^__platform_(.+)__$/.test(dataSourceId); } - -export function findLegacyPlatformDataSourceIdsForAdoption(dataSources: DataSource[]): string[] { - return dataSources - .filter((dataSource) => isPlatformFieldDataSource(dataSource)) - .filter((dataSource) => dataSource.id !== GLOBAL_PLATFORM_DATA_SOURCE_ID) - .filter((dataSource) => !isCanonicalPlatformDeviceDataSourceId(dataSource.id)) - .map((dataSource) => dataSource.id); -} - -export function hasPlatformDataSourceBoundToDevice( - dataSources: DataSource[], - deviceId: string, -): boolean { - return dataSources.some((dataSource) => { - if (!isPlatformFieldDataSource(dataSource)) return false; - if (dataSource.id === getPlatformDeviceDataSourceId(deviceId)) return true; - - const config = (dataSource.config ?? {}) as PlatformFieldConfig; - return config.deviceId === deviceId; - }); -} - -export function inferSinglePlatformDeviceId(dataSources: DataSource[]): string | null { - const deviceIds = new Set(); - - dataSources.forEach((dataSource) => { - if (!isPlatformFieldDataSource(dataSource)) return; - const config = (dataSource.config ?? {}) as PlatformFieldConfig; - if (typeof config.deviceId === 'string' && config.deviceId.trim()) { - deviceIds.add(config.deviceId); - } - }); - - return deviceIds.size === 1 ? (Array.from(deviceIds)[0] ?? null) : null; -} - -export function adoptLegacyPlatformDataSources( - dataSources: DataSource[], - deviceId: string, -): DataSource[] { - return dataSources.map((dataSource) => { - if (!isPlatformFieldDataSource(dataSource)) return dataSource; - if (dataSource.id === GLOBAL_PLATFORM_DATA_SOURCE_ID) return dataSource; - if (isCanonicalPlatformDeviceDataSourceId(dataSource.id)) return dataSource; - - const config = (dataSource.config ?? {}) as PlatformFieldConfig; - return { - ...dataSource, - config: { - ...config, - deviceId, - }, - } as DataSource; - }); -} diff --git a/apps/studio/src/embed/runtimeVariables.ts b/apps/studio/src/embed/runtimeVariables.ts new file mode 100644 index 00000000..cab3c626 --- /dev/null +++ b/apps/studio/src/embed/runtimeVariables.ts @@ -0,0 +1,66 @@ +export type RuntimeVariableDefinition = { + name: string; + type: 'string' | 'number' | 'boolean' | 'object' | 'array'; + defaultValue?: unknown; + description?: string; +}; + +export const EMBED_RUNTIME_VARIABLES: RuntimeVariableDefinition[] = [ + { name: 'platformApiBaseUrl', type: 'string', defaultValue: '' }, + { name: 'thingsvisApiBaseUrl', type: 'string', defaultValue: '' }, + { name: 'deviceId', type: 'string', defaultValue: '' }, + { name: 'dateRange', type: 'object', defaultValue: { startTime: '', endTime: '' } }, +]; + +function readConfigString(config: Record | undefined, key: string) { + const value = config?.[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +export function resolveThingsVisApiBaseUrl(config: Record | undefined) { + return readConfigString(config, 'thingsvisApiBaseUrl'); +} + +export function buildEmbedRuntimeVariableValues( + config: Record | undefined, + fallbackDeviceId?: string | null, +): Record { + const platformApiBaseUrl = readConfigString(config, 'platformApiBaseUrl'); + const thingsvisApiBaseUrl = resolveThingsVisApiBaseUrl(config); + const platformToken = + readConfigString(config, 'platformToken') || readConfigString(config, 'token'); + const deviceId = readConfigString(config, 'deviceId') || fallbackDeviceId || undefined; + const dateRange = config?.dateRange; + + return { + ...(platformApiBaseUrl ? { platformApiBaseUrl } : {}), + ...(thingsvisApiBaseUrl ? { thingsvisApiBaseUrl } : {}), + ...(platformToken ? { platformToken } : {}), + ...(deviceId ? { deviceId } : {}), + ...(dateRange && typeof dateRange === 'object' ? { dateRange } : {}), + }; +} + +export function mergeEmbedRuntimeVariableDefinitions( + definitions: unknown[] | undefined, + runtimeValues: Record, +): RuntimeVariableDefinition[] { + const merged = Array.isArray(definitions) + ? ([...definitions] as RuntimeVariableDefinition[]) + : []; + const existingNames = new Set( + merged + .map((definition) => definition?.name) + .filter((name): name is string => typeof name === 'string' && name.length > 0), + ); + + EMBED_RUNTIME_VARIABLES.forEach((definition) => { + if (existingNames.has(definition.name)) return; + merged.push({ + ...definition, + defaultValue: runtimeValues[definition.name] ?? definition.defaultValue, + }); + }); + + return merged; +} diff --git a/apps/studio/src/hooks/useEditorDragDrop.ts b/apps/studio/src/hooks/useEditorDragDrop.ts index 71e642f4..93bc2047 100644 --- a/apps/studio/src/hooks/useEditorDragDrop.ts +++ b/apps/studio/src/hooks/useEditorDragDrop.ts @@ -59,6 +59,8 @@ export function useEditorDragDrop(markDirty: () => void) { const defaultProps = resolveInitialWidgetProps({ schema: entry.schema, standaloneDefaults: entry.standaloneDefaults, + previewDefaults: entry.previewDefaults, + sampleData: entry.sampleData, fallbackDefaults: (entry as { defaultProps?: Record }).defaultProps, }); const initialSize = resolveInitialNodeSize( @@ -104,6 +106,8 @@ export function useEditorDragDrop(markDirty: () => void) { const defaultProps = resolveInitialWidgetProps({ schema: entry.schema, standaloneDefaults: entry.standaloneDefaults, + previewDefaults: entry.previewDefaults, + sampleData: entry.sampleData, fallbackDefaults: (entry as { defaultProps?: Record }).defaultProps, }); const initialSize = resolveInitialNodeSize( diff --git a/apps/studio/src/hooks/useProjectBootstrap.ts b/apps/studio/src/hooks/useProjectBootstrap.ts index 34ed264b..34346807 100644 --- a/apps/studio/src/hooks/useProjectBootstrap.ts +++ b/apps/studio/src/hooks/useProjectBootstrap.ts @@ -1,7 +1,6 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import { useParams } from 'react-router-dom'; import { - DEFAULT_PLATFORM_FIELD_CONFIG, validateCanvasTheme, type CanvasThemeId, type NodeSchemaType, @@ -31,6 +30,15 @@ import { platformFieldStore } from '../lib/stores/platformFieldStore'; import { augmentPlatformDataSourcesForNodes } from '../lib/platformDatasourceBindings'; import { getEmbedSessionSnapshot, setEmbedSessionSnapshot } from '../lib/embed/sessionSnapshot'; import { deriveCanvasBackgroundState, normalizeCanvasBackground } from '../lib/canvasBackground'; +import { resolveEditorServiceConfig } from '../lib/embedded/service-config'; +import { + buildEmbeddedProviderDataSources, + resolveEmbeddedProviderCatalog, +} from '../lib/embedded/embedded-data-source-registry'; +import { + buildEmbedRuntimeVariableValues, + mergeEmbedRuntimeVariableDefinitions, +} from '../embed/runtimeVariables'; export const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; @@ -38,6 +46,52 @@ type CanvasBackground = string | NonNullable; type PreviewScaleMode = 'fit-min' | 'fit-width' | 'fit-height' | 'stretch' | 'original'; type PreviewAlignY = 'top' | 'center'; +function normalizeEmbeddedProviderDataSources( + dataSources: Array>, + runtimeVariableValues: Record, +): Array> { + const serviceConfig = resolveEditorServiceConfig(); + if (serviceConfig.mode !== 'embedded') return dataSources; + + const providerCatalog = resolveEmbeddedProviderCatalog(serviceConfig.provider); + if (!providerCatalog) return dataSources; + + const protectedGroups = + serviceConfig.context === 'dashboard' + ? (['dashboard'] as const) + : serviceConfig.context === 'device-template' + ? (['dashboard', 'current-device', 'current-device-history'] as const) + : undefined; + + const providerDefaults = buildEmbeddedProviderDataSources( + serviceConfig.provider, + runtimeVariableValues, + protectedGroups ? { groups: [...protectedGroups] } : undefined, + ); + const definitionsById = new Map(providerDefaults.map((source) => [source.id, source])); + + const normalized = dataSources.map((dataSource: Record) => { + const sourceId = typeof dataSource?.id === 'string' ? dataSource.id : ''; + const definition = definitionsById.get(sourceId); + if (!definition) return dataSource; + + return { + ...definition, + name: + typeof dataSource?.name === 'string' && dataSource.name ? dataSource.name : definition.id, + mode: + typeof dataSource?.mode === 'string' && dataSource.mode ? dataSource.mode : definition.mode, + }; + }); + + providerDefaults.forEach((definition) => { + if (normalized.some((dataSource) => dataSource.id === definition.id)) return; + normalized.push(definition); + }); + + return normalized; +} + export type CanvasConfigSchema = { // Meta - 基础身份 id: string; @@ -136,6 +190,22 @@ function normalizePreviewAlignY(value: unknown): PreviewAlignY { return value === 'top' ? 'top' : 'center'; } +function applyRuntimeVariables( + definitions: unknown[] | undefined, + runtimeValues: Record = {}, +) { + const mergedDefinitions = mergeEmbedRuntimeVariableDefinitions(definitions, runtimeValues); + store.getState().setVariableDefinitions(mergedDefinitions as any); + store.getState().initVariablesFromDefinitions(mergedDefinitions as any); + Object.entries(runtimeValues).forEach(([name, value]) => { + if (value !== undefined) { + store.getState().setVariableValue(name, value); + } + }); + + return mergedDefinitions; +} + export function useProjectBootstrap({ embedVisibility, isAuthenticated: _isAuthenticated, @@ -283,6 +353,11 @@ export function useProjectBootstrap({ dataSources: (loaded.dataSources as any) ?? prev.dataSources, })); + if (loaded.variables && Array.isArray(loaded.variables)) { + store.getState().setVariableDefinitions(loaded.variables as any); + store.getState().initVariablesFromDefinitions(loaded.variables as any); + } + if (loaded.dataSources && Array.isArray(loaded.dataSources)) { for (const ds of loaded.dataSources) { try { @@ -307,11 +382,6 @@ export function useProjectBootstrap({ } as any); } - if (loaded.variables && Array.isArray(loaded.variables)) { - store.getState().setVariableDefinitions(loaded.variables as any); - store.getState().initVariablesFromDefinitions(loaded.variables as any); - } - try { store.temporal.getState().clear?.(); } catch {} @@ -518,15 +588,15 @@ export function useProjectBootstrap({ let nodesToLoad = processed.nodes; let loadedMeta: any = null; let loadedCanvas: any = null; + let variablesToLoad = Array.isArray((payload as any)?.data?.variables) + ? ((payload as any).data.variables as unknown[]) + : []; let mergedDataSources = [...processed.dataSources] as Array>; const inheritedPlatformBufferSize = getResolvedPlatformBufferSize( mergedDataSources as any, processed.platformBufferSize, ); - if (processed.platformFieldScope) { - platformFieldStore.setScope(processed.platformFieldScope as any); - } if (Array.isArray(processed.platformFields) && processed.platformFields.length > 0) { platformFieldStore.setFields(processed.platformFields as any); } @@ -545,22 +615,6 @@ export function useProjectBootstrap({ } } - if (!mergedDataSources.some((ds) => ds.id === '__platform__')) { - mergedDataSources.push({ - id: '__platform__', - name: 'System Platform', - type: 'PLATFORM_FIELD', - config: { - ...DEFAULT_PLATFORM_FIELD_CONFIG, - source: 'platform', - bufferSize: inheritedPlatformBufferSize, - requestedFields: (processed.platformFields || []) - .map((field: any) => field?.id) - .filter((id: unknown) => typeof id === 'string'), - }, - }); - } - deviceArr.forEach((device: any) => { if (!device?.deviceId) return; const dsId = `__platform_${device.deviceId}__`; @@ -570,8 +624,8 @@ export function useProjectBootstrap({ name: device.deviceName || `Device ${device.deviceId}`, type: 'PLATFORM_FIELD', config: { - ...DEFAULT_PLATFORM_FIELD_CONFIG, source: 'platform', + fieldMappings: {}, deviceId: device.deviceId, bufferSize: inheritedPlatformBufferSize, requestedFields: [], @@ -596,6 +650,9 @@ export function useProjectBootstrap({ } }); } + if (Array.isArray((cloudProject.schema as any)?.variables)) { + variablesToLoad = (cloudProject.schema as any).variables; + } } } catch (err) { console.warn('[Editor] ⚠️ 获取云端数据失败,使用宿主传来的数据:', err); @@ -681,6 +738,16 @@ export function useProjectBootstrap({ }); } + const embedRuntimeVariableValues = buildEmbedRuntimeVariableValues( + (payload as any)?.config as Record, + ); + applyRuntimeVariables(variablesToLoad, embedRuntimeVariableValues); + + mergedDataSources = normalizeEmbeddedProviderDataSources( + mergedDataSources, + embedRuntimeVariableValues, + ); + try { store.temporal.getState().clear?.(); } catch {} diff --git a/apps/studio/src/i18n/locales/en/editor.json b/apps/studio/src/i18n/locales/en/editor.json index bf16d2b1..914d5ebb 100644 --- a/apps/studio/src/i18n/locales/en/editor.json +++ b/apps/studio/src/i18n/locales/en/editor.json @@ -267,8 +267,7 @@ "commands": "Commands", "noFields": "No platform fields available", "noFieldsTip": "Please configure device model in the host platform", - "title": "Platform Fields", - "bindingTip": "Click field to create data binding with real-time updates" + "title": "Platform Fields" }, "dataSourceSelector": { "noDataSources": "No data sources available", diff --git a/apps/studio/src/i18n/locales/zh/editor.json b/apps/studio/src/i18n/locales/zh/editor.json index d3586bfa..6e2df646 100644 --- a/apps/studio/src/i18n/locales/zh/editor.json +++ b/apps/studio/src/i18n/locales/zh/editor.json @@ -260,8 +260,7 @@ "commands": "命令", "noFields": "暂无平台字段", "noFieldsTip": "请在宿主平台中配置设备物模型", - "title": "平台字段", - "bindingTip": "点击字段可创建数据源绑定,支持实时数据推送" + "title": "平台字段" }, "dataSourceSelector": { "noDataSources": "暂无可用数据源", diff --git a/apps/studio/src/lib/devicePresetHydration.test.ts b/apps/studio/src/lib/devicePresetHydration.test.ts index d8eaab0b..fb58efc0 100644 --- a/apps/studio/src/lib/devicePresetHydration.test.ts +++ b/apps/studio/src/lib/devicePresetHydration.test.ts @@ -17,14 +17,14 @@ describe('devicePresetHydration', () => { data: [ { targetProp: 'data', - expression: '{{ ds.__platform__.data.temperature__history }}', + expression: '{{ ds.__device_platform_template__.data.temperature__history }}', }, ], } as any, ], dataSources: [ { - id: '__platform__', + id: '__device_platform_template__', name: 'Template Device', type: 'PLATFORM_FIELD', config: { @@ -57,7 +57,7 @@ describe('devicePresetHydration', () => { data: [ { targetProp: 'text', - expression: '{{ ds.__platform__.data.temperature }}', + expression: '{{ ds.__device_platform_template__.data.temperature }}', }, ], }, diff --git a/apps/studio/src/lib/devicePresetHydration.ts b/apps/studio/src/lib/devicePresetHydration.ts index 1ccf4e0f..1798ef1c 100644 --- a/apps/studio/src/lib/devicePresetHydration.ts +++ b/apps/studio/src/lib/devicePresetHydration.ts @@ -11,8 +11,8 @@ export type DevicePresetSchema = { dataSources?: unknown[]; }; -const GENERIC_PLATFORM_DATA_SOURCE_ID = '__platform__'; -const GENERIC_PLATFORM_BINDING_RE = /\bds\.__platform__(?=\.data\b)/g; +const GENERIC_PLATFORM_DATA_SOURCE_ID = '__device_platform_template__'; +const GENERIC_PLATFORM_BINDING_RE = /\bds\.__device_platform_template__(?=\.data\b)/g; function cloneValue(value: T): T { if (value == null) return value; diff --git a/apps/studio/src/lib/embedded/default-platform-fields.ts b/apps/studio/src/lib/embedded/default-platform-fields.ts deleted file mode 100644 index 6c311151..00000000 --- a/apps/studio/src/lib/embedded/default-platform-fields.ts +++ /dev/null @@ -1,262 +0,0 @@ -export type DefaultPlatformField = { - id: string; - name: string; - alias: string; - type: 'number' | 'string' | 'boolean' | 'json'; - dataType: 'attribute' | 'telemetry' | 'command' | 'event'; - unit?: string; - description?: string; - scopes: PlatformFieldScope[]; -}; - -export type PlatformFieldScope = 'tenant' | 'super-admin' | 'all'; - -const PLATFORM_FIELD_SCOPE_ALIASES: Record = { - tenant: 'tenant', - user: 'tenant', - tenant_user: 'tenant', - 'tenant-user': 'tenant', - admin: 'super-admin', - superadmin: 'super-admin', - super_admin: 'super-admin', - 'super-admin': 'super-admin', - sysadmin: 'super-admin', - system_admin: 'super-admin', - 'system-admin': 'super-admin', - all: 'all', -}; - -// These fields make value-card and line-chart widgets bindable in embedded mode -// even before the host injects a richer field schema. -export const DEFAULT_AGGREGATE_PLATFORM_FIELDS: DefaultPlatformField[] = [ - { - id: 'device_total', - name: '设备总数', - alias: '设备总数', - type: 'number', - dataType: 'telemetry', - description: '首页聚合指标:设备总数', - scopes: ['tenant', 'super-admin'], - }, - { - id: 'device_online', - name: '在线设备数', - alias: '在线设备数', - type: 'number', - dataType: 'telemetry', - description: '首页聚合指标:在线设备数', - scopes: ['tenant', 'super-admin'], - }, - { - id: 'device_offline', - name: '离线设备数', - alias: '离线设备数', - type: 'number', - dataType: 'telemetry', - description: '首页聚合指标:离线设备数', - scopes: ['tenant', 'super-admin'], - }, - { - id: 'device_activity', - name: '激活设备数', - alias: '激活设备数', - type: 'number', - dataType: 'telemetry', - description: '首页聚合指标:激活设备数', - scopes: ['tenant', 'super-admin'], - }, - { - id: 'alarm_device_total', - name: '告警设备数', - alias: '告警设备数', - type: 'number', - dataType: 'telemetry', - description: '首页聚合指标:当前处于告警状态的设备数', - scopes: ['tenant', 'super-admin'], - }, - { - id: 'tenant_added_yesterday', - name: '昨日新增租户', - alias: '昨日新增租户', - type: 'number', - dataType: 'telemetry', - description: '超管首页聚合指标:昨日新增租户数', - scopes: ['super-admin'], - }, - { - id: 'tenant_added_month', - name: '本月新增租户', - alias: '本月新增租户', - type: 'number', - dataType: 'telemetry', - description: '超管首页聚合指标:本月新增租户数', - scopes: ['super-admin'], - }, - { - id: 'tenant_total', - name: '租户总数', - alias: '租户总数', - type: 'number', - dataType: 'telemetry', - description: '超管首页聚合指标:租户总数', - scopes: ['super-admin'], - }, - { - id: 'cpu_usage', - name: 'CPU 使用率', - alias: 'CPU 使用率', - type: 'number', - dataType: 'telemetry', - unit: '%', - description: '超管首页聚合指标:CPU 当前使用率', - scopes: ['super-admin'], - }, - { - id: 'memory_usage', - name: '内存使用率', - alias: '内存使用率', - type: 'number', - dataType: 'telemetry', - unit: '%', - description: '超管首页聚合指标:内存当前使用率', - scopes: ['super-admin'], - }, - { - id: 'disk_usage', - name: '磁盘使用率', - alias: '磁盘使用率', - type: 'number', - dataType: 'telemetry', - unit: '%', - description: '超管首页聚合指标:磁盘当前使用率', - scopes: ['super-admin'], - }, - { - id: 'device_total__history', - name: 'Total Devices Trend', - alias: 'Total Devices Trend', - type: 'json', - dataType: 'telemetry', - description: 'Embedded aggregate trend history for total devices.', - scopes: ['tenant', 'super-admin'], - }, - { - id: 'device_activity__history', - name: 'Active Devices Trend', - alias: 'Active Devices Trend', - type: 'json', - dataType: 'telemetry', - description: 'Embedded aggregate trend history for active devices.', - scopes: ['tenant', 'super-admin'], - }, - { - id: 'alarm_device_total__history', - name: 'Alarm Devices Trend', - alias: 'Alarm Devices Trend', - type: 'json', - dataType: 'telemetry', - description: 'Embedded aggregate trend history for alarm devices.', - scopes: ['tenant', 'super-admin'], - }, - { - id: 'home_alarm_items', - name: '首页告警列表', - alias: '首页告警列表', - type: 'json', - dataType: 'telemetry', - description: '首页告警信息列表。', - scopes: ['tenant', 'super-admin'], - }, - { - id: 'home_latest_report_rows', - name: '首页最近上报数据', - alias: '首页最近上报数据', - type: 'json', - dataType: 'telemetry', - description: '首页最近上报数据表格。', - scopes: ['tenant', 'super-admin'], - }, - { - id: 'device_online__history', - name: '在线设备趋势', - alias: '在线设备趋势', - type: 'json', - dataType: 'telemetry', - description: '首页聚合趋势:在线设备数历史序列', - scopes: ['tenant', 'super-admin'], - }, - { - id: 'device_offline__history', - name: '离线设备趋势', - alias: '离线设备趋势', - type: 'json', - dataType: 'telemetry', - description: '首页聚合趋势:离线设备数历史序列', - scopes: ['tenant', 'super-admin'], - }, - { - id: 'tenant_growth__history', - name: '租户增长趋势', - alias: '租户增长趋势', - type: 'json', - dataType: 'telemetry', - description: '超管首页聚合趋势:租户新增历史序列', - scopes: ['super-admin'], - }, - { - id: 'cpu_usage__history', - name: 'CPU 趋势', - alias: 'CPU 趋势', - type: 'json', - dataType: 'telemetry', - description: '超管首页聚合趋势:CPU 使用率历史序列', - scopes: ['super-admin'], - }, - { - id: 'memory_usage__history', - name: '内存趋势', - alias: '内存趋势', - type: 'json', - dataType: 'telemetry', - description: '超管首页聚合趋势:内存使用率历史序列', - scopes: ['super-admin'], - }, - { - id: 'disk_usage__history', - name: '磁盘趋势', - alias: '磁盘趋势', - type: 'json', - dataType: 'telemetry', - description: '超管首页聚合趋势:磁盘使用率历史序列', - scopes: ['super-admin'], - }, -]; - -export function normalizePlatformFieldScope(rawScope?: unknown): PlatformFieldScope { - if (typeof rawScope !== 'string') return 'all'; - return PLATFORM_FIELD_SCOPE_ALIASES[rawScope.trim().toLowerCase()] ?? 'all'; -} - -export function getDefaultAggregatePlatformFields( - scope: PlatformFieldScope = 'all', -): DefaultPlatformField[] { - if (scope === 'all') return [...DEFAULT_AGGREGATE_PLATFORM_FIELDS]; - return DEFAULT_AGGREGATE_PLATFORM_FIELDS.filter((field) => field.scopes.includes(scope)); -} - -export function mergeWithDefaultAggregatePlatformFields( - fields?: T[] | null, - scope: PlatformFieldScope = 'all', -): Array { - const merged = new Map(); - - getDefaultAggregatePlatformFields(scope).forEach((field) => { - merged.set(field.id, field); - }); - - (fields ?? []).forEach((field) => { - merged.set(field.id, field); - }); - - return Array.from(merged.values()); -} diff --git a/apps/studio/src/lib/embedded/embedded-data-source-registry.ts b/apps/studio/src/lib/embedded/embedded-data-source-registry.ts new file mode 100644 index 00000000..2b75aa1b --- /dev/null +++ b/apps/studio/src/lib/embedded/embedded-data-source-registry.ts @@ -0,0 +1,77 @@ +import type { + EmbeddedDataSourceDef, + EmbeddedDataSourceGroup, + EmbeddedProviderCatalog, +} from './embedded-data-source'; +import { thingspanelCatalog } from './providers/thingspanel.catalog'; + +const EMBEDDED_PROVIDER_CATALOGS: Record = { + [thingspanelCatalog.provider]: thingspanelCatalog, +}; + +export function resolveEmbeddedProviderCatalog( + provider?: string | null, +): EmbeddedProviderCatalog | undefined { + const normalized = typeof provider === 'string' ? provider.trim().toLowerCase() : ''; + if (!normalized) return undefined; + return EMBEDDED_PROVIDER_CATALOGS[normalized]; +} + +function shouldIncludeGroup( + source: EmbeddedDataSourceDef, + groups?: EmbeddedDataSourceGroup[], +): boolean { + if (!Array.isArray(groups) || groups.length === 0) return true; + return groups.includes(source.group); +} + +export function listEmbeddedProviderDataSourceIds( + provider?: string | null, + options?: { groups?: EmbeddedDataSourceGroup[] }, +): string[] { + const catalog = resolveEmbeddedProviderCatalog(provider); + if (!catalog) return []; + + return catalog.dataSources + .filter((source) => shouldIncludeGroup(source, options?.groups)) + .map((source) => source.id); +} + +export function buildEmbeddedProviderDataSources( + provider?: string | null, + runtimeVariableValues: Record = {}, + options?: { groups?: EmbeddedDataSourceGroup[] }, +): Array> { + const catalog = resolveEmbeddedProviderCatalog(provider); + if (!catalog) return []; + + const hasRuntimeDeviceId = + typeof runtimeVariableValues.deviceId === 'string' && + runtimeVariableValues.deviceId.trim().length > 0; + + return catalog.dataSources + .filter((source) => shouldIncludeGroup(source, options?.groups)) + .map((source) => { + const isCurrentDeviceScoped = + source.group === 'current-device' || source.group === 'current-device-history'; + + return { + id: source.id, + name: source.id, + type: 'REST', + config: { + url: source.url, + method: 'GET', + headers: { + 'x-token': '{{ var.platformToken }}', + }, + params: source.params ?? {}, + pollingInterval: 60, + timeout: 30, + auth: { type: 'none' }, + }, + transformation: source.transformation, + ...(isCurrentDeviceScoped && !hasRuntimeDeviceId ? { mode: 'manual' } : {}), + }; + }); +} diff --git a/apps/studio/src/lib/embedded/embedded-data-source.ts b/apps/studio/src/lib/embedded/embedded-data-source.ts new file mode 100644 index 00000000..d3c2ffb5 --- /dev/null +++ b/apps/studio/src/lib/embedded/embedded-data-source.ts @@ -0,0 +1,31 @@ +import type { I18nLabel } from '@thingsvis/schema'; + +export type EmbeddedFieldType = 'string' | 'number' | 'boolean' | 'array' | 'object'; + +export type EmbeddedFieldDef = { + id: string; + label: I18nLabel; + type: EmbeddedFieldType; +}; + +export type EmbeddedRuntimeDeviceFieldDef = EmbeddedFieldDef & { + alias?: I18nLabel; +}; + +export type EmbeddedDataSourceGroup = 'dashboard' | 'current-device' | 'current-device-history'; + +export type EmbeddedDataSourceDef = { + id: string; + group: EmbeddedDataSourceGroup; + label: I18nLabel; + url: string; + params?: Record; + fields: EmbeddedFieldDef[]; + transformation: string; +}; + +export type EmbeddedProviderCatalog = { + provider: string; + runtimeDeviceFields?: EmbeddedRuntimeDeviceFieldDef[]; + dataSources: EmbeddedDataSourceDef[]; +}; diff --git a/apps/studio/src/lib/embedded/providers/thingspanel.catalog.ts b/apps/studio/src/lib/embedded/providers/thingspanel.catalog.ts new file mode 100644 index 00000000..a7cac42f --- /dev/null +++ b/apps/studio/src/lib/embedded/providers/thingspanel.catalog.ts @@ -0,0 +1,408 @@ +import type { EmbeddedProviderCatalog } from '../embedded-data-source'; + +const zhEn = (zh: string, en: string) => ({ zh, en }); + +export const thingspanelCatalog: EmbeddedProviderCatalog = { + provider: 'thingspanel', + runtimeDeviceFields: [ + { + id: 'is_online', + label: zhEn('在线状态', 'Online Status'), + alias: zhEn('在线状态', 'Online Status'), + type: 'number', + }, + { + id: 'online_text', + label: zhEn('状态描述', 'Status Text'), + alias: zhEn('状态描述', 'Status Text'), + type: 'string', + }, + { + id: 'online_status_updated_at', + label: zhEn('状态更新时间', 'Status Updated At'), + alias: zhEn('状态更新时间', 'Status Updated At'), + type: 'number', + }, + ], + dataSources: [ + { + id: 'thingspanel_device_summary', + group: 'dashboard', + label: zhEn('设备统计', 'Device Summary'), + url: '{{ var.platformApiBaseUrl }}/board/trend', + fields: [ + { id: 'device_total', label: zhEn('设备总数', 'Total Devices'), type: 'number' }, + { id: 'device_online', label: zhEn('在线设备数', 'Online Devices'), type: 'number' }, + { id: 'device_offline', label: zhEn('离线设备数', 'Offline Devices'), type: 'number' }, + { id: 'device_activity', label: zhEn('活跃设备数', 'Active Devices'), type: 'number' }, + ], + transformation: ` +const payload = data && typeof data === 'object' && data.data ? data.data : data; +const points = Array.isArray(payload?.points) ? payload.points : Array.isArray(payload) ? payload : []; +const latest = points.length > 0 ? points[points.length - 1] : payload; +const total = Number(latest?.device_total ?? payload?.device_total ?? 0); +const online = Number(latest?.device_online ?? latest?.device_on ?? payload?.device_online ?? payload?.device_on ?? 0); +return { + device_total: total, + device_online: online, + device_offline: Math.max(0, total - online), + device_activity: online +}; +`, + }, + { + id: 'thingspanel_alarm_summary', + group: 'dashboard', + label: zhEn('告警设备统计', 'Alarm Device Summary'), + url: '{{ var.platformApiBaseUrl }}/alarm/device/counts', + fields: [ + { + id: 'alarm_device_total', + label: zhEn('告警设备数', 'Alarm Device Count'), + type: 'number', + }, + ], + transformation: ` +const payload = data && typeof data === 'object' && data.data ? data.data : data; +return { alarm_device_total: Number(payload?.alarm_device_total ?? 0) }; +`, + }, + { + id: 'thingspanel_device_trend', + group: 'dashboard', + label: zhEn('设备在线趋势', 'Device Online Trend'), + url: '{{ var.platformApiBaseUrl }}/board/trend', + fields: [ + { + id: 'device_total__history', + label: zhEn('设备总数趋势', 'Total Device Trend'), + type: 'array', + }, + { + id: 'device_online__history', + label: zhEn('在线设备数趋势', 'Online Device Trend'), + type: 'array', + }, + { + id: 'device_offline__history', + label: zhEn('离线设备数趋势', 'Offline Device Trend'), + type: 'array', + }, + { + id: 'device_activity__history', + label: zhEn('活跃设备数趋势', 'Active Device Trend'), + type: 'array', + }, + ], + transformation: ` +const payload = data && typeof data === 'object' && data.data ? data.data : data; +const points = Array.isArray(payload?.points) ? payload.points : Array.isArray(payload) ? payload : []; +const mapSeries = (field) => points.map((point) => ({ + timestamp: point.timestamp ?? point.time ?? point.created_at, + value: Number(point[field] ?? 0) +})); +return { + device_total__history: mapSeries('device_total'), + device_online__history: mapSeries('device_online'), + device_offline__history: mapSeries('device_offline'), + device_activity__history: mapSeries('device_online') +}; +`, + }, + { + id: 'thingspanel_home_alarm_history', + group: 'dashboard', + label: zhEn('告警列表', 'Alarm List'), + url: '{{ var.platformApiBaseUrl }}/alarm/info/history', + params: { page: 1, page_size: 10 }, + fields: [ + { id: 'alarm_rows', label: zhEn('告警列表', 'Alarm Rows'), type: 'array' }, + { id: 'alarm_total', label: zhEn('告警总数', 'Alarm Total'), type: 'number' }, + { + id: 'latest_alarm_level', + label: zhEn('最新告警级别', 'Latest Alarm Level'), + type: 'string', + }, + { + id: 'latest_alarm_message', + label: zhEn('最新告警内容', 'Latest Alarm Message'), + type: 'string', + }, + ], + transformation: ` +const payload = data && typeof data === 'object' && data.data ? data.data : data; +const rows = Array.isArray(payload?.list) + ? payload.list + : Array.isArray(payload?.data) + ? payload.data + : Array.isArray(payload) + ? payload + : []; +const latest = rows[0] ?? null; +return { + alarm_rows: rows.map((row) => ({ + id: row?.id, + device_id: row?.device_id, + device_name: row?.device_name ?? row?.name ?? '', + level: row?.alarm_level ?? row?.level ?? '', + status: row?.alarm_status ?? row?.status ?? '', + message: row?.alarm_description ?? row?.alarm_message ?? row?.message ?? '', + time: row?.create_time ?? row?.created_at ?? row?.time ?? '' + })), + alarm_total: Number(payload?.total ?? rows.length ?? 0), + latest_alarm_level: String(latest?.alarm_level ?? latest?.level ?? ''), + latest_alarm_message: String(latest?.alarm_description ?? latest?.alarm_message ?? latest?.message ?? '') +}; +`, + }, + { + id: 'thingspanel_home_latest_telemetry', + group: 'dashboard', + label: zhEn('最近数据上报', 'Latest Telemetry Uploads'), + url: '{{ var.platformApiBaseUrl }}/device/telemetry/latest', + fields: [ + { + id: 'latest_devices', + label: zhEn('最近活跃设备', 'Latest Active Devices'), + type: 'array', + }, + { + id: 'latest_telemetry_rows', + label: zhEn('最近上报数据', 'Latest Telemetry Rows'), + type: 'array', + }, + { + id: 'latest_device_count', + label: zhEn('最近活跃设备数', 'Latest Active Device Count'), + type: 'number', + }, + ], + transformation: ` +const payload = data && typeof data === 'object' && data.data ? data.data : data; +const devices = Array.isArray(payload) ? payload : []; +const latestDevices = devices.map((device) => ({ + device_id: device?.device_id ?? '', + device_name: device?.device_name ?? '', + is_online: Number(device?.is_online ?? 0), + last_push_time: device?.last_push_time ?? '', + telemetry_data: Array.isArray(device?.telemetry_data) ? device.telemetry_data : [] +})); +const rows = latestDevices.flatMap((device) => + (Array.isArray(device.telemetry_data) ? device.telemetry_data : []).map((item) => ({ + device_id: device.device_id, + device_name: device.device_name, + key: item?.key ?? '', + label: item?.label ?? item?.key ?? '', + unit: item?.unit ?? '', + value: item?.value, + last_push_time: device.last_push_time, + is_online: device.is_online + })) +); +return { + latest_devices: latestDevices, + latest_telemetry_rows: rows, + latest_device_count: latestDevices.length +}; +`, + }, + { + id: 'thingspanel_current_device_telemetry_snapshot', + group: 'current-device', + label: zhEn('当前设备遥测值', 'Current Device Telemetry'), + url: '{{ var.platformApiBaseUrl }}/telemetry/datas/current/{{ var.deviceId }}', + fields: [], + transformation: ` +const payload = data && typeof data === 'object' && data.data ? data.data : data; +const rows = Array.isArray(payload) ? payload : Array.isArray(payload?.telemetry_data) ? payload.telemetry_data : []; +if (rows.length === 0 && payload && typeof payload === 'object' && !Array.isArray(payload)) { + return payload; +} +return rows.reduce((acc, item) => { + const key = item?.key ?? item?.id ?? item?.identifier; + if (!key) return acc; + acc[key] = item?.value; + acc[String(key) + '__meta'] = { + label: item?.label ?? item?.name ?? key, + unit: item?.unit ?? '', + ts: item?.ts ?? item?.timestamp ?? item?.time ?? '' + }; + return acc; +}, {}); +`, + }, + { + id: 'thingspanel_current_device_status_history', + group: 'current-device-history', + label: zhEn('设备在线状态变更记录', 'Device Online Status History'), + url: '{{ var.platformApiBaseUrl }}/device/status/history', + params: { device_id: '{{ var.deviceId }}', page: 1, page_size: 50 }, + fields: [ + { + id: 'online_status_rows', + label: zhEn('状态变更记录', 'Status Change Rows'), + type: 'array', + }, + { + id: 'online_status_total', + label: zhEn('状态变更次数', 'Status Change Count'), + type: 'number', + }, + { + id: 'online_status__series', + label: zhEn('在线状态变化趋势', 'Online Status Trend'), + type: 'array', + }, + ], + transformation: ` +const payload = data && typeof data === 'object' && data.data ? data.data : data; +const rows = Array.isArray(payload?.list) ? payload.list : Array.isArray(payload) ? payload : []; +return { + online_status_rows: rows.map((row) => ({ + id: row?.id, + device_id: row?.device_id ?? '', + status: Number(row?.status ?? 0), + status_text: Number(row?.status ?? 0) === 1 ? '在线' : '离线', + change_time: row?.change_time ?? row?.created_at ?? row?.time ?? '' + })), + online_status_total: Number(payload?.total ?? rows.length ?? 0), + online_status__series: rows.map((row) => ({ + timestamp: row?.change_time ?? row?.created_at ?? row?.time ?? '', + value: Number(row?.status ?? 0) + })) +}; +`, + }, + { + id: 'thingspanel_current_device_alarm_history', + group: 'current-device-history', + label: zhEn('设备告警历史', 'Device Alarm History'), + url: '{{ var.platformApiBaseUrl }}/alarm/info/history', + params: { device_id: '{{ var.deviceId }}', page: 1, page_size: 10 }, + fields: [ + { + id: 'device_alarm_rows', + label: zhEn('设备告警列表', 'Device Alarm Rows'), + type: 'array', + }, + { + id: 'device_alarm_total', + label: zhEn('设备告警总数', 'Device Alarm Total'), + type: 'number', + }, + ], + transformation: ` +const payload = data && typeof data === 'object' && data.data ? data.data : data; +const rows = Array.isArray(payload?.list) + ? payload.list + : Array.isArray(payload?.data) + ? payload.data + : Array.isArray(payload) + ? payload + : []; +return { + device_alarm_rows: rows.map((row) => ({ + id: row?.id, + device_id: row?.device_id, + device_name: row?.device_name ?? row?.name ?? '', + level: row?.alarm_level ?? row?.level ?? '', + status: row?.alarm_status ?? row?.status ?? '', + message: row?.alarm_description ?? row?.alarm_message ?? row?.message ?? '', + time: row?.create_time ?? row?.created_at ?? row?.time ?? '' + })), + device_alarm_total: Number(payload?.total ?? rows.length ?? 0) +}; +`, + }, + { + id: 'thingspanel_tenant_summary', + group: 'dashboard', + label: zhEn('租户统计', 'Tenant Summary'), + url: '{{ var.platformApiBaseUrl }}/board/tenant', + fields: [ + { id: 'tenant_total', label: zhEn('租户总数', 'Tenant Total'), type: 'number' }, + { + id: 'tenant_added_yesterday', + label: zhEn('昨日新增租户', 'New Tenants Yesterday'), + type: 'number', + }, + { + id: 'tenant_added_month', + label: zhEn('本月新增租户', 'New Tenants This Month'), + type: 'number', + }, + { + id: 'tenant_growth__history', + label: zhEn('租户增长趋势', 'Tenant Growth Trend'), + type: 'array', + }, + ], + transformation: ` +const payload = data && typeof data === 'object' && data.data ? data.data : data; +const year = new Date().getFullYear(); +const rows = Array.isArray(payload?.user_list_month) ? payload.user_list_month : []; +return { + tenant_total: Number(payload?.user_total ?? 0), + tenant_added_yesterday: Number(payload?.user_added_yesterday ?? 0), + tenant_added_month: Number(payload?.user_added_month ?? 0), + tenant_growth__history: rows.map((row) => ({ + timestamp: new Date(year, Number(row.mon ?? 1) - 1, 1).toISOString(), + value: Number(row.num ?? 0) + })) +}; +`, + }, + { + id: 'thingspanel_system_metrics', + group: 'dashboard', + label: zhEn('系统资源', 'System Metrics'), + url: '{{ var.platformApiBaseUrl }}/system/metrics/current', + fields: [ + { id: 'cpu_usage', label: zhEn('CPU 占用率', 'CPU Usage'), type: 'number' }, + { id: 'memory_usage', label: zhEn('内存占用率', 'Memory Usage'), type: 'number' }, + { id: 'disk_usage', label: zhEn('磁盘占用率', 'Disk Usage'), type: 'number' }, + ], + transformation: ` +const payload = data && typeof data === 'object' && data.data ? data.data : data; +return { + cpu_usage: Number(payload?.cpu_usage ?? 0), + memory_usage: Number(payload?.memory_usage ?? 0), + disk_usage: Number(payload?.disk_usage ?? 0) +}; +`, + }, + { + id: 'thingspanel_system_metrics_trend', + group: 'dashboard', + label: zhEn('系统资源趋势', 'System Metric Trends'), + url: '{{ var.platformApiBaseUrl }}/system/metrics/history', + params: { hours: 24 }, + fields: [ + { id: 'cpu_usage__history', label: zhEn('CPU 占用趋势', 'CPU Usage Trend'), type: 'array' }, + { + id: 'memory_usage__history', + label: zhEn('内存占用趋势', 'Memory Usage Trend'), + type: 'array', + }, + { + id: 'disk_usage__history', + label: zhEn('磁盘占用趋势', 'Disk Usage Trend'), + type: 'array', + }, + ], + transformation: ` +const payload = data && typeof data === 'object' && data.data ? data.data : data; +const rows = Array.isArray(payload) ? payload : []; +const mapSeries = (usageKey, fallbackKey) => rows.map((row) => ({ + timestamp: row.timestamp ?? row.time ?? row.created_at, + value: Number(row[usageKey] ?? row[fallbackKey] ?? 0) +})); +return { + cpu_usage__history: mapSeries('cpu_usage', 'cpu'), + memory_usage__history: mapSeries('memory_usage', 'memory'), + disk_usage__history: mapSeries('disk_usage', 'disk') +}; +`, + }, + ], +}; diff --git a/apps/studio/src/lib/embedded/service-config.ts b/apps/studio/src/lib/embedded/service-config.ts index 165e1833..7a092f97 100644 --- a/apps/studio/src/lib/embedded/service-config.ts +++ b/apps/studio/src/lib/embedded/service-config.ts @@ -1,11 +1,6 @@ -import { - mergeWithDefaultAggregatePlatformFields, - normalizePlatformFieldScope, - type PlatformFieldScope, -} from './default-platform-fields'; - export type EditorServiceMode = 'standalone' | 'embedded'; export type IntegrationLevel = 'full' | 'minimal'; +export type EmbeddedEditorContext = 'dashboard' | 'device-template'; export type EditorUiConfig = { /** Left panel (component library / layers) */ @@ -36,9 +31,10 @@ export type SaveTarget = 'self' | 'host'; export type EditorServiceConfig = { mode: EditorServiceMode; integrationLevel: IntegrationLevel; + provider?: string; + context?: EmbeddedEditorContext; ui: EditorUiConfig; saveTarget?: SaveTarget; - platformFieldScope?: PlatformFieldScope; platformFields?: PlatformField[]; warnings: string[]; }; @@ -120,6 +116,10 @@ export function resolveEditorServiceConfig(): EditorServiceConfig { const integrationParam = (getParam('integration') || getParam('integrationLevel') || '') .trim() .toLowerCase(); + const providerParam = (getParam('provider') || '').trim().toLowerCase(); + const contextParam = (getParam('context') || '').trim().toLowerCase(); + const context: EmbeddedEditorContext | undefined = + contextParam === 'dashboard' || contextParam === 'device-template' ? contextParam : undefined; let integrationLevel: IntegrationLevel = mode === 'embedded' ? 'full' : 'full'; if (integrationParam) { if (integrationParam === 'minimal') integrationLevel = 'minimal'; @@ -155,28 +155,14 @@ export function resolveEditorServiceConfig(): EditorServiceConfig { ? (saveTargetParam as SaveTarget) : undefined; - const platformFieldScope = normalizePlatformFieldScope( - getParam('platformFieldScope') || getParam('roleScope') || getParam('role'), - ); - // -------- Platform fields -------- - // Embedded mode always exposes a small built-in aggregate field set so - // homepage/dashboard widgets can bind value-card and line-chart data even - // before the host injects a richer schema. - let platformFields: PlatformField[] | undefined = mergeWithDefaultAggregatePlatformFields( - [], - platformFieldScope, - ) as PlatformField[]; + let platformFields: PlatformField[] | undefined; try { const fieldsParam = getParam('platformFields'); if (fieldsParam) { // URLSearchParams.get() already decodes the parameter, no need to decode again - const parsed = JSON.parse(fieldsParam) as PlatformField[]; - platformFields = mergeWithDefaultAggregatePlatformFields( - parsed, - platformFieldScope, - ) as PlatformField[]; + platformFields = JSON.parse(fieldsParam) as PlatformField[]; } } catch (e) { warnings.push('Failed to parse platform fields'); @@ -200,6 +186,8 @@ export function resolveEditorServiceConfig(): EditorServiceConfig { return { mode, integrationLevel, + provider: providerParam || undefined, + context, ui: { showComponentLibrary: false, showPropsPanel: false, @@ -209,7 +197,6 @@ export function resolveEditorServiceConfig(): EditorServiceConfig { toolbarItems: undefined, }, saveTarget, - platformFieldScope, platformFields, warnings, }; @@ -219,9 +206,10 @@ export function resolveEditorServiceConfig(): EditorServiceConfig { return { mode, integrationLevel, + provider: providerParam || undefined, + context, ui: requestedUi, saveTarget, - platformFieldScope, platformFields, warnings, }; diff --git a/apps/studio/src/lib/platformDatasourceBindings.test.ts b/apps/studio/src/lib/platformDatasourceBindings.test.ts index 00d2e992..6a45629c 100644 --- a/apps/studio/src/lib/platformDatasourceBindings.test.ts +++ b/apps/studio/src/lib/platformDatasourceBindings.test.ts @@ -45,18 +45,12 @@ describe('platformDatasourceBindings', () => { needsHistory: true, requestedFields: new Set(['cpu_usage']), }); - expect(requirements.get('__platform__')).toEqual({ - needsHistory: false, - requestedFields: new Set(['temperature']), - }); + expect(requirements.has('__platform__')).toBe(false); }); it('augments existing platform data sources so history bindings enable buffers', () => { const nextConfigs = augmentPlatformDataSourcesForNodes( - [ - createPlatformDataSource('__platform__'), - createPlatformDataSource('__platform_dev-1__', { deviceId: 'dev-1' }), - ], + [createPlatformDataSource('__platform_dev-1__', { deviceId: 'dev-1' })], [ { type: 'chart/uplot-line', diff --git a/apps/studio/src/lib/platformDatasourceBindings.ts b/apps/studio/src/lib/platformDatasourceBindings.ts index 0d0f30e9..6dde4662 100644 --- a/apps/studio/src/lib/platformDatasourceBindings.ts +++ b/apps/studio/src/lib/platformDatasourceBindings.ts @@ -25,7 +25,7 @@ function visitStringLeaves(value: unknown, visitor: (input: string) => void): vo } function isPlatformDataSourceId(dataSourceId: string): boolean { - return dataSourceId === '__platform__' || /^__platform_(.+)__$/.test(dataSourceId); + return /^__platform_(.+)__$/.test(dataSourceId); } function getFieldRoot(fieldPath?: string): string | null { diff --git a/apps/studio/src/lib/registry/resolveInitialWidgetProps.test.ts b/apps/studio/src/lib/registry/resolveInitialWidgetProps.test.ts index 708f42c8..5b5b0c6c 100644 --- a/apps/studio/src/lib/registry/resolveInitialWidgetProps.test.ts +++ b/apps/studio/src/lib/registry/resolveInitialWidgetProps.test.ts @@ -43,7 +43,7 @@ describe('resolveInitialWidgetProps', () => { }); }); - it('returns only schema defaults in embedded mode', () => { + it('merges embedded preview defaults over schema defaults in embedded mode', () => { mockedResolveEditorServiceConfig.mockReturnValue({ mode: 'embedded', integrationLevel: 'full', @@ -67,11 +67,82 @@ describe('resolveInitialWidgetProps', () => { standaloneDefaults: { data: [{ name: 'Mon', value: 18 }], }, + sampleData: { + data: [{ name: 'Sample', value: 12 }], + }, + previewDefaults: { + title: 'Preview CPU', + }, + }); + + expect(result).toEqual({ + title: 'Preview CPU', + data: [{ name: 'Sample', value: 12 }], + }); + }); + + it('does not apply embedded sample data in standalone mode', () => { + mockedResolveEditorServiceConfig.mockReturnValue({ + mode: 'standalone', + integrationLevel: 'full', + ui: { + showComponentLibrary: true, + showPropsPanel: true, + showTopLeft: true, + showToolbar: true, + showTopRight: true, + }, + warnings: [], + }); + + const schema = z.object({ + data: z.array(z.any()).default([]), + }); + + const result = resolveInitialWidgetProps({ + schema, + standaloneDefaults: { + data: [{ name: 'Standalone', value: 18 }], + }, + sampleData: { + data: [{ name: 'Embedded', value: 12 }], + }, + }); + + expect(result).toEqual({ + data: [{ name: 'Standalone', value: 18 }], + }); + }); + + it('uses standalone defaults as an embedded fallback for older widget bundles', () => { + mockedResolveEditorServiceConfig.mockReturnValue({ + mode: 'embedded', + integrationLevel: 'full', + ui: { + showComponentLibrary: true, + showPropsPanel: true, + showTopLeft: true, + showToolbar: true, + showTopRight: true, + }, + warnings: [], + }); + + const schema = z.object({ + title: z.string().default('CPU'), + data: z.any().default(null), + }); + + const result = resolveInitialWidgetProps({ + schema, + standaloneDefaults: { + data: [{ name: 'CPU', value: 67 }], + }, }); expect(result).toEqual({ title: 'CPU', - data: [], + data: [{ name: 'CPU', value: 67 }], }); }); diff --git a/apps/studio/src/lib/registry/resolveInitialWidgetProps.ts b/apps/studio/src/lib/registry/resolveInitialWidgetProps.ts index d3a2b112..d350531b 100644 --- a/apps/studio/src/lib/registry/resolveInitialWidgetProps.ts +++ b/apps/studio/src/lib/registry/resolveInitialWidgetProps.ts @@ -4,6 +4,8 @@ import { extractDefaults } from './schemaUtils'; type ResolveInitialWidgetPropsInput = { schema?: unknown; standaloneDefaults?: Record; + previewDefaults?: Record; + sampleData?: Record; fallbackDefaults?: Record; }; @@ -27,13 +29,22 @@ type ResolveInitialWidgetPropsInput = { export function resolveInitialWidgetProps({ schema, standaloneDefaults, + previewDefaults, + sampleData, fallbackDefaults, }: ResolveInitialWidgetPropsInput): Record { // Priority 1: canonical schema defaults (or migration fallback if no schema) const schemaDefaults = schema != null ? extractDefaults(schema) : { ...(fallbackDefaults ?? {}) }; if (resolveEditorServiceConfig().mode !== 'standalone') { - return schemaDefaults; + const hasEmbeddedDefaults = sampleData != null || previewDefaults != null; + + return { + ...schemaDefaults, + ...(hasEmbeddedDefaults ? {} : (standaloneDefaults ?? {})), + ...(sampleData ?? {}), + ...(previewDefaults ?? {}), + }; } // Priority 2: merge standaloneDefaults over schema defaults (standalone only) diff --git a/apps/studio/src/lib/stores/platformFieldStore.ts b/apps/studio/src/lib/stores/platformFieldStore.ts index d983315c..9335225b 100644 --- a/apps/studio/src/lib/stores/platformFieldStore.ts +++ b/apps/studio/src/lib/stores/platformFieldStore.ts @@ -1,9 +1,4 @@ import { create } from 'zustand'; -import { - getDefaultAggregatePlatformFields, - mergeWithDefaultAggregatePlatformFields, - type PlatformFieldScope, -} from '../embedded/default-platform-fields'; // ============================================================================= // Platform Field Store @@ -21,49 +16,27 @@ export interface PlatformFieldItem { } interface PlatformFieldState { - /** Active role scope used to expose aggregate defaults */ - scope: PlatformFieldScope; - /** List of fields provided by the platform */ fields: PlatformFieldItem[]; - /** Host-provided fields without built-in aggregate defaults */ - customFields: PlatformFieldItem[]; - /** Completely replace the field definitions */ setFields: (fields: PlatformFieldItem[]) => void; - /** Update role scope and rebuild the effective field list */ - setScope: (scope: PlatformFieldScope) => void; - /** Clear all fields */ clearFields: () => void; } export const usePlatformFieldStore = create((set) => ({ - scope: 'all', - customFields: [], - fields: getDefaultAggregatePlatformFields('all') as PlatformFieldItem[], + fields: [], setFields: (fields) => - set((state) => ({ - customFields: fields, - fields: mergeWithDefaultAggregatePlatformFields(fields, state.scope) as PlatformFieldItem[], - })), - - setScope: (scope) => - set((state) => ({ - scope, - fields: mergeWithDefaultAggregatePlatformFields( - state.customFields, - scope, - ) as PlatformFieldItem[], + set(() => ({ + fields, })), clearFields: () => - set((state) => ({ - customFields: [], - fields: getDefaultAggregatePlatformFields(state.scope) as PlatformFieldItem[], + set(() => ({ + fields: [], })), })); @@ -72,8 +45,6 @@ export const usePlatformFieldStore = create((set) => ({ */ export const platformFieldStore = { getFields: () => usePlatformFieldStore.getState().fields, - getScope: () => usePlatformFieldStore.getState().scope, setFields: (fields: PlatformFieldItem[]) => usePlatformFieldStore.getState().setFields(fields), - setScope: (scope: PlatformFieldScope) => usePlatformFieldStore.getState().setScope(scope), clearFields: () => usePlatformFieldStore.getState().clearFields(), }; diff --git a/apps/studio/src/pages/DataSourcesPage.tsx b/apps/studio/src/pages/DataSourcesPage.tsx index 777474ce..51690891 100644 --- a/apps/studio/src/pages/DataSourcesPage.tsx +++ b/apps/studio/src/pages/DataSourcesPage.tsx @@ -41,6 +41,12 @@ import CodeMirror from '@uiw/react-codemirror'; import { json } from '@codemirror/lang-json'; import { dataSourceManager, store } from '../lib/store'; import { buildHashRoute } from '../lib/embed/navigation'; +import { resolveEditorServiceConfig } from '../lib/embedded/service-config'; +import { + listEmbeddedProviderDataSourceIds, + resolveEmbeddedProviderCatalog, +} from '../lib/embedded/embedded-data-source-registry'; +import { resolveControlText } from '../lib/i18n/controlText'; // Default configurations for new data sources const DEFAULT_REST_CONFIG: RESTConfig = { @@ -66,7 +72,7 @@ export default function DataSourcesPage() { const { states } = useDataSourceRegistry(store); const [selectedId, setSelectedId] = useState(null); const [isAdding, setIsAdding] = useState(false); - const { t } = useTranslation('pages'); + const { t, i18n } = useTranslation('pages'); // Toast state const [toast, setToast] = useState<{ @@ -104,12 +110,48 @@ export default function DataSourcesPage() { }); const jsonExtensions = useMemo(() => [json()], []); + const serviceConfig = useMemo(() => resolveEditorServiceConfig(), []); + const providerDataSourceNameMap = useMemo(() => { + const catalog = resolveEmbeddedProviderCatalog(serviceConfig.provider); + if (!catalog) return new Map(); + + const locale = i18n.resolvedLanguage ?? i18n.language; + return new Map( + catalog.dataSources.map((source) => [ + source.id, + resolveControlText(source.label, locale, t as any), + ]), + ); + }, [i18n.language, i18n.resolvedLanguage, serviceConfig.provider, t]); + const protectedDataSourceIds = useMemo(() => { + if (serviceConfig.mode !== 'embedded') return new Set(); + + const groups = + serviceConfig.context === 'dashboard' + ? ['dashboard'] + : serviceConfig.context === 'device-template' + ? ['dashboard', 'current-device', 'current-device-history'] + : undefined; + + return new Set( + listEmbeddedProviderDataSourceIds( + serviceConfig.provider, + groups ? { groups: groups as any } : undefined, + ), + ); + }, [serviceConfig.context, serviceConfig.mode, serviceConfig.provider]); const showToast = (message: string, type: 'success' | 'error' = 'success') => { setToast({ message, visible: true, type }); setTimeout(() => setToast((prev) => ({ ...prev, visible: false })), 3000); }; + const getDisplayName = (dataSourceId: string): string => { + const config = dataSourceManager.getConfig(dataSourceId); + if (config?.name && config.name !== dataSourceId) return config.name; + return providerDataSourceNameMap.get(dataSourceId) ?? dataSourceId; + }; + const syncStaticJsonTextFromConfig = (configValue: unknown) => { try { setStaticJsonText(JSON.stringify(configValue ?? {}, null, 2)); @@ -199,6 +241,10 @@ export default function DataSourcesPage() { const handleDeleteClick = (e: React.MouseEvent, id: string) => { e.stopPropagation(); + if (protectedDataSourceIds.has(id)) { + showToast(t('dataSources.deleteFailed'), 'error'); + return; + } // Find name for confirmation const config = dataSourceManager.getConfig(id); @@ -209,6 +255,12 @@ export default function DataSourcesPage() { const confirmDelete = async () => { if (!sourceToDelete) return; + if (protectedDataSourceIds.has(sourceToDelete)) { + showToast(t('dataSources.deleteFailed'), 'error'); + setDeleteDialogOpen(false); + setSourceToDelete(null); + return; + } try { await dataSourceManager.unregisterDataSource(sourceToDelete); @@ -338,22 +390,14 @@ export default function DataSourcesPage() { className={`w-2 h-2 rounded-full shrink-0 ${ds.status === 'connected' ? 'bg-green-400 shadow-[0_0_6px_rgba(74,222,128,0.6)]' : 'bg-yellow-400'}`} />
- - {(() => { - const cfg = dataSourceManager.getConfig(ds.id); - return cfg?.name || ds.id; - })()} - - {(() => { - const cfg = dataSourceManager.getConfig(ds.id); - return cfg?.name ? ( - - {ds.id} - - ) : null; - })()} + {getDisplayName(ds.id)} + {getDisplayName(ds.id) !== ds.id ? ( + + {ds.id} + + ) : null}
{(() => { const mode = dataSourceManager.getResolvedMode(ds.id); @@ -376,12 +420,14 @@ export default function DataSourcesPage() { ); })()}
- + {!protectedDataSourceIds.has(ds.id) && ( + + )}
))} {Object.keys(states).length === 0 && !isAdding && ( diff --git a/apps/studio/src/pages/EmbedPage.runtimeVariables.test.ts b/apps/studio/src/pages/EmbedPage.runtimeVariables.test.ts new file mode 100644 index 00000000..000b5c82 --- /dev/null +++ b/apps/studio/src/pages/EmbedPage.runtimeVariables.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { + buildEmbedRuntimeVariableValues, + mergeEmbedRuntimeVariableDefinitions, +} from '@/embed/runtimeVariables'; + +describe('EmbedPage runtime variables', () => { + it('maps embed config into explicit runtime variables', () => { + expect( + buildEmbedRuntimeVariableValues( + { + platformApiBaseUrl: 'https://platform.example.com', + thingsvisApiBaseUrl: 'https://thingsvis.example.com', + platformToken: 'platform-token', + deviceId: 'device-001', + dateRange: { startTime: 1000, endTime: 2000 }, + }, + null, + ), + ).toEqual({ + platformApiBaseUrl: 'https://platform.example.com', + thingsvisApiBaseUrl: 'https://thingsvis.example.com', + platformToken: 'platform-token', + deviceId: 'device-001', + dateRange: { startTime: 1000, endTime: 2000 }, + }); + }); + + it('adds missing runtime variable definitions without replacing dashboard definitions', () => { + const merged = mergeEmbedRuntimeVariableDefinitions( + [{ name: 'deviceId', type: 'string', defaultValue: 'from-dashboard' }], + { + platformApiBaseUrl: 'https://platform.example.com', + platformToken: 'runtime-only-token', + deviceId: 'device-001', + }, + ); + + expect(merged.find((definition) => definition.name === 'deviceId')?.defaultValue).toBe( + 'from-dashboard', + ); + expect( + merged.find((definition) => definition.name === 'platformApiBaseUrl')?.defaultValue, + ).toBe('https://platform.example.com'); + expect(merged.map((definition) => definition.name)).toEqual([ + 'deviceId', + 'platformApiBaseUrl', + 'thingsvisApiBaseUrl', + 'dateRange', + ]); + expect(merged.some((definition) => definition.name === 'platformToken')).toBe(false); + }); +}); diff --git a/apps/studio/src/pages/EmbedPage.tsx b/apps/studio/src/pages/EmbedPage.tsx index 49eb1d85..9b4eca98 100644 --- a/apps/studio/src/pages/EmbedPage.tsx +++ b/apps/studio/src/pages/EmbedPage.tsx @@ -26,17 +26,18 @@ import { platformDeviceStore } from '@/lib/stores/platformDeviceStore'; import { messageRouter, MSG_TYPES } from '@/embed/message-router'; import { applyPlatformBufferSize, - adoptLegacyPlatformDataSources, - findLegacyPlatformDataSourceIdsForAdoption, - hasPlatformDataSourceBoundToDevice, getResolvedPlatformBufferSize, - inferSinglePlatformDeviceId, } from '@/embed/platformDeviceCompat'; import { buildPlatformReplayPayloads, cachePlatformData, type PlatformDataSnapshot, } from '@/embed/platformDataSnapshot'; +import { + buildEmbedRuntimeVariableValues, + mergeEmbedRuntimeVariableDefinitions, + resolveThingsVisApiBaseUrl, +} from '@/embed/runtimeVariables'; import { augmentPlatformDataSourcesForNodes } from '@/lib/platformDatasourceBindings'; import { ScaleScreen } from '@/components/ScaleScreen'; import { ErrorBoundary } from '@/components/ErrorBoundary'; @@ -52,6 +53,37 @@ interface EmbedState { variables: Record; } +function buildRuntimeConfigFromSearchParams(searchParams: URLSearchParams) { + const config: Record = {}; + ['platformApiBaseUrl', 'thingsvisApiBaseUrl', 'deviceId'].forEach((key) => { + const value = searchParams.get(key); + if (value) config[key] = value; + }); + + const startTime = searchParams.get('startTime'); + const endTime = searchParams.get('endTime'); + if (startTime || endTime) { + config.dateRange = { startTime: startTime ?? '', endTime: endTime ?? '' }; + } + + return config; +} + +function applyEmbedRuntimeVariables( + definitions: unknown[] | undefined, + runtimeValues: Record, +) { + const mergedDefinitions = mergeEmbedRuntimeVariableDefinitions(definitions, runtimeValues); + store.getState().setVariableDefinitions(mergedDefinitions as any); + store.getState().initVariablesFromDefinitions(mergedDefinitions as any); + Object.entries(runtimeValues).forEach(([name, value]) => { + if (value !== undefined) { + store.getState().setVariableValue(name, value); + } + }); + return mergedDefinitions; +} + // Message types for postMessage communication type EmbedMessage = | { type: 'LOAD_DASHBOARD'; payload: any } @@ -129,13 +161,12 @@ export default function EmbedPage() { const embedTokenRef = useRef(searchParams.get('token')); /** Whether data sources from the last tv:init have been registered and are ready. */ const initDoneRef = useRef(false); + /** Fingerprint for the last processed tv:init payload to suppress duplicate host retries. */ + const lastInitFingerprintRef = useRef(''); /** Buffer for tv:platform-data messages that arrive before adapters are ready. */ const pendingPlatformDataRef = useRef< Array<{ fieldId: string; value: unknown; timestamp: number; deviceId?: string }> >([]); - /** Legacy platform datasource ids that should bind to the runtime device in single-device detail pages. */ - const legacyPlatformDataSourceIdsRef = useRef([]); - const adoptedLegacyDeviceIdRef = useRef(null); /** Latest platform field snapshot, retained across repeated tv:init re-registration. */ const latestPlatformDataRef = useRef({}); @@ -289,11 +320,11 @@ export default function EmbedPage() { }); } - const variables = (dashboard.variables as any[]) || []; - if (Array.isArray(variables)) { - store.getState().setVariableDefinitions(variables as any); - store.getState().initVariablesFromDefinitions(variables as any); - } + const variables = Array.isArray(dashboard.variables) ? (dashboard.variables as any[]) : []; + applyEmbedRuntimeVariables( + variables, + buildEmbedRuntimeVariableValues(buildRuntimeConfigFromSearchParams(searchParams)), + ); setState({ isLoading: false, @@ -319,7 +350,7 @@ export default function EmbedPage() { postToParent({ type: 'ERROR', payload: errorMessage }); } }, - [postToParent], + [postToParent, searchParams], ); // Load dashboard from API by ID @@ -366,11 +397,11 @@ export default function EmbedPage() { }); } - const variables = (dashboard.variables as any[]) || []; - if (Array.isArray(variables)) { - store.getState().setVariableDefinitions(variables as any); - store.getState().initVariablesFromDefinitions(variables as any); - } + const variables = Array.isArray(dashboard.variables) ? (dashboard.variables as any[]) : []; + applyEmbedRuntimeVariables( + variables, + buildEmbedRuntimeVariableValues(buildRuntimeConfigFromSearchParams(searchParams)), + ); setState({ isLoading: false, @@ -397,12 +428,12 @@ export default function EmbedPage() { postToParent({ type: 'ERROR', payload: errorMessage }); } }, - [postToParent, setEmbedApiToken], + [postToParent, searchParams, setEmbedApiToken], ); // Load dashboard from schema data const loadFromSchema = useCallback( - (schema: any) => { + (schema: any, options?: { skipVariableInitialization?: boolean }) => { setState((s) => ({ ...s, isLoading: true, error: null })); if (!schema || !schema.canvas) { @@ -500,7 +531,7 @@ export default function EmbedPage() { } const variables = (schema.variables as any[]) || []; - if (Array.isArray(variables)) { + if (!options?.skipVariableInitialization && Array.isArray(variables)) { store.getState().setVariableDefinitions(variables as any); store.getState().initVariablesFromDefinitions(variables as any); } @@ -532,51 +563,6 @@ export default function EmbedPage() { // TODO: Dispatch variable update to kernel when variable system is implemented }, []); - const adoptLegacyPlatformBindings = useCallback(async (deviceId?: string) => { - if (!deviceId) return false; - if (adoptedLegacyDeviceIdRef.current === deviceId) return false; - - const legacyIds = legacyPlatformDataSourceIdsRef.current; - if (legacyIds.length === 0) return false; - - const currentConfigs = dataSourceManager.getAllConfigs(); - if (hasPlatformDataSourceBoundToDevice(currentConfigs, deviceId)) { - adoptedLegacyDeviceIdRef.current = deviceId; - legacyPlatformDataSourceIdsRef.current = []; - return false; - } - - const adoptedConfigs = adoptLegacyPlatformDataSources( - currentConfigs.filter((config) => legacyIds.includes(config.id)), - deviceId, - ); - - if (adoptedConfigs.length === 0) return false; - - await Promise.all( - adoptedConfigs.map((config) => - dataSourceManager.registerDataSource(config, false).catch((error) => { - console.error( - '[EmbedPage] Failed to adopt legacy platform datasource:', - config.id, - error, - ); - }), - ), - ); - - adoptedLegacyDeviceIdRef.current = deviceId; - legacyPlatformDataSourceIdsRef.current = []; - - console.warn( - '[EmbedPage] Adopted legacy platform datasource bindings for runtime device:', - deviceId, - adoptedConfigs.map((config) => config.id), - ); - - return true; - }, []); - // Phase 3: Handle messages from parent via messageRouter useEffect(() => { const cleanups: Array<() => void> = []; @@ -613,23 +599,54 @@ export default function EmbedPage() { platformDevices?: Array>; platformFields?: Array>; platformBufferSize?: number; - platformFieldScope?: string; - roleScope?: string; }; if (!msg.data) return; - const apiBaseUrl = msg.config?.apiBaseUrl; - if (typeof apiBaseUrl === 'string' && apiBaseUrl) { - apiClient.configure({ baseUrl: apiBaseUrl }); + const initData = msg.data; + const meta = initData.meta as { id?: string; name?: string } | undefined; + const nextFingerprint = JSON.stringify({ + dashboardId: meta?.id ?? null, + dashboardName: meta?.name ?? null, + nodeCount: Array.isArray(initData.nodes) ? initData.nodes.length : 0, + dataSourceIds: Array.isArray(initData.dataSources) + ? initData.dataSources.map((ds: any) => ds?.id ?? null) + : [], + variableCount: Array.isArray(initData.variables) ? initData.variables.length : 0, + canvas: initData.canvas ?? null, + platformDeviceGroupIds: Array.isArray(msg.platformDeviceGroups) + ? msg.platformDeviceGroups.map((group: any) => group?.groupId ?? group?.id ?? null) + : [], + platformDeviceIds: Array.isArray(msg.platformDevices) + ? msg.platformDevices.map((device: any) => device?.deviceId ?? device?.id ?? null) + : [], + platformFieldIds: Array.isArray(msg.platformFields) + ? msg.platformFields.map((field: any) => field?.id ?? null) + : [], + platformBufferSize: msg.platformBufferSize ?? null, + config: msg.config ?? null, + }); + if (nextFingerprint === lastInitFingerprintRef.current) { + return; + } + lastInitFingerprintRef.current = nextFingerprint; + const thingsvisApiBaseUrl = resolveThingsVisApiBaseUrl(msg.config); + if (thingsvisApiBaseUrl) { + apiClient.configure({ baseUrl: thingsvisApiBaseUrl }); } { - const initData = msg.data; - const meta = initData.meta as { id?: string; name?: string } | undefined; - const schema = { + const schema: { + id?: string; + name?: string; + canvas: unknown; + nodes?: unknown[]; + dataSources: unknown[]; + variables: unknown[]; + } = { id: meta?.id, name: meta?.name, canvas: initData.canvas, nodes: initData.nodes as unknown[] | undefined, dataSources: (initData.dataSources as unknown[] | undefined) ?? [], + variables: (initData.variables as unknown[] | undefined) ?? [], }; const platformDeviceGroups = Array.isArray(msg.platformDeviceGroups) @@ -643,13 +660,6 @@ export default function EmbedPage() { ? initData.platformFields : topLevelPlatformFields; - if (typeof msg.platformFieldScope === 'string' || typeof msg.roleScope === 'string') { - platformFieldStore.setScope( - (msg.platformFieldScope || msg.roleScope || 'all') as Parameters< - typeof platformFieldStore.setScope - >[0], - ); - } if (platformDeviceGroups.length > 0) { platformDeviceStore.setGroups(platformDeviceGroups as never); } @@ -659,26 +669,6 @@ export default function EmbedPage() { msg.platformBufferSize, ); - // Auto-inject __platform__ data source only when the host has not already provided one. - // Preserving the host-supplied entry is critical: it may carry a non-zero bufferSize - // that enables the rolling history buffer in PlatformFieldAdapter. - if (!schema.dataSources?.some((ds: any) => ds.id === '__platform__')) { - if (!schema.dataSources) schema.dataSources = []; - schema.dataSources.push({ - id: '__platform__', - name: 'System Platform', - type: 'PLATFORM_FIELD', - config: { - source: 'platform', - fieldMappings: {}, - requestedFields: initPlatformFields - .map((field: any) => field?.id) - .filter((id: unknown) => typeof id === 'string'), - bufferSize: inheritedPlatformBufferSize, - }, - }); - } - if (platformDevices.length > 0) { platformDeviceStore.setDevices(platformDevices as never); platformDevices.forEach((device: any) => { @@ -721,29 +711,16 @@ export default function EmbedPage() { platformDevices.length === 1 && typeof platformDevices[0]?.deviceId === 'string' ? (platformDevices[0].deviceId as string) : null; - const inferredRuntimeDeviceId = - singleRuntimeDeviceId ?? - inferSinglePlatformDeviceId((schema.dataSources ?? []) as DataSource[]); - - if (inferredRuntimeDeviceId) { - schema.dataSources = adoptLegacyPlatformDataSources( - (schema.dataSources ?? []) as DataSource[], - inferredRuntimeDeviceId, - ); - adoptedLegacyDeviceIdRef.current = inferredRuntimeDeviceId; - legacyPlatformDataSourceIdsRef.current = []; - } else { - legacyPlatformDataSourceIdsRef.current = findLegacyPlatformDataSourceIdsForAdoption( - (schema.dataSources ?? []) as DataSource[], - ); - adoptedLegacyDeviceIdRef.current = null; - } - if (legacyPlatformDataSourceIdsRef.current.length > 0) { - console.warn( - '[EmbedPage] Legacy platform bindings detected; waiting for runtime deviceId:', - legacyPlatformDataSourceIdsRef.current, - ); + schema.variables = applyEmbedRuntimeVariables( + schema.variables, + buildEmbedRuntimeVariableValues(msg.config, singleRuntimeDeviceId), + ); + + const schemaVariables = Array.isArray(schema.variables) ? schema.variables : []; + if (schemaVariables.length > 0) { + store.getState().setVariableDefinitions(schemaVariables as any); + store.getState().initVariablesFromDefinitions(schemaVariables as any); } // Platform Fields @@ -767,7 +744,7 @@ export default function EmbedPage() { } } } - loadFromSchema(schema); + loadFromSchema(schema, { skipVariableInitialization: true }); // Replay any platform-data messages that arrived before adapters were ready. if (pendingPlatformDataRef.current.length > 0) { @@ -807,26 +784,8 @@ export default function EmbedPage() { latestPlatformDataRef.current = cachePlatformData(latestPlatformDataRef.current, payload); void (async () => { - const adopted = - payload.__legacyCompatReplay === true - ? false - : await adoptLegacyPlatformBindings(payload.deviceId); - // Per-field messages are handled by PlatformFieldAdapter directly. - // If a legacy datasource was rebound just now, replay the same message once. if (payload.fieldId !== undefined) { - if (adopted) { - window.postMessage( - { - type: MSG_TYPES.PLATFORM_DATA, - payload: { - ...payload, - __legacyCompatReplay: true, - }, - }, - '*', - ); - } return; } @@ -891,7 +850,7 @@ export default function EmbedPage() { cleanupRef.current = cleanups; return () => cleanups.forEach((fn) => fn()); - }, [adoptLegacyPlatformBindings, loadFromSchema, setEmbedApiToken, updateVariables]); + }, [loadFromSchema, setEmbedApiToken, updateVariables]); // Initial load from URL params useEffect(() => { diff --git a/apps/studio/src/strategies/WidgetModeStrategy.ts b/apps/studio/src/strategies/WidgetModeStrategy.ts index b20ba3bf..534fd251 100644 --- a/apps/studio/src/strategies/WidgetModeStrategy.ts +++ b/apps/studio/src/strategies/WidgetModeStrategy.ts @@ -14,7 +14,6 @@ import type { ProjectFile } from '../lib/storage/schemas'; import { messageRouter, MSG_TYPES } from '../embed/message-router'; import { usePlatformFieldStore, type PlatformFieldItem } from '../lib/stores/platformFieldStore'; import { usePlatformDeviceStore } from '../lib/stores/platformDeviceStore'; -import { normalizePlatformFieldScope } from '../lib/embedded/default-platform-fields'; import { dataSourceManager } from '../lib/store'; import { DEFAULT_PLATFORM_FIELD_CONFIG } from '@thingsvis/schema'; import { augmentPlatformDataSourcesForNodes } from '../lib/platformDatasourceBindings'; @@ -41,8 +40,6 @@ export interface EmbedInitPayload { }; nodes?: Record[]; dataSources?: Record[]; - platformFieldScope?: string; - roleScope?: string; platformFields?: PlatformFieldItem[]; platformDevices?: PlatformDevice[]; [key: string]: unknown; @@ -92,21 +89,7 @@ export class WidgetModeStrategy implements EditorStrategy { return; } - // 1. Inject the implicit Platform Data Source (Legacy default) - dataSourceManager.registerDataSource({ - id: '__platform__', - name: 'System Platform', - type: 'PLATFORM_FIELD', - config: { - ...DEFAULT_PLATFORM_FIELD_CONFIG, - source: 'plugin-identifier', - requestedFields: (payload.platformFields || []) - .map((field: any) => field?.id) - .filter((id: unknown) => typeof id === 'string'), - }, - }); - - // 1.5 Inject per-device Data Sources if platformDevices are provided (Multi-device Phase 4) + // 1. Register per-device Data Sources if platformDevices are provided. if (Array.isArray(payload.platformDevices)) { payload.platformDevices.forEach((device) => { if (!device.deviceId) return; @@ -126,10 +109,6 @@ export class WidgetModeStrategy implements EditorStrategy { } // 2. Process Platform Fields provided by Host - usePlatformFieldStore - .getState() - .setScope(normalizePlatformFieldScope(payload.platformFieldScope || payload.roleScope)); - if (Array.isArray(payload.platformFields)) { usePlatformFieldStore.getState().setFields(payload.platformFields); } diff --git a/packages/thingsvis-kernel/src/datasources/DataSourceManager.ts b/packages/thingsvis-kernel/src/datasources/DataSourceManager.ts index 9c6e3f44..782c8610 100644 --- a/packages/thingsvis-kernel/src/datasources/DataSourceManager.ts +++ b/packages/thingsvis-kernel/src/datasources/DataSourceManager.ts @@ -375,6 +375,11 @@ export class DataSourceManager { } }); + const currentVariableValues = this.store?.getState().variableValues; + if (currentVariableValues) { + await adapter.refreshWithVariables(currentVariableValues); + } + // Determine trigger mode: 'manual' data sources only prepare (no fetch/polling on load) const effectiveMode = this.resolveEffectiveMode(config, normalizedType); this.resolvedModes.set(config.id, effectiveMode); diff --git a/packages/thingsvis-kernel/src/datasources/PlatformFieldAdapter.ts b/packages/thingsvis-kernel/src/datasources/PlatformFieldAdapter.ts index b433de16..5f214b0b 100644 --- a/packages/thingsvis-kernel/src/datasources/PlatformFieldAdapter.ts +++ b/packages/thingsvis-kernel/src/datasources/PlatformFieldAdapter.ts @@ -13,6 +13,11 @@ export class PlatformFieldAdapter extends BaseAdapter { /** Rolling time-series buffers — populated only when bufferSize > 0. */ private dataBuffers: Map> = new Map(); private messageListener: ((event: MessageEvent) => void) | null = null; + private pendingWrites = new Map< + string, + { resolve: (result: WriteResult) => void; timeoutId: ReturnType } + >(); + private readonly writeTimeoutMs = 5000; constructor() { super('PLATFORM_FIELD'); @@ -44,6 +49,11 @@ export class PlatformFieldAdapter extends BaseAdapter { // Security: In production, verify event.origin against the known ThingsVis origin. const msgType: unknown = event.data?.type; + if (msgType === 'tv:platform-write-result') { + this.handleWriteResult(event.data); + return; + } + // ── Bulk history pre-fill ──────────────────────────────────────────────── if (msgType === 'tv:platform-history') { const { fieldId, history, deviceId } = event.data.payload as { @@ -55,7 +65,7 @@ export class PlatformFieldAdapter extends BaseAdapter { const historyPlatformConfig = this.config?.config as PlatformFieldConfig | undefined; // Strict routing: // - device-scoped datasource: only accept matching deviceId - // - global datasource (__platform__): only accept messages without deviceId + // - unscoped datasource: only accept messages without deviceId if (historyPlatformConfig?.deviceId) { if (historyPlatformConfig.deviceId !== deviceId) return; } else if (deviceId !== undefined) { @@ -95,7 +105,7 @@ export class PlatformFieldAdapter extends BaseAdapter { const platformConfig = this.config?.config as PlatformFieldConfig | undefined; // Strict routing: // - device-scoped datasource: only accept matching deviceId - // - global datasource (__platform__): only accept messages without deviceId + // - unscoped datasource: only accept messages without deviceId if (platformConfig?.deviceId) { if (platformConfig.deviceId !== deviceId) return; } else if (deviceId !== undefined) { @@ -134,6 +144,43 @@ export class PlatformFieldAdapter extends BaseAdapter { window.addEventListener('message', this.messageListener); } + private createWriteRequestId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `platform-write-${Date.now()}-${Math.random().toString(36).slice(2)}`; + } + + private handleWriteResult(message: unknown) { + const resultMessage = message as { + requestId?: unknown; + success?: unknown; + error?: unknown; + echo?: unknown; + }; + if (typeof resultMessage.requestId !== 'string') return; + + const pending = this.pendingWrites.get(resultMessage.requestId); + if (!pending) return; + + clearTimeout(pending.timeoutId); + this.pendingWrites.delete(resultMessage.requestId); + + const success = resultMessage.success === true; + pending.resolve({ + success, + ...(success + ? {} + : { + error: + typeof resultMessage.error === 'string' + ? resultMessage.error + : 'Platform write failed', + }), + echo: resultMessage.echo, + }); + } + /** * Request initial field data from host platform */ @@ -208,26 +255,42 @@ export class PlatformFieldAdapter extends BaseAdapter { if (!this.config) { return { success: false, error: 'PlatformFieldAdapter is not connected' }; } - try { - const platformConfig = this.config.config as PlatformFieldConfig | undefined; - const normalizedPayload = this.normalizeWritePayload(payload); - window.parent.postMessage( - { - type: 'tv:platform-write', - payload: { - dataSourceId: this.config.id, - deviceId: platformConfig?.deviceId, - data: normalizedPayload, + const requestId = this.createWriteRequestId(); + const platformConfig = this.config.config as PlatformFieldConfig | undefined; + const normalizedPayload = this.normalizeWritePayload(payload); + + return new Promise((resolve) => { + const timeoutId = setTimeout(() => { + this.pendingWrites.delete(requestId); + resolve({ + success: false, + error: `Platform write timed out after ${this.writeTimeoutMs / 1000}s`, + }); + }, this.writeTimeoutMs); + + this.pendingWrites.set(requestId, { resolve, timeoutId }); + + try { + window.parent.postMessage( + { + type: 'tv:platform-write', + requestId, + payload: { + dataSourceId: this.config?.id, + deviceId: platformConfig?.deviceId, + data: normalizedPayload, + }, }, - }, - '*', - ); - return { success: true }; - } catch (e) { - const err = e instanceof Error ? e.message : String(e); - console.error('[PlatformFieldAdapter] write postMessage failed:', e); - return { success: false, error: err }; - } + '*', + ); + } catch (e) { + clearTimeout(timeoutId); + this.pendingWrites.delete(requestId); + const err = e instanceof Error ? e.message : String(e); + console.error('[PlatformFieldAdapter] write postMessage failed:', e); + resolve({ success: false, error: err }); + } + }); } /** @@ -295,6 +358,14 @@ export class PlatformFieldAdapter extends BaseAdapter { window.removeEventListener('message', this.messageListener); this.messageListener = null; } + for (const pending of this.pendingWrites.values()) { + clearTimeout(pending.timeoutId); + pending.resolve({ + success: false, + error: 'PlatformFieldAdapter disconnected before write result', + }); + } + this.pendingWrites.clear(); this.platformDataCache.clear(); this.dataBuffers.clear(); } diff --git a/packages/thingsvis-kernel/src/datasources/RESTAdapter.ts b/packages/thingsvis-kernel/src/datasources/RESTAdapter.ts index bc2fed6c..b447d418 100644 --- a/packages/thingsvis-kernel/src/datasources/RESTAdapter.ts +++ b/packages/thingsvis-kernel/src/datasources/RESTAdapter.ts @@ -20,6 +20,66 @@ function resolveVarExpressions(template: string, variableValues: Record): unknown { + if (typeof value === 'string') { + return resolveVarExpressions(value, variableValues); + } + if (Array.isArray(value)) { + return value.map((item) => resolveConfigValue(item, variableValues)); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record).map(([key, nestedValue]) => [ + key, + resolveConfigValue(nestedValue, variableValues), + ]), + ); + } + return value; +} + +function toQueryValues(value: unknown): string[] { + if (value === undefined || value === null) return []; + if (Array.isArray(value)) { + return value.flatMap((item) => toQueryValues(item)); + } + if (typeof value === 'object') { + return [JSON.stringify(value)]; + } + return [String(value)]; +} + +function getUrlBase(): string { + if (typeof globalThis.location?.origin === 'string' && globalThis.location.origin) { + return globalThis.location.origin; + } + return 'http://localhost'; +} + +function buildRequestUrl( + urlTemplate: string, + paramsTemplate: Record | undefined, + variableValues: Record, + includeParams: boolean, +): string { + const resolvedUrl = resolveVarExpressions(urlTemplate, variableValues); + const url = new URL(resolvedUrl, getUrlBase()); + + if (includeParams && paramsTemplate) { + const resolvedParams = resolveConfigValue(paramsTemplate, variableValues) as Record< + string, + unknown + >; + for (const [key, value] of Object.entries(resolvedParams)) { + for (const item of toQueryValues(value)) { + url.searchParams.append(key, item); + } + } + } + + return url.toString(); +} + /** * RESTAdapter: Handles standard REST API data fetching with polling support. * @@ -101,7 +161,7 @@ export class RESTAdapter extends BaseAdapter { } try { const config = this.restConfig; - const url = resolveVarExpressions(config.url, this.variableValues); + const url = buildRequestUrl(config.url, config.params, this.variableValues, true); const auth = config.auth ?? DEFAULT_AUTH_CONFIG; const authParams = generateAuthParams(auth); const finalUrl = appendAuthParamsToUrl(url, authParams); @@ -110,7 +170,7 @@ export class RESTAdapter extends BaseAdapter { const resolvedHeaders: Record = {}; if (config.headers) { for (const [k, v] of Object.entries(config.headers)) { - resolvedHeaders[k] = resolveVarExpressions(v, this.variableValues); + resolvedHeaders[k] = String(resolveConfigValue(v, this.variableValues) ?? ''); } } @@ -125,8 +185,6 @@ export class RESTAdapter extends BaseAdapter { // Strings are assumed to already be JSON; objects are stringified. const body = typeof payload === 'string' ? payload : JSON.stringify(payload); - console.debug('[RESTAdapter] write →', finalUrl, '\nbody:', body); - const response = await fetch(finalUrl, { method: 'POST', headers, body }); if (!response.ok) { @@ -155,8 +213,13 @@ export class RESTAdapter extends BaseAdapter { this.abortController?.abort(); this.abortController = new AbortController(); - // Resolve variable expressions in URL - const rawUrl = resolveVarExpressions(config.url, this.variableValues); + const shouldAppendParamsToUrl = config.method === 'GET' || config.method === 'DELETE'; + const rawUrl = buildRequestUrl( + config.url, + config.params, + this.variableValues, + shouldAppendParamsToUrl, + ); // Build URL with auth params if needed const auth = config.auth ?? DEFAULT_AUTH_CONFIG; @@ -167,7 +230,7 @@ export class RESTAdapter extends BaseAdapter { const resolvedHeaders: Record = {}; if (config.headers) { for (const [k, v] of Object.entries(config.headers)) { - resolvedHeaders[k] = resolveVarExpressions(v, this.variableValues); + resolvedHeaders[k] = String(resolveConfigValue(v, this.variableValues) ?? ''); } } @@ -185,13 +248,12 @@ export class RESTAdapter extends BaseAdapter { // Determine request body (resolve variable expressions in body too) let body: string | undefined; - if (config.method !== 'GET') { - const rawBody = - config.body ?? - (config.params && Object.keys(config.params).length > 0 - ? JSON.stringify(config.params) - : undefined); - body = rawBody ? resolveVarExpressions(rawBody, this.variableValues) : undefined; + if (config.method !== 'GET' && config.method !== 'DELETE') { + if (config.body) { + body = resolveVarExpressions(config.body, this.variableValues); + } else if (config.params && Object.keys(config.params).length > 0) { + body = JSON.stringify(resolveConfigValue(config.params, this.variableValues)); + } } // Execute fetch with timeout diff --git a/packages/thingsvis-kernel/tests/restAdapter.spec.ts b/packages/thingsvis-kernel/tests/restAdapter.spec.ts new file mode 100644 index 00000000..d73be18f --- /dev/null +++ b/packages/thingsvis-kernel/tests/restAdapter.spec.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { RESTConfigSchema, type DataSource } from '@thingsvis/schema'; +import { RESTAdapter } from '../src/datasources/RESTAdapter'; +import { DataSourceManager } from '../src/datasources/DataSourceManager'; +import { createKernelStore } from '../src/store/KernelStore'; + +function mockJsonFetch(body: unknown = { ok: true }) { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => body, + })) as unknown as typeof fetch; + vi.stubGlobal('fetch', fetchMock); + return fetchMock as unknown as ReturnType; +} + +describe('RESTAdapter runtime variables', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('allows template URLs in REST config schema', () => { + const parsed = RESTConfigSchema.parse({ + url: '{{ var.platformApiBaseUrl }}/devices/{{ var.deviceId }}/telemetry', + method: 'GET', + }); + + expect(parsed.url).toBe('{{ var.platformApiBaseUrl }}/devices/{{ var.deviceId }}/telemetry'); + }); + + it('resolves URL, headers, and GET params from runtime variables', async () => { + const fetchMock = mockJsonFetch({ temperature: 26 }); + const adapter = new RESTAdapter(); + + await adapter.refreshWithVariables({ + platformApiBaseUrl: 'https://platform.example.com', + deviceId: 'device-001', + token: 'abc', + page: 2, + }); + + await adapter.connect({ + id: 'rest-telemetry', + name: 'Telemetry', + type: 'REST', + config: { + url: '{{ var.platformApiBaseUrl }}/api/devices/{{ var.deviceId }}/telemetry', + method: 'GET', + headers: { + Authorization: 'Bearer {{ var.token }}', + }, + params: { + page: '{{ var.page }}', + fields: ['temperature', '{{ var.deviceId }}'], + }, + }, + } as DataSource); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, options] = fetchMock.mock.calls[0]; + const parsedUrl = new URL(String(url)); + expect(parsedUrl.origin).toBe('https://platform.example.com'); + expect(parsedUrl.pathname).toBe('/api/devices/device-001/telemetry'); + expect(parsedUrl.searchParams.get('page')).toBe('2'); + expect(parsedUrl.searchParams.getAll('fields')).toEqual(['temperature', 'device-001']); + expect((options as RequestInit).headers).toMatchObject({ + Authorization: 'Bearer abc', + }); + }); + + it('uses current store variable values for the first manager-registered REST request', async () => { + const fetchMock = mockJsonFetch({ status: 'online' }); + const useStore = createKernelStore(); + const manager = new DataSourceManager(); + (manager as any).store = useStore; + + useStore.getState().setVariableDefinitions([ + { name: 'platformApiBaseUrl', type: 'string', defaultValue: 'https://platform.example.com' }, + { name: 'deviceId', type: 'string', defaultValue: 'device-001' }, + ] as any); + useStore.getState().initVariablesFromDefinitions(useStore.getState().variableDefinitions); + + await manager.registerDataSource( + { + id: 'rest-status', + name: 'Status', + type: 'REST', + config: { + url: '{{ var.platformApiBaseUrl }}/api/devices/{{ var.deviceId }}/status', + method: 'GET', + }, + } as DataSource, + false, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'https://platform.example.com/api/devices/device-001/status', + ); + }); +}); diff --git a/packages/thingsvis-schema/src/datasource/index.ts b/packages/thingsvis-schema/src/datasource/index.ts index 7cf6ec64..1bc91ccf 100644 --- a/packages/thingsvis-schema/src/datasource/index.ts +++ b/packages/thingsvis-schema/src/datasource/index.ts @@ -36,7 +36,7 @@ export const DataSourceModeSchema = z.enum(['auto', 'manual']); */ export const RESTConfigSchema = z.object({ // Existing fields (backward compatible) - url: z.string().url(), + url: z.string().min(1), method: z.enum(['GET', 'POST', 'PUT', 'DELETE']).default('GET'), headers: z.record(z.string()).optional().default({}), params: z.record(z.unknown()).optional().default({}), diff --git a/packages/thingsvis-schema/src/widget-module.ts b/packages/thingsvis-schema/src/widget-module.ts index 54244e8b..e2020b01 100644 --- a/packages/thingsvis-schema/src/widget-module.ts +++ b/packages/thingsvis-schema/src/widget-module.ts @@ -85,6 +85,17 @@ export type WidgetMainModule = { * Embedded hosts remain source-of-truth and should ignore this field. */ standaloneDefaults?: Record; + /** + * Preview props applied when a widget is first created in an embedded editor. + * These values are author-owned display defaults and must not be treated as + * persisted business data once real bindings are configured. + */ + previewDefaults?: Record; + /** + * Widget-owned sample data used for embedded-editor preview state. + * Real data bindings override these values through normal runtime resolution. + */ + sampleData?: Record; /** * Whether the widget supports resizing. * - true: widget can be resized by user (default) diff --git a/packages/thingsvis-ui/src/components/DataContainer.tsx b/packages/thingsvis-ui/src/components/DataContainer.tsx index 47604bc3..677d7855 100644 --- a/packages/thingsvis-ui/src/components/DataContainer.tsx +++ b/packages/thingsvis-ui/src/components/DataContainer.tsx @@ -12,22 +12,31 @@ interface DataContainerProps { /** * DataContainer: A component that resolves data bindings in props before rendering children. * Useful for React-based widget renderers. - * Platform data is accessed via ds.__platform__ in the kernel store. + * Platform data is accessed through explicit data source bindings in the kernel store. */ export const DataContainer: React.FC = ({ store, nodeId, children }) => { const kernelState = useSyncExternalStore( useCallback((subscribe) => store.subscribe(subscribe), [store]), - () => store.getState() as KernelState + () => store.getState() as KernelState, ); const node = kernelState.nodesById[nodeId]; const resolvedProps = useMemo(() => { if (!node) return {}; - const variableValues = (kernelState as KernelState & { variableValues?: Record }).variableValues; - return PropertyResolver.resolve(node, kernelState.dataSources as Record, variableValues); - }, [node, kernelState.dataSources, (kernelState as KernelState & { variableValues?: Record }).variableValues]); + const variableValues = ( + kernelState as KernelState & { variableValues?: Record } + ).variableValues; + return PropertyResolver.resolve( + node, + kernelState.dataSources as Record, + variableValues, + ); + }, [ + node, + kernelState.dataSources, + (kernelState as KernelState & { variableValues?: Record }).variableValues, + ]); return children(resolvedProps); }; - diff --git a/packages/thingsvis-ui/src/engine/PropertyResolver.ts b/packages/thingsvis-ui/src/engine/PropertyResolver.ts index c21b917c..79890a78 100644 --- a/packages/thingsvis-ui/src/engine/PropertyResolver.ts +++ b/packages/thingsvis-ui/src/engine/PropertyResolver.ts @@ -8,47 +8,6 @@ import { ExpressionEvaluator } from '@thingsvis/utils'; * for direct renderer updates. */ export class PropertyResolver { - private static readonly GLOBAL_PLATFORM_DATA_SOURCE_ID = '__platform__' - - private static readonly TEMPLATE_PLATFORM_DATA_SOURCE_ID = '__platform___template____' - - private static isPlatformDataSourceId(dataSourceId: string): boolean { - return dataSourceId === this.GLOBAL_PLATFORM_DATA_SOURCE_ID || /^__platform_.+__$/.test(dataSourceId) - } - - private static hasUsablePlatformData(runtimeState: unknown): boolean { - if (!runtimeState || typeof runtimeState !== 'object') return false - const data = (runtimeState as Record).data - return !!data && typeof data === 'object' && !Array.isArray(data) && Object.keys(data as Record).length > 0 - } - - private static applyPlatformAliases(resolved: Record): Record { - const platformIds = Object.keys(resolved).filter((dataSourceId) => this.isPlatformDataSourceId(dataSourceId)) - if (platformIds.length === 0) return resolved - - const preferredPlatformId = - platformIds.find((dataSourceId) => dataSourceId !== this.TEMPLATE_PLATFORM_DATA_SOURCE_ID && this.hasUsablePlatformData(resolved[dataSourceId])) ?? - platformIds.find((dataSourceId) => this.hasUsablePlatformData(resolved[dataSourceId])) ?? - null - - if (!preferredPlatformId) return resolved - - const preferredEntry = resolved[preferredPlatformId] - const aliasIds = new Set([ - this.GLOBAL_PLATFORM_DATA_SOURCE_ID, - this.TEMPLATE_PLATFORM_DATA_SOURCE_ID, - ...platformIds, - ]) - - aliasIds.forEach((aliasId) => { - if (!this.hasUsablePlatformData(resolved[aliasId])) { - resolved[aliasId] = preferredEntry - } - }) - - return resolved - } - private static buildExpressionDataSources( dataSources: Record, ): Record { @@ -79,7 +38,7 @@ export class PropertyResolver { resolved[dataSourceId] = entry; }); - return this.applyPlatformAliases(resolved); + return resolved; } /** @@ -90,20 +49,19 @@ export class PropertyResolver { public static resolve( node: NodeState, dataSources: Record, - variableValues?: Record + variableValues?: Record, ): Record { const rawProps = (node.schemaRef.props ?? {}) as Record; const resolvedProps: Record = { ...rawProps }; // Preparation: Context for expression evaluation - // Platform data is accessed via ds.__platform__ (standard DataSource path) const context = { ds: this.buildExpressionDataSources(dataSources), var: variableValues ?? {}, }; // 1. Resolve standard property bindings (legacy/simple) - Object.keys(resolvedProps).forEach(key => { + Object.keys(resolvedProps).forEach((key) => { let val = resolvedProps[key]; if (typeof val === 'string') { if (val.includes('{{')) { @@ -122,13 +80,20 @@ export class PropertyResolver { if (resolvedValue !== undefined && resolvedValue !== null) { // Apply optional JS transform snippet. // Receives: `value` (the resolved field value), `data` (full DS snapshot for cross-field access) - if (binding.transform && typeof binding.transform === 'string' && binding.transform.trim()) { + if ( + binding.transform && + typeof binding.transform === 'string' && + binding.transform.trim() + ) { try { // Resolve full DS snapshot so transforms can access sibling fields // binding.dataSourcePath is like 'ds.myDs.data' — evaluate it from context let dsSnapshot: unknown = undefined; if (binding.dataSourcePath && typeof binding.dataSourcePath === 'string') { - dsSnapshot = ExpressionEvaluator.evaluate(`{{ ${binding.dataSourcePath} }}`, context); + dsSnapshot = ExpressionEvaluator.evaluate( + `{{ ${binding.dataSourcePath} }}`, + context, + ); } // Use SafeExecutor sandbox (blocks window/document/fetch access) const result = SafeExecutor.executeScript(binding.transform.trim(), { @@ -136,7 +101,8 @@ export class PropertyResolver { data: dsSnapshot, }); resolvedProps[binding.targetProp] = result ?? resolvedValue; - } catch { /* transform eval failed — use raw resolved value */ + } catch { + /* transform eval failed — use raw resolved value */ resolvedProps[binding.targetProp] = resolvedValue; } } else { @@ -150,4 +116,3 @@ export class PropertyResolver { return resolvedProps; } } - diff --git a/packages/thingsvis-ui/src/engine/VisualEngine.ts b/packages/thingsvis-ui/src/engine/VisualEngine.ts index ab9b744b..e247705c 100644 --- a/packages/thingsvis-ui/src/engine/VisualEngine.ts +++ b/packages/thingsvis-ui/src/engine/VisualEngine.ts @@ -40,7 +40,10 @@ function shouldTreatNodeAsResizable(node: NodeState, renderer: RendererFactory): return true; } - const schema = node.schemaRef as { type?: string; props?: Record } | null | undefined; + const schema = node.schemaRef as + | { type?: string; props?: Record } + | null + | undefined; return isPipeNodeType(schema?.type) && !hasPipeEndpointBinding(schema?.props); } @@ -252,7 +255,7 @@ export class VisualEngine { private getAnchorWorldPoint( position: { x: number; y: number }, size: { width: number; height: number }, - anchor?: string + anchor?: string, ): { x: number; y: number } { const cx = position.x + size.width / 2; const cy = position.y + size.height / 2; @@ -271,7 +274,13 @@ export class VisualEngine { } } - private scheduleAutoLayoutConnectedLine(node: NodeState, linkedNodes: Record) { + private scheduleAutoLayoutConnectedLine( + node: NodeState, + linkedNodes: Record< + string, + { id: string; position: { x: number; y: number }; size: { width: number; height: number } } + >, + ) { // Only for line nodes that have endpoint bindings if (!isConnectorNodeType(node.schemaRef.type)) return; const schema = node.schemaRef as any; @@ -289,9 +298,12 @@ export class VisualEngine { const firstPt = pts && pts.length >= 2 ? pts[0] : null; const lastPt = pts && pts.length >= 2 ? pts[pts.length - 1] : null; const normalizedPts = - firstPt && lastPt && - typeof firstPt?.x === 'number' && typeof firstPt?.y === 'number' && - typeof lastPt?.x === 'number' && typeof lastPt?.y === 'number' && + firstPt && + lastPt && + typeof firstPt?.x === 'number' && + typeof firstPt?.y === 'number' && + typeof lastPt?.x === 'number' && + typeof lastPt?.y === 'number' && Math.max(firstPt.x, firstPt.y, lastPt.x, lastPt.y) <= 1; const localToWorld = (p: any, fallback: { x: number; y: number }) => { @@ -312,11 +324,12 @@ export class VisualEngine { const padding = getConnectorPadding(props.strokeWidth); let worldPoints: Array<{ x: number; y: number }>; if (isPipeNodeType(node.schemaRef.type)) { - const explicitWaypoints = Array.isArray((props as any).waypoints) && (props as any).waypoints.length > 0 - ? ((props as any).waypoints as Array) - : pts && pts.length >= 3 - ? pts.slice(1, -1) - : []; + const explicitWaypoints = + Array.isArray((props as any).waypoints) && (props as any).waypoints.length > 0 + ? ((props as any).waypoints as Array) + : pts && pts.length >= 3 + ? pts.slice(1, -1) + : []; if (explicitWaypoints.length > 0) { worldPoints = orthogonalizePipePoints( @@ -334,7 +347,12 @@ export class VisualEngine { props.targetAnchor, ); } else { - worldPoints = buildElbowRoutePoints(startWorld, endWorld, props.sourceAnchor, props.targetAnchor); + worldPoints = buildElbowRoutePoints( + startWorld, + endWorld, + props.sourceAnchor, + props.targetAnchor, + ); } } else { worldPoints = [startWorld, endWorld]; @@ -348,7 +366,7 @@ export class VisualEngine { const nextPosition = { x: minPointX - padding, y: minPointY - padding }; const nextSize = { width: Math.max(40, maxPointX - minPointX + padding * 2), - height: Math.max(40, maxPointY - minPointY + padding * 2) + height: Math.max(40, maxPointY - minPointY + padding * 2), }; const nextPoints = worldPoints.map((point) => ({ @@ -363,7 +381,14 @@ export class VisualEngine { // Avoid re-entrancy: schedule into next frame. requestAnimationFrame(() => { const { updateNode } = this.store.getState() as KernelState & { - updateNode?: (id: string, changes: { position?: { x: number; y: number }; size?: { width: number; height: number }; props?: Record }) => void; + updateNode?: ( + id: string, + changes: { + position?: { x: number; y: number }; + size?: { width: number; height: number }; + props?: Record; + }, + ) => void; }; if (!updateNode) return; @@ -381,14 +406,17 @@ export class VisualEngine { const samePoints = Array.isArray(curPts) && curPts.length === nextPoints.length && - curPts.every((point: any, index: number) => point?.x === nextPoints[index]?.x && point?.y === nextPoints[index]?.y); + curPts.every( + (point: any, index: number) => + point?.x === nextPoints[index]?.x && point?.y === nextPoints[index]?.y, + ); if (!posChanged && !sizeChanged && samePoints) return; updateNode(node.id, { position: nextPosition, size: nextSize, - props: { ...curProps, points: nextPoints } + props: { ...curProps, points: nextPoints }, }); }); } @@ -400,8 +428,8 @@ export class VisualEngine { editable?: boolean; actionRuntime?: ActionRuntime; locale?: string; - } - ) { } + }, + ) {} /** * 构建连接节点信息,用于 line 插件的节点连接功能 @@ -410,9 +438,15 @@ export class VisualEngine { */ private buildLinkedNodes( node: NodeState, - allNodes: Record - ): Record { - const result: Record = {}; + allNodes: Record, + ): Record< + string, + { id: string; position: { x: number; y: number }; size: { width: number; height: number } } + > { + const result: Record< + string, + { id: string; position: { x: number; y: number }; size: { width: number; height: number } } + > = {}; const props = (node.schemaRef as any).props || {}; const nodeIds = [props.sourceNodeId, props.targetNodeId].filter(Boolean) as string[]; @@ -454,12 +488,14 @@ export class VisualEngine { const node = (this.store.getState() as KernelState).nodesById[nodeId]; if (shouldCommit && node) { - const currentProps = (((node.schemaRef as any)?.props ?? {}) as Record); + const currentProps = ((node.schemaRef as any)?.props ?? {}) as Record; const nextText = textarea.value; if ((currentProps.text ?? '') !== nextText) { - (this.store.getState() as KernelState & { - updateNode?: (id: string, changes: { props?: Record }) => void; - }).updateNode?.(nodeId, { + ( + this.store.getState() as KernelState & { + updateNode?: (id: string, changes: { props?: Record }) => void; + } + ).updateNode?.(nodeId, { props: { ...currentProps, text: nextText, @@ -480,7 +516,7 @@ export class VisualEngine { this.closeInlineTextEditor({ commit: true }); - const props = (((node?.schemaRef as any)?.props ?? {}) as Record); + const props = ((node?.schemaRef as any)?.props ?? {}) as Record; const textarea = document.createElement('textarea'); textarea.value = typeof props.text === 'string' ? props.text : String(props.text ?? ''); textarea.style.position = 'absolute'; @@ -567,9 +603,9 @@ export class VisualEngine { event.preventDefault(); event.stopPropagation(); - (this.store.getState() as KernelState & { selectNode?: (id: string | null) => void }).selectNode?.( - target.nodeId, - ); + ( + this.store.getState() as KernelState & { selectNode?: (id: string | null) => void } + ).selectNode?.(target.nodeId); this.openInlineTextEditor(target.nodeId, target.overlayBox); }; @@ -594,7 +630,7 @@ export class VisualEngine { this.app = new App({ view: container, - tree: {} + tree: {}, }); this.root = this.app.tree as unknown as Group; @@ -665,7 +701,11 @@ export class VisualEngine { for (const entry of this.instanceMap.values()) { entry.overlayClickCleanup?.(); if (entry.reactRoot) { - try { entry.reactRoot.unmount(); } catch { /* already unmounted */ } + try { + entry.reactRoot.unmount(); + } catch { + /* already unmounted */ + } } } this.instanceMap.clear(); @@ -691,11 +731,13 @@ export class VisualEngine { } } - - private isSyncing = false; - sync(nodes: Record, connections: ConnectionState[] = [], layerOrder: string[] = []) { + sync( + nodes: Record, + connections: ConnectionState[] = [], + layerOrder: string[] = [], + ) { if (!this.app || !this.root || this.isSyncing) return; this.isSyncing = true; @@ -705,7 +747,8 @@ export class VisualEngine { // Sync canvas theme attribute on overlayRoot so --w-* CSS tokens cascade to all widgets. // When the theme changes, invalidate all props caches to force a full widget update cycle. if (this.overlayRoot) { - const currentTheme = (this.store.getState().page as any)?.config?.theme ?? DEFAULT_CANVAS_THEME; + const currentTheme = + (this.store.getState().page as any)?.config?.theme ?? DEFAULT_CANVAS_THEME; if (currentTheme !== this.lastAppliedCanvasTheme) { this.overlayRoot.dataset['canvasTheme'] = currentTheme; this.lastAppliedCanvasTheme = currentTheme; @@ -725,14 +768,19 @@ export class VisualEngine { entry.renderer.destroy(entry.instance); if (entry.renderer.destroyOverlay && entry.overlayInst) { // Pass the node state (may be undefined if node was removed from store — use cached id) - const removingNode = nodes[id] ?? { id, schemaRef: {} } as unknown as NodeState; + const removingNode = nodes[id] ?? ({ id, schemaRef: {} } as unknown as NodeState); entry.renderer.destroyOverlay(entry.overlayInst as any, removingNode); } // Unmount React root before removing DOM if (entry.reactRoot) { - try { entry.reactRoot.unmount(); } catch { /* already unmounted */ } + try { + entry.reactRoot.unmount(); + } catch { + /* already unmounted */ + } } - if (entry.overlayBox?.parentElement) entry.overlayBox.parentElement.removeChild(entry.overlayBox); + if (entry.overlayBox?.parentElement) + entry.overlayBox.parentElement.removeChild(entry.overlayBox); this.instanceMap.delete(id); // Clean up props cache this.lastNodePropsCache.delete(id); @@ -740,7 +788,7 @@ export class VisualEngine { } // Add or update visible nodes - Object.values(nodes).forEach(node => { + Object.values(nodes).forEach((node) => { // Wrap each node's sync in try-catch to isolate errors try { this.syncSingleNode(node, root, nodes); @@ -757,7 +805,11 @@ export class VisualEngine { this.instanceMap.set(node.id, { instance, renderer: errorRenderer }); } } catch (placeholderError) { - console.error('[VisualEngine] errorRenderer itself failed for node', node.id, placeholderError); + console.error( + '[VisualEngine] errorRenderer itself failed for node', + node.id, + placeholderError, + ); } } }); @@ -780,8 +832,8 @@ export class VisualEngine { if (!this.root) return; const visibleIds = Object.values(nodes) - .filter(n => n.visible) - .map(n => n.id); + .filter((n) => n.visible) + .map((n) => n.id); const ordered: string[] = []; const seen = new Set(); @@ -834,7 +886,9 @@ export class VisualEngine { if (!type || typeof type !== 'string') { const msg = 'Invalid node type'; this.errorMessageByNode.set(node.id, msg); - const { setNodeError } = this.store.getState() as KernelState & { setNodeError?: (id: string, msg: string) => void }; + const { setNodeError } = this.store.getState() as KernelState & { + setNodeError?: (id: string, msg: string) => void; + }; setNodeError?.(node.id, msg); return; } @@ -859,7 +913,9 @@ export class VisualEngine { // If renderer is the errorRenderer, surface a non-fatal error into kernel state for visibility. const errMsg = this.errorMessageByNode.get(node.id) ?? this.errorMessageByType.get(type); if (errMsg && node.error !== errMsg) { - const { setNodeError } = this.store.getState() as KernelState & { setNodeError?: (id: string, msg: string) => void }; + const { setNodeError } = this.store.getState() as KernelState & { + setNodeError?: (id: string, msg: string) => void; + }; setNodeError?.(node.id, errMsg); } root.add(instance as any); @@ -897,7 +953,7 @@ export class VisualEngine { undefined, this.opts?.actionRuntime, ), - on: (_event: string, _handler: (payload?: unknown) => void) => () => { }, + on: (_event: string, _handler: (payload?: unknown) => void) => () => {}, }; const ov = rendererToUse.createOverlay(contextWithLinks); @@ -912,18 +968,20 @@ export class VisualEngine { const reactRoot = createRoot(wrapperEl); reactRoot.render( - React.createElement(WidgetErrorBoundary, { - widgetType, - onError: (error: Error) => { - console.error(`[VisualEngine] Widget "${widgetType}" runtime error:`, error); - const { setNodeError } = this.store.getState() as KernelState & { - setNodeError?: (id: string, msg: string) => void; - }; - setNodeError?.(node.id, error.message); + React.createElement( + WidgetErrorBoundary, + { + widgetType, + onError: (error: Error) => { + console.error(`[VisualEngine] Widget "${widgetType}" runtime error:`, error); + const { setNodeError } = this.store.getState() as KernelState & { + setNodeError?: (id: string, msg: string) => void; + }; + setNodeError?.(node.id, error.message); + }, }, - }, - React.createElement(DomBridge, { element: ov.element }) - ) + React.createElement(DomBridge, { element: ov.element }), + ), ); // Track reactRoot for cleanup @@ -975,7 +1033,11 @@ export class VisualEngine { } catch (e) { // overlay 创建失败 — 显示红色错误占位框,不传播到 App 级别(防白屏) // eslint-disable-next-line no-console - console.error('[VisualEngine] Widget overlay failed to mount:', (node.schemaRef as any)?.type, e); + console.error( + '[VisualEngine] Widget overlay failed to mount:', + (node.schemaRef as any)?.type, + e, + ); if (overlayBox) { const widgetType = (node.schemaRef as any)?.type ?? 'unknown'; const errMsg = e instanceof Error ? e.message : String(e); @@ -1099,7 +1161,8 @@ export class VisualEngine { // Stringify both props and data bindings once — used to scan for ds./var. references // so that expressions in either location trigger cache invalidation. - const stringifiedContext = JSON.stringify((node.schemaRef as any).props || {}) + JSON.stringify(dataBindings); + const stringifiedContext = + JSON.stringify((node.schemaRef as any).props || {}) + JSON.stringify(dataBindings); let dataSourceKey = ''; if (stringifiedContext.includes('ds.')) { @@ -1119,7 +1182,7 @@ export class VisualEngine { } // Extract referenced variables from props and bindings - // We stringify both and search for `var.` references + // We stringify both and search for `var.` references // to ensure variable updates trigger a visual refresh. let variableKey = ''; if (stringifiedContext.includes('var.')) { @@ -1147,8 +1210,16 @@ export class VisualEngine { // Only call updateOverlay if props, linkedNodes, data source values, variable values, // binding expressions, or theme changed. - // Platform field data (ds.__platform__) is already covered by dataSourceKey above. - const propsKey = JSON.stringify((node.schemaRef as any).props || {}) + linkedKey + dataSourceKey + variableKey + '||' + bindingExprsKey + '||' + canvasTheme; + // Platform field data is already covered by dataSourceKey above. + const propsKey = + JSON.stringify((node.schemaRef as any).props || {}) + + linkedKey + + dataSourceKey + + variableKey + + '||' + + bindingExprsKey + + '||' + + canvasTheme; const lastPropsKey = this.lastNodePropsCache.get(node.id); if (propsKey !== lastPropsKey) { @@ -1168,15 +1239,27 @@ export class VisualEngine { // 销毁旧的崩溃实例 if (existing.reactRoot) { - try { existing.reactRoot.unmount(); } catch { /* teardown — already unmounted */ } + try { + existing.reactRoot.unmount(); + } catch { + /* teardown — already unmounted */ + } } if (existing.renderer.destroyOverlay) { - try { existing.renderer.destroyOverlay(existing.overlayInst as any, node); } catch { /* teardown — overlay already destroyed */ } + try { + existing.renderer.destroyOverlay(existing.overlayInst as any, node); + } catch { + /* teardown — overlay already destroyed */ + } } if (existing.overlayBox) { existing.overlayBox.innerHTML = ''; // 清理 DOM 残留 } - try { existing.renderer.destroy(existing.instance); } catch { /* teardown — instance already destroyed */ } + try { + existing.renderer.destroy(existing.instance); + } catch { + /* teardown — instance already destroyed */ + } // 创建 errorRenderer 并替换当前渲染器 existing.renderer = errorRenderer; @@ -1185,18 +1268,21 @@ export class VisualEngine { existing.overlayInst = undefined; root.add(existing.instance as any); - const { setNodeError } = this.store.getState() as KernelState & { setNodeError?: (id: string, msg: string) => void }; + const { setNodeError } = this.store.getState() as KernelState & { + setNodeError?: (id: string, msg: string) => void; + }; setNodeError?.(node.id, e instanceof Error ? e.message : String(e)); } } } - private getNodeEventHandlers(node: NodeState, eventName: string): EventHandlerConfig[] { const handlers = (node.schemaRef as any)?.events; if (!Array.isArray(handlers)) return []; return handlers.filter((handler): handler is EventHandlerConfig => { - return handler?.event === eventName && Array.isArray(handler.actions) && handler.actions.length > 0; + return ( + handler?.event === eventName && Array.isArray(handler.actions) && handler.actions.length > 0 + ); }); } @@ -1206,14 +1292,15 @@ export class VisualEngine { overlayBox?: HTMLDivElement; overlayClickCleanup?: () => void; overlayClickSignature?: string; - } + }, ) { const box = entry.overlayBox; if (!box) return; const clickHandlers = this.getNodeEventHandlers(node, 'click'); const type = String((node.schemaRef as any)?.type ?? ''); - const shouldBind = this.opts?.editable === false && type.startsWith('basic/') && clickHandlers.length > 0; + const shouldBind = + this.opts?.editable === false && type.startsWith('basic/') && clickHandlers.length > 0; const nextSignature = shouldBind ? JSON.stringify(clickHandlers) : ''; if (entry.overlayClickSignature === nextSignature) { @@ -1253,9 +1340,9 @@ export class VisualEngine { // Built-in fallback for legacy "rect" nodes if (type === 'rect') { const builtIn: RendererFactory = { - create: node => new Rect(this.toRectProps(node)), + create: (node) => new Rect(this.toRectProps(node)), update: (inst: any, node) => inst.set?.(this.toRectProps(node)), - destroy: inst => inst.remove?.() + destroy: (inst) => inst.remove?.(), }; this.rendererByType.set(type, builtIn); return builtIn; @@ -1296,7 +1383,6 @@ export class VisualEngine { this.failedRendererTypes.set(type, Date.now()); this.errorMessageByType.set(type, e instanceof Error ? e.message : String(e)); // eslint-disable-next-line no-console - } })(); this.pendingRendererLoad.set(type, p); @@ -1384,10 +1470,12 @@ export class VisualEngine { // Border if (baseStyle.border) { - box.style.borderWidth = baseStyle.border.width !== undefined ? `${baseStyle.border.width}px` : ''; + box.style.borderWidth = + baseStyle.border.width !== undefined ? `${baseStyle.border.width}px` : ''; box.style.borderColor = baseStyle.border.color || ''; box.style.borderStyle = baseStyle.border.style || 'solid'; - box.style.borderRadius = baseStyle.border.radius !== undefined ? `${baseStyle.border.radius}px` : ''; + box.style.borderRadius = + baseStyle.border.radius !== undefined ? `${baseStyle.border.radius}px` : ''; } else { box.style.borderWidth = ''; box.style.borderColor = ''; @@ -1436,7 +1524,7 @@ export class VisualEngine { rotation, fill: 'transparent', // Always transparent - visual rendering is done via DOM overlay draggable: isEditable, - cursor: isEditable ? 'pointer' : 'default' + cursor: isEditable ? 'pointer' : 'default', }; } } diff --git a/packages/thingsvis-ui/src/engine/executeActions.ts b/packages/thingsvis-ui/src/engine/executeActions.ts index c854a2f4..82dece4b 100644 --- a/packages/thingsvis-ui/src/engine/executeActions.ts +++ b/packages/thingsvis-ui/src/engine/executeActions.ts @@ -84,7 +84,7 @@ function resolveNavigateDestination( */ function buildExpressionContext( state: Record, - payload: unknown + payload: unknown, ): Record { return { payload, @@ -107,10 +107,7 @@ function buildExpressionContext( * `{"id": "{{payload ? 1 : 0}}"}` → `{"id": "1"}` * `{{payload}}` → `true` */ -function resolveTemplateExpressions( - template: string, - context: Record -): string { +function resolveTemplateExpressions(template: string, context: Record): string { return template.replace(/\{\{(.+?)\}\}/gs, (_match, expr: string) => { const result = SafeExecutor.executeScript(expr.trim(), context); if (result === undefined || result === null) return ''; @@ -127,17 +124,25 @@ function resolveTemplateExpressions( * 3. If not valid JSON — try evaluating as a full JS expression via SafeExecutor * 4. Final fallback — return raw string */ -function resolvePayload( - raw: unknown, - context: Record -): unknown { +function resolvePayload(raw: unknown, context: Record): unknown { if (typeof raw !== 'string') return raw; const hasMustache = /\{\{.+?\}\}/s.test(raw); if (hasMustache) { + const singleExpression = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (singleExpression) { + const evaluated = SafeExecutor.executeScript((singleExpression[1] ?? '').trim(), context); + if (evaluated !== undefined && evaluated !== null) return evaluated; + return ''; + } + const resolved = resolveTemplateExpressions(raw, context); - try { return JSON.parse(resolved); } catch { /* not JSON after resolution */ } + try { + return JSON.parse(resolved); + } catch { + /* not JSON after resolution */ + } // If the entire string was a single {{expr}}, the resolved value might be // a primitive that doesn't JSON.parse — evaluate it directly const evaluated = SafeExecutor.executeScript(resolved, context); @@ -146,7 +151,11 @@ function resolvePayload( } // No mustache — try static JSON first (most common case for callWrite payloads) - try { return JSON.parse(raw); } catch { /* not valid JSON */ } + try { + return JSON.parse(raw); + } catch { + /* not valid JSON */ + } // Not JSON — try as full JS expression (e.g. `payload ? {...} : {...}`) const evaluated = SafeExecutor.executeScript(raw, context); @@ -156,7 +165,10 @@ function resolvePayload( } // Exported for testing -export { resolvePayload as _resolvePayload, resolveTemplateExpressions as _resolveTemplateExpressions }; +export { + resolvePayload as _resolvePayload, + resolveTemplateExpressions as _resolveTemplateExpressions, +}; export { buildExpressionContext as _buildExpressionContext }; export { resolveNavigateDestination as _resolveNavigateDestination }; @@ -202,21 +214,34 @@ export function executeAction( } case 'callWrite': { - const dsId = action.dataSourceId; - if (!dsId) break; + const rawDsId = action.dataSourceId; + if (!rawDsId) break; + + const context = buildExpressionContext(state, payload); + const resolvedDsId = resolvePayload(rawDsId, context); + const dsId = typeof resolvedDsId === 'string' ? resolvedDsId.trim() : ''; + if (!dsId) { + console.warn('[ThingsVis] callWrite skipped: dataSourceId did not resolve to a string', { + dataSourceId: rawDsId, + resolved: resolvedDsId, + }); + break; + } const dataSourceManager = runtime.dataSourceManager; if (!dataSourceManager) { - console.warn('[ThingsVis] callWrite skipped: no runtime-scoped dataSourceManager provided', { - dataSourceId: dsId, - }); + console.warn( + '[ThingsVis] callWrite skipped: no runtime-scoped dataSourceManager provided', + { + dataSourceId: dsId, + }, + ); break; } // Use configured payload if set, otherwise fall back to event payload const rawPayload = action.payload ?? payload; // Resolve dynamic expressions: {{expr}}, full JS expressions, or static JSON - const context = buildExpressionContext(state, payload); const writePayload = resolvePayload(rawPayload, context); if ((import.meta as any).env?.DEV) { console.debug('[ThingsVis] callWrite →', dsId, { @@ -225,13 +250,14 @@ export function executeAction( resolvedType: typeof writePayload, }); } - dataSourceManager.writeDataSource(dsId, writePayload) - .then(result => { + dataSourceManager + .writeDataSource(dsId, writePayload) + .then((result) => { if (!result.success) { console.error('[ThingsVis] callWrite failed for dataSource', dsId, result.error); } }) - .catch(e => { + .catch((e) => { console.error('[ThingsVis] callWrite error for dataSource', dsId, e); }); break; @@ -289,7 +315,7 @@ export function buildEmit( return (eventName: string, payload?: unknown) => { const schema = getSchema(); const handlers = (schema?.events ?? []) as EventHandlerConfig[]; - const matching = handlers.filter(h => h.event === eventName); + const matching = handlers.filter((h) => h.event === eventName); const state = getState(); diff --git a/packages/thingsvis-ui/src/hooks/usePlatformData.ts b/packages/thingsvis-ui/src/hooks/usePlatformData.ts index ccf1f817..30bb7121 100644 --- a/packages/thingsvis-ui/src/hooks/usePlatformData.ts +++ b/packages/thingsvis-ui/src/hooks/usePlatformData.ts @@ -1,17 +1,15 @@ /** * usePlatformData Hook * - * @deprecated Platform data is now accessed exclusively via the ds.__platform__ - * data source in the kernel store. This hook is kept as a no-op stub to avoid - * breaking any downstream widget code that might import it. + * @deprecated Platform data is now accessed through explicit data source bindings. + * This hook is kept as a no-op stub to avoid breaking downstream widget code. * - * Returns an empty object — widgets should use {{ ds.__platform__.data.xxx }} - * expressions instead of {{ platform.xxx }}. + * Returns an empty object. */ /** - * @deprecated Returns an empty object. Platform data flows through ds.__platform__ now. + * @deprecated Returns an empty object. */ export function usePlatformData(): Record { - return {}; + return {}; } diff --git a/packages/thingsvis-ui/tests/executeActions.spec.ts b/packages/thingsvis-ui/tests/executeActions.spec.ts new file mode 100644 index 00000000..7413572f --- /dev/null +++ b/packages/thingsvis-ui/tests/executeActions.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest'; +import { executeAction } from '../src/engine/executeActions'; + +describe('executeAction callWrite', () => { + it('resolves dataSourceId and payload expressions before writing', () => { + const writeDataSource = vi.fn(async () => ({ success: true })); + + executeAction( + { + type: 'callWrite', + dataSourceId: '{{vars.targetDataSourceId}}', + payload: '{"value": "{{payload}}"}', + }, + { + variableValues: { targetDataSourceId: 'platform-device-1' }, + dataSources: {}, + }, + true, + { dataSourceManager: { writeDataSource } as any }, + ); + + expect(writeDataSource).toHaveBeenCalledWith('platform-device-1', { value: 'true' }); + }); +}); diff --git a/packages/thingsvis-ui/tests/propertyResolver.spec.ts b/packages/thingsvis-ui/tests/propertyResolver.spec.ts index ad086719..e92c1660 100644 --- a/packages/thingsvis-ui/tests/propertyResolver.spec.ts +++ b/packages/thingsvis-ui/tests/propertyResolver.spec.ts @@ -18,200 +18,215 @@ import type { NodeState } from '@thingsvis/kernel'; // Helper to construct a minimal NodeState function makeNode(props: Record, data?: any[]): NodeState { - return { - id: 'test-node', - visible: true, - locked: false, - error: undefined, - schemaRef: { - id: 'test-node', - type: 'test/widget', - position: { x: 0, y: 0 }, - size: { width: 100, height: 100 }, - props, - data: data ?? [], - } as any, - } as NodeState; + return { + id: 'test-node', + visible: true, + locked: false, + error: undefined, + schemaRef: { + id: 'test-node', + type: 'test/widget', + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + props, + data: data ?? [], + } as any, + } as NodeState; } describe('PropertyResolver', () => { - // ── Static props ─────────────────────────────────────────────────────────── - - describe('static props (no bindings)', () => { - it('returns static props unchanged', () => { - const node = makeNode({ fontSize: 16, color: '#ff0000', title: 'Hello' }); - const result = PropertyResolver.resolve(node, {}); - - expect(result.fontSize).toBe(16); - expect(result.color).toBe('#ff0000'); - expect(result.title).toBe('Hello'); - }); - - it('returns empty object when props is empty', () => { - const node = makeNode({}); - const result = PropertyResolver.resolve(node, {}); - expect(result).toEqual({}); - }); - - it('preserves boolean and null props', () => { - const node = makeNode({ enabled: true, disabled: false, nothing: null }); - const result = PropertyResolver.resolve(node, {}); - expect(result.enabled).toBe(true); - expect(result.disabled).toBe(false); - // null is kept as-is (not overriding) - }); - }); - - // ── Inline expression binding in prop value ──────────────────────────────── - - describe('inline {{ }} expressions in prop values', () => { - const dataSources = { - sensor: { data: { temperature: 42.5, unit: '°C' }, status: 'connected' }, - }; - - it('resolves {{ ds.sensor.data.temperature }} in a string prop', () => { - const node = makeNode({ value: '{{ ds.sensor.data.temperature }}' }); - const result = PropertyResolver.resolve(node, dataSources); - expect(result.value).toBe(42.5); - }); - - it('resolves expression embedded in a template string prop', () => { - const node = makeNode({ label: 'Temp: {{ ds.sensor.data.temperature }}{{ ds.sensor.data.unit }}' }); - const result = PropertyResolver.resolve(node, dataSources); - // ExpressionEvaluator evaluates the whole string — result may be the first match value - // We just assert the resolution happened (not a raw template string) - expect(result.label).not.toContain('{{'); - }); - - it('leaves props without {{ }} unchanged', () => { - const node = makeNode({ title: 'Static Title' }); - const result = PropertyResolver.resolve(node, dataSources); - expect(result.title).toBe('Static Title'); - }); - - it('returns undefined/null gracefully when DS is missing', () => { - const node = makeNode({ value: '{{ ds.missing.data.field }}' }); - // Empty data sources — no crash expected - expect(() => PropertyResolver.resolve(node, {})).not.toThrow(); - }); - }); - - // ── Explicit DataBinding entries (node.data array) ──────────────────────── - - describe('explicit DataBinding entries (node.schemaRef.data)', () => { - const dataSources = { - power: { - data: { current: 3.7, voltage: 220, status: 'OK' }, - status: 'connected', - }, - }; - - it('overwrites static prop when a DataBinding targets the same key', () => { - const node = makeNode( - { value: 0 }, // static fallback - [{ targetProp: 'value', expression: '{{ ds.power.data.current }}' }] - ); - const result = PropertyResolver.resolve(node, dataSources); - // Bound value (3.7) takes precedence over static (0) - expect(result.value).toBe(3.7); - }); - - it('resolves nested data path', () => { - const node = makeNode( - {}, - [{ targetProp: 'voltage', expression: '{{ ds.power.data.voltage }}' }] - ); - const result = PropertyResolver.resolve(node, dataSources); - expect(result.voltage).toBe(220); - }); - - it('keeps static prop when no DataBinding targets it', () => { - const node = makeNode( - { color: '#00ff00', value: 0 }, - [{ targetProp: 'value', expression: '{{ ds.power.data.current }}' }] - ); - const result = PropertyResolver.resolve(node, dataSources); - expect(result.color).toBe('#00ff00'); // untouched - expect(result.value).toBe(3.7); // overwritten by binding - }); - - it('does NOT overwrite prop when resolved value is undefined (missing path)', () => { - const node = makeNode( - { value: 99 }, - [{ targetProp: 'value', expression: '{{ ds.power.data.nonexistent }}' }] - ); - const result = PropertyResolver.resolve(node, dataSources); - // The binding resolves to undefined → static prop 99 should remain - expect(result.value).toBe(99); - }); - - it('handles multiple DataBindings for different props', () => { - const node = makeNode( - {}, - [ - { targetProp: 'current', expression: '{{ ds.power.data.current }}' }, - { targetProp: 'voltage', expression: '{{ ds.power.data.voltage }}' }, - ] - ); - const result = PropertyResolver.resolve(node, dataSources); - expect(result.current).toBe(3.7); - expect(result.voltage).toBe(220); - }); - }); - - // ── JS transform on DataBinding ─────────────────────────────────────────── - - describe('DataBinding with JS transform', () => { - const dataSources = { - meter: { data: { value: 0.75 }, status: 'connected' }, - }; - - it('applies transform: value * 100 → percentage', () => { - const node = makeNode( - {}, - [{ - targetProp: 'percent', - expression: '{{ ds.meter.data.value }}', - transform: 'value * 100', - }] - ); - const result = PropertyResolver.resolve(node, dataSources); - expect(result.percent).toBeCloseTo(75); - }); - - it('falls back to raw value when transform throws', () => { - const node = makeNode( - {}, - [{ - targetProp: 'x', - expression: '{{ ds.meter.data.value }}', - transform: 'THIS IS NOT VALID JS !!!', - }] - ); - expect(() => PropertyResolver.resolve(node, dataSources)).not.toThrow(); - // Raw value should be used as fallback - const result = PropertyResolver.resolve(node, dataSources); - expect(result.x).toBe(0.75); - }); - }); - - // ── Live data source update scenario ────────────────────────────────────── - - describe('data source update propagation (the original bug scenario)', () => { - it('reflects updated DS data on second call (simulates save+reconnect)', () => { - const node = makeNode( - { value: 0 }, - [{ targetProp: 'value', expression: '{{ ds.sensor.data.reading }}' }] - ); - - const dsV1 = { sensor: { data: { reading: 100 } } }; - const dsV2 = { sensor: { data: { reading: 200 } } }; // updated after save - - const resultV1 = PropertyResolver.resolve(node, dsV1); - const resultV2 = PropertyResolver.resolve(node, dsV2); - - expect(resultV1.value).toBe(100); - expect(resultV2.value).toBe(200); // must reflect new value - }); + // ── Static props ─────────────────────────────────────────────────────────── + + describe('static props (no bindings)', () => { + it('returns static props unchanged', () => { + const node = makeNode({ fontSize: 16, color: '#ff0000', title: 'Hello' }); + const result = PropertyResolver.resolve(node, {}); + + expect(result.fontSize).toBe(16); + expect(result.color).toBe('#ff0000'); + expect(result.title).toBe('Hello'); + }); + + it('returns empty object when props is empty', () => { + const node = makeNode({}); + const result = PropertyResolver.resolve(node, {}); + expect(result).toEqual({}); + }); + + it('preserves boolean and null props', () => { + const node = makeNode({ enabled: true, disabled: false, nothing: null }); + const result = PropertyResolver.resolve(node, {}); + expect(result.enabled).toBe(true); + expect(result.disabled).toBe(false); + // null is kept as-is (not overriding) + }); + }); + + // ── Inline expression binding in prop value ──────────────────────────────── + + describe('inline {{ }} expressions in prop values', () => { + const dataSources = { + sensor: { data: { temperature: 42.5, unit: '°C' }, status: 'connected' }, + }; + + it('resolves {{ ds.sensor.data.temperature }} in a string prop', () => { + const node = makeNode({ value: '{{ ds.sensor.data.temperature }}' }); + const result = PropertyResolver.resolve(node, dataSources); + expect(result.value).toBe(42.5); + }); + + it('resolves expression embedded in a template string prop', () => { + const node = makeNode({ + label: 'Temp: {{ ds.sensor.data.temperature }}{{ ds.sensor.data.unit }}', + }); + const result = PropertyResolver.resolve(node, dataSources); + // ExpressionEvaluator evaluates the whole string — result may be the first match value + // We just assert the resolution happened (not a raw template string) + expect(result.label).not.toContain('{{'); + }); + + it('leaves props without {{ }} unchanged', () => { + const node = makeNode({ title: 'Static Title' }); + const result = PropertyResolver.resolve(node, dataSources); + expect(result.title).toBe('Static Title'); + }); + + it('returns undefined/null gracefully when DS is missing', () => { + const node = makeNode({ value: '{{ ds.missing.data.field }}' }); + // Empty data sources — no crash expected + expect(() => PropertyResolver.resolve(node, {})).not.toThrow(); + }); + }); + + // ── Explicit DataBinding entries (node.data array) ──────────────────────── + + describe('explicit DataBinding entries (node.schemaRef.data)', () => { + const dataSources = { + power: { + data: { current: 3.7, voltage: 220, status: 'OK' }, + status: 'connected', + }, + }; + + it('overwrites static prop when a DataBinding targets the same key', () => { + const node = makeNode( + { value: 0 }, // static fallback + [{ targetProp: 'value', expression: '{{ ds.power.data.current }}' }], + ); + const result = PropertyResolver.resolve(node, dataSources); + // Bound value (3.7) takes precedence over static (0) + expect(result.value).toBe(3.7); + }); + + it('overwrites embedded sample data with real bound data', () => { + const node = makeNode({ data: [{ name: 'Sample', value: 1 }] }, [ + { targetProp: 'data', expression: '{{ ds.power.data.series }}' }, + ]); + const result = PropertyResolver.resolve(node, { + power: { data: { series: [{ name: 'Real', value: 9 }] } }, + }); + + expect(result.data).toEqual([{ name: 'Real', value: 9 }]); + }); + + it('keeps an empty real array instead of falling back to sample data', () => { + const node = makeNode({ data: [{ name: 'Sample', value: 1 }] }, [ + { targetProp: 'data', expression: '{{ ds.power.data.series }}' }, + ]); + const result = PropertyResolver.resolve(node, { + power: { data: { series: [] } }, + }); + + expect(result.data).toEqual([]); + }); + + it('resolves nested data path', () => { + const node = makeNode({}, [ + { targetProp: 'voltage', expression: '{{ ds.power.data.voltage }}' }, + ]); + const result = PropertyResolver.resolve(node, dataSources); + expect(result.voltage).toBe(220); + }); + + it('keeps static prop when no DataBinding targets it', () => { + const node = makeNode({ color: '#00ff00', value: 0 }, [ + { targetProp: 'value', expression: '{{ ds.power.data.current }}' }, + ]); + const result = PropertyResolver.resolve(node, dataSources); + expect(result.color).toBe('#00ff00'); // untouched + expect(result.value).toBe(3.7); // overwritten by binding + }); + + it('does NOT overwrite prop when resolved value is undefined (missing path)', () => { + const node = makeNode({ value: 99 }, [ + { targetProp: 'value', expression: '{{ ds.power.data.nonexistent }}' }, + ]); + const result = PropertyResolver.resolve(node, dataSources); + // The binding resolves to undefined → static prop 99 should remain + expect(result.value).toBe(99); + }); + + it('handles multiple DataBindings for different props', () => { + const node = makeNode({}, [ + { targetProp: 'current', expression: '{{ ds.power.data.current }}' }, + { targetProp: 'voltage', expression: '{{ ds.power.data.voltage }}' }, + ]); + const result = PropertyResolver.resolve(node, dataSources); + expect(result.current).toBe(3.7); + expect(result.voltage).toBe(220); + }); + }); + + // ── JS transform on DataBinding ─────────────────────────────────────────── + + describe('DataBinding with JS transform', () => { + const dataSources = { + meter: { data: { value: 0.75 }, status: 'connected' }, + }; + + it('applies transform: value * 100 → percentage', () => { + const node = makeNode({}, [ + { + targetProp: 'percent', + expression: '{{ ds.meter.data.value }}', + transform: 'value * 100', + }, + ]); + const result = PropertyResolver.resolve(node, dataSources); + expect(result.percent).toBeCloseTo(75); + }); + + it('falls back to raw value when transform throws', () => { + const node = makeNode({}, [ + { + targetProp: 'x', + expression: '{{ ds.meter.data.value }}', + transform: 'THIS IS NOT VALID JS !!!', + }, + ]); + expect(() => PropertyResolver.resolve(node, dataSources)).not.toThrow(); + // Raw value should be used as fallback + const result = PropertyResolver.resolve(node, dataSources); + expect(result.x).toBe(0.75); + }); + }); + + // ── Live data source update scenario ────────────────────────────────────── + + describe('data source update propagation (the original bug scenario)', () => { + it('reflects updated DS data on second call (simulates save+reconnect)', () => { + const node = makeNode({ value: 0 }, [ + { targetProp: 'value', expression: '{{ ds.sensor.data.reading }}' }, + ]); + + const dsV1 = { sensor: { data: { reading: 100 } } }; + const dsV2 = { sensor: { data: { reading: 200 } } }; // updated after save + + const resultV1 = PropertyResolver.resolve(node, dsV1); + const resultV2 = PropertyResolver.resolve(node, dsV2); + + expect(resultV1.value).toBe(100); + expect(resultV2.value).toBe(200); // must reflect new value }); + }); }); diff --git a/packages/thingsvis-widget-sdk/src/define-widget.test.ts b/packages/thingsvis-widget-sdk/src/define-widget.test.ts index 8446f9a4..f184b5ab 100644 --- a/packages/thingsvis-widget-sdk/src/define-widget.test.ts +++ b/packages/thingsvis-widget-sdk/src/define-widget.test.ts @@ -124,4 +124,20 @@ describe('defineWidget createOverlay event bridge', () => { requestAnimationFrameSpy.mockRestore(); cancelAnimationFrameSpy.mockRestore(); }); + + it('returns preview defaults and sample data as widget metadata', () => { + const previewWidget = defineWidget({ + id: 'test/preview-contract', + name: 'Preview Contract', + schema: z.object({ + label: z.string().default(''), + }), + previewDefaults: { label: 'Preview' }, + sampleData: { data: [{ value: 1 }] }, + render: () => ({}), + }); + + expect(previewWidget.previewDefaults).toEqual({ label: 'Preview' }); + expect(previewWidget.sampleData).toEqual({ data: [{ value: 1 }] }); + }); }); diff --git a/packages/thingsvis-widget-sdk/src/define-widget.ts b/packages/thingsvis-widget-sdk/src/define-widget.ts index 86af960c..0463b070 100644 --- a/packages/thingsvis-widget-sdk/src/define-widget.ts +++ b/packages/thingsvis-widget-sdk/src/define-widget.ts @@ -1,6 +1,6 @@ /** * defineWidget - 一站式插件定义 - * + * * 借鉴 Grafana 的设计理念,提供简洁的 API */ @@ -69,24 +69,28 @@ export type DefineWidgetConfig = { schema: z.ZodObject; /** Standalone-only demo props merged over schema defaults on initial creation. */ standaloneDefaults?: Record; + /** Embedded-editor preview props merged over schema defaults on initial creation. */ + previewDefaults?: Record; + /** Widget-owned embedded-editor sample data. */ + sampleData?: Record; /** 多语言翻译配置 (可选) */ locales?: Record>; - /** + /** * 控件配置 - * + * * 支持两种模式: * 1. 简化模式:{ Content: ['title'], Style: ['color'] } * 2. 详细模式:{ Content: [{ title: { binding: true } }] } */ controls?: SimpleGroupConfig | WidgetControls; - /** + /** * 一键开启所有绑定(傻瓜模式) * 设为 true 则所有字段自动支持数据绑定 */ enableAllBindings?: boolean; /** * 属性迁移函数 - * + * * 当保存的 widgetVersion 与当前 widget.version 不匹配时,宿主调用此函数 * @param props - 保存的旧属性对象 * @param fromVersion - 保存时的 widget 版本 @@ -98,9 +102,9 @@ export type DefineWidgetConfig = { * 返回值将放入 WidgetOverlayContext.data 供组件使用。 */ transformData?: (rawData: unknown, props: Record) => unknown; - /** + /** * 渲染函数 - * + * * @param el - DOM 容器元素 * @param props - 组件属性 * @param ctx - 组件上下文(位置、尺寸、主题等) @@ -109,7 +113,7 @@ export type DefineWidgetConfig = { render: ( el: HTMLElement, props: z.infer>, - ctx: WidgetOverlayContext + ctx: WidgetOverlayContext, ) => { update?: (props: z.infer>, ctx: WidgetOverlayContext) => void; destroy?: () => void; @@ -121,13 +125,22 @@ export type DefineWidgetConfig = { // ============================================================================ function isWidgetControls(obj: unknown): obj is WidgetControls { - return typeof obj === 'object' && obj !== null && 'groups' in obj && Array.isArray((obj as WidgetControls).groups); + return ( + typeof obj === 'object' && + obj !== null && + 'groups' in obj && + Array.isArray((obj as WidgetControls).groups) + ); } function normalizeSimpleGroupConfig( simpleConfig: SimpleGroupConfig, - enableAllBindings: boolean -): { groups: Record; overrides: Record>; bindings: Record } { + enableAllBindings: boolean, +): { + groups: Record; + overrides: Record>; + bindings: Record; +} { const groups: Record = {}; const overrides: Record> = {}; const bindings: Record = {}; @@ -176,27 +189,27 @@ function normalizeSimpleGroupConfig( /** * 定义 ThingsVis 插件 - * + * * @example * ```typescript * import { defineWidget } from '@thingsvis/widget-sdk'; * import { z } from 'zod'; - * + * * export default defineWidget({ * id: 'my-chart', * name: '我的图表', * category: 'chart', - * + * * schema: z.object({ * title: z.string().default('标题'), * color: z.string().default('#1890ff'), * }), - * + * * controls: { * Content: ['title'], * Style: [{ color: { kind: 'color', binding: true } }], * }, - * + * * render: (el, props) => { * el.innerHTML = `

${props.title}

`; * return { @@ -211,9 +224,7 @@ function normalizeSimpleGroupConfig( * }); * ``` */ -export function defineWidget( - config: DefineWidgetConfig -) { +export function defineWidget(config: DefineWidgetConfig) { const { id, name, @@ -222,6 +233,8 @@ export function defineWidget( version = '1.0.0', schema, standaloneDefaults, + previewDefaults, + sampleData, controls: controlsConfig, locales, enableAllBindings = false, @@ -239,16 +252,24 @@ export function defineWidget( if (!controlsConfig) { // 无配置:自动从 schema 生成,所有字段放 Advanced controls = generateControls(schema, { - bindings: enableAllBindings ? Object.fromEntries( - Object.keys(schema.shape).map(key => [key, { enabled: true, modes: ['static', 'field', 'expr'] }]) - ) : undefined, + bindings: enableAllBindings + ? Object.fromEntries( + Object.keys(schema.shape).map((key) => [ + key, + { enabled: true, modes: ['static', 'field', 'expr'] }, + ]), + ) + : undefined, }); } else if (isWidgetControls(controlsConfig)) { // 已是完整格式 controls = controlsConfig; } else { // 简化格式,需要转换 - const { groups, overrides, bindings } = normalizeSimpleGroupConfig(controlsConfig, enableAllBindings); + const { groups, overrides, bindings } = normalizeSimpleGroupConfig( + controlsConfig, + enableAllBindings, + ); controls = generateControls(schema, { groups, overrides, bindings }); } @@ -309,7 +330,10 @@ export function defineWidget( element, update: (newCtx: WidgetOverlayContext) => { currentCtx = newCtx; - currentProps = { ...defaultProps, ...(newCtx.props as Partial>>) }; + currentProps = { + ...defaultProps, + ...(newCtx.props as Partial>>), + }; lifecycle.update?.(currentProps, newCtx); }, destroy: () => { @@ -331,6 +355,8 @@ export function defineWidget( icon, version, standaloneDefaults, + previewDefaults, + sampleData, schema, controls, locales, diff --git a/packages/thingsvis-widget-sdk/src/types.ts b/packages/thingsvis-widget-sdk/src/types.ts index d3cda0ae..b00367fd 100644 --- a/packages/thingsvis-widget-sdk/src/types.ts +++ b/packages/thingsvis-widget-sdk/src/types.ts @@ -130,6 +130,10 @@ export type WidgetMainModule> = { locales?: Record; /** Standalone-only demo props merged over schema defaults on initial creation. */ standaloneDefaults?: Record; + /** Embedded-editor preview props merged over schema defaults on initial creation. */ + previewDefaults?: Record; + /** Widget-owned embedded-editor sample data. */ + sampleData?: Record; /** Zod Schema (generic for authoring-time type inference) */ schema?: z.ZodType; /** 控件配置 */ @@ -141,16 +145,16 @@ export type WidgetMainModule> = { /** 是否支持调整大小(默认 true) */ resizable?: boolean; create?: (ctx?: WidgetOverlayContext) => unknown; - /** + /** * 属性迁移函数 - * + * * 当保存的 widgetVersion 与当前 widget.version 不匹配时,宿主调用此函数将旧格式 props 迁移为新格式 * @param props - 保存的旧属性对象 * @param fromVersion - 保存时的 widget 版本 * @returns 迁移后的新属性对象 */ migrate?: (props: unknown, fromVersion: string) => unknown; - /** + /** * 创建 DOM Overlay */ createOverlay?: (ctx: WidgetOverlayContext) => PluginOverlayInstance; diff --git a/packages/widgets/basic/table/src/index.ts b/packages/widgets/basic/table/src/index.ts index 9859eed0..619bc6b0 100644 --- a/packages/widgets/basic/table/src/index.ts +++ b/packages/widgets/basic/table/src/index.ts @@ -10,6 +10,21 @@ import en from './locales/en.json'; // Apple Flat Design Table - Clean & Minimal // ============================================================================ +const SAMPLE_TABLE_COLUMNS = [ + { key: 'name', title: '设备名称', align: 'left' }, + { key: 'status', title: '运行状态', align: 'center' }, + { key: 'value', title: '系统负载', align: 'right' }, +]; + +const SAMPLE_TABLE_DATA = [ + { name: '1号冷水机组', status: '在线', value: '78.5%' }, + { name: '2号冷却塔', status: '离线', value: '0%' }, + { name: '3号空压机', status: '运行中', value: '87.2%' }, + { name: '新风机组A', status: '在线', value: '42.1%' }, + { name: '新风机组B', status: '告警', value: '96.3%' }, + { name: '排风风机', status: '在线', value: '30.1%' }, +]; + function escapeHtml(input: unknown): string { return String(input ?? '') .replace(/&/g, '&') @@ -180,6 +195,9 @@ export const Main = defineWidget({ locales: { zh, en }, schema: PropsSchema, controls, + sampleData: { columns: SAMPLE_TABLE_COLUMNS, data: SAMPLE_TABLE_DATA }, + standaloneDefaults: { columns: SAMPLE_TABLE_COLUMNS, data: SAMPLE_TABLE_DATA }, + previewDefaults: { columns: SAMPLE_TABLE_COLUMNS, data: SAMPLE_TABLE_DATA }, render: (element: HTMLElement, props: Props, ctx: WidgetOverlayContext) => { let currentProps = props; diff --git a/packages/widgets/chart/echarts-bar/src/index.ts b/packages/widgets/chart/echarts-bar/src/index.ts index ba7a76ce..926842e0 100644 --- a/packages/widgets/chart/echarts-bar/src/index.ts +++ b/packages/widgets/chart/echarts-bar/src/index.ts @@ -6,7 +6,13 @@ import * as echarts from 'echarts'; import { metadata } from './metadata'; import { PropsSchema, getDefaultProps, type Props } from './schema'; import { controls } from './controls'; -import { defineWidget, resolveLayeredColor, resolveWidgetColors, type WidgetColors, type WidgetOverlayContext } from '@thingsvis/widget-sdk'; +import { + defineWidget, + resolveLayeredColor, + resolveWidgetColors, + type WidgetColors, + type WidgetOverlayContext, +} from '@thingsvis/widget-sdk'; import zh from './locales/zh.json'; import en from './locales/en.json'; @@ -18,321 +24,362 @@ const LEGEND_FONT_SIZE = 12; const TITLE_LINE_HEIGHT = 18; const LEGEND_BLOCK_HEIGHT = 20; const STANDALONE_BAR_SERIES = [ - { name: 'Mon', value: 18 }, - { name: 'Tue', value: 24 }, - { name: 'Wed', value: 31 }, - { name: 'Thu', value: 27 }, + { name: 'Mon', value: 18 }, + { name: 'Tue', value: 24 }, + { name: 'Wed', value: 31 }, + { name: 'Thu', value: 27 }, ]; function withAlpha(color: string, alpha: number): string { - const clamped = Math.max(0, Math.min(1, alpha)); - const normalized = color.trim(); - - const hexMatch = normalized.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/); - if (hexMatch && hexMatch[1]) { - const hex = hexMatch[1]; - const fullHex = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex; - const num = Number.parseInt(fullHex, 16); - const r = (num >> 16) & 255; - const g = (num >> 8) & 255; - const b = num & 255; - return `rgba(${r}, ${g}, ${b}, ${clamped})`; + const clamped = Math.max(0, Math.min(1, alpha)); + const normalized = color.trim(); + + const hexMatch = normalized.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/); + if (hexMatch && hexMatch[1]) { + const hex = hexMatch[1]; + const fullHex = + hex.length === 3 + ? hex + .split('') + .map((c) => c + c) + .join('') + : hex; + const num = Number.parseInt(fullHex, 16); + const r = (num >> 16) & 255; + const g = (num >> 8) & 255; + const b = num & 255; + return `rgba(${r}, ${g}, ${b}, ${clamped})`; + } + + const rgbMatch = normalized.match(/^rgba?\(([^)]+)\)$/i); + if (rgbMatch && rgbMatch[1]) { + const parts = rgbMatch[1].split(',').map((part) => part.trim()); + if (parts.length >= 3) { + return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${clamped})`; } + } - const rgbMatch = normalized.match(/^rgba?\(([^)]+)\)$/i); - if (rgbMatch && rgbMatch[1]) { - const parts = rgbMatch[1].split(',').map((part) => part.trim()); - if (parts.length >= 3) { - return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${clamped})`; - } - } - - const hslMatch = normalized.match(/^hsla?\(([^)]+)\)$/i); - if (hslMatch && hslMatch[1]) { - const parts = hslMatch[1].split(',').map((part) => part.trim()); - if (parts.length >= 3) { - return `hsla(${parts[0]}, ${parts[1]}, ${parts[2]}, ${clamped})`; - } + const hslMatch = normalized.match(/^hsla?\(([^)]+)\)$/i); + if (hslMatch && hslMatch[1]) { + const parts = hslMatch[1].split(',').map((part) => part.trim()); + if (parts.length >= 3) { + return `hsla(${parts[0]}, ${parts[1]}, ${parts[2]}, ${clamped})`; } + } - return normalized; + return normalized; } function formatCategoryLabel(raw: unknown): string { - if (raw instanceof Date) { - return raw.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } - - if (typeof raw === 'number') { - if (raw > 1e9) { - const ms = raw > 1e11 ? raw : raw * 1000; - const date = new Date(ms); - if (Number.isFinite(date.getTime())) { - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } - } - return String(raw); + if (raw instanceof Date) { + return raw.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + + if (typeof raw === 'number') { + if (raw > 1e9) { + const ms = raw > 1e11 ? raw : raw * 1000; + const date = new Date(ms); + if (Number.isFinite(date.getTime())) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } } + return String(raw); + } - if (typeof raw === 'string') { - const trimmed = raw.trim(); - if (!trimmed) return ''; + if (typeof raw === 'string') { + const trimmed = raw.trim(); + if (!trimmed) return ''; - const asNum = Number(trimmed); - if (Number.isFinite(asNum) && asNum > 1e9) { - return formatCategoryLabel(asNum); - } - - const parsed = Date.parse(trimmed); - if (Number.isFinite(parsed)) { - return formatCategoryLabel(parsed); - } + const asNum = Number(trimmed); + if (Number.isFinite(asNum) && asNum > 1e9) { + return formatCategoryLabel(asNum); + } - return trimmed; + const parsed = Date.parse(trimmed); + if (Number.isFinite(parsed)) { + return formatCategoryLabel(parsed); } - return ''; + return trimmed; + } + + return ''; } function normalizeCategoryData(data: Props['data']) { - if (Array.isArray(data)) { - return data.flatMap((entry, index) => { - if (Array.isArray(entry)) { - const [nameRaw, valueRaw] = entry; - const numericValue = typeof valueRaw === 'number' ? valueRaw : Number(valueRaw); - if (Number.isFinite(numericValue)) { - return [{ name: formatCategoryLabel(nameRaw) || `项 ${index + 1}`, value: numericValue }]; - } - return []; - } - - if (entry && typeof entry === 'object') { - const record = entry as Record; - const name = record.name ?? record.label ?? record.x ?? record.category ?? record.time ?? record.timestamp ?? record.ts; - const value = record.value ?? record.y; - const numericValue = typeof value === 'number' ? value : Number(value); - if (name != null && Number.isFinite(numericValue)) { - return [{ name: formatCategoryLabel(name) || `项 ${index + 1}`, value: numericValue }]; - } - } - - const numericValue = typeof entry === 'number' ? entry : Number(entry); - if (Number.isFinite(numericValue)) { - return [{ name: `项 ${index + 1}`, value: numericValue }]; - } - - return []; - }); - } + if (Array.isArray(data)) { + return data.flatMap((entry, index) => { + if (Array.isArray(entry)) { + const [nameRaw, valueRaw] = entry; + const numericValue = typeof valueRaw === 'number' ? valueRaw : Number(valueRaw); + if (Number.isFinite(numericValue)) { + return [{ name: formatCategoryLabel(nameRaw) || `项 ${index + 1}`, value: numericValue }]; + } + return []; + } + + if (entry && typeof entry === 'object') { + const record = entry as Record; + const name = + record.name ?? + record.label ?? + record.x ?? + record.category ?? + record.time ?? + record.timestamp ?? + record.ts; + const value = record.value ?? record.y; + const numericValue = typeof value === 'number' ? value : Number(value); + if (name != null && Number.isFinite(numericValue)) { + return [{ name: formatCategoryLabel(name) || `项 ${index + 1}`, value: numericValue }]; + } + } - if (data && typeof data === 'object') { - return Object.entries(data as Record).flatMap(([key, value]) => { - const numericValue = typeof value === 'number' ? value : Number(value); - if (!Number.isFinite(numericValue)) { - return []; - } - return [{ name: key, value: numericValue }]; - }); - } + const numericValue = typeof entry === 'number' ? entry : Number(entry); + if (Number.isFinite(numericValue)) { + return [{ name: `项 ${index + 1}`, value: numericValue }]; + } - return []; + return []; + }); + } + + if (data && typeof data === 'object') { + return Object.entries(data as Record).flatMap(([key, value]) => { + const numericValue = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(numericValue)) { + return []; + } + return [{ name: key, value: numericValue }]; + }); + } + + return []; } function resolveChartLeft(align: Props['titleAlign']): 'left' | 'center' | 'right' { - if (align === 'center') return 'center'; - if (align === 'right') return 'right'; - return 'left'; + if (align === 'center') return 'center'; + if (align === 'right') return 'right'; + return 'left'; } function resolveTitleTextAlign(align: Props['titleAlign']): 'left' | 'center' | 'right' { - if (align === 'center') return 'center'; - if (align === 'right') return 'right'; - return 'left'; + if (align === 'center') return 'center'; + if (align === 'right') return 'right'; + return 'left'; } /** * Build ECharts option from props and theme colors */ function buildOption(props: Props, colors: WidgetColors, scale: number = 1): echarts.EChartsOption { - const { title, titleAlign, data, primaryColor, titleColor, axisLabelColor, showLegend, showXAxis, showYAxis } = props; - const normalizedData = normalizeCategoryData(data); - const hasData = normalizedData.length > 0; - - const resolvedTitleColor = resolveLayeredColor({ - instance: titleColor, - theme: colors.fg, - fallback: colors.fg, - }); - const resolvedAxisLabelColor = resolveLayeredColor({ - instance: axisLabelColor, - theme: colors.fg, - fallback: colors.fg, - }); - const splitLineColor = colors.axis; - const seriesColor = resolveLayeredColor({ - instance: primaryColor, - theme: colors.series[0] ?? colors.primary, - fallback: LEGACY_DEFAULT_PRIMARY, - inheritValues: [LEGACY_DEFAULT_PRIMARY], - }); - const padding = Math.round(CHART_PADDING * scale); - const titleSpace = title ? Math.round(TITLE_LINE_HEIGHT * scale) + padding : 0; - const legendSpace = showLegend ? Math.round(LEGEND_BLOCK_HEIGHT * scale) + padding : 0; - const seriesName = title || '数值'; - - const gradientColor = new echarts.graphic.LinearGradient(0, 0, 0, 1, [ - { offset: 0, color: seriesColor }, - { offset: 1, color: withAlpha(seriesColor, 0.25) } - ]); - - return { - backgroundColor: 'transparent', - color: colors.series, - graphic: hasData ? undefined : { - type: 'text', - left: 'center', - top: 'middle', - silent: true, - style: { - text: '暂无数据', - fill: resolvedAxisLabelColor, - opacity: 0.65, - fontSize: Math.round(14 * scale), - }, - }, - title: title ? { - text: title, - left: resolveChartLeft(titleAlign), - textAlign: resolveTitleTextAlign(titleAlign), - textStyle: { fontSize: Math.round(TITLE_FONT_SIZE * scale), color: resolvedTitleColor }, - top: padding, - } : undefined, - tooltip: { - trigger: 'axis', - axisPointer: { type: 'shadow' }, - }, - legend: { - show: showLegend, - data: [seriesName], - bottom: padding, - left: 'center', - selectedMode: true, - icon: 'roundRect', - textStyle: { color: resolvedAxisLabelColor, fontSize: Math.round(LEGEND_FONT_SIZE * scale) }, - }, - grid: { - left: padding, - right: padding, - bottom: padding + legendSpace, - top: padding + titleSpace, - containLabel: true, - }, - dataset: hasData ? { - dimensions: [{ name: 'name', displayName: '维度' }, { name: 'value', displayName: seriesName }], - source: normalizedData - } : undefined, - xAxis: { - show: showXAxis !== false, - type: 'category', - axisLabel: { color: resolvedAxisLabelColor, fontSize: Math.round(12 * scale) }, - axisLine: { lineStyle: { color: splitLineColor } }, - axisTick: { show: true, alignWithLabel: true, lineStyle: { color: splitLineColor } }, - }, - yAxis: { - show: showYAxis !== false, - type: 'value', - splitLine: { lineStyle: { color: splitLineColor } }, - axisLabel: { color: resolvedAxisLabelColor, fontSize: Math.round(12 * scale) }, - axisLine: { show: true, lineStyle: { color: splitLineColor } }, - axisTick: { show: true, lineStyle: { color: splitLineColor } }, + const { + title, + titleAlign, + data, + primaryColor, + titleColor, + axisLabelColor, + showLegend, + showXAxis, + showYAxis, + } = props; + const normalizedData = normalizeCategoryData(data); + const hasData = normalizedData.length > 0; + + const resolvedTitleColor = resolveLayeredColor({ + instance: titleColor, + theme: colors.fg, + fallback: colors.fg, + }); + const resolvedAxisLabelColor = resolveLayeredColor({ + instance: axisLabelColor, + theme: colors.fg, + fallback: colors.fg, + }); + const splitLineColor = colors.axis; + const seriesColor = resolveLayeredColor({ + instance: primaryColor, + theme: colors.series[0] ?? colors.primary, + fallback: LEGACY_DEFAULT_PRIMARY, + inheritValues: [LEGACY_DEFAULT_PRIMARY], + }); + const padding = Math.round(CHART_PADDING * scale); + const titleSpace = title ? Math.round(TITLE_LINE_HEIGHT * scale) + padding : 0; + const legendSpace = showLegend ? Math.round(LEGEND_BLOCK_HEIGHT * scale) + padding : 0; + const seriesName = title || '数值'; + + const gradientColor = new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: seriesColor }, + { offset: 1, color: withAlpha(seriesColor, 0.25) }, + ]); + + return { + backgroundColor: 'transparent', + color: colors.series, + graphic: hasData + ? undefined + : { + type: 'text', + left: 'center', + top: 'middle', + silent: true, + style: { + text: '暂无数据', + fill: resolvedAxisLabelColor, + opacity: 0.65, + fontSize: Math.round(14 * scale), + }, }, - series: hasData ? [ - { - type: 'bar', - name: seriesName, - encode: { x: 'name', y: 'value', tooltip: ['value'] }, - itemStyle: { - color: gradientColor, - borderRadius: [4, 4, 0, 0], - }, + title: title + ? { + text: title, + left: resolveChartLeft(titleAlign), + textAlign: resolveTitleTextAlign(titleAlign), + textStyle: { fontSize: Math.round(TITLE_FONT_SIZE * scale), color: resolvedTitleColor }, + top: padding, + } + : undefined, + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow' }, + }, + legend: { + show: showLegend, + data: [seriesName], + bottom: padding, + left: 'center', + selectedMode: true, + icon: 'roundRect', + textStyle: { color: resolvedAxisLabelColor, fontSize: Math.round(LEGEND_FONT_SIZE * scale) }, + }, + grid: { + left: padding, + right: padding, + bottom: padding + legendSpace, + top: padding + titleSpace, + containLabel: true, + }, + dataset: hasData + ? { + dimensions: [ + { name: 'name', displayName: '维度' }, + { name: 'value', displayName: seriesName }, + ], + source: normalizedData, + } + : undefined, + xAxis: { + show: showXAxis !== false, + type: 'category', + axisLabel: { color: resolvedAxisLabelColor, fontSize: Math.round(12 * scale) }, + axisLine: { lineStyle: { color: splitLineColor } }, + axisTick: { show: true, alignWithLabel: true, lineStyle: { color: splitLineColor } }, + }, + yAxis: { + show: showYAxis !== false, + type: 'value', + splitLine: { lineStyle: { color: splitLineColor } }, + axisLabel: { color: resolvedAxisLabelColor, fontSize: Math.round(12 * scale) }, + axisLine: { show: true, lineStyle: { color: splitLineColor } }, + axisTick: { show: true, lineStyle: { color: splitLineColor } }, + }, + series: hasData + ? [ + { + type: 'bar', + name: seriesName, + encode: { x: 'name', y: 'value', tooltip: ['value'] }, + itemStyle: { + color: gradientColor, + borderRadius: [4, 4, 0, 0], }, - ] : [], - }; + }, + ] + : [], + }; } export const Main = defineWidget({ - id: metadata.id, - name: metadata.name, - category: metadata.category, - icon: metadata.icon, - version: metadata.version, - defaultSize: metadata.defaultSize, - constraints: metadata.constraints, - resizable: metadata.resizable, - locales: { zh, en }, - schema: PropsSchema, - standaloneDefaults: { data: STANDALONE_BAR_SERIES }, - controls, - render: (element: HTMLElement, props: Props, ctx: WidgetOverlayContext) => { - let currentProps = props; - let colors: WidgetColors = resolveWidgetColors(element); - - // Initialize ECharts - const chart = echarts.init(element); - chart.setOption(buildOption(currentProps, colors, 1)); - - const scheduleResize = () => { - try { - requestAnimationFrame(() => { - if (!chart.isDisposed()) { - chart.resize(); - const cw = element.clientWidth || 300; - const ch = element.clientHeight || 200; - const minDim = Math.min(cw, ch); - const scale = Math.max(0.6, Math.min(1.5, minDim / 300)); - chart.setOption(buildOption(currentProps, colors, scale), { replaceMerge: ['dataset', 'series', 'xAxis', 'yAxis'] }); - } - }); - } catch { - if (!chart.isDisposed()) chart.resize(); - } - }; + id: metadata.id, + name: metadata.name, + category: metadata.category, + icon: metadata.icon, + version: metadata.version, + defaultSize: metadata.defaultSize, + constraints: metadata.constraints, + resizable: metadata.resizable, + locales: { zh, en }, + schema: PropsSchema, + sampleData: { data: STANDALONE_BAR_SERIES }, + standaloneDefaults: { data: STANDALONE_BAR_SERIES }, + previewDefaults: { data: STANDALONE_BAR_SERIES }, + controls, + render: (element: HTMLElement, props: Props, ctx: WidgetOverlayContext) => { + let currentProps = props; + let colors: WidgetColors = resolveWidgetColors(element); + + // Initialize ECharts + const chart = echarts.init(element); + chart.setOption(buildOption(currentProps, colors, 1)); + + const scheduleResize = () => { + try { + requestAnimationFrame(() => { + if (!chart.isDisposed()) { + chart.resize(); + const cw = element.clientWidth || 300; + const ch = element.clientHeight || 200; + const minDim = Math.min(cw, ch); + const scale = Math.max(0.6, Math.min(1.5, minDim / 300)); + chart.setOption(buildOption(currentProps, colors, scale), { + replaceMerge: ['dataset', 'series', 'xAxis', 'yAxis'], + }); + } + }); + } catch { + if (!chart.isDisposed()) chart.resize(); + } + }; - scheduleResize(); + scheduleResize(); - let ro: ResizeObserver | null = null; - let themeObserver: MutationObserver | null = null; - if (typeof ResizeObserver !== 'undefined') { - ro = new ResizeObserver(() => scheduleResize()); - ro.observe(element); - } + let ro: ResizeObserver | null = null; + let themeObserver: MutationObserver | null = null; + if (typeof ResizeObserver !== 'undefined') { + ro = new ResizeObserver(() => scheduleResize()); + ro.observe(element); + } - const themeTarget = element.closest('[data-canvas-theme]'); - if (themeTarget && typeof MutationObserver !== 'undefined') { - themeObserver = new MutationObserver(() => { - colors = resolveWidgetColors(element); - scheduleResize(); - }); - themeObserver.observe(themeTarget, { attributes: true, attributeFilter: ['data-canvas-theme'] }); - } + const themeTarget = element.closest('[data-canvas-theme]'); + if (themeTarget && typeof MutationObserver !== 'undefined') { + themeObserver = new MutationObserver(() => { + colors = resolveWidgetColors(element); + scheduleResize(); + }); + themeObserver.observe(themeTarget, { + attributes: true, + attributeFilter: ['data-canvas-theme'], + }); + } - return { - update: (newProps: Props, newCtx: WidgetOverlayContext) => { - currentProps = newProps; - colors = resolveWidgetColors(element); + return { + update: (newProps: Props, newCtx: WidgetOverlayContext) => { + currentProps = newProps; + colors = resolveWidgetColors(element); - // scheduleResize handles setOption with scale logic - if (newCtx.size || !newCtx.size) { - scheduleResize(); - } - }, - destroy: () => { - ro?.disconnect(); - themeObserver?.disconnect(); - chart.dispose(); - }, - }; - } + // scheduleResize handles setOption with scale logic + if (newCtx.size || !newCtx.size) { + scheduleResize(); + } + }, + destroy: () => { + ro?.disconnect(); + themeObserver?.disconnect(); + chart.dispose(); + }, + }; + }, }); export default Main; diff --git a/packages/widgets/chart/echarts-gauge/src/index.ts b/packages/widgets/chart/echarts-gauge/src/index.ts index 0a4f4f2d..841b0473 100644 --- a/packages/widgets/chart/echarts-gauge/src/index.ts +++ b/packages/widgets/chart/echarts-gauge/src/index.ts @@ -6,7 +6,13 @@ import * as echarts from 'echarts'; import { metadata } from './metadata'; import { PropsSchema, getDefaultProps, type Props } from './schema'; import { controls } from './controls'; -import { defineWidget, resolveLayeredColor, type WidgetOverlayContext, resolveWidgetColors, type WidgetColors } from '@thingsvis/widget-sdk'; +import { + defineWidget, + resolveLayeredColor, + type WidgetOverlayContext, + resolveWidgetColors, + type WidgetColors, +} from '@thingsvis/widget-sdk'; import zh from './locales/zh.json'; import en from './locales/en.json'; @@ -15,284 +21,306 @@ const CHART_PADDING = 16; const LEGACY_DEFAULT_PRIMARY = '#6965db'; const STANDALONE_GAUGE_SERIES = [{ name: 'CPU', value: 67 }]; -function parseGaugeData(raw: Props['data'], fallbackName: string): { value: number; name: string } | null { - const entry = Array.isArray(raw) ? raw[raw.length - 1] : raw; +function parseGaugeData( + raw: Props['data'], + fallbackName: string, +): { value: number; name: string } | null { + const entry = Array.isArray(raw) ? raw[raw.length - 1] : raw; - if (typeof entry === 'number' || typeof entry === 'string') { - const value = typeof entry === 'number' ? entry : Number(entry); - if (!Number.isFinite(value)) return null; - return { value, name: fallbackName }; - } - - if (!entry || typeof entry !== 'object') return null; - const record = entry as Record; - const valueRaw = record.value ?? record.y ?? record.current ?? record.score; - const value = typeof valueRaw === 'number' ? valueRaw : Number(valueRaw); + if (typeof entry === 'number' || typeof entry === 'string') { + const value = typeof entry === 'number' ? entry : Number(entry); if (!Number.isFinite(value)) return null; - return { - value, - name: typeof record.name === 'string' - ? record.name - : typeof record.label === 'string' - ? record.label - : fallbackName, - }; -} + return { value, name: fallbackName }; + } + if (!entry || typeof entry !== 'object') return null; + const record = entry as Record; + const valueRaw = record.value ?? record.y ?? record.current ?? record.score; + const value = typeof valueRaw === 'number' ? valueRaw : Number(valueRaw); + if (!Number.isFinite(value)) return null; + return { + value, + name: + typeof record.name === 'string' + ? record.name + : typeof record.label === 'string' + ? record.label + : fallbackName, + }; +} /** * 根据 Props 和 Theme 生成 ECharts Option */ function withAlpha(color: string, alpha: number): string { - const clamped = Math.max(0, Math.min(1, alpha)); - const normalized = color.trim(); - const hexMatch = normalized.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/); - if (hexMatch?.[1]) { - const hex = hexMatch[1]; - const fullHex = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex; - const num = Number.parseInt(fullHex, 16); - const r = (num >> 16) & 255; - const g = (num >> 8) & 255; - const b = num & 255; - return `rgba(${r}, ${g}, ${b}, ${clamped})`; - } - const rgbMatch = normalized.match(/^rgba?\(([^)]+)\)$/i); - if (rgbMatch?.[1]) { - const parts = rgbMatch[1].split(',').map((part) => part.trim()); - if (parts.length >= 3) { - return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${clamped})`; - } + const clamped = Math.max(0, Math.min(1, alpha)); + const normalized = color.trim(); + const hexMatch = normalized.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/); + if (hexMatch?.[1]) { + const hex = hexMatch[1]; + const fullHex = + hex.length === 3 + ? hex + .split('') + .map((c) => c + c) + .join('') + : hex; + const num = Number.parseInt(fullHex, 16); + const r = (num >> 16) & 255; + const g = (num >> 8) & 255; + const b = num & 255; + return `rgba(${r}, ${g}, ${b}, ${clamped})`; + } + const rgbMatch = normalized.match(/^rgba?\(([^)]+)\)$/i); + if (rgbMatch?.[1]) { + const parts = rgbMatch[1].split(',').map((part) => part.trim()); + if (parts.length >= 3) { + return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${clamped})`; } - return normalized; + } + return normalized; } function buildOption(props: Props, colors: WidgetColors, scale: number = 1): echarts.EChartsOption { - const { title, data, primaryColor, titleColor, axisLabelColor, detailColor, max } = props; + const { title, data, primaryColor, titleColor, axisLabelColor, detailColor, max } = props; - const accentColor = resolveLayeredColor({ - instance: primaryColor, - theme: colors.series[0] ?? colors.primary, - fallback: LEGACY_DEFAULT_PRIMARY, - inheritValues: [LEGACY_DEFAULT_PRIMARY], - }); - const accentTailColor = (primaryColor ?? '').trim() - ? withAlpha(accentColor, 0.55) - : (colors.series[1] ?? accentColor); - const resolvedTitleColor = resolveLayeredColor({ - instance: titleColor, - theme: colors.fg, - fallback: colors.fg, - }); - const resolvedAxisLabelColor = resolveLayeredColor({ - instance: axisLabelColor, - theme: colors.fg, - fallback: colors.fg, - }); - const resolvedDetailColor = resolveLayeredColor({ - instance: detailColor, - theme: colors.fg, - fallback: colors.fg, - }); - const splitLineColor = colors.axis; - const axisLineColor = colors.axis; - const padding = Math.round(CHART_PADDING * scale); + const accentColor = resolveLayeredColor({ + instance: primaryColor, + theme: colors.series[0] ?? colors.primary, + fallback: LEGACY_DEFAULT_PRIMARY, + inheritValues: [LEGACY_DEFAULT_PRIMARY], + }); + const accentTailColor = (primaryColor ?? '').trim() + ? withAlpha(accentColor, 0.55) + : (colors.series[1] ?? accentColor); + const resolvedTitleColor = resolveLayeredColor({ + instance: titleColor, + theme: colors.fg, + fallback: colors.fg, + }); + const resolvedAxisLabelColor = resolveLayeredColor({ + instance: axisLabelColor, + theme: colors.fg, + fallback: colors.fg, + }); + const resolvedDetailColor = resolveLayeredColor({ + instance: detailColor, + theme: colors.fg, + fallback: colors.fg, + }); + const splitLineColor = colors.axis; + const axisLineColor = colors.axis; + const padding = Math.round(CHART_PADDING * scale); - // Extract current value and name - const dataEntry = parseGaugeData(data, title || ''); - const val = dataEntry?.value ?? 0; - const itemName = dataEntry?.name ?? (title || ''); - const hasData = dataEntry !== null; + // Extract current value and name + const dataEntry = parseGaugeData(data, title || ''); + const val = dataEntry?.value ?? 0; + const itemName = dataEntry?.name ?? (title || ''); + const hasData = dataEntry !== null; - return { - backgroundColor: 'transparent', - color: colors.series, - graphic: hasData ? undefined : { - type: 'text', - left: 'center', - top: 'middle', - silent: true, - style: { - text: '暂无数据', - fill: resolvedAxisLabelColor, - opacity: 0.65, - fontSize: Math.round(14 * scale), - }, + return { + backgroundColor: 'transparent', + color: colors.series, + graphic: hasData + ? undefined + : { + type: 'text', + left: 'center', + top: 'middle', + silent: true, + style: { + text: '暂无数据', + fill: resolvedAxisLabelColor, + opacity: 0.65, + fontSize: Math.round(14 * scale), + }, }, - title: title ? { - text: title, - left: 'center', - textStyle: { - fontSize: Math.round(14 * scale), - color: resolvedTitleColor, - fontWeight: 'normal' + title: title + ? { + text: title, + left: 'center', + textStyle: { + fontSize: Math.round(14 * scale), + color: resolvedTitleColor, + fontWeight: 'normal', + }, + top: padding, + } + : undefined, + series: hasData + ? [ + { + type: 'gauge', + center: ['50%', title ? '58%' : '54%'], + radius: '76%', + startAngle: 210, + endAngle: -30, + min: 0, + max: max, + splitNumber: 10, + axisLine: { + lineStyle: { + width: Math.round(8 * scale), + color: [[1, axisLineColor]], + }, }, - top: padding, - } : undefined, - series: hasData ? [ - { - type: 'gauge', - center: ['50%', title ? '58%' : '54%'], - radius: '76%', - startAngle: 210, - endAngle: -30, - min: 0, - max: max, - splitNumber: 10, - axisLine: { - lineStyle: { - width: Math.round(8 * scale), - color: [[1, axisLineColor]] - } - }, - progress: { - show: true, - width: Math.round(12 * scale), - itemStyle: { - color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [ - { offset: 0, color: accentColor }, - { offset: 1, color: accentTailColor } - ]), - borderRadius: Math.round(6 * scale) - } - }, - pointer: { - show: true, - length: '60%', - width: Math.round(4 * scale), - offsetCenter: [0, '5%'], - itemStyle: { - color: accentColor - } - }, - axisTick: { - show: true, - distance: Math.round(-15 * scale), - lineStyle: { - color: splitLineColor, - width: 1 - } - }, - splitLine: { - show: true, - distance: Math.round(-15 * scale), - length: Math.round(10 * scale), - lineStyle: { - color: splitLineColor, - width: 2 - } - }, - axisLabel: { - show: true, - distance: Math.round(-30 * scale), - color: resolvedAxisLabelColor, - fontSize: Math.round(10 * scale) - }, - anchor: { - show: true, - showAbove: true, - size: Math.round(14 * scale), - itemStyle: { - borderWidth: Math.round(3 * scale), - borderColor: accentColor, - color: '#fff', - shadowBlur: 10, - shadowColor: 'rgba(0,0,0,0.2)' - } - }, - title: { - show: true, - offsetCenter: [0, '40%'], - fontSize: Math.round(12 * scale), - color: resolvedAxisLabelColor, - opacity: 0.8 - }, - detail: { - valueAnimation: true, - offsetCenter: [0, '75%'], - formatter: '{value}', - color: resolvedDetailColor, - fontSize: Math.round(26 * scale), - fontWeight: 'bold' - }, - data: [{ value: val, name: itemName }] - } - ] : [], - }; + progress: { + show: true, + width: Math.round(12 * scale), + itemStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [ + { offset: 0, color: accentColor }, + { offset: 1, color: accentTailColor }, + ]), + borderRadius: Math.round(6 * scale), + }, + }, + pointer: { + show: true, + length: '60%', + width: Math.round(4 * scale), + offsetCenter: [0, '5%'], + itemStyle: { + color: accentColor, + }, + }, + axisTick: { + show: true, + distance: Math.round(-15 * scale), + lineStyle: { + color: splitLineColor, + width: 1, + }, + }, + splitLine: { + show: true, + distance: Math.round(-15 * scale), + length: Math.round(10 * scale), + lineStyle: { + color: splitLineColor, + width: 2, + }, + }, + axisLabel: { + show: true, + distance: Math.round(-30 * scale), + color: resolvedAxisLabelColor, + fontSize: Math.round(10 * scale), + }, + anchor: { + show: true, + showAbove: true, + size: Math.round(14 * scale), + itemStyle: { + borderWidth: Math.round(3 * scale), + borderColor: accentColor, + color: '#fff', + shadowBlur: 10, + shadowColor: 'rgba(0,0,0,0.2)', + }, + }, + title: { + show: true, + offsetCenter: [0, '40%'], + fontSize: Math.round(12 * scale), + color: resolvedAxisLabelColor, + opacity: 0.8, + }, + detail: { + valueAnimation: true, + offsetCenter: [0, '75%'], + formatter: '{value}', + color: resolvedDetailColor, + fontSize: Math.round(26 * scale), + fontWeight: 'bold', + }, + data: [{ value: val, name: itemName }], + }, + ] + : [], + }; } export const Main = defineWidget({ - id: metadata.id, - name: metadata.name, - category: metadata.category, - icon: metadata.icon, - version: metadata.version, - defaultSize: metadata.defaultSize, - constraints: metadata.constraints, - resizable: metadata.resizable, - locales: { zh, en }, - schema: PropsSchema, - standaloneDefaults: { data: STANDALONE_GAUGE_SERIES }, - controls, - render: (element: HTMLElement, props: Props, ctx: WidgetOverlayContext) => { - let currentProps = props; - let colors: WidgetColors = resolveWidgetColors(element); + id: metadata.id, + name: metadata.name, + category: metadata.category, + icon: metadata.icon, + version: metadata.version, + defaultSize: metadata.defaultSize, + constraints: metadata.constraints, + resizable: metadata.resizable, + locales: { zh, en }, + schema: PropsSchema, + sampleData: { data: STANDALONE_GAUGE_SERIES }, + standaloneDefaults: { data: STANDALONE_GAUGE_SERIES }, + previewDefaults: { data: STANDALONE_GAUGE_SERIES }, + controls, + render: (element: HTMLElement, props: Props, ctx: WidgetOverlayContext) => { + let currentProps = props; + let colors: WidgetColors = resolveWidgetColors(element); - const chart = echarts.init(element); - chart.setOption(buildOption(currentProps, colors, 1)); + const chart = echarts.init(element); + chart.setOption(buildOption(currentProps, colors, 1)); - const scheduleResize = () => { - try { - requestAnimationFrame(() => { - if (!chart.isDisposed()) { - chart.resize(); - const cw = element.clientWidth || 300; - const ch = element.clientHeight || 200; - const minDim = Math.min(cw, ch); - const scale = Math.max(0.6, Math.min(1.5, minDim / 300)); - chart.setOption(buildOption(currentProps, colors, scale), { replaceMerge: ['dataset', 'series'] }); - } - }); - } catch { - if (!chart.isDisposed()) chart.resize(); - } - }; + const scheduleResize = () => { + try { + requestAnimationFrame(() => { + if (!chart.isDisposed()) { + chart.resize(); + const cw = element.clientWidth || 300; + const ch = element.clientHeight || 200; + const minDim = Math.min(cw, ch); + const scale = Math.max(0.6, Math.min(1.5, minDim / 300)); + chart.setOption(buildOption(currentProps, colors, scale), { + replaceMerge: ['dataset', 'series'], + }); + } + }); + } catch { + if (!chart.isDisposed()) chart.resize(); + } + }; - scheduleResize(); + scheduleResize(); - let ro: ResizeObserver | null = null; - let themeObserver: MutationObserver | null = null; - if (typeof ResizeObserver !== 'undefined') { - ro = new ResizeObserver(() => scheduleResize()); - ro.observe(element); - } + let ro: ResizeObserver | null = null; + let themeObserver: MutationObserver | null = null; + if (typeof ResizeObserver !== 'undefined') { + ro = new ResizeObserver(() => scheduleResize()); + ro.observe(element); + } - const themeTarget = element.closest('[data-canvas-theme]'); - if (themeTarget && typeof MutationObserver !== 'undefined') { - themeObserver = new MutationObserver(() => { - colors = resolveWidgetColors(element); - scheduleResize(); - }); - themeObserver.observe(themeTarget, { attributes: true, attributeFilter: ['data-canvas-theme'] }); - } + const themeTarget = element.closest('[data-canvas-theme]'); + if (themeTarget && typeof MutationObserver !== 'undefined') { + themeObserver = new MutationObserver(() => { + colors = resolveWidgetColors(element); + scheduleResize(); + }); + themeObserver.observe(themeTarget, { + attributes: true, + attributeFilter: ['data-canvas-theme'], + }); + } - return { - update: (newProps: Props, newCtx: WidgetOverlayContext) => { - currentProps = newProps; - colors = resolveWidgetColors(element); + return { + update: (newProps: Props, newCtx: WidgetOverlayContext) => { + currentProps = newProps; + colors = resolveWidgetColors(element); - chart.setOption(buildOption(currentProps, colors), { replaceMerge: ['dataset', 'series'] }); + chart.setOption(buildOption(currentProps, colors), { replaceMerge: ['dataset', 'series'] }); - if (newCtx.size) { - scheduleResize(); - } - }, - destroy: () => { - ro?.disconnect(); - themeObserver?.disconnect(); - chart.dispose(); - }, - }; - } + if (newCtx.size) { + scheduleResize(); + } + }, + destroy: () => { + ro?.disconnect(); + themeObserver?.disconnect(); + chart.dispose(); + }, + }; + }, }); export default Main; diff --git a/packages/widgets/chart/echarts-line/src/index.ts b/packages/widgets/chart/echarts-line/src/index.ts index ed94b488..5ef0d518 100644 --- a/packages/widgets/chart/echarts-line/src/index.ts +++ b/packages/widgets/chart/echarts-line/src/index.ts @@ -68,7 +68,13 @@ function withAlpha(color: string, alpha: number): string { const hexMatch = normalized.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/); if (hexMatch && hexMatch[1]) { const hex = hexMatch[1]; - const fullHex = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex; + const fullHex = + hex.length === 3 + ? hex + .split('') + .map((c) => c + c) + .join('') + : hex; const num = Number.parseInt(fullHex, 16); const r = (num >> 16) & 255; const g = (num >> 8) & 255; @@ -152,10 +158,10 @@ function formatTimeLabel(timeMs: number, spanMs: number): string { } function normalizeLineData(data: Props['data'], timeRangePreset: Props['timeRangePreset']) { - if (!Array.isArray(data) || data.length === 0) { - return { - mode: 'category' as const, - categoryData: [], + if (!Array.isArray(data) || data.length === 0) { + return { + mode: 'category' as const, + categoryData: [], timeData: [], timeSpanMs: 0, }; @@ -218,9 +224,10 @@ function normalizeLineData(data: Props['data'], timeRangePreset: Props['timeRang mode: 'time' as const, categoryData: [], timeData: filtered.length > 0 ? filtered : [sorted[sorted.length - 1]!], - timeSpanMs: filtered.length > 1 - ? filtered[filtered.length - 1]!.timeMs - filtered[0]!.timeMs - : fullSpanMs, + timeSpanMs: + filtered.length > 1 + ? filtered[filtered.length - 1]!.timeMs - filtered[0]!.timeMs + : fullSpanMs, }; } @@ -240,8 +247,12 @@ function normalizeLineData(data: Props['data'], timeRangePreset: Props['timeRang }; } -function getEmptyTimeWindow(timeRangePreset: Props['timeRangePreset']): { startMs: number; endMs: number } { - const fallbackRangeMs = timeRangePreset === 'all' ? TIME_RANGE_MS['1h'] : TIME_RANGE_MS[timeRangePreset]; +function getEmptyTimeWindow(timeRangePreset: Props['timeRangePreset']): { + startMs: number; + endMs: number; +} { + const fallbackRangeMs = + timeRangePreset === 'all' ? TIME_RANGE_MS['1h'] : TIME_RANGE_MS[timeRangePreset]; const endMs = Date.now(); return { startMs: endMs - fallbackRangeMs, endMs }; } @@ -299,62 +310,74 @@ function buildOption( const titleSpace = title ? Math.round(TITLE_LINE_HEIGHT * scale) + padding : 0; const legendSpace = showLegend ? Math.round(LEGEND_BLOCK_HEIGHT * scale) + padding : 0; const seriesName = title || messages.runtime?.defaultSeriesName || 'Value'; - const hasData = normalizedData.mode === 'time' - ? normalizedData.timeData.length > 0 - : normalizedData.categoryData.length > 0; + const hasData = + normalizedData.mode === 'time' + ? normalizedData.timeData.length > 0 + : normalizedData.categoryData.length > 0; const emptyTimeWindow = getEmptyTimeWindow(timeRangePreset); const useEmptyTimeSkeleton = !hasData; const isTimeSeries = normalizedData.mode === 'time' || useEmptyTimeSkeleton; - const xAxis: echarts.XAXisComponentOption = isTimeSeries ? { - show: showXAxis !== false, - type: 'time', - min: hasData ? undefined : emptyTimeWindow.startMs, - max: hasData ? undefined : emptyTimeWindow.endMs, - axisLabel: { - color: resolvedAxisLabelColor, - fontSize: Math.round(12 * scale), - hideOverlap: true, - formatter: (value: string | number) => - formatTimeLabel(Number(value), hasData ? normalizedData.timeSpanMs : emptyTimeWindow.endMs - emptyTimeWindow.startMs), - }, - axisLine: { lineStyle: { color: splitLineColor } }, - axisTick: { show: true, lineStyle: { color: splitLineColor } }, - } : { - show: showXAxis !== false, - type: 'category', - axisLabel: { color: resolvedAxisLabelColor, fontSize: Math.round(12 * scale) }, - axisLine: { lineStyle: { color: splitLineColor } }, - axisTick: { show: true, alignWithLabel: true, lineStyle: { color: splitLineColor } }, - }; + const xAxis: echarts.XAXisComponentOption = isTimeSeries + ? { + show: showXAxis !== false, + type: 'time', + min: hasData ? undefined : emptyTimeWindow.startMs, + max: hasData ? undefined : emptyTimeWindow.endMs, + axisLabel: { + color: resolvedAxisLabelColor, + fontSize: Math.round(12 * scale), + hideOverlap: true, + formatter: (value: string | number) => + formatTimeLabel( + Number(value), + hasData ? normalizedData.timeSpanMs : emptyTimeWindow.endMs - emptyTimeWindow.startMs, + ), + }, + axisLine: { lineStyle: { color: splitLineColor } }, + axisTick: { show: true, lineStyle: { color: splitLineColor } }, + } + : { + show: showXAxis !== false, + type: 'category', + axisLabel: { color: resolvedAxisLabelColor, fontSize: Math.round(12 * scale) }, + axisLine: { lineStyle: { color: splitLineColor } }, + axisTick: { show: true, alignWithLabel: true, lineStyle: { color: splitLineColor } }, + }; // 面积阴影渐变色(如果开启) - const areaGradient = showArea ? new echarts.graphic.LinearGradient(0, 0, 0, 1, [ - { offset: 0, color: withAlpha(seriesColor, 0.5) }, - { offset: 1, color: withAlpha(seriesColor, 0) } - ]) : undefined; + const areaGradient = showArea + ? new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: withAlpha(seriesColor, 0.5) }, + { offset: 1, color: withAlpha(seriesColor, 0) }, + ]) + : undefined; return { backgroundColor: 'transparent', color: colors.series, - graphic: hasData ? undefined : { - type: 'text', - left: 'center', - top: '38%', - silent: true, - style: { - text: messages.runtime?.emptyState || 'Add data points or bind a data series', - fill: resolvedAxisLabelColor, - opacity: 0.58, - fontSize: Math.round(12 * scale), - }, - }, - title: title ? { - text: title, - left: resolveChartLeft(titleAlign), - textAlign: resolveTitleTextAlign(titleAlign), - textStyle: { fontSize: Math.round(TITLE_FONT_SIZE * scale), color: resolvedTitleColor }, - top: padding, - } : undefined, + graphic: hasData + ? undefined + : { + type: 'text', + left: 'center', + top: '38%', + silent: true, + style: { + text: messages.runtime?.emptyState || 'Add data points or bind a data series', + fill: resolvedAxisLabelColor, + opacity: 0.58, + fontSize: Math.round(12 * scale), + }, + }, + title: title + ? { + text: title, + left: resolveChartLeft(titleAlign), + textAlign: resolveTitleTextAlign(titleAlign), + textStyle: { fontSize: Math.round(TITLE_FONT_SIZE * scale), color: resolvedTitleColor }, + top: padding, + } + : undefined, tooltip: { trigger: 'axis', }, @@ -374,10 +397,16 @@ function buildOption( top: padding + titleSpace, containLabel: true, }, - dataset: !isTimeSeries && normalizedData.categoryData.length > 0 ? { - dimensions: [{ name: 'name', displayName: 'Category' }, { name: 'value', displayName: seriesName }], - source: normalizedData.categoryData - } : undefined, + dataset: + !isTimeSeries && normalizedData.categoryData.length > 0 + ? { + dimensions: [ + { name: 'name', displayName: 'Category' }, + { name: 'value', displayName: seriesName }, + ], + source: normalizedData.categoryData, + } + : undefined, xAxis, yAxis: { show: showYAxis !== false, @@ -394,14 +423,15 @@ function buildOption( { type: 'line', name: seriesName, - encode: isTimeSeries || !hasData ? undefined : { x: 'name', y: 'value', tooltip: ['value'] }, + encode: + isTimeSeries || !hasData ? undefined : { x: 'name', y: 'value', tooltip: ['value'] }, data: isTimeSeries - ? (hasData - ? normalizedData.timeData.map((point) => [point.timeMs, point.value]) - : [ - [emptyTimeWindow.startMs, null], - [emptyTimeWindow.endMs, null], - ]) + ? hasData + ? normalizedData.timeData.map((point) => [point.timeMs, point.value]) + : [ + [emptyTimeWindow.startMs, null], + [emptyTimeWindow.endMs, null], + ] : undefined, smooth: smooth, showSymbol: false, @@ -414,9 +444,12 @@ function buildOption( opacity: hasData ? 1 : 0.35, type: hasData ? 'solid' : 'dashed', }, - areaStyle: showArea && hasData ? { - color: areaGradient, - } : undefined, + areaStyle: + showArea && hasData + ? { + color: areaGradient, + } + : undefined, }, ], }; @@ -469,7 +502,10 @@ function renderChart(element: HTMLElement, props: Props, ctx: WidgetOverlayConte colors = resolveWidgetColors(element); scheduleResize(); }); - themeObserver.observe(themeTarget, { attributes: true, attributeFilter: ['data-canvas-theme'] }); + themeObserver.observe(themeTarget, { + attributes: true, + attributeFilter: ['data-canvas-theme'], + }); } return { @@ -493,7 +529,9 @@ export const Main = defineWidget({ ...metadata, locales: { zh, en }, schema: PropsSchema, + sampleData: { data: STANDALONE_LINE_SERIES }, standaloneDefaults: { data: STANDALONE_LINE_SERIES }, + previewDefaults: { data: STANDALONE_LINE_SERIES }, controls, render: renderChart, }); diff --git a/packages/widgets/chart/echarts-pie/src/index.ts b/packages/widgets/chart/echarts-pie/src/index.ts index 023886b7..624d42aa 100644 --- a/packages/widgets/chart/echarts-pie/src/index.ts +++ b/packages/widgets/chart/echarts-pie/src/index.ts @@ -33,6 +33,11 @@ function normalizePieData(data: Props['data']) { } const LEGACY_DEFAULT_PRIMARY = '#6965db'; +const SAMPLE_PIE_SERIES = [ + { name: '设备在线', value: 68 }, + { name: '设备离线', value: 18 }, + { name: '设备告警', value: 14 }, +]; /** * 根据 Props 和 Theme 生成 ECharts Option @@ -137,6 +142,9 @@ export const Main = defineWidget({ locales: { zh, en }, schema: PropsSchema, controls, + sampleData: { data: SAMPLE_PIE_SERIES }, + standaloneDefaults: { data: SAMPLE_PIE_SERIES }, + previewDefaults: { data: SAMPLE_PIE_SERIES }, render: (element: HTMLElement, props: Props, ctx: WidgetOverlayContext) => { let currentProps = props; let colors: WidgetColors = resolveWidgetColors(element); diff --git a/packages/widgets/chart/uplot-line/index.test.ts b/packages/widgets/chart/uplot-line/index.test.ts index cc5bcb0a..d83a51d7 100644 --- a/packages/widgets/chart/uplot-line/index.test.ts +++ b/packages/widgets/chart/uplot-line/index.test.ts @@ -7,9 +7,16 @@ class UPlotMock { linear: () => undefined, }; + static lastData: unknown = null; + static throwOnConstruct = false; + root: HTMLDivElement; - constructor(_opts: unknown, _data: unknown, target: HTMLElement) { + constructor(_opts: unknown, data: unknown, target: HTMLElement) { + UPlotMock.lastData = data; + if (UPlotMock.throwOnConstruct) { + throw new RangeError('Invalid array length'); + } this.root = document.createElement('div'); this.root.className = 'uplot'; target.appendChild(this.root); @@ -29,6 +36,8 @@ vi.mock('uplot/dist/uPlot.min.css', () => ({})); describe('chart/uplot-line widget', () => { beforeEach(() => { + UPlotMock.lastData = null; + UPlotMock.throwOnConstruct = false; vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { cb(0); return 1; @@ -54,8 +63,47 @@ describe('chart/uplot-line widget', () => { }, }); - expect(harness.element.textContent).toContain('Preview style - data appears after binding'); + expect(harness.element.textContent).toContain('Waiting for time series data'); harness.destroy(); }); + + it('expands a single valid time-series point before handing data to uPlot', async () => { + const { default: Main } = await import('./src/index'); + const harness = mountWidget(Main, { + locale: 'en', + props: { + data: [{ timestamp: '2026-01-01T00:00:00Z', value: 18 }], + }, + }); + + const data = UPlotMock.lastData as [number[], number[]]; + expect(data[0]).toHaveLength(2); + expect(data[1]).toEqual([18, 18]); + expect(data[0][0]).toBeLessThan(data[0][1]); + + harness.destroy(); + }); + + it('does not let uPlot render failures escape the widget render loop', async () => { + const originalConsoleError = console.error; + console.error = vi.fn(); + UPlotMock.throwOnConstruct = true; + + const { default: Main } = await import('./src/index'); + try { + const harness = mountWidget(Main, { + locale: 'en', + props: { + data: [{ timestamp: '2026-01-01T00:00:00Z', value: 18 }], + }, + }); + + expect(harness.element.textContent).toContain('Waiting for time series data'); + harness.destroy(); + } finally { + UPlotMock.throwOnConstruct = false; + console.error = originalConsoleError; + } + }); }); diff --git a/packages/widgets/chart/uplot-line/src/index.ts b/packages/widgets/chart/uplot-line/src/index.ts index 7242bdba..a2e6aa42 100644 --- a/packages/widgets/chart/uplot-line/src/index.ts +++ b/packages/widgets/chart/uplot-line/src/index.ts @@ -4,12 +4,12 @@ import { metadata } from './metadata'; import { PropsSchema, type Props } from './schema'; import { controls } from './controls'; import { - defineWidget, - resolveLayeredColor, - resolveLocaleRecord, - type WidgetOverlayContext, - resolveWidgetColors, - type WidgetColors, + defineWidget, + resolveLayeredColor, + resolveLocaleRecord, + type WidgetOverlayContext, + resolveWidgetColors, + type WidgetColors, } from '@thingsvis/widget-sdk'; import zh from './locales/zh.json'; @@ -30,234 +30,266 @@ const LEGEND_AXIS_GAP_BASE = WIDGET_PADDING / 2; * `legendSpace` plus axis→legend gap so margin does not get clipped by overflow:hidden. */ function computeUplotInnerSize( - containerW: number, - containerH: number, - showLegend: boolean, - scale: number, + containerW: number, + containerH: number, + showLegend: boolean, + scale: number, ): { width: number; height: number } { - const legendAxisGap = Math.round(LEGEND_AXIS_GAP_BASE * scale); - const reserve = showLegend - ? Math.round(LEGEND_BLOCK_HEIGHT * scale) - + Math.round(WIDGET_PADDING * scale) - + legendAxisGap - : 0; - return { - width: Math.max(0, Math.floor(containerW)), - height: Math.max(48, Math.floor(containerH - reserve)), - }; + const legendAxisGap = Math.round(LEGEND_AXIS_GAP_BASE * scale); + const reserve = showLegend + ? Math.round(LEGEND_BLOCK_HEIGHT * scale) + Math.round(WIDGET_PADDING * scale) + legendAxisGap + : 0; + return { + width: Math.max(0, Math.floor(containerW)), + height: Math.max(48, Math.floor(containerH - reserve)), + }; } const TIME_RANGE_SEC: Record, number> = { - '1h': 60 * 60, - '6h': 6 * 60 * 60, - '24h': 24 * 60 * 60, - '7d': 7 * 24 * 60 * 60, - '30d': 30 * 24 * 60 * 60, + '1h': 60 * 60, + '6h': 6 * 60 * 60, + '24h': 24 * 60 * 60, + '7d': 7 * 24 * 60 * 60, + '30d': 30 * 24 * 60 * 60, }; const STANDALONE_UPLOT_SERIES = [ - { timestamp: '2026-01-01T00:00:00Z', value: 18 }, - { timestamp: '2026-01-01T01:00:00Z', value: 22 }, - { timestamp: '2026-01-01T02:00:00Z', value: 26 }, - { timestamp: '2026-01-01T03:00:00Z', value: 24 }, + { timestamp: '2026-01-01T00:00:00Z', value: 18 }, + { timestamp: '2026-01-01T01:00:00Z', value: 22 }, + { timestamp: '2026-01-01T02:00:00Z', value: 26 }, + { timestamp: '2026-01-01T03:00:00Z', value: 24 }, ]; type ParsedPoint = { tsSec: number; value: number }; type RuntimeMessages = { - runtime?: { - defaultSeriesName?: string; - emptyState?: string; - previewState?: string; - }; + runtime?: { + defaultSeriesName?: string; + emptyState?: string; + }; }; function getRuntimeMessages(locale: string | undefined): RuntimeMessages { - return resolveLocaleRecord(localeCatalog, locale) as RuntimeMessages; + return resolveLocaleRecord(localeCatalog, locale) as RuntimeMessages; } function withAlpha(color: string, alpha: number): string { - const clamped = Math.max(0, Math.min(1, alpha)); - const normalized = color.trim(); - - const hexMatch = normalized.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/); - if (hexMatch && hexMatch[1]) { - const hex = hexMatch[1]; - const fullHex = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex; - const num = Number.parseInt(fullHex, 16); - const r = (num >> 16) & 255; - const g = (num >> 8) & 255; - const b = num & 255; - return `rgba(${r}, ${g}, ${b}, ${clamped})`; - } - - const rgbMatch = normalized.match(/^rgba?\(([^)]+)\)$/i); - if (rgbMatch && rgbMatch[1]) { - const parts = rgbMatch[1].split(',').map((part) => part.trim()); - if (parts.length >= 3) { - return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${clamped})`; - } + const clamped = Math.max(0, Math.min(1, alpha)); + const normalized = color.trim(); + + const hexMatch = normalized.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/); + if (hexMatch && hexMatch[1]) { + const hex = hexMatch[1]; + const fullHex = + hex.length === 3 + ? hex + .split('') + .map((c) => c + c) + .join('') + : hex; + const num = Number.parseInt(fullHex, 16); + const r = (num >> 16) & 255; + const g = (num >> 8) & 255; + const b = num & 255; + return `rgba(${r}, ${g}, ${b}, ${clamped})`; + } + + const rgbMatch = normalized.match(/^rgba?\(([^)]+)\)$/i); + if (rgbMatch && rgbMatch[1]) { + const parts = rgbMatch[1].split(',').map((part) => part.trim()); + if (parts.length >= 3) { + return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${clamped})`; } + } - const hslMatch = normalized.match(/^hsla?\(([^)]+)\)$/i); - if (hslMatch && hslMatch[1]) { - const parts = hslMatch[1].split(',').map((part) => part.trim()); - if (parts.length >= 3) { - return `hsla(${parts[0]}, ${parts[1]}, ${parts[2]}, ${clamped})`; - } + const hslMatch = normalized.match(/^hsla?\(([^)]+)\)$/i); + if (hslMatch && hslMatch[1]) { + const parts = hslMatch[1].split(',').map((part) => part.trim()); + if (parts.length >= 3) { + return `hsla(${parts[0]}, ${parts[1]}, ${parts[2]}, ${clamped})`; } + } - return normalized; + return normalized; } function parseTimestampMs(raw: unknown): number | null { - if (raw instanceof Date) { - const ms = raw.getTime(); - return Number.isFinite(ms) ? ms : null; - } - - if (typeof raw === 'number') { - if (!Number.isFinite(raw)) return null; - if (raw > 1e11) return raw; - if (raw > 1e9) return raw * 1000; - return null; - } - - if (typeof raw === 'string') { - const trimmed = raw.trim(); - if (!trimmed) return null; + if (raw instanceof Date) { + const ms = raw.getTime(); + return Number.isFinite(ms) ? ms : null; + } + + if (typeof raw === 'number') { + if (!Number.isFinite(raw)) return null; + if (raw > 1e11) return raw; + if (raw > 1e9) return raw * 1000; + return null; + } - const asNum = Number(trimmed); - if (Number.isFinite(asNum)) { - if (asNum > 1e11) return asNum; - if (asNum > 1e9) return asNum * 1000; - } + if (typeof raw === 'string') { + const trimmed = raw.trim(); + if (!trimmed) return null; - const parsed = Date.parse(trimmed); - return Number.isFinite(parsed) ? parsed : null; + const asNum = Number(trimmed); + if (Number.isFinite(asNum)) { + if (asNum > 1e11) return asNum; + if (asNum > 1e9) return asNum * 1000; } - return null; + const parsed = Date.parse(trimmed); + return Number.isFinite(parsed) ? parsed : null; + } + + return null; } function parseNumber(raw: unknown): number | null { - if (typeof raw === 'number') { - return Number.isFinite(raw) ? raw : null; - } - if (typeof raw === 'string') { - const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : null; - } - return null; + if (typeof raw === 'number') { + return Number.isFinite(raw) ? raw : null; + } + if (typeof raw === 'string') { + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : null; + } + return null; } function toParsedPoint(entry: unknown): ParsedPoint | null { - if (Array.isArray(entry)) { - const [timeRaw, valueRaw] = entry; - const ms = parseTimestampMs(timeRaw); - const value = parseNumber(valueRaw); - if (ms === null || value === null) return null; - return { tsSec: Math.round(ms / 1000), value }; - } - - if (entry && typeof entry === 'object') { - const record = entry as Record; - const timeRaw = record.time ?? record.timestamp ?? record.ts ?? record.name ?? record.x; - const valueRaw = record.value ?? record.y; - const ms = parseTimestampMs(timeRaw); - const value = parseNumber(valueRaw); - if (ms === null || value === null) return null; - return { tsSec: Math.round(ms / 1000), value }; - } - - return null; + if (Array.isArray(entry)) { + const [timeRaw, valueRaw] = entry; + const ms = parseTimestampMs(timeRaw); + const value = parseNumber(valueRaw); + if (ms === null || value === null) return null; + return { tsSec: Math.round(ms / 1000), value }; + } + + if (entry && typeof entry === 'object') { + const record = entry as Record; + const timeRaw = record.time ?? record.timestamp ?? record.ts ?? record.name ?? record.x; + const valueRaw = record.value ?? record.y; + const ms = parseTimestampMs(timeRaw); + const value = parseNumber(valueRaw); + if (ms === null || value === null) return null; + return { tsSec: Math.round(ms / 1000), value }; + } + + return null; } function normalizeSeries(data: unknown, timeRangePreset: Props['timeRangePreset']): ParsedPoint[] { - if (!Array.isArray(data)) return []; + if (!Array.isArray(data)) return []; - const points = data.map(toParsedPoint).filter((p): p is ParsedPoint => p !== null); - if (points.length === 0) return []; + const points = data.map(toParsedPoint).filter((p): p is ParsedPoint => p !== null); + if (points.length === 0) return []; - points.sort((a, b) => a.tsSec - b.tsSec); - const deduped = new Map(); - points.forEach((p) => deduped.set(p.tsSec, p.value)); + points.sort((a, b) => a.tsSec - b.tsSec); + const deduped = new Map(); + points.forEach((p) => deduped.set(p.tsSec, p.value)); - const normalized = Array.from(deduped.entries()) - .sort((a, b) => a[0] - b[0]) - .map(([tsSec, value]) => ({ tsSec, value })); + const normalized = Array.from(deduped.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([tsSec, value]) => ({ tsSec, value })); - if (timeRangePreset === 'all' || normalized.length === 0) { - return normalized; - } + if (timeRangePreset === 'all' || normalized.length === 0) { + return normalized; + } + + const rangeSec = TIME_RANGE_SEC[timeRangePreset]; + const endSec = normalized[normalized.length - 1]!.tsSec; + const startSec = endSec - rangeSec; + const filtered = normalized.filter((point) => point.tsSec >= startSec); + return filtered.length > 0 ? filtered : [normalized[normalized.length - 1]!]; +} - const rangeSec = TIME_RANGE_SEC[timeRangePreset]; - const endSec = normalized[normalized.length - 1]!.tsSec; - const startSec = endSec - rangeSec; - const filtered = normalized.filter((point) => point.tsSec >= startSec); - return filtered.length > 0 ? filtered : [normalized[normalized.length - 1]!]; +function ensureRenderableSeries(points: ParsedPoint[], fallbackRangeSec: number): ParsedPoint[] { + const finitePoints = points.filter( + (point) => Number.isFinite(point.tsSec) && Number.isFinite(point.value), + ); + if (finitePoints.length === 0) return []; + + const sorted = finitePoints.slice().sort((a, b) => a.tsSec - b.tsSec); + if (sorted.length > 1 && sorted[0]!.tsSec !== sorted[sorted.length - 1]!.tsSec) { + return sorted; + } + + const point = sorted[sorted.length - 1]!; + const offsetSec = Math.max(60, Math.floor(fallbackRangeSec / 12)); + return [ + { tsSec: point.tsSec - offsetSec, value: point.value }, + point, + ]; +} + +function ensureFiniteRange(min: number, max: number): [number, number] { + if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1]; + if (min > max) return ensureFiniteRange(max, min); + if (min === max) { + const pad = Math.max(1, Math.abs(min) * 0.1); + return [min - pad, max + pad]; + } + return [min, max]; } function getFallbackRangeSec(timeRangePreset: Props['timeRangePreset']): number { - return timeRangePreset === 'all' ? TIME_RANGE_SEC['1h'] : TIME_RANGE_SEC[timeRangePreset]; + return timeRangePreset === 'all' ? TIME_RANGE_SEC['1h'] : TIME_RANGE_SEC[timeRangePreset]; } function buildPreviewSeries(timeRangePreset: Props['timeRangePreset']): ParsedPoint[] { - const rangeSec = getFallbackRangeSec(timeRangePreset); - const endSec = Math.floor(Date.now() / 1000); - const pointCount = 6; - const stepSec = Math.max(60, Math.floor(rangeSec / (pointCount - 1))); - const sampleValues = [18, 24, 21, 29, 34, 31]; - - return sampleValues.map((value, index) => ({ - tsSec: endSec - stepSec * (pointCount - index - 1), - value, - })); + const rangeSec = getFallbackRangeSec(timeRangePreset); + const endSec = Math.floor(Date.now() / 1000); + const pointCount = 6; + const stepSec = Math.max(60, Math.floor(rangeSec / (pointCount - 1))); + const sampleValues = [18, 24, 21, 29, 34, 31]; + + return sampleValues.map((value, index) => ({ + tsSec: endSec - stepSec * (pointCount - index - 1), + value, + })); } function pickLineColor(props: Props, colors: WidgetColors): string { - return resolveLayeredColor({ - instance: props.primaryColor, - theme: colors.series[0] ?? colors.primary, - fallback: LEGACY_DEFAULT_PRIMARY, - inheritValues: [LEGACY_DEFAULT_PRIMARY], - }); + return resolveLayeredColor({ + instance: props.primaryColor, + theme: colors.series[0] ?? colors.primary, + fallback: LEGACY_DEFAULT_PRIMARY, + inheritValues: [LEGACY_DEFAULT_PRIMARY], + }); } function pad2(value: number): string { - return String(value).padStart(2, '0'); + return String(value).padStart(2, '0'); } function formatTick(tsSec: number, spanSec: number): string { - const date = new Date(tsSec * 1000); - if (!Number.isFinite(date.getTime())) return ''; + const date = new Date(tsSec * 1000); + if (!Number.isFinite(date.getTime())) return ''; - const hhmm = `${pad2(date.getHours())}:${pad2(date.getMinutes())}`; - if (spanSec <= 24 * 60 * 60) { - return hhmm; - } + const hhmm = `${pad2(date.getHours())}:${pad2(date.getMinutes())}`; + if (spanSec <= 24 * 60 * 60) { + return hhmm; + } - return `${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${hhmm}`; + return `${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${hhmm}`; } function getTitleAlignment(align: Props['titleAlign']): 'left' | 'center' | 'right' { - if (align === 'center') return 'center'; - if (align === 'right') return 'right'; - return 'left'; + if (align === 'center') return 'center'; + if (align === 'right') return 'right'; + return 'left'; } function escapeHtml(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); } type HoverTooltipCtx = { - spanSec: number; - seriesLabel: string; - fg: string; - tooltipBg: string; - tooltipBorder: string; + spanSec: number; + seriesLabel: string; + fg: string; + tooltipBg: string; + tooltipBorder: string; }; /** @@ -265,109 +297,111 @@ type HoverTooltipCtx = { * 把 DOM 挂在 u.over 上更新 — https://leeoniya.github.io/uPlot/demos/tooltips.html */ function createHoverTooltipPlugin(ctx: HoverTooltipCtx) { - let tt: HTMLDivElement | null = null; - - return { - hooks: { - init(u: uPlot) { - tt = document.createElement('div'); - tt.className = 'thingsvis-uplot-tooltip'; - tt.style.cssText = [ - 'position:absolute', - 'display:none', - 'pointer-events:none', - 'z-index:20', - 'max-width:260px', - 'padding:6px 10px', - 'border-radius:6px', - 'font-size:12px', - 'line-height:1.45', - 'font-family:Inter,system-ui,Noto Sans SC,Noto Sans,sans-serif', - 'font-weight:500', - `color:${ctx.fg}`, - `background:${ctx.tooltipBg}`, - `border:1px solid ${ctx.tooltipBorder}`, - 'box-shadow:0 4px 14px rgba(0,0,0,0.1)', - ].join(';'); - u.over.appendChild(tt); - }, - setCursor(u: uPlot) { - if (!tt) return; - const { idx } = u.cursor; - const left = u.cursor.left ?? -1; - const top = u.cursor.top ?? -1; - if (idx == null || left < 0 || top < 0) { - tt.style.display = 'none'; - return; - } - const xRaw = u.data[0][idx]; - const yRaw = u.data[1]?.[idx]; - if (yRaw == null || (typeof yRaw === 'number' && Number.isNaN(yRaw))) { - tt.style.display = 'none'; - return; - } - const timeStr = formatTick(Number(xRaw), ctx.spanSec); - const yStr = typeof yRaw === 'number' ? String(yRaw) : String(yRaw); - tt.innerHTML = `${escapeHtml(ctx.seriesLabel)}
${escapeHtml(timeStr)} · ${escapeHtml(yStr)}`; - tt.style.display = 'block'; - - const pad = 12; - let lx = left + pad; - let ty = top + pad; - const ow = u.over.clientWidth; - const oh = u.over.clientHeight; - tt.style.left = `${lx}px`; - tt.style.top = `${ty}px`; - const tw = tt.offsetWidth; - const th = tt.offsetHeight; - if (lx + tw > ow - 6) lx = Math.max(6, left - tw - pad); - if (ty + th > oh - 6) ty = Math.max(6, top - th - pad); - tt.style.left = `${lx}px`; - tt.style.top = `${ty}px`; - }, - destroy() { - tt?.remove(); - tt = null; - }, - }, - }; + let tt: HTMLDivElement | null = null; + + return { + hooks: { + init(u: uPlot) { + tt = document.createElement('div'); + tt.className = 'thingsvis-uplot-tooltip'; + tt.style.cssText = [ + 'position:absolute', + 'display:none', + 'pointer-events:none', + 'z-index:20', + 'max-width:260px', + 'padding:6px 10px', + 'border-radius:6px', + 'font-size:12px', + 'line-height:1.45', + 'font-family:Inter,system-ui,Noto Sans SC,Noto Sans,sans-serif', + 'font-weight:500', + `color:${ctx.fg}`, + `background:${ctx.tooltipBg}`, + `border:1px solid ${ctx.tooltipBorder}`, + 'box-shadow:0 4px 14px rgba(0,0,0,0.1)', + ].join(';'); + u.over.appendChild(tt); + }, + setCursor(u: uPlot) { + if (!tt) return; + const { idx } = u.cursor; + const left = u.cursor.left ?? -1; + const top = u.cursor.top ?? -1; + if (idx == null || left < 0 || top < 0) { + tt.style.display = 'none'; + return; + } + const xRaw = u.data[0][idx]; + const yRaw = u.data[1]?.[idx]; + if (yRaw == null || (typeof yRaw === 'number' && Number.isNaN(yRaw))) { + tt.style.display = 'none'; + return; + } + const timeStr = formatTick(Number(xRaw), ctx.spanSec); + const yStr = typeof yRaw === 'number' ? String(yRaw) : String(yRaw); + tt.innerHTML = `${escapeHtml(ctx.seriesLabel)}
${escapeHtml(timeStr)} · ${escapeHtml(yStr)}`; + tt.style.display = 'block'; + + const pad = 12; + let lx = left + pad; + let ty = top + pad; + const ow = u.over.clientWidth; + const oh = u.over.clientHeight; + tt.style.left = `${lx}px`; + tt.style.top = `${ty}px`; + const tw = tt.offsetWidth; + const th = tt.offsetHeight; + if (lx + tw > ow - 6) lx = Math.max(6, left - tw - pad); + if (ty + th > oh - 6) ty = Math.max(6, top - th - pad); + tt.style.left = `${lx}px`; + tt.style.top = `${ty}px`; + }, + destroy() { + tt?.remove(); + tt = null; + }, + }, + }; } export const Main = defineWidget({ - id: metadata.id, - name: metadata.name, - category: metadata.category, - icon: metadata.icon, - version: metadata.version, - defaultSize: metadata.defaultSize, - constraints: metadata.constraints, - locales: { zh, en }, - schema: PropsSchema, - standaloneDefaults: { data: STANDALONE_UPLOT_SERIES }, - controls, - render: (element: HTMLElement, props: Props, ctx: WidgetOverlayContext) => { - let currentProps = props; - let currentLocale = ctx.locale; - let colors: WidgetColors = resolveWidgetColors(element); - - let chart: uPlot | null = null; - let ro: ResizeObserver | null = null; - let themeObserver: MutationObserver | null = null; - let lastScale = -1; - - element.style.display = 'flex'; - element.style.flexDirection = 'column'; - element.style.width = '100%'; - element.style.height = '100%'; - element.style.boxSizing = 'border-box'; - element.style.overflow = 'hidden'; - element.setAttribute('data-thingsvis-uplot-line', ''); - - // Descendant selectors: `.uplot` lives inside chartContainer, not as a sibling of this