562 lines
22 KiB
QML
562 lines
22 KiB
QML
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()
|
||
}
|
||
}
|