feat: 引入 view-ui-plus 框架, 完成初步布局和文件树的渲染

This commit is contained in:
Frankie Huang 2025-04-05 19:57:24 +08:00
parent c09c8b5e9c
commit 540f9ba28f
7 changed files with 420 additions and 198 deletions

View File

@ -49,3 +49,10 @@ This template should help get you started developing with Tauri + Vue 3 in Vite.
1. 新建 SelectFolder.vue 用于打开本地目录
2. 在 App.vue 使用组件 SelectFolder 和 MarkdownEditor.vue
3. 使用 plugin-fs 插件读写本地文件内容
## 引入 view-ui-plus 组件库,优化 UI 布局
1. `npm install view-ui-plus --save`
2. 使用 <Split> 分割左右面板
3. 使用 <Tree> 树形控件渲染文件树
4. 联动 SelectFolder、FolderTree 和 MarkdownEditor 组件

124
package-lock.json generated
View File

@ -13,6 +13,7 @@
"@tauri-apps/plugin-fs": "^2.2.1",
"@tauri-apps/plugin-opener": "^2",
"scriptjs": "^2.5.9",
"view-ui-plus": "^1.3.19",
"vue": "^3.5.13"
},
"devDependencies": {
@ -1153,12 +1154,54 @@
"integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==",
"license": "MIT"
},
"node_modules/async-validator": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-3.5.2.tgz",
"integrity": "sha512-8eLCg00W9pIRZSB781UUX/H6Oskmm8xloZfr09lz5bikRpBVDlJ3hRVuxxP1SxcwsEYfJ4IU8Q19Y8/893r3rQ==",
"license": "MIT"
},
"node_modules/batch-processor": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/batch-processor/-/batch-processor-1.0.0.tgz",
"integrity": "sha512-xoLQD8gmmR32MeuBHgH0Tzd5PuSZx71ZsbhVxOCRbgktZEPe4SQy7s9Z50uPp0F/f7iw2XmkHN2xkgbMfckMDA==",
"license": "MIT"
},
"node_modules/countup.js": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/countup.js/-/countup.js-1.9.3.tgz",
"integrity": "sha512-UHf2P/mFKaESqdPq+UdBJm/1y8lYdlcDd0nTZHNC8cxWoJwZr1Eldm1PpWui446vDl5Pd8PtRYkr3q6K4+Qa5A==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
"integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/element-resize-detector": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/element-resize-detector/-/element-resize-detector-1.2.4.tgz",
"integrity": "sha512-Fl5Ftk6WwXE0wqCgNoseKWndjzZlDCwuPTcoVZfCP9R3EHQF8qUtr3YUPNETegRBOKqQKPW3n4kiIWngGi8tKg==",
"license": "MIT",
"dependencies": {
"batch-processor": "1.0.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@ -1233,6 +1276,24 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/js-calendar": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/js-calendar/-/js-calendar-1.2.3.tgz",
"integrity": "sha512-dAA1/Zbp4+c5E+ARCVTIuKepXsNLzSYfzvOimiYD4S5eeP9QuplSHLcdhfqFSwyM1o1u6ku6RRRCyaZ0YAjiBw==",
"license": "ISC"
},
"node_modules/lodash.chunk": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.chunk/-/lodash.chunk-4.2.0.tgz",
"integrity": "sha512-ZzydJKfUHJwHa+hF5X66zLFCBrWn5GeF28OHEr4WVWtNDXlQ/IjWKPBiikqKo2ne0+v6JgCgJ0GzJp8k8bHC7w==",
"license": "MIT"
},
"node_modules/lodash.throttle": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
@ -1260,12 +1321,32 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/numeral": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz",
"integrity": "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/popper.js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==",
"deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/postcss": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
@ -1340,6 +1421,12 @@
"integrity": "sha512-qGVDoreyYiP1pkQnbnFAUIS5AjenNwwQBdl7zeos9etl+hYKWahjRTfzAZZYBv5xNHx7vNKCmaLDQZ6Fr2AEXg==",
"license": "MIT"
},
"node_modules/select": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz",
"integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1349,6 +1436,43 @@
"node": ">=0.10.0"
}
},
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
"license": "MIT"
},
"node_modules/v-click-outside-x": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/v-click-outside-x/-/v-click-outside-x-3.7.1.tgz",
"integrity": "sha512-WmUgmcIXr9clVpm1AYS/FgHtcDicfnfoxgQCNg4O6vfk9GVnxA0vSqO321ogUo0b7czYTidj7fQENvWFMWOkUg==",
"license": "MIT",
"engines": {
"node": ">=8.11.4",
"npm": "6.4.1"
}
},
"node_modules/view-ui-plus": {
"version": "1.3.19",
"resolved": "https://registry.npmjs.org/view-ui-plus/-/view-ui-plus-1.3.19.tgz",
"integrity": "sha512-CBIuO/9+mbTTQnCWs2GOY+jyQiJLlc5EdxdvuyEGIiKV0YxQW/rLXUQabmm1w9n0iNTXV6adtqXYo2xzdr1NIA==",
"license": "MIT",
"dependencies": {
"async-validator": "^3.3.0",
"countup.js": "^1.9.3",
"dayjs": "^1.11.0",
"deepmerge": "^2.2.1",
"element-resize-detector": "^1.2.0",
"js-calendar": "^1.2.3",
"lodash.chunk": "^4.2.0",
"lodash.throttle": "^4.1.1",
"numeral": "^2.0.6",
"popper.js": "^1.14.6",
"select": "^1.1.2",
"tinycolor2": "^1.4.1",
"v-click-outside-x": "^3.7.1"
}
},
"node_modules/vite": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz",

View File

@ -15,6 +15,7 @@
"@tauri-apps/plugin-fs": "^2.2.1",
"@tauri-apps/plugin-opener": "^2",
"scriptjs": "^2.5.9",
"view-ui-plus": "^1.3.19",
"vue": "^3.5.13"
},
"devDependencies": {

View File

@ -1,181 +1,123 @@
<script setup>
import { ref } from "vue";
import { ref, watch, onMounted, onUnmounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
import MarkdownEditor from './components/MarkdownEditor.vue'
import SelectFolder from './components/SelectFolder.vue'
import FolderTree from './components/FolderTree.vue'
const markdownRef = ref(null);
const filePath = ref("");
//
const greetMsg = ref("");
const name = ref("");
async function greet() {
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
greetMsg.value = await invoke("greet", { name: name.value });
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
greetMsg.value = await invoke("greet", { name: name.value });
}
async function showFileTree(tree) {
console.log(tree)
//
const split = ref(0.2)
watch(split, (newSplit, oldSplit) => {
// split
if (newSplit < 0.05) {
// TODO
}
// TODO MarkdownEditor
// let width = newSplit < 0.2 ? '1400px' : '1200px'
// markdownRef.value.resetWidth(width)
// markdownRef.value.resetHeight(window.innerHeight - 15)
})
// markdown
const windowWidth = ref(window.innerWidth)
const windowHeight = ref(window.innerHeight)
const handleResize = () => {
windowWidth.value = window.innerWidth
windowHeight.value = window.innerHeight
// console.log(windowWidth.value)
// console.log(windowHeight.value)
markdownRef.value.resetWidth(windowWidth.value - 15)
markdownRef.value.resetHeight(windowHeight.value - 15)
}
onMounted(() => window.addEventListener('resize', handleResize))
onUnmounted(() => window.removeEventListener('resize', handleResize))
// <SelectFolder> folderTreeData <FolderTree>
const folderTreeData = ref(null)
function showFileTree(treeData) {
folderTreeData.value = treeData
}
async function readFileContent() {
try {
console.log('文件读取成功:', markdownRef.value.setMarkdownCode(await readTextFile(filePath.value)))
} catch (err) {
console.error('文件读取失败:', err);
}
// <FolderTree> currentFilePath
// currentFilePath MarkdownEditor
const currentFilePath = ref("");
const markdownRef = ref(null);
async function loadFileContent(fileNodeData) {
console.log(fileNodeData)
const filePath = fileNodeData.path
currentFilePath.value = filePath
await readFileContent(filePath)
}
async function readFileContent(filePath) {
try {
console.log('文件读取成功:', markdownRef.value.setMarkdownCode(await readTextFile(filePath)))
} catch (err) {
console.error('文件读取失败:', err);
}
}
async function writeFileContent() {
console.log('文件新的内容:', markdownRef.value.getMarkdownCode())
try {
console.log('文件更新成功:', await writeTextFile(filePath.value, markdownRef.value.getMarkdownCode()))
} catch (err) {
console.error('文件更新失败:', err);
}
console.log('文件新的内容:', markdownRef.value.getMarkdownCode())
try {
console.log('文件更新成功:', await writeTextFile(currentFilePath.value, markdownRef.value.getMarkdownCode()))
} catch (err) {
console.error('文件更新失败:', err);
}
}
</script>
<template>
<main class="container">
<div class="row">
<SelectFolder @folder-selected="showFileTree" />
<div>
<Split v-model="split">
<template #left>
<div class="split-left">
<SelectFolder @folder-selected="showFileTree" />
<FolderTree :treeData="folderTreeData" @file-selected="loadFileContent" />
</div>
</template>
<template #trigger>
<div class="split-trigger"></div>
</template>
<template #right>
<div class="split-right">
<MarkdownEditor ref="markdownRef" width='800px' height='500px' markdownCode="# hello tauri" />
</div>
</template>
</Split>
</div>
<div class="row">
<input id="file-path-input" v-model="filePath" placeholder="Enter a file path..." />
<button @click="readFileContent">读取指定文件</button>
<button @click="writeFileContent">更新指定文件</button>
</div>
<MarkdownEditor ref="markdownRef"
width='1000px'
height='1000px'
markdownCode="# hello tauri"
/>
</main>
</template>
<style scoped>
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
body {
overflow: hidden;
/* 禁用所有方向滚动 */
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #249b73);
}
</style>
<style>
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
.container {
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex;
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none;
}
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
a:hover {
color: #24c8db;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
}
.split-left {
height: 100vh;
border-right: 1px solid #dcdee2;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: none;
}
.split-trigger {
width: 5px;
height: 100vh;
background-color: gray;
}
.split-trigger:hover {
cursor: ew-resize;
}
</style>
<style></style>

View File

@ -0,0 +1,120 @@
<template>
<Tree ref="tree" :class="hiddenClass" :data="treeRenderData" :render="renderContent" expand-node></Tree>
</template>
<script>
import { resolveComponent } from 'vue'
export default {
props: {
treeData: {
type: Object,
required: false,
default: {
path: "/Users/Frankie/rootFolder",
name: "rootFolder",
directory: true,
nodes: [],
},
}
},
data() {
return {
treeRenderData: [],
hiddenClass: "hidden"
}
},
mounted() {
this.renderTreeByTreeData(this.treeData)
},
watch: {
treeData(newTreeData) {
this.renderTreeByTreeData(newTreeData)
},
},
methods: {
renderTreeByTreeData(currentTreeData) {
this.treeRenderData = [this.renderNodeByNodeData(currentTreeData)]
this.isHiddenTree(currentTreeData)
},
renderNodeByNodeData(nodeData) {
//
if (nodeData == null || nodeData == undefined) {
return {}
}
if (nodeData.path == undefined ||
nodeData.name == undefined ||
nodeData.directory == undefined
) {
return {}
}
if (nodeData.directory == true && nodeData.nodes == undefined) {
return {}
}
let nodeRenderData = {
title: nodeData.name,
directory: nodeData.directory,
expand: nodeData.directory ? true : false,
//
path: nodeData.path,
}
if (nodeData.directory) {
let childrenRenderData = []
nodeData.nodes.forEach(node => {
childrenRenderData.push(this.renderNodeByNodeData(node))
});
nodeRenderData.children = childrenRenderData
}
return nodeRenderData
},
isHiddenTree(currentTreeData) {
if (currentTreeData == null) {
this.hiddenClass = "hidden"
} else {
this.hiddenClass = ""
}
},
renderContent(h, { root, node, data }) {
return h('span', {
style: {
display: 'inline-block',
width: '100%'
},
onClick: () => { this.selectNode(data) }
}, [
h('span', [
h(resolveComponent('Icon'), {
type: data.directory ? 'ios-folder-outline' : 'ios-paper-outline',
style: {
marginRight: '8px'
}
}),
h('span', data.title)
])
]);
},
selectNode(nodeData) {
//
if (nodeData.directory) {
return
}
//
let selectedNodes = this.$refs.tree.getSelectedNodes()
selectedNodes.forEach(element => {
element.selected = false
});
nodeData.selected = true
//
this.$emit('file-selected', nodeData);
}
}
}
</script>
<style>
.hidden {
display: none;
}
</style>

View File

@ -1,25 +1,22 @@
<template>
<button @click="selectFolder">选择目录</button>
</template>
<script>
import { join } from '@tauri-apps/api/path';
import { open } from '@tauri-apps/plugin-dialog';
import { readDir } from '@tauri-apps/plugin-fs';
//
let folderTreeData = {
<!--
简述支持选择目录遍历返回文件树
注意配置好 fs:scope 的权限范围否则会报错 forbidden path
文件树示例
{
"path": "root-path/root",
"name": "root",
"directory": true,
"nodes": [
{
"path": "root-path/root/folder1",
"name": "folder1",
"directory": true,
"nodes": [
{
"path": "root-path/root/folder1/folder1-file2",
"name": "folder1-file2.txt.md",
"suffix": "md",
"directory": false,
},
]
},
@ -27,49 +24,78 @@ let folderTreeData = {
"path": "root-path/root/file3",
"name": "file3.txt",
"suffix": "txt",
"directory": false,
},
]
}
-->
<template>
<Button type="primary" @click="selectFolder">选择目录</Button>
</template>
async function readDirRecursively(parent, path) {
//
const folderPath = await join(parent, path);
let folderTreeNode = {
"path": folderPath,
"name": folderPath.split('/').pop(),
"nodes": []
}
//
const entries = await readDir(folderPath);
for (const entry of entries) {
const entryPath = await join(folderPath, entry.name);
if (entry.isDirectory) {
folderTreeNode.nodes.push(await readDirRecursively(folderPath, entry.name))
} else {
const fileSuffix = entry.name.split('.').pop()
folderTreeNode.nodes.push({
"path": entryPath,
"name": entry.name,
"suffix": fileSuffix,
})
}
}
return folderTreeNode
}
<script>
import { join } from '@tauri-apps/api/path';
import { open } from '@tauri-apps/plugin-dialog';
import { readDir } from '@tauri-apps/plugin-fs';
export default {
methods: {
async selectFolder() {
const filePath = await open(
{
filters: [{ name: 'All Files', extensions: ['.'] }],
directory: true
});
if (filePath) {
folderTreeData = await readDirRecursively(filePath, '')
const folderPath = await open(
{
filters: [{ name: 'All Files', extensions: ['.'] }],
directory: true
}
);
if (folderPath) {
const folderTreeData = await this.readDirRecursively(folderPath)
this.$emit('folder-selected', folderTreeData);
}
},
//
async readDirRecursively(folderPath) {
//
let folderTreeNode = {
"path": folderPath,
"name": folderPath.split('/').pop(),
"nodes": [],
"directory": true,
}
// folderPath
let entries;
try {
entries = await readDir(folderPath);
} catch (err) {
let errorMessage = ""
if (err.startsWith("forbidden path")) {
errorMessage = "no permission. " + err
} else {
errorMessage = "readDir failed. " + err
}
this.$Message.error({
content: errorMessage,
duration: 10,
closable: true
});
return folderTreeNode
}
//
for (const entry of entries) {
const entryPath = await join(folderPath, entry.name);
if (entry.isDirectory) {
folderTreeNode.nodes.push(await this.readDirRecursively(entryPath))
} else {
const fileSuffix = entry.name.split('.').pop()
folderTreeNode.nodes.push({
"path": entryPath,
"name": entry.name,
"suffix": fileSuffix,
"directory": false,
})
}
}
return folderTreeNode
}
}
}

View File

@ -1,4 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import ViewUIPlus from 'view-ui-plus'
import 'view-ui-plus/dist/styles/viewuiplus.css'
createApp(App).mount("#app");
createApp(App).use(ViewUIPlus).mount("#app");