429 lines
15 KiB
QML
429 lines
15 KiB
QML
|
|
// =============== 文件表格面板 ===============
|
|||
|
|
|
|||
|
|
import QtQuick 2.15
|
|||
|
|
import QtQuick.Controls 2.15
|
|||
|
|
import Qt.labs.qmlmodels 1.0 // 表格
|
|||
|
|
import QtGraphicalEffects 1.15 // 子元素圆角
|
|||
|
|
import QtQuick.Dialogs 1.3 // 文件对话框
|
|||
|
|
|
|||
|
|
Item {
|
|||
|
|
id: fTableRoot
|
|||
|
|
|
|||
|
|
// ========================= 【定义】 =========================
|
|||
|
|
|
|||
|
|
// 表头。定义每一列。
|
|||
|
|
property var headers: [
|
|||
|
|
// 第一列也作为总key(tk),不允许重复。
|
|||
|
|
{key: "path", title: "文件", },
|
|||
|
|
{key: "time", title: "耗时", },
|
|||
|
|
{key: "state", title: "状态", },
|
|||
|
|
// 可选项:
|
|||
|
|
// btn: true 启用按钮
|
|||
|
|
// onClicked: 单击函数
|
|||
|
|
// left: true 左对齐
|
|||
|
|
// display: 显示函数,输入value,返回显示文本
|
|||
|
|
]
|
|||
|
|
property string openBtnText: "选择文件"
|
|||
|
|
property string clearBtnText: "清空"
|
|||
|
|
property string defaultTips: "拖入或选择文件"
|
|||
|
|
property string fileDialogTitle: "请选择文件"
|
|||
|
|
property var fileDialogNameFilters: ["文件 (*.jpg *.jpe *.jpeg *.jfif *.png *.webp *.bmp *.tif *.tiff)"]
|
|||
|
|
property int spacing: size_.smallLine // 表项水平间隔
|
|||
|
|
property int minWidth0: size_.smallLine * 5 // 第0列最小宽度
|
|||
|
|
property bool isLock: false // 是否锁定UI操作
|
|||
|
|
|
|||
|
|
|
|||
|
|
// ========================= 【调用接口】 =========================
|
|||
|
|
|
|||
|
|
// 增:添加一项。 row:字典,key在headers中,如 { "path" "time" "state" }
|
|||
|
|
// ik:可以是表格行index(int),也可以是总key(string)
|
|||
|
|
function add(row, ik=-1) {
|
|||
|
|
const key = row[headerKey]
|
|||
|
|
if(key in dataDict) {
|
|||
|
|
console.warn(`add: ${key} 已在dataDict中!`)
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
if(ik === -1 || ik === rowCount) {
|
|||
|
|
dataDict[key] = rowCount
|
|||
|
|
dataModel.append(row)
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
const i = ik2i(ik)
|
|||
|
|
if(i < 0) {
|
|||
|
|
console.warn(`add: ik ${ik} ${i} < 0 !`)
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
dataDict[key] = i
|
|||
|
|
dataModel.insert(i, row)
|
|||
|
|
}
|
|||
|
|
updateWidth()
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
// 删:删除一项
|
|||
|
|
function del(ik) {
|
|||
|
|
const i = ik2i(ik)
|
|||
|
|
if(i < 0) {
|
|||
|
|
console.warn(`del: ik ${ik} ${i} < 0 !`)
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
const key = dataModel.get(i)[headerKey]
|
|||
|
|
delete dataDict[key]
|
|||
|
|
dataModel.remove(i)
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
// 删:清空
|
|||
|
|
function clear() {
|
|||
|
|
dataModel.clear()
|
|||
|
|
dataDict = {}
|
|||
|
|
}
|
|||
|
|
// 改:属性字典
|
|||
|
|
function set(ik, columnDict) {
|
|||
|
|
const i = ik2i(ik)
|
|||
|
|
if(i < 0) {
|
|||
|
|
console.warn(`set: ik ${ik} ${i} < 0 !`)
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
dataModel.set(i, columnDict)
|
|||
|
|
updateWidth()
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
// 改:单个属性
|
|||
|
|
function setProperty(ik, columnKey, value) {
|
|||
|
|
const i = ik2i(ik)
|
|||
|
|
if(i < 0) {
|
|||
|
|
console.warn(`setProperty: ik ${ik} ${i} < 0 !`)
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
dataModel.setProperty(i, columnKey, value)
|
|||
|
|
updateWidth()
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
// 查:ik转index。返回-1表示失败。
|
|||
|
|
function ik2i(ik) {
|
|||
|
|
if (typeof ik === "number") {
|
|||
|
|
if(ik >= 0 && ik < rowCount)
|
|||
|
|
return ik
|
|||
|
|
} else if (typeof ik === "string") {
|
|||
|
|
if(ik in dataDict)
|
|||
|
|
return dataDict[ik]
|
|||
|
|
}
|
|||
|
|
return -1
|
|||
|
|
}
|
|||
|
|
// 查:获取单个行的字典
|
|||
|
|
function get(ik) {
|
|||
|
|
const i = ik2i(ik)
|
|||
|
|
if(i < 0) {
|
|||
|
|
console.warn(`get: ik ${ik} ${i} < 0 !`)
|
|||
|
|
return {}
|
|||
|
|
}
|
|||
|
|
return dataModel.get(i)
|
|||
|
|
}
|
|||
|
|
// 查:获取key列的所有数据,返回每项为value
|
|||
|
|
function getColumnsValue(key) {
|
|||
|
|
let list = []
|
|||
|
|
for(let y = 0; y < rowCount; y++) {
|
|||
|
|
list.push( dataModel.get(y)[key] )
|
|||
|
|
}
|
|||
|
|
return list
|
|||
|
|
}
|
|||
|
|
// 查:获取多个列的数据,返回每项为字典
|
|||
|
|
function getColumnsValues(keys=[]) {
|
|||
|
|
let list = []
|
|||
|
|
if(keys.length > 0) {
|
|||
|
|
for(let y = 0; y < rowCount; y++) {
|
|||
|
|
const data = dataModel.get(y)
|
|||
|
|
const d = {}
|
|||
|
|
for(let i in keys)
|
|||
|
|
d[keys[i]] = data[keys[i]]
|
|||
|
|
list.push(d)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
for(let y = 0; y < rowCount; y++)
|
|||
|
|
list.push(dataModel.get(y))
|
|||
|
|
}
|
|||
|
|
return list
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 定义信号
|
|||
|
|
signal addPaths(var paths) // 添加文件的信号
|
|||
|
|
signal click(var info) // 点击条目的信号
|
|||
|
|
|
|||
|
|
Component.onCompleted: {
|
|||
|
|
dataDict = {}
|
|||
|
|
columnCount = headers.length
|
|||
|
|
for(let i=0; i<columnCount; i++){
|
|||
|
|
headerModel.append({
|
|||
|
|
"key": headers[i].key,
|
|||
|
|
"title": headers[i].title,
|
|||
|
|
"width": 1,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
headerKey = headers[0].key
|
|||
|
|
updateWidth(true)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ========================= 【逻辑】 =========================
|
|||
|
|
|
|||
|
|
property int columnCount: 0 // 列数量, onCompleted 中初始化
|
|||
|
|
property int rowCount: dataModel.count // 行数量
|
|||
|
|
property string headerKey: "" // 自动
|
|||
|
|
// 表头, key title width
|
|||
|
|
ListModel { id: headerModel }
|
|||
|
|
// 数据, 项为headers的key
|
|||
|
|
ListModel { id: dataModel }
|
|||
|
|
property var dataDict: {} // 指向 dataModel 的 index
|
|||
|
|
onRowCountChanged: {
|
|||
|
|
headerModel.setProperty(0, "title", headers[0].title + ` (${rowCount})`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 宽度更新
|
|||
|
|
Timer {
|
|||
|
|
id: updateWidthTimer
|
|||
|
|
interval: 100
|
|||
|
|
repeat: false
|
|||
|
|
onTriggered: {
|
|||
|
|
updateWidth(true)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// 更新全部宽度
|
|||
|
|
function updateWidth(timer=false) {
|
|||
|
|
if(!timer) { // 启动计时器,减少调用频率
|
|||
|
|
updateWidthTimer.restart()
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
let ws = Array(columnCount).fill(1)
|
|||
|
|
// 表头
|
|||
|
|
for(let i = 1; i < columnCount; i++) {
|
|||
|
|
let maxWidth = headerRepeater.itemAt(i).maxWidth + fTableRoot.spacing*2
|
|||
|
|
if(maxWidth > ws[i]) ws[i] = maxWidth
|
|||
|
|
}
|
|||
|
|
// 表体
|
|||
|
|
for(let y in tableView.items) {
|
|||
|
|
const repeater = tableView.items[y].repeater
|
|||
|
|
for(let x = 1; x < columnCount; x++) {
|
|||
|
|
let maxWidth = repeater.itemAt(x).maxWidth + fTableRoot.spacing*2
|
|||
|
|
if(maxWidth > ws[x]) ws[x] = maxWidth
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// 赋值 / 计算第0列宽度
|
|||
|
|
let w0 = tableArea.width
|
|||
|
|
for(let i = 1; i < columnCount; i++) {
|
|||
|
|
headerModel.setProperty(i, "width", ws[i])
|
|||
|
|
w0 -= ws[i]
|
|||
|
|
}
|
|||
|
|
// 更新第0列宽度
|
|||
|
|
updateWidth0(w0)
|
|||
|
|
}
|
|||
|
|
// 更新第0列宽度
|
|||
|
|
function updateWidth0(w0 = -1) {
|
|||
|
|
if(headerModel.count <= 0) return
|
|||
|
|
if(w0 < 0) {
|
|||
|
|
w0 = tableArea.width
|
|||
|
|
for(let i = 1; i < columnCount; i++)
|
|||
|
|
w0 -= headerModel.get(i).width
|
|||
|
|
}
|
|||
|
|
w0 += columnCount-10 // 避让右侧滚动条空间
|
|||
|
|
if(w0 < minWidth0) w0 = minWidth0
|
|||
|
|
headerModel.setProperty(0, "width", w0)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ========================= 【布局】 =========================
|
|||
|
|
|
|||
|
|
// 表格区域
|
|||
|
|
Rectangle {
|
|||
|
|
id: tableArea
|
|||
|
|
anchors.fill: parent
|
|||
|
|
color: theme.bgColor
|
|||
|
|
|
|||
|
|
Item {
|
|||
|
|
id: tableContainer
|
|||
|
|
anchors.fill: parent
|
|||
|
|
|
|||
|
|
// 上方操控版
|
|||
|
|
Item {
|
|||
|
|
id: tableTopPanel
|
|||
|
|
anchors.top: parent.top
|
|||
|
|
anchors.left: parent.left
|
|||
|
|
anchors.right: parent.right
|
|||
|
|
height: size_.line * 2
|
|||
|
|
|
|||
|
|
// 左打开图片按钮
|
|||
|
|
IconTextButton {
|
|||
|
|
id: openBtn
|
|||
|
|
visible: parent.width > openBtn.width + clearBtn.width // 容器宽度过小时隐藏
|
|||
|
|
anchors.left: parent.left
|
|||
|
|
anchors.top: parent.top
|
|||
|
|
anchors.bottom: parent.bottom
|
|||
|
|
anchors.margins: size_.smallSpacing * 0.5
|
|||
|
|
icon_: "folder"
|
|||
|
|
text_: openBtnText
|
|||
|
|
|
|||
|
|
onClicked: {
|
|||
|
|
if(isLock) return
|
|||
|
|
fileDialog.open()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 右清空按钮
|
|||
|
|
IconTextButton {
|
|||
|
|
id: clearBtn
|
|||
|
|
anchors.right: parent.right
|
|||
|
|
anchors.top: parent.top
|
|||
|
|
anchors.bottom: parent.bottom
|
|||
|
|
anchors.margins: size_.smallSpacing * 0.5
|
|||
|
|
icon_: "clear"
|
|||
|
|
text_: clearBtnText
|
|||
|
|
|
|||
|
|
onClicked: {
|
|||
|
|
if(isLock) return
|
|||
|
|
fTableRoot.clear()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 提示
|
|||
|
|
DefaultTips {
|
|||
|
|
visibleFlag: fTableRoot.rowCount
|
|||
|
|
anchors.top: tableTopPanel.bottom
|
|||
|
|
anchors.left: parent.left
|
|||
|
|
anchors.right: parent.right
|
|||
|
|
anchors.bottom: parent.bottom
|
|||
|
|
tips: defaultTips
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 表头
|
|||
|
|
Item {
|
|||
|
|
id: tableHeaderContainer
|
|||
|
|
visible: fTableRoot.rowCount > 0
|
|||
|
|
anchors.top: tableTopPanel.bottom
|
|||
|
|
anchors.left: parent.left
|
|||
|
|
anchors.right: parent.right
|
|||
|
|
height: size_.line * 1.5
|
|||
|
|
onWidthChanged: updateWidth0()
|
|||
|
|
|
|||
|
|
Row {
|
|||
|
|
anchors.fill: parent
|
|||
|
|
spacing: -1
|
|||
|
|
Repeater {
|
|||
|
|
model: headerModel
|
|||
|
|
id: headerRepeater
|
|||
|
|
Rectangle {
|
|||
|
|
width: model.width
|
|||
|
|
anchors.top: parent.top
|
|||
|
|
anchors.bottom: parent.bottom
|
|||
|
|
color: theme.bgColor
|
|||
|
|
border.width: 1
|
|||
|
|
border.color: theme.coverColor2
|
|||
|
|
property alias maxWidth: hText.width
|
|||
|
|
Text_ {
|
|||
|
|
id: hText
|
|||
|
|
anchors.top: parent.top
|
|||
|
|
anchors.bottom: parent.bottom
|
|||
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|||
|
|
verticalAlignment: Text.AlignVCenter // 垂直居中
|
|||
|
|
font.pixelSize: size_.smallText
|
|||
|
|
text: model.title
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// 表体
|
|||
|
|
TableView {
|
|||
|
|
id: tableView
|
|||
|
|
anchors.top: tableHeaderContainer.bottom
|
|||
|
|
anchors.left: parent.left
|
|||
|
|
anchors.right: parent.right
|
|||
|
|
anchors.bottom: parent.bottom
|
|||
|
|
flickableDirection: Flickable.VerticalFlick // 只允许垂直滚动
|
|||
|
|
boundsBehavior: Flickable.StopAtBounds // 禁止flick过冲。不影响滚轮滚动的过冲
|
|||
|
|
model: dataModel
|
|||
|
|
clip: true
|
|||
|
|
property var items: tableView.children[0].children
|
|||
|
|
rowSpacing: -1
|
|||
|
|
delegate: Item {
|
|||
|
|
Component.onCompleted: updateWidth()
|
|||
|
|
TableView.onReused: updateWidth()
|
|||
|
|
implicitHeight: size_.smallLine * 1.5
|
|||
|
|
implicitWidth: 1
|
|||
|
|
property int rowIndex: index
|
|||
|
|
property var rowModel: model
|
|||
|
|
property alias repeater: repeater
|
|||
|
|
Row {
|
|||
|
|
anchors.fill: parent
|
|||
|
|
spacing: -1
|
|||
|
|
Repeater {
|
|||
|
|
id: repeater
|
|||
|
|
model: headerModel
|
|||
|
|
Rectangle {
|
|||
|
|
width: model.width
|
|||
|
|
anchors.top: parent.top
|
|||
|
|
anchors.bottom: parent.bottom
|
|||
|
|
color: theme.bgColor
|
|||
|
|
border.width: 1
|
|||
|
|
border.color: theme.coverColor2
|
|||
|
|
property alias maxWidth: hText.width
|
|||
|
|
property int columnIndex: index
|
|||
|
|
property string columnKey: model.key
|
|||
|
|
property var header: headers[columnIndex]
|
|||
|
|
clip: true
|
|||
|
|
Button_ {
|
|||
|
|
visible: header.btn?true:false
|
|||
|
|
anchors.fill: parent
|
|||
|
|
radius: 0
|
|||
|
|
onClicked: {
|
|||
|
|
if(header.onClicked) {
|
|||
|
|
header.onClicked(rowIndex)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Text_ {
|
|||
|
|
id: hText
|
|||
|
|
property bool isLeft: headers[columnIndex].left?true:false
|
|||
|
|
anchors.top: parent.top
|
|||
|
|
anchors.bottom: parent.bottom
|
|||
|
|
anchors.left: isLeft? parent.left : undefined
|
|||
|
|
anchors.leftMargin: fTableRoot.spacing * 0.5
|
|||
|
|
anchors.horizontalCenter: isLeft? undefined : parent.horizontalCenter
|
|||
|
|
verticalAlignment: Text.AlignVCenter // 垂直居中
|
|||
|
|
font.pixelSize: size_.smallText
|
|||
|
|
color: (columnKey != "state"|| typeof rowModel.state != "string" || rowModel.state.length == 0) ? theme.subTextColor :
|
|||
|
|
(rowModel.state.startsWith("×") ? theme.noColor : (rowModel.state.startsWith("√") ? theme.yesColor : theme.subTextColor))
|
|||
|
|
text: header.display ? header.display(rowModel[columnKey]) : rowModel[columnKey]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// 滚动条
|
|||
|
|
ScrollBar.vertical: ScrollBar { }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 内圆角裁切
|
|||
|
|
layer.enabled: true
|
|||
|
|
layer.effect: OpacityMask {
|
|||
|
|
maskSource: Rectangle {
|
|||
|
|
width: tableContainer.width
|
|||
|
|
height: tableContainer.height
|
|||
|
|
radius: size_.btnRadius
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 文件选择对话框
|
|||
|
|
// QT-5.15.2 会报错:“Model size of -225 is less than 0”,不影响使用。
|
|||
|
|
// QT-5.15.5 修复了这个Bug,但是PySide2尚未更新到这个版本号。只能先忍忍了
|
|||
|
|
// https://bugreports.qt.io/browse/QTBUG-92444
|
|||
|
|
FileDialog_ {
|
|||
|
|
id: fileDialog
|
|||
|
|
title: fileDialogTitle
|
|||
|
|
nameFilters: fileDialogNameFilters
|
|||
|
|
folder: shortcuts.pictures
|
|||
|
|
selectMultiple: true // 多选
|
|||
|
|
onAccepted: {
|
|||
|
|
addPaths(fileDialog.fileUrls_)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|