完成主要交互、高性能组件、国际化和A型传感器数据包接收

This commit is contained in:
2026-01-13 16:34:28 +08:00
parent 47e6dc7244
commit 1960e6a5b9
84 changed files with 7752 additions and 332 deletions

View File

@@ -1,6 +1,5 @@
import QtQuick
import "content"
import "./content"
App {
}

View File

@@ -1,18 +1,56 @@
import QtQuick
import QtQuick.Window
import QtQuick.Controls.Material
import QtQuick.Layouts
import "./"
import TactileIPC 1.0
Rectangle {
// width: Constants.width
// height: Constants.height
width: 360
// minimumWidth: 800
// minimumHeight: 600
Item {
id: root
width: 1280
height: 720
visible: true
Material.theme: Backend.lightMode ? Material.Light : Material.Dark
Material.accent: Material.Green
Material.primary: Material.Green
ControlPanel {
ColumnLayout {
anchors.fill: parent
spacing: 0
NavBar {
Layout.fillWidth: true
}
RowLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 0
LeftPanel {
Layout.preferredWidth: 500
Layout.fillWidth: true
Layout.fillHeight: true
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
radius: 10
color: Qt.rgba(0, 0, 0, 0.04)
border.color: Qt.rgba(0, 0, 0, 0.08)
Column {
anchors.centerIn: parent
spacing: 8
Label { text: "OpenGL View"; font.pixelSize: 16 }
Label { text: "(QWidget GLWidget attached in C++)"; font.pixelSize: 12 }
}
}
RightPanel {
Layout.preferredWidth: 320
Layout.fillHeight: true
}
}
}
}

View File

@@ -0,0 +1,117 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Material
import QtQuick.Layouts
import TactileIPC 1.0
Item {
id: root
width: 350
property alias title: titleText.text
property bool expanded: true
property int contentPadding: 12
property color panelBgColor: Backend.lightMode ? "#FFFFFF" : "#2F2F2F"
property color panelBorderColor: Backend.lightMode ? "#E0E0E0" : "#3A3A3A"
property color titleColor: Backend.lightMode ? "#424242" : "#E0E0E0"
property color dividerColor: Backend.lightMode ? "#EEEEEE" : "#3A3A3A"
default property alias content: contentArea.data
implicitHeight: header.height + contentWrapper.height
Rectangle {
id: panelBg
anchors.fill: parent
radius: 6
color: root.panelBgColor
border.color: root.panelBorderColor
}
Rectangle {
id: header
width: parent.width
height: 44
radius: 6
color: "transparent"
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
MouseArea {
anchors.fill: parent
onClicked: root.expanded = !root.expanded
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 8
spacing: 8
Rectangle {
width: 10
height: 10
radius: 2
color: Material.color(Material.Green)
Layout.alignment: Qt.AlignVCenter
}
Label {
id: titleText
text: "placehold"
font.pixelSize: 16
color: root.titleColor
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
}
ToolButton {
id: arrowBtn
text: "\u25BE"
font.pixelSize: 18
background: null
rotation: root.expanded ? 180 : 0
Layout.alignment: Qt.AlignVCenter
Behavior on rotation {
NumberAnimation {
duration: 200;
easing.type: Easing.InOutQuad
}
}
onClicked: root.expanded = !root.expanded
}
}
Rectangle {
height: 1
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
color: root.dividerColor
visible: true
}
}
Item {
id: contentWrapper
anchors.top: header.bottom
anchors.left: parent.left
anchors.right: parent.right
height: root.expanded ? contentArea.implicitHeight + root.contentPadding * 2 : 0
clip: true
Behavior on height {
NumberAnimation {
duration: 220;
easing.type: Easing.InOutQuad
}
}
ColumnLayout {
id: contentArea
anchors.fill: parent
anchors.margins: root.contentPadding
spacing: 10
}
}
}

View File

@@ -1,10 +1,10 @@
import QtQuick
import QtQuick3D
import QtQuick.Controls.Material
import QtQuick.Controls
import QtQuick.Layouts
import QtQml
import "."
import TactileIPC 1.0
Pane {
id: root
@@ -21,11 +21,17 @@ Pane {
Toggle {
id: darkModeToggle
text: qsTr("Dark mode")
text: Qt.binding(function() {
I18n.retranslateToken
return qsTr("Dark mode")
})
}
GroupBox {
title: qsTr("Render")
title: Qt.binding(function() {
I18n.retranslateToken
return qsTr("Render")
})
Layout.fillWidth: true
ColumnLayout {
@@ -34,17 +40,23 @@ Pane {
RowLayout {
Layout.fillWidth: true
Label { text: qsTr("Mode"); Layout.alignment: Qt.AlignVCenter }
Label {
text: Qt.binding(function() {
I18n.retranslateToken
return qsTr("Mode")
})
Layout.alignment: Qt.AlignVCenter
}
ComboBox {
id: renderModeBox
Layout.fillWidth: true
model: ["dataViz", "realistic"]
Component.onCompleted: currentIndex = backend.renderMode === "realistic" ? 1 : 0
onActivated: backend.renderMode = currentText
Component.onCompleted: currentIndex = Backend.renderMode === "realistic" ? 1 : 0
onActivated: Backend.renderMode = currentText
Connections {
target: backend
target: Backend
function onRenderModeChanged() {
renderModeBox.currentIndex = backend.renderMode === "realistic" ? 1 : 0
renderModeBox.currentIndex = Backend.renderMode === "realistic" ? 1 : 0
}
}
}
@@ -52,22 +64,28 @@ Pane {
RowLayout {
Layout.fillWidth: true
Label { text: qsTr("Labels"); Layout.alignment: Qt.AlignVCenter }
Label {
text: Qt.binding(function() {
I18n.retranslateToken
return qsTr("Labels")
})
Layout.alignment: Qt.AlignVCenter
}
ComboBox {
id: labelModeBox
Layout.fillWidth: true
model: ["off", "hover", "always"]
Component.onCompleted: {
if (backend.labelMode === "always") labelModeBox.currentIndex = 2
else if (backend.labelMode === "hover") labelModeBox.currentIndex = 1
if (Backend.labelMode === "always") labelModeBox.currentIndex = 2
else if (Backend.labelMode === "hover") labelModeBox.currentIndex = 1
else labelModeBox.currentIndex = 0
}
onActivated: backend.labelMode = currentText
onActivated: Backend.labelMode = currentText
Connections {
target: backend
target: Backend
function onLabelModeChanged() {
if (backend.labelMode === "always") labelModeBox.currentIndex = 2
else if (backend.labelMode === "hover") labelModeBox.currentIndex = 1
if (Backend.labelMode === "always") labelModeBox.currentIndex = 2
else if (Backend.labelMode === "hover") labelModeBox.currentIndex = 1
else labelModeBox.currentIndex = 0
}
}
@@ -76,15 +94,21 @@ Pane {
Toggle {
id: legendToggle
text: qsTr("Legend")
checked: backend.showLegend
onCheckedChanged: backend.showLegend = checked
text: Qt.binding(function() {
I18n.retranslateToken
return qsTr("Legend")
})
checked: Backend.showLegend
onCheckedChanged: Backend.showLegend = checked
}
}
}
GroupBox {
title: qsTr("Scale")
title: Qt.binding(function() {
I18n.retranslateToken
return qsTr("Scale")
})
Layout.fillWidth: true
ColumnLayout {
@@ -93,35 +117,47 @@ Pane {
RowLayout {
Layout.fillWidth: true
Label { text: qsTr("Min"); Layout.alignment: Qt.AlignVCenter }
Label {
text: Qt.binding(function() {
I18n.retranslateToken
return qsTr("Min")
})
Layout.alignment: Qt.AlignVCenter
}
SpinBox {
id: minBox
Layout.fillWidth: true
from: -999999
to: 999999
value: backend.minValue
onValueModified: backend.minValue = value
value: Backend.minValue
onValueModified: Backend.minValue = value
}
}
RowLayout {
Layout.fillWidth: true
Label { text: qsTr("Max"); Layout.alignment: Qt.AlignVCenter }
Label {
text: Qt.binding(function() {
I18n.retranslateToken
return qsTr("Max")
})
Layout.alignment: Qt.AlignVCenter
}
SpinBox {
id: maxBox
Layout.fillWidth: true
from: -999999
to: 999999
value: backend.maxValue
onValueModified: backend.maxValue = value
value: Backend.maxValue
onValueModified: Backend.maxValue = value
}
}
Legend {
Layout.alignment: Qt.AlignHCenter
visible: backend.showLegend
minValue: backend.minValue
maxValue: backend.maxValue
visible: Backend.showLegend
minValue: Backend.minValue
maxValue: Backend.maxValue
}
}
}

View File

@@ -1,8 +1,12 @@
import QtQuick
import QtQuick.Controls
import TactileIPC 1.0
Slider {
property string lableText: qsTr("Text")
property string lableText: Qt.binding(function() {
I18n.retranslateToken
return qsTr("Text")
})
stepSize: 1
Label {

421
qml/content/LeftPanel.qml Normal file
View File

@@ -0,0 +1,421 @@
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
import QtQuick.Layouts
import "."
import TactileIPC 1.0
Rectangle {
id: root
width: 350
color: Backend.lightMode ? "#F5F5F5" : "#2C2C2C"
radius: 8
border.color: Qt.rgba(0, 0, 0, 0.08)
property color textColor: Backend.lightMode ? "#424242" : "#E0E0E0"
function formatHexByte(value) {
const hex = Number(value).toString(16).toUpperCase()
return "0x" + ("00" + hex).slice(-2)
}
function formatHexValue(value) {
const hex = Number(value).toString(16).toUpperCase()
return "0x" + hex
}
function parseHexValue(text, maxValue) {
let trimmed = String(text).trim()
if (trimmed.startsWith("0x") || trimmed.startsWith("0X"))
trimmed = trimmed.slice(2)
if (trimmed.length === 0)
return NaN
const value = parseInt(trimmed, 16)
if (isNaN(value) || value < 0 || value > maxValue)
return NaN
return value
}
function tr(text) {
I18n.retranslateToken
return qsTr(text)
}
Material.theme: Backend.lightMode ? Material.Light : Material.Dark
Material.accent: Material.Green
Material.primary: Material.Green
ScrollView {
anchors.fill: parent
ScrollBar.vertical.policy: ScrollBar.AlwaysOff
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ColumnLayout {
id: layout
anchors.fill: parent
anchors.margins: 12
spacing: 12
CollapsiblePanel {
title: root.tr("连接设置")
expanded: true
Layout.fillWidth: true
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: root.tr("COM Port")
Layout.preferredWidth: 90
color: root.textColor
}
ComboBox {
id: portBox
Layout.fillWidth: true
model: Backend.serial.availablePorts
Component.onCompleted: {
for (let i = 0; i < portBox.count; i++) {
if (portBox.textAt(i) === Backend.serial.portName) {
currentIndex = i
break
}
}
}
onActivated: Backend.serial.portName = currentText
}
ToolButton {
text: "\u21bb"
onClicked: Backend.serial.refreshPorts()
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: root.tr("Baud")
Layout.preferredWidth: 90
color: root.textColor
}
ComboBox {
Layout.fillWidth: true
model: ["9600", "57600", "115200", "230400", "912600"]
Component.onCompleted: {
const idx = model.indexOf(String(Backend.serial.baudRate))
if (idx >= 0)
currentIndex = idx
}
onActivated: Backend.serial.baudRate = parseInt(currentText)
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: root.tr("模式")
Layout.preferredWidth: 90
color: root.textColor
}
ComboBox {
Layout.fillWidth: true
textRole: "text"
model: [
{ text: root.tr("从站"), value: "slave" },
{ text: root.tr("主站"), value: "master" }
]
function syncModeIndex() {
for (let i = 0; i < model.length; i++) {
if (model[i].value === Backend.serial.mode) {
currentIndex = i
break
}
}
}
Component.onCompleted: syncModeIndex()
onModelChanged: syncModeIndex()
Connections {
target: Backend.serial
function onModeChanged() {
syncModeIndex()
}
}
onActivated: Backend.serial.mode = model[currentIndex].value
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: root.tr("设备地址")
Layout.preferredWidth: 90
color: root.textColor
}
TextField {
id: addrField
Layout.fillWidth: true
text: root.formatHexByte(Backend.serial.deviceAddress)
placeholderText: "0x01"
inputMethodHints: Qt.ImhPreferUppercase
validator: RegularExpressionValidator {
regularExpression: /^(0x|0X)?[0-9a-fA-F]{1,2}$/
}
onEditingFinished: {
const value = root.parseHexValue(text, 255)
if (isNaN(value)) {
text = root.formatHexByte(Backend.serial.deviceAddress)
return
}
Backend.serial.deviceAddress = value
text = root.formatHexByte(Backend.serial.deviceAddress)
}
Connections {
target: Backend.serial
function onDeviceAddressChanged() {
addrField.text = root.formatHexByte(Backend.serial.deviceAddress)
}
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: root.tr("采样周期")
Layout.preferredWidth: 90
color: root.textColor
}
SpinBox {
Layout.fillWidth: true
from: 1
to: 2000
value: Backend.serial.pollIntervalMs
enabled: Backend.serial.mode === "slave"
onValueModified: Backend.serial.pollIntervalMs = value
}
}
RowLayout {
Layout.fillWidth: true
spacing: 12
Button {
Layout.fillWidth: true
text: root.tr("连接")
highlighted: true
enabled: !Backend.serial.connected
onClicked: Backend.serial.open()
}
Button {
Layout.fillWidth: true
text: root.tr("断开")
enabled: Backend.serial.connected
onClicked: Backend.serial.close()
}
}
}
CollapsiblePanel {
title: root.tr("采样参数")
expanded: true
Layout.fillWidth: true
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: root.tr("功能码")
Layout.preferredWidth: 90
color: root.textColor
}
SpinBox {
id: requestFunctionBox
Layout.fillWidth: true
from: 0
to: 255
editable: true
value: Backend.serial.requestFunction
textFromValue: function(value, locale) {
return root.formatHexByte(value)
}
valueFromText: function(text, locale) {
const parsed = root.parseHexValue(text, requestFunctionBox.to)
return isNaN(parsed) ? requestFunctionBox.value : parsed
}
validator: RegularExpressionValidator {
regularExpression: /^(0x|0X)?[0-9a-fA-F]{1,2}$/
}
onValueModified: Backend.serial.requestFunction = value
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: root.tr("起始地址")
Layout.preferredWidth: 90
color: root.textColor
}
SpinBox {
id: requestStartAddressBox
Layout.fillWidth: true
from: 0
to: 1000000
editable: true
value: Backend.serial.requestStartAddress
textFromValue: function(value, locale) {
return root.formatHexValue(value)
}
valueFromText: function(text, locale) {
const parsed = root.parseHexValue(text, requestStartAddressBox.to)
return isNaN(parsed) ? requestStartAddressBox.value : parsed
}
validator: RegularExpressionValidator {
regularExpression: /^(0x|0X)?[0-9a-fA-F]{1,8}$/
}
onValueModified: Backend.serial.requestStartAddress = value
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: root.tr("读取长度")
Layout.preferredWidth: 90
color: root.textColor
}
SpinBox {
Layout.fillWidth: true
from: 0
to: 65535
editable: true
value: Backend.serial.requestLength
onValueModified: Backend.serial.requestLength = value
}
}
}
CollapsiblePanel {
title: root.tr("传感器规格")
expanded: true
Layout.fillWidth: true
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: root.tr("协议")
Layout.preferredWidth: 90
color: root.textColor
}
Label {
text: Backend.serial.protocol
Layout.fillWidth: true
horizontalAlignment: Text.AlignRight
color: root.textColor
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: root.tr("型号")
Layout.preferredWidth: 90
color: root.textColor
}
Label {
text: Backend.serial.sensorModel
Layout.fillWidth: true
horizontalAlignment: Text.AlignRight
color: root.textColor
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: root.tr("规格")
Layout.preferredWidth: 90
color: root.textColor
}
Label {
text: Backend.serial.sensorGrid
Layout.fillWidth: true
horizontalAlignment: Text.AlignRight
color: root.textColor
}
}
Button {
Layout.fillWidth: true
text: root.tr("重新识别")
highlighted: true
}
}
CollapsiblePanel {
title: root.tr("显示控制")
expanded: true
Layout.fillWidth: true
CheckBox {
text: root.tr("显示网络")
checked: Backend.showGrid
onToggled: Backend.showGrid = checked
}
CheckBox {
text: root.tr("显示坐标轴")
checked: false
}
RowLayout {
Layout.fillWidth: true
spacing: 12
Button {
Layout.fillWidth: true
text: root.tr("回放数据")
highlighted: true
}
Button {
Layout.fillWidth: true
text: root.tr("导出数据")
onClicked: exportDlg.open()
}
}
}
Item { Layout.fillHeight: true }
}
}
SaveAsExportDialog {
id: exportDlg
/* onSaveTo: (folder, filename, format, method) => {
console.log("保存目录:", folder)
console.log("文件名:", filename)
console.log("格式:", format, "方式:", method)
} */
onSaveTo: (folder, filename, format, method) => {
Backend.data.exportHandler(folder, filename, format, method)
}
}
}

View File

@@ -1,6 +1,7 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import TactileIPC 1.0
Item {
id: root

View File

@@ -0,0 +1,123 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import TactileIPC 1.0
import LiveTrend 1.0
Rectangle {
id: root
width: 260
height: 180
radius: 10
color: Backend.lightMode ? "#FFFFFF" : "#2C2C2C"
border.color: Backend.lightMode ? "#E6E6E6" : "#3A3A3A"
border.width: 1
property alias plot: plot
property string title: "Live Trend"
property bool follow: true
property color accentColor: Backend.lightMode ? "#39D353" : "#5BE37A"
property color textColor: Backend.lightMode ? "#2B2B2B" : "#E0E0E0"
property color plotBackground: Backend.lightMode ? "#FFFFFF" : "#1F1F1F"
property color gridColor: Backend.lightMode ? Qt.rgba(0, 0, 0, 0.08) : Qt.rgba(1, 1, 1, 0.08)
property color plotTextColor: Backend.lightMode ? Qt.rgba(0, 0, 0, 0.65) : Qt.rgba(1, 1, 1, 0.65)
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 6
RowLayout {
Layout.fillWidth: true
spacing: 8
Rectangle {
width: 8
height: 8
radius: 2
color: root.accentColor
Layout.alignment: Qt.AlignVCenter
}
Label {
text: root.title
font.pixelSize: 14
color: root.textColor
Layout.fillWidth: true
elide: Text.ElideRight
}
ToolButton {
text: root.follow ? "▲" : "↺"
font.pixelSize: 14
onClicked: {
root.follow = true
plot.follow = true
}
}
}
// Plot
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
radius: 8
color: root.plotBackground
SparklinePlot {
id: plot
anchors.fill: parent
leftPadding: 40
rightPadding: 6
topPadding: 8
bottomPadding: 18
yTickCount: 5
lineColor: root.accentColor
gridColor: root.gridColor
textColor: root.plotTextColor
viewCount: 5
follow: root.follow
// 60fps
Timer {
interval: 16
running: true
repeat: true
onTriggered: plot.update()
}
DragHandler {
onActiveChanged: if (active) {
root.follow = false
plot.follow = false
}
onTranslationChanged: (t) => {
let move = Math.round(-t.x / 12)
if (move !== 0) {
plot.viewStart = Math.max(0, plot.viewStart + move)
}
}
}
WheelHandler {
onWheel: (w) => {
root.follow = false
plot.follow = false
let factor = w.angleDelta.y > 0 ? 0.85 : 1.15
plot.viewCount = Math.max(10, Math.min(2000000, Math.floor(plot.viewCount * factor)))
}
}
TapHandler {
onDoubleTapped: {
root.follow = true
plot.follow = true
}
}
}
}
}
}

165
qml/content/NavBar.qml Normal file
View File

@@ -0,0 +1,165 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Material
import QtQuick.Layouts
import TactileIPC 1.0
Rectangle {
id: root
height: 56
color: Backend.lightMode ? "#F5F7F5" : "#2B2F2B"
Material.theme: Backend.lightMode ? Material.Light : Material.Dark
Material.accent: Material.Green
Material.primary: Material.Green
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: Qt.rgba(0, 0, 0, 0.08)
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 16
anchors.rightMargin: 16
spacing: 14
Label {
text: "Tactile IPC 3D"
font.pixelSize: 18
font.weight: Font.DemiBold
color: Material.color(Material.Green)
}
Item { Layout.fillWidth: true }
RowLayout {
spacing: 6
Layout.alignment: Qt.AlignVCenter
Rectangle {
width: 10
height: 10
radius: 5
color: Backend.connected ? "#2e7d32" : "#d32f2f"
}
Label {
text: Qt.binding(function() {
I18n.retranslateToken
return Backend.connected ? qsTr("CONNECTED") : qsTr("DISCONNECTED")
})
font.pixelSize: 12
font.weight: Font.DemiBold
color: Backend.connected ? "#2e7d32" : "#d32f2f"
}
}
Switch {
id: themeSwitch
text: Qt.binding(function() {
I18n.retranslateToken
return Backend.lightMode ? qsTr("Light") : qsTr("Dark")
})
checked: Backend.lightMode
onCheckedChanged: Backend.lightMode = checked
}
ComboBox {
id: langBox
implicitHeight: 32
leftPadding: 10
rightPadding: 26
Layout.alignment: Qt.AlignVCenter
model: [
{ text: "中文", value: "zh_CN", icon: "qrc:/images/china.png" },
{ text: "English", value: "en_US", icon: "qrc:/images/united-states.png" }
]
textRole: "text"
delegate: ItemDelegate {
width: langBox.width
height: 28
onClicked: {
langBox.currentIndex = index
langBox.popup.close()
}
contentItem: Row {
spacing: 8
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 8
Image {
width: 18
height: 12
fillMode: Image.PreserveAspectFit
source: modelData.icon
}
Label {
text: modelData.text
font: langBox.font
}
}
}
contentItem: Item {
anchors.fill: parent
Row {
spacing: 8
anchors.verticalCenter: parent.verticalCenter
Image {
width: 18
height: 12
fillMode: Image.PreserveAspectFit
source: langBox.model[langBox.currentIndex]
? langBox.model[langBox.currentIndex].icon
: ""
}
Label {
text: langBox.displayText
font: langBox.font
verticalAlignment: Text.AlignVCenter
}
}
}
popup: Popup {
y: langBox.height
width: langBox.width
implicitHeight: contentItem.implicitHeight
padding: 0
modal: false
focus: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
popupType: Popup.Window
contentItem: ListView {
implicitHeight: contentHeight
model: langBox.delegateModel
currentIndex: langBox.highlightedIndex
delegate: langBox.delegate
clip: true
}
}
function syncLanguage() {
for (let i = 0; i < model.length; i++) {
if (model[i].value === Backend.language) {
if (currentIndex !== i)
currentIndex = i
return
}
}
}
Component.onCompleted: syncLanguage()
Connections {
target: Backend
function onLanguageChanged() {
langBox.syncLanguage()
}
}
onCurrentIndexChanged: {
if (model[currentIndex])
Backend.language = model[currentIndex].value
}
}
}
}

198
qml/content/RightPanel.qml Normal file
View File

@@ -0,0 +1,198 @@
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
import QtQuick.Layouts
import "."
import TactileIPC 1.0
Rectangle {
id: root
width: 340
color: Backend.lightMode ? "#F5F5F5" : "#2C2C2C"
radius: 8
border.color: Qt.rgba(0, 0, 0, 0.08)
property color accentColor: "#43a047"
function tr(text) {
I18n.retranslateToken
return qsTr(text)
}
Material.theme: Backend.lightMode ? Material.Light : Material.Dark
Material.accent: Material.Green
Material.primary: Material.Green
ColumnLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 12
LiveTrendCard {
id: card
Layout.fillWidth: true
Layout.preferredHeight: 180
title: root.tr("Payload Sum")
Connections {
target: Backend.data
function onMetricsChanged() {
if (Backend.data.frameCount > 0)
card.plot.append(Backend.data.metricSum)
}
}
}
CollapsiblePanel {
title: root.tr("Live Trend")
expanded: true
Layout.fillWidth: true
/*
Canvas {
id: trendCanvas
Layout.fillWidth: true
Layout.preferredHeight: 180
property var samples: [0.08, 0.24, 0.52, 0.41, 0.63, 0.47, 0.72, 0.58, 0.82, 0.69]
onPaint: {
const ctx = getContext("2d")
const w = width
const h = height
ctx.clearRect(0, 0, w, h)
ctx.strokeStyle = "#D8EAD9"
ctx.lineWidth = 1
for (let i = 1; i < 5; i++) {
const y = (h / 5) * i
ctx.beginPath()
ctx.moveTo(0, y)
ctx.lineTo(w, y)
ctx.stroke()
}
ctx.strokeStyle = root.accentColor
ctx.lineWidth = 2
ctx.beginPath()
for (let i = 0; i < samples.length; i++) {
const x = (w - 12) * (i / (samples.length - 1)) + 6
const y = h - (h - 12) * samples[i] - 6
if (i === 0)
ctx.moveTo(x, y)
else
ctx.lineTo(x, y)
}
ctx.stroke()
}
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
Component.onCompleted: requestPaint()
}
*/
}
CollapsiblePanel {
title: root.tr("Metrics")
expanded: true
Layout.fillWidth: true
RowLayout {
Layout.fillWidth: true
spacing: 8
Rectangle {
Layout.fillWidth: true
radius: 6
color: Qt.rgba(0, 0, 0, 0.03)
border.color: Qt.rgba(0, 0, 0, 0.08)
height: 72
Column {
anchors.centerIn: parent
spacing: 4
Label { text: root.tr("峰值"); font.pixelSize: 12 }
Label { text: Backend.data.metricPeak.toFixed(0); font.pixelSize: 18; font.bold: true }
}
}
Rectangle {
Layout.fillWidth: true
radius: 6
color: Qt.rgba(0, 0, 0, 0.03)
border.color: Qt.rgba(0, 0, 0, 0.08)
height: 72
Column {
anchors.centerIn: parent
spacing: 4
Label { text: root.tr("均方根"); font.pixelSize: 12 }
Label { text: Backend.data.metricRms.toFixed(0); font.pixelSize: 18; font.bold: true }
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Rectangle {
Layout.fillWidth: true
radius: 6
color: Qt.rgba(0, 0, 0, 0.03)
border.color: Qt.rgba(0, 0, 0, 0.08)
height: 72
Column {
anchors.centerIn: parent
spacing: 4
Label { text: root.tr("平均值"); font.pixelSize: 12 }
Label { text: Backend.data.metricAvg.toFixed(0); font.pixelSize: 18; font.bold: true }
}
}
Rectangle {
Layout.fillWidth: true
radius: 6
color: Qt.rgba(0, 0, 0, 0.03)
border.color: Qt.rgba(0, 0, 0, 0.08)
height: 72
Column {
anchors.centerIn: parent
spacing: 4
Label { text: root.tr("变化量"); font.pixelSize: 12 }
Label { text: Backend.data.metricDelta.toFixed(0); font.pixelSize: 18; font.bold: true }
}
}
}
}
CollapsiblePanel {
title: root.tr("Session")
expanded: true
Layout.fillWidth: true
RowLayout {
Layout.fillWidth: true
spacing: 8
Label { text: root.tr("Frames"); Layout.preferredWidth: 80 }
Label { text: Backend.data.frameCount; Layout.fillWidth: true; horizontalAlignment: Text.AlignRight }
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label { text: root.tr("Playback"); Layout.preferredWidth: 80 }
Label {
text: Backend.data.playbackRunning ? root.tr("Running") : root.tr("Idle")
Layout.fillWidth: true
horizontalAlignment: Text.AlignRight
}
}
}
Item { Layout.fillHeight: true }
}
}

View File

@@ -0,0 +1,561 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Controls.Material 2.15
import QtQuick.Layouts 1.15
import QtQuick.Window 2.15
import Qt.labs.folderlistmodel 2.15
import QtCore 6.2
import QtQuick.Dialogs
import TactileIPC 1.0
Window {
id: root
width: 980
height: 640
minimumWidth: 880
minimumHeight: 560
visible: false
modality: Qt.ApplicationModal
flags: Qt.Dialog | Qt.WindowTitleHint | Qt.WindowCloseButtonHint
title: root.tr("导出数据")
color: windowBg
readonly property bool isDark: !Backend.lightMode
readonly property color windowBg: isDark ? "#1B1F1B" : "#F7F8F9"
Material.accent: root.accent
Material.primary: root.accent
Material.theme: root.isDark ? Material.Dark : Material.Light
readonly property color accent: "#21A453"
readonly property color accentSoft: root.isDark ? "#1F3A2A" : "#E6F6EC"
readonly property color panel: root.isDark ? "#242924" : "#FFFFFF"
readonly property color border: root.isDark ? "#343A35" : "#E1E5EA"
readonly property color text: root.isDark ? "#E6ECE7" : "#1E2A32"
readonly property color subText: root.isDark ? "#9AA5A0" : "#6E7A86"
readonly property color fieldBg: root.isDark ? "#1E221E" : "#FFFFFF"
readonly property color surfaceAlt: root.isDark ? "#202520" : "#F9FAFB"
readonly property color hoverBg: root.isDark ? "#2C322D" : "#F3F6F8"
readonly property color iconBg: root.isDark ? "#25362B" : "#E8F3EA"
readonly property color iconBgAlt: root.isDark ? "#2A302A" : "#EFF2F5"
readonly property color disabledBg: root.isDark ? "#4B544E" : "#C9D2D8"
readonly property string uiFont: "Microsoft YaHei UI"
property url currentFolder: StandardPaths.writableLocation(StandardPaths.DocumentsLocation) + "/"
property string chosenFilename: ""
property string exportFormat: formatBox.currentValue
property string exportMethod: methodBox.currentValue
property string lastMethodValue: "overwrite"
signal saveTo(url folder, string filename, string format, string method)
function open() {
centerOnScreen_()
visible = true
requestActivate()
}
function accept() {
visible = false
}
function reject() {
visible = false
}
function centerOnScreen_() {
x = Math.round((Screen.width - width) / 2)
y = Math.round((Screen.height - height) / 2)
}
function normalizeFolder_(path) {
if (!path)
return path
if (path.endsWith("/"))
return path
return path + "/"
}
function tr(text) {
I18n.retranslateToken
return qsTr(text)
}
onVisibleChanged: if (visible) centerOnScreen_()
ColumnLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 12
Rectangle {
Layout.fillWidth: true
height: 54
radius: 6
color: root.panel
border.color: root.border
RowLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 8
ToolButton {
id: backBtn
text: "<"
font.family: root.uiFont
onClicked: nav.back()
background: Rectangle {
radius: 4
color: backBtn.hovered ? root.accentSoft : "transparent"
border.color: backBtn.hovered ? root.accent : root.border
}
}
ToolButton {
id: forwardBtn
text: ">"
font.family: root.uiFont
onClicked: nav.forward()
background: Rectangle {
radius: 4
color: forwardBtn.hovered ? root.accentSoft : "transparent"
border.color: forwardBtn.hovered ? root.accent : root.border
}
}
ToolButton {
id: upBtn
text: "^"
font.family: root.uiFont
onClicked: nav.up()
background: Rectangle {
radius: 4
color: upBtn.hovered ? root.accentSoft : "transparent"
border.color: upBtn.hovered ? root.accent : root.border
}
}
TextField {
id: breadcrumb
Layout.fillWidth: true
readOnly: true
font.family: root.uiFont
color: root.text
text: root.currentFolder.toString()
background: Rectangle {
radius: 4
color: root.surfaceAlt
border.color: root.border
}
}
TextField {
id: searchField
Layout.preferredWidth: 220
placeholderText: root.tr("在此位置中搜索")
font.family: root.uiFont
background: Rectangle {
radius: 4
color: root.fieldBg
border.color: root.border
}
}
}
}
RowLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 12
Rectangle {
Layout.preferredWidth: 220
Layout.fillHeight: true
radius: 6
color: root.panel
border.color: root.border
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 8
Label {
text: root.tr("位置")
font.bold: true
font.family: root.uiFont
color: root.text
}
ListView {
id: places
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
model: [
{ name: root.tr("此电脑"), url: "file:///", icon: root.isDark ? "qrc:/images/computer_dark.png" : "qrc:/images/computer_light.png" },
{ name: root.tr("桌面"), url: StandardPaths.writableLocation(StandardPaths.DesktopLocation) + "/", icon: root.isDark ? "qrc:/images/desktop_dark.png" : "qrc:/images/desktop_light.png" },
{ name: root.tr("文档"), url: StandardPaths.writableLocation(StandardPaths.DocumentsLocation) + "/", icon: root.isDark ? "qrc:/images/docs_dark.png" : "qrc:/images/docs_light.png" },
{ name: root.tr("下载"), url: StandardPaths.writableLocation(StandardPaths.DownloadLocation) + "/", icon: root.isDark ? "qrc:/images/download_dark.png" : "qrc:/images/download_light.png" }
]
delegate: ItemDelegate {
width: ListView.view.width
onClicked: {
places.currentIndex = index
root.currentFolder = normalizeFolder_(modelData.url)
}
background: Rectangle {
radius: 4
color: places.currentIndex === index ? root.accentSoft : "transparent"
border.color: places.currentIndex === index ? root.accent : "transparent"
}
contentItem: RowLayout {
spacing: 8
Image {
width: 16
height: 16
source: modelData.icon
fillMode: Image.PreserveAspectFit
smooth: true
}
Label {
text: modelData.name
font.family: root.uiFont
color: root.text
}
}
}
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
radius: 6
color: root.panel
border.color: root.border
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 6
RowLayout {
Layout.fillWidth: true
Label { text: root.tr("名称"); Layout.fillWidth: true; font.bold: true; font.family: root.uiFont; color: root.text }
Label { text: root.tr("修改时间"); Layout.preferredWidth: 160; font.bold: true; font.family: root.uiFont; color: root.text }
}
Rectangle { Layout.fillWidth: true; height: 1; color: root.border }
FolderListModel {
id: fileModel
folder: root.currentFolder
showDotAndDotDot: false
showDirs: true
showFiles: false
sortField: FolderListModel.Name
nameFilters: searchField.text.trim().length > 0
? ["*" + searchField.text.trim() + "*"]
: ["*"]
}
ListView {
id: fileList
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
model: fileModel
delegate: ItemDelegate {
id: fileRow
width: ListView.view.width
onDoubleClicked: {
root.currentFolder = normalizeFolder_(fileModel.get(index, "filePath"))
}
onClicked: {
fileList.currentIndex = index
}
background: Rectangle {
radius: 4
color: fileRow.hovered ? root.hoverBg : "transparent"
}
contentItem: RowLayout {
spacing: 8
Rectangle {
width: 18
height: 18
radius: 3
color: root.iconBg
border.color: root.border
// TODO: replace with folder icons.
// Text {
// anchors.centerIn: parent
// text: "DIR"
// font.pixelSize: 10
// color: root.subText
// }
Image {
// anchors.centerIn: parent
width: 16
height: 16
source: root.isDark ? "qrc:/images/folder_dark.png" : "qrc:/images/folder_light.png"
fillMode: Image.PreserveAspectFit
smooth: true
}
}
Label {
Layout.fillWidth: true
elide: Text.ElideRight
text: fileModel.get(index, "fileName") || ""
font.family: root.uiFont
color: root.text
}
Label {
Layout.preferredWidth: 160
text: {
const d = fileModel.get(index, "modified")
return d ? Qt.formatDateTime(d, "yyyy/MM/dd hh:mm") : ""
}
font.family: root.uiFont
color: root.subText
}
}
}
}
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: footerLayout.implicitHeight + 20
Layout.preferredHeight: implicitHeight
Layout.minimumHeight: implicitHeight
radius: 6
color: root.panel
border.color: root.border
GridLayout {
id: footerLayout
anchors.fill: parent
anchors.margins: 10
columns: 6
columnSpacing: 10
rowSpacing: 8
Label { text: root.tr("文件名"); font.family: root.uiFont; color: root.text }
TextField {
id: filenameField
Layout.columnSpan: 3
Layout.fillWidth: true
placeholderText: root.tr("输入要保存的文件名")
font.family: root.uiFont
background: Rectangle {
radius: 4
color: root.fieldBg
border.color: root.border
}
}
Label { text: root.tr("文件类型"); font.family: root.uiFont; color: root.text }
ComboBox {
id: formatBox
Layout.fillWidth: true
implicitHeight: 32
Layout.preferredHeight: implicitHeight
textRole: "label"
valueRole: "value"
font.family: root.uiFont
topPadding: 6
bottomPadding: 6
contentItem: Item {
anchors.fill: parent
Text {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 24
text: formatBox.displayText
font: formatBox.font
color: root.text
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
model: [
{ label: "CSV (*.csv)", value: "csv", suffix: ".csv" },
{ label: "JSON (*.json)", value: "json", suffix: ".json" },
{ label: "Excel (*.xlsx)", value: "xlsx", suffix: ".xlsx" }
]
background: Rectangle {
radius: 4
color: root.fieldBg
border.color: root.border
}
}
Label { text: root.tr("导出方式"); font.family: root.uiFont; color: root.text }
ComboBox {
id: methodBox
Layout.columnSpan: 5
Layout.fillWidth: true
implicitHeight: 32
Layout.preferredHeight: implicitHeight
textRole: "label"
valueRole: "value"
font.family: root.uiFont
topPadding: 6
bottomPadding: 6
contentItem: Item {
anchors.fill: parent
Text {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 24
text: methodBox.displayText
font: methodBox.font
color: root.text
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
model: [
{ label: root.tr("覆盖导出(同名替换)"), value: "overwrite" },
{ label: root.tr("追加导出(写入同一文件)"), value: "append" },
{ label: root.tr("压缩导出zip"), value: "zip" }
]
function syncMethodIndex() {
for (let i = 0; i < model.length; i++) {
if (model[i].value === root.lastMethodValue) {
currentIndex = i
break
}
}
}
Component.onCompleted: syncMethodIndex()
onModelChanged: syncMethodIndex()
onCurrentValueChanged: root.lastMethodValue = currentValue
background: Rectangle {
radius: 4
color: root.fieldBg
border.color: root.border
}
}
Item { Layout.columnSpan: 4; Layout.fillWidth: true }
RowLayout {
Layout.columnSpan: 2
Layout.alignment: Qt.AlignRight
spacing: 10
Button {
id: cancelBtn
text: root.tr("取消")
font.family: root.uiFont
background: Rectangle {
radius: 4
color: "transparent"
border.color: root.border
}
contentItem: Text {
text: cancelBtn.text
font.family: root.uiFont
color: root.text
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: root.reject()
}
Button {
id: saveBtn
text: root.tr("保存")
enabled: filenameField.text.trim().length > 0
font.family: root.uiFont
background: Rectangle {
radius: 4
color: saveBtn.enabled ? root.accent : root.disabledBg
border.color: saveBtn.enabled ? root.accent : root.disabledBg
}
contentItem: Text {
text: saveBtn.text
font.family: root.uiFont
color: "#FFFFFF"
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: doSave()
}
}
}
}
}
MessageDialog {
id: overwriteDlg
title: root.tr("文件已存在")
text: root.tr("目标文件已存在,是否覆盖?")
buttons: MessageDialog.Yes | MessageDialog.No
onButtonClicked: function (button, role) {
switch (button) {
case MessageDialog.Yes:
finalizeSave(true)
break
case MessageDialog.No:
break
}
}
}
function finalizeSave(forceOverwrite) {
root.saveTo(root.currentFolder, chosenFilename, exportFormat, exportMethod)
root.accept()
}
function doSave() {
let name = filenameField.text.trim()
if (name.length === 0) return
const suffix = formatBox.model[formatBox.currentIndex].suffix
if (!name.endsWith(suffix)) name += suffix
chosenFilename = name
// TODO: check file existence and show overwriteDlg when needed.
finalizeSave(false)
}
QtObject {
id: nav
property var backStack: []
property var forwardStack: []
function push(url) {
backStack.push(url)
forwardStack = []
}
function back() {
if (backStack.length === 0) return
forwardStack.push(root.currentFolder)
root.currentFolder = backStack.pop()
}
function forward() {
if (forwardStack.length === 0) return
backStack.push(root.currentFolder)
root.currentFolder = forwardStack.pop()
}
function up() {
let s = root.currentFolder.toString()
if (s.endsWith("/")) s = s.slice(0, -1)
const idx = s.lastIndexOf("/")
if (idx > 8) root.currentFolder = s.slice(0, idx + 1)
}
}
onCurrentFolderChanged: {
if (breadcrumb.text && breadcrumb.text !== root.currentFolder.toString())
nav.push(breadcrumb.text)
breadcrumb.text = root.currentFolder.toString()
}
}

View File

@@ -1,5 +1,6 @@
import QtQuick
import QtQuick.Controls
import TactileIPC 1.0
Item {
property string text