|
@@ -0,0 +1,266 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, reactive, toRaw, provide, onMounted } from "vue";
|
|
|
+import {v4 as uuid4} from 'uuid'
|
|
|
+import {
|
|
|
+ S3Client,
|
|
|
+ PutObjectCommand,
|
|
|
+ ListObjectsCommand,
|
|
|
+ GetObjectCommand,
|
|
|
+ DeleteObjectsCommand,
|
|
|
+} from "@aws-sdk/client-s3";
|
|
|
+import {
|
|
|
+ Plus,
|
|
|
+ Refresh,
|
|
|
+} from "@element-plus/icons-vue";
|
|
|
+import Node from "./Node.vue";
|
|
|
+import AppendModal from "./AppendModal.vue";
|
|
|
+import type { TreeData } from "./Tree";
|
|
|
+import { ElNotification, type ElTree } from "element-plus";
|
|
|
+import { MdEditor } from "md-editor-v3";
|
|
|
+import { pathsToTree, getTreeLeafs } from "@/utils/s3Helper";
|
|
|
+import "md-editor-v3/lib/style.css";
|
|
|
+
|
|
|
+const DOCS_LIST_BUCKET = "cococlass-help-docs";
|
|
|
+const DOCS_MEDIA_BUCKET = "cococlass-help-docs-medias";
|
|
|
+
|
|
|
+const s3 = new S3Client({
|
|
|
+ credentials: {
|
|
|
+ accessKeyId: import.meta.env.VITE_AWS_S3_ACCESS_KEY_ID,
|
|
|
+ secretAccessKey: import.meta.env.VITE_AWS_S3_SECRET_ACCESS_KEY,
|
|
|
+ },
|
|
|
+ region: import.meta.env.VITE_AWS_S3_REGION,
|
|
|
+});
|
|
|
+provide("s3", s3);
|
|
|
+provide("DOCS_LIST_BUCKET", DOCS_LIST_BUCKET);
|
|
|
+
|
|
|
+const tree$ = ref<InstanceType<typeof ElTree>>();
|
|
|
+
|
|
|
+const dataSource = ref<TreeData[]>([]);
|
|
|
+const sideLoading = ref(false);
|
|
|
+const loadS3DocsListObjects = async () => {
|
|
|
+ const command = new ListObjectsCommand({ Bucket: DOCS_LIST_BUCKET });
|
|
|
+ const result = await s3.send(command);
|
|
|
+ return result.Contents!.map((item) => item.Key!).slice(0, 200);
|
|
|
+};
|
|
|
+const loadSideBar = async () => {
|
|
|
+ sideLoading.value = true;
|
|
|
+ dataSource.value = pathsToTree(await loadS3DocsListObjects());
|
|
|
+ sideLoading.value = false;
|
|
|
+};
|
|
|
+onMounted(() => {
|
|
|
+ loadSideBar();
|
|
|
+});
|
|
|
+
|
|
|
+const currentOpenData = ref<TreeData>();
|
|
|
+
|
|
|
+const onNodeClick = async (data: TreeData, node) => {
|
|
|
+ if (data.isDir) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ textLoading.value = true;
|
|
|
+ const command = new GetObjectCommand({
|
|
|
+ Bucket: DOCS_LIST_BUCKET,
|
|
|
+ Key: data.key,
|
|
|
+ ResponseCacheControl: "no-cache",
|
|
|
+ });
|
|
|
+ const file = await s3.send(command);
|
|
|
+ text.value = await file.Body?.transformToString()!;
|
|
|
+ currentOpenData.value = data;
|
|
|
+
|
|
|
+ textLoading.value = false;
|
|
|
+};
|
|
|
+
|
|
|
+const text = ref("");
|
|
|
+const textLoading = ref(false);
|
|
|
+
|
|
|
+const onUploadImg = async (files: File[], callback: (urls: string[] | { url: string; alt: string; title: string }[]) => void) => {
|
|
|
+ console.log(files)
|
|
|
+ let result = []
|
|
|
+ for (const file of files) {
|
|
|
+ try {
|
|
|
+ const key = `${uuid4()}::${file.name}`
|
|
|
+ const command = new PutObjectCommand({
|
|
|
+ Bucket: DOCS_MEDIA_BUCKET,
|
|
|
+ Key: key,
|
|
|
+ Body: file,
|
|
|
+ ACL: 'public-read',
|
|
|
+ });
|
|
|
+ const res = await s3.send(command);
|
|
|
+ result.push({url: `https://${DOCS_MEDIA_BUCKET}.s3.amazonaws.com/${key}`, alt: file.name, title: file.name})
|
|
|
+ } catch (e) {
|
|
|
+ console.error(e)
|
|
|
+ ElNotification.error(`${file.name} 上传失败`)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ callback(result)
|
|
|
+};
|
|
|
+const onSave = async () => {
|
|
|
+ if (!currentOpenData.value) {
|
|
|
+ ElNotification.info('请先选择文件')
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ textLoading.value = true
|
|
|
+ try {
|
|
|
+ const command = new PutObjectCommand({
|
|
|
+ Bucket: DOCS_LIST_BUCKET,
|
|
|
+ Key: currentOpenData.value?.key,
|
|
|
+ Body: text.value,
|
|
|
+ });
|
|
|
+ await s3.send(command);
|
|
|
+ ElNotification.success(`${currentOpenData.value?.key} 保存成功`)
|
|
|
+ } catch (e) {
|
|
|
+ console.error(e)
|
|
|
+ ElNotification.error(`${currentOpenData.value?.key} 保存失败`)
|
|
|
+ } finally {
|
|
|
+ textLoading.value = false
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+let resolveAppend: Function | undefined;
|
|
|
+let rejectAppend: Function | undefined;
|
|
|
+const showAppendModal = ref(false);
|
|
|
+const appendLoading = ref(false)
|
|
|
+const appendContextData = ref()
|
|
|
+const onAppend = async (data: TreeData) => {
|
|
|
+ const modalConfirmPromise = new Promise((resolve, reject) => {
|
|
|
+ resolveAppend = resolve;
|
|
|
+ rejectAppend = reject;
|
|
|
+ });
|
|
|
+ appendContextData.value = data
|
|
|
+ showAppendModal.value = true;
|
|
|
+ let filename, isDir
|
|
|
+ try {
|
|
|
+ ( { filename, isDir } = await modalConfirmPromise);
|
|
|
+ } catch (e) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ appendLoading.value = true
|
|
|
+ const key = `${data.key ? `${data.key}/` : ''}${filename}`
|
|
|
+ if (!isDir) {
|
|
|
+ const command = new PutObjectCommand({
|
|
|
+ Bucket: DOCS_LIST_BUCKET,
|
|
|
+ Key: key,
|
|
|
+ Body: "",
|
|
|
+ });
|
|
|
+ await s3.send(command);
|
|
|
+ }
|
|
|
+ const newChild: TreeData = {
|
|
|
+ key,
|
|
|
+ label: filename,
|
|
|
+ children: [],
|
|
|
+ isDir,
|
|
|
+ };
|
|
|
+ if (!data.children) {
|
|
|
+ data.children = [];
|
|
|
+ }
|
|
|
+ data.children.push(newChild);
|
|
|
+ } catch (e) {
|
|
|
+ console.error(e);
|
|
|
+ ElNotification.error('添加失败')
|
|
|
+ } finally {
|
|
|
+ appendLoading.value = false
|
|
|
+ showAppendModal.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+const onRemove = async (node: Node, data: TreeData) => {
|
|
|
+ const parent = node.parent;
|
|
|
+ const children = parent.data.children || parent.data;
|
|
|
+ const index = children.findIndex((d) => d.key === data.key);
|
|
|
+
|
|
|
+ sideLoading.value = true
|
|
|
+ try {
|
|
|
+ const leafDatas = getTreeLeafs(data)
|
|
|
+ const command = new DeleteObjectsCommand({
|
|
|
+ Bucket: DOCS_LIST_BUCKET,
|
|
|
+ Delete: {
|
|
|
+ Objects: leafDatas.map(d => ({Key: d.key})),
|
|
|
+ Quiet: false,
|
|
|
+ }
|
|
|
+ })
|
|
|
+ await s3.send(command)
|
|
|
+ // TODO request S3 delete
|
|
|
+ children.splice(index, 1);
|
|
|
+ } catch (e) {
|
|
|
+ console.error(e)
|
|
|
+ ElNotification.error('删除失败')
|
|
|
+ } finally {
|
|
|
+ sideLoading.value = false
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+<template>
|
|
|
+ <div class="md-container">
|
|
|
+ <div class="left" v-loading="sideLoading">
|
|
|
+ <el-button-group>
|
|
|
+ <el-button
|
|
|
+ @click="onAppend({ key: '', label: '', children: dataSource })"
|
|
|
+ type="primary"
|
|
|
+ :icon="Plus"
|
|
|
+ >
|
|
|
+ </el-button>
|
|
|
+ <el-button @click="loadSideBar" type="info" :icon="Refresh"></el-button>
|
|
|
+ </el-button-group>
|
|
|
+ <el-tree
|
|
|
+ ref="tree$"
|
|
|
+ :data="dataSource"
|
|
|
+ node-key="key"
|
|
|
+ default-expand-all
|
|
|
+ :expand-on-click-node="false"
|
|
|
+ @node-click="onNodeClick"
|
|
|
+ >
|
|
|
+ <template #default="{ node, data }">
|
|
|
+ <Node
|
|
|
+ :node="node"
|
|
|
+ :data="data"
|
|
|
+ @append="() => onAppend(data)"
|
|
|
+ @remove="() => onRemove(node, data)"
|
|
|
+ />
|
|
|
+ </template>
|
|
|
+ </el-tree>
|
|
|
+ </div>
|
|
|
+ <MdEditor
|
|
|
+ v-loading="textLoading"
|
|
|
+ :disabled="!currentOpenData"
|
|
|
+ v-model="text"
|
|
|
+ @save="onSave"
|
|
|
+ @uploadImg="onUploadImg"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <AppendModal
|
|
|
+ v-model:show="showAppendModal"
|
|
|
+ :contextData="appendContextData"
|
|
|
+ :appendLoading="appendLoading"
|
|
|
+ @ok="(formData) => resolveAppend?.(formData)"
|
|
|
+ @cancel="() => rejectAppend?.()"
|
|
|
+ />
|
|
|
+</template>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.md-container {
|
|
|
+ width: 100vw;
|
|
|
+ height: 100dvh;
|
|
|
+ display: flex;
|
|
|
+ align-items: stretch;
|
|
|
+ .md-editor {
|
|
|
+ flex: 1;
|
|
|
+ height: 100%;
|
|
|
+ }
|
|
|
+ .left {
|
|
|
+ flex: 0 0 300px;
|
|
|
+ padding: 10px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: stretch;
|
|
|
+ .el-tree {
|
|
|
+ overflow: auto;
|
|
|
+ flex: 1;
|
|
|
+ :deep(.el-tree-node.is-current) {
|
|
|
+ & > .el-tree-node__content {
|
|
|
+ background-color: aqua;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|