index.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. <script setup lang="ts">
  2. import { ref, onMounted, computed, nextTick } from "vue";
  3. import _ from 'lodash'
  4. import {v4 as uuid4} from 'uuid'
  5. import {
  6. S3Client,
  7. PutObjectCommand,
  8. ListObjectsCommand,
  9. GetObjectCommand,
  10. DeleteObjectsCommand,
  11. CopyObjectCommand,
  12. } from "@aws-sdk/client-s3";
  13. import {
  14. Plus,
  15. Refresh,
  16. } from "@element-plus/icons-vue";
  17. import Node from "./Node.vue";
  18. import AppendModal from "./AppendModal.vue";
  19. import type { TreeData } from "./Tree";
  20. import { ElNotification, type ElTree } from "element-plus";
  21. import { MdEditor } from "md-editor-v3";
  22. import { s3ContentsToTree, getTreeFlatten } from "@/utils/s3Helper";
  23. import "md-editor-v3/lib/style.css";
  24. import type ETNode from 'element-plus/es/components/tree/src/model/node'
  25. import type { DragEvents } from 'element-plus/es/components/tree/src/model/useDragNode'
  26. import type {
  27. AllowDropType,
  28. NodeDropType,
  29. } from 'element-plus/es/components/tree/src/tree.type'
  30. import path from "path-browserify";
  31. const s3 = new S3Client({
  32. credentials: {
  33. accessKeyId: import.meta.env.VITE_AWS_S3_ACCESS_KEY_ID,
  34. secretAccessKey: import.meta.env.VITE_AWS_S3_SECRET_ACCESS_KEY,
  35. },
  36. region: import.meta.env.VITE_AWS_S3_REGION,
  37. });
  38. const tree$ = ref<InstanceType<typeof ElTree>>();
  39. const langSelect = ref<'zh-CN'|'zh-HK'|'en-US'>('zh-CN')
  40. const langKeyPrefix = computed(() => {
  41. return {
  42. 'zh-CN': 'docs/',
  43. 'zh-HK': 'zh-HK/docs/',
  44. 'en-US': 'en-US/docs/',
  45. }[langSelect.value]
  46. })
  47. const langDataSource = ref<Partial<Record<'zh-CN'|'zh-HK'|'en-US', TreeData[]>>>({})
  48. const langSelectedDataSource = computed(() => {
  49. return langDataSource.value[langSelect.value]
  50. })
  51. const dataSource = ref<unknown[]>([]);
  52. const sideLoading = ref(false);
  53. const loadS3DocsListObjects = async () => {
  54. const command = new ListObjectsCommand({ Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET });
  55. const result = await s3.send(command);
  56. return result.Contents!;
  57. };
  58. const loadSideBar = async () => {
  59. sideLoading.value = true;
  60. dataSource.value = await loadS3DocsListObjects();
  61. [ 'zh-CN','zh-HK','en-US' ].forEach((lang) => {
  62. const prefix = {
  63. 'zh-CN': 'docs/',
  64. 'zh-HK': 'zh-HK/docs/',
  65. 'en-US': 'en-US/docs/',
  66. }[lang]
  67. const filtered = dataSource.value.filter(cont => cont.Key.startsWith(prefix)).map(cont => ({...cont, Key: cont.Key.replace(new RegExp(`^${prefix}`), '')}))
  68. langDataSource.value[lang] = s3ContentsToTree(filtered, {}, (r, label, i, a, thisContent) => ({
  69. isDir: a.length !== i + 1,
  70. ...( a.length === i + 1 ? thisContent : {} )
  71. }));
  72. })
  73. sideLoading.value = false;
  74. };
  75. onMounted(() => {
  76. loadSideBar();
  77. });
  78. const currentOpenData = ref<TreeData>();
  79. const onNodeClick = async (data: TreeData, node) => {
  80. if (data.isDir) {
  81. return;
  82. }
  83. textLoading.value = true;
  84. const command = new GetObjectCommand({
  85. Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
  86. Key: `${langKeyPrefix.value}${data.key}`,
  87. ResponseCacheControl: "no-cache",
  88. });
  89. const file = await s3.send(command);
  90. text.value = await file.Body?.transformToString()!;
  91. currentOpenData.value = data;
  92. textLoading.value = false;
  93. };
  94. const text = ref("");
  95. const textLoading = ref(false);
  96. const onUploadImg = async (files: File[], callback: (urls: string[] | { url: string; alt: string; title: string }[]) => void) => {
  97. let result = []
  98. for (const file of files) {
  99. try {
  100. const key = `${uuid4()}::${file.name}`
  101. const command = new PutObjectCommand({
  102. Bucket: import.meta.env.DOCS_MEDIA_BUCKET,
  103. Key: key,
  104. Body: file,
  105. ACL: 'public-read',
  106. });
  107. const res = await s3.send(command);
  108. result.push({url: `https://${import.meta.env.DOCS_MEDIA_BUCKET}.s3.amazonaws.com/${key}`, alt: file.name, title: file.name})
  109. } catch (e) {
  110. console.error(e)
  111. ElNotification.error(`${file.name} 上传失败`)
  112. }
  113. }
  114. callback(result)
  115. };
  116. const onSave = async () => {
  117. if (!currentOpenData.value) {
  118. ElNotification.info('请先选择文件')
  119. return;
  120. }
  121. textLoading.value = true
  122. try {
  123. const command = new PutObjectCommand({
  124. Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
  125. Key: `${langKeyPrefix.value}${currentOpenData.value?.key}`,
  126. Body: text.value,
  127. });
  128. await s3.send(command);
  129. ElNotification.success(`${currentOpenData.value?.key} 保存成功`)
  130. } catch (e) {
  131. console.error(e)
  132. ElNotification.error(`${currentOpenData.value?.key} 保存失败`)
  133. } finally {
  134. textLoading.value = false
  135. }
  136. };
  137. let resolveAppend: Function | undefined;
  138. let rejectAppend: Function | undefined;
  139. const showAppendModal = ref(false);
  140. const appendLoading = ref(false)
  141. const appendContextData = ref()
  142. const onAppend = async (data: TreeData) => {
  143. const modalConfirmPromise = new Promise((resolve, reject) => {
  144. resolveAppend = resolve;
  145. rejectAppend = reject;
  146. });
  147. appendContextData.value = data
  148. showAppendModal.value = true;
  149. let filename, isDir
  150. try {
  151. ( { filename, isDir } = await modalConfirmPromise);
  152. } catch (e) {
  153. return
  154. }
  155. try {
  156. appendLoading.value = true
  157. const key = `${langKeyPrefix.value}${data.key ? `${data.key}/` : ''}${filename}`
  158. if (!isDir) {
  159. const command = new PutObjectCommand({
  160. Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
  161. Key: key,
  162. Body: "",
  163. });
  164. await s3.send(command);
  165. }
  166. const newChild: TreeData = {
  167. key: `${data.key ? `${data.key}/` : ''}${filename}`,
  168. label: filename,
  169. children: [],
  170. isDir,
  171. };
  172. if (!data.children) {
  173. data.children = [];
  174. }
  175. data.children.push(newChild);
  176. // if (!data.key) {
  177. // loadSideBar()
  178. // }
  179. } catch (e) {
  180. console.error(e);
  181. ElNotification.error('添加失败')
  182. } finally {
  183. appendLoading.value = false
  184. showAppendModal.value = false;
  185. }
  186. };
  187. const onRemove = async (node: ETNode, data: TreeData) => {
  188. const parent = node.parent;
  189. const children = parent.data.children || parent.data;
  190. const index = children.findIndex((d) => d.key === data.key);
  191. sideLoading.value = true
  192. try {
  193. const leafDatas = getTreeFlatten(data)
  194. const command = new DeleteObjectsCommand({
  195. Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
  196. Delete: {
  197. Objects: leafDatas.map(d => ({Key: `${langKeyPrefix.value}${d.key}`})),
  198. Quiet: false,
  199. }
  200. })
  201. await s3.send(command)
  202. children.splice(index, 1);
  203. // if (parent.level === 0) {
  204. // nextTick(loadSideBar)
  205. // }
  206. } catch (e) {
  207. console.error(e)
  208. ElNotification.error('删除失败')
  209. } finally {
  210. sideLoading.value = false
  211. }
  212. };
  213. const saveSideBarSort = () => {
  214. // TODO
  215. const sortedNodes = getTreeFlatten( { children: tree$.value!.data } as TreeData, 'preorder', 'all' )
  216. const sortedKeyMap = sortedNodes.reduce((a, v, i)=> ( {...a, [v.key ?? '']: i} ), {})
  217. }
  218. const onDragEnd = async (
  219. draggingNode: ETNode,
  220. dropNode: ETNode,
  221. dropType: NodeDropType,
  222. ev: DragEvents
  223. ) => {
  224. if (dropType === 'none') {
  225. return
  226. }
  227. sideLoading.value = true
  228. const copyTargetPath = path.join(langKeyPrefix.value, dropType === 'inner' ? dropNode.data.key : dropNode.parent.data.key ?? '')
  229. const copySourceNodes = getTreeFlatten(draggingNode.data)
  230. const waitForDelete = []
  231. for (const sourceNode of copySourceNodes) {
  232. if (sourceNode.isDir) {
  233. continue
  234. }
  235. const copySource = path.join(langKeyPrefix.value, sourceNode.key)
  236. const copyTarget = path.join( copyTargetPath, path.basename(draggingNode.data.key), path.relative(draggingNode.data.key, sourceNode.key) )
  237. if (copySource === copyTarget) {
  238. continue
  239. }
  240. try {
  241. const command = new CopyObjectCommand({
  242. Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
  243. CopySource: encodeURIComponent(`/${import.meta.env.VITE_DOCS_LIST_BUCKET}/${copySource}`),
  244. Key: copyTarget
  245. })
  246. await s3.send(command)
  247. waitForDelete.push({ Key: copySource })
  248. } catch (e) {
  249. console.error(e)
  250. console.warn(`${ copySource } 复制失败`)
  251. }
  252. }
  253. if (waitForDelete.length) {
  254. const command = new DeleteObjectsCommand({
  255. Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
  256. Delete: {
  257. Objects: waitForDelete,
  258. Quiet: false,
  259. }
  260. })
  261. await s3.send(command)
  262. }
  263. await loadSideBar()
  264. sideLoading.value = false
  265. nextTick(() => {
  266. saveSideBarSort()
  267. })
  268. }
  269. const allowDrop = (draggingNode: Node, dropNode: Node, type: AllowDropType) => {
  270. return !( !dropNode.data.isDir && type === 'inner' )
  271. }
  272. </script>
  273. <template>
  274. <div class="md-container">
  275. <div class="left" v-loading="sideLoading">
  276. <div class="toolbar">
  277. <el-radio-group v-model="langSelect" size="small">
  278. <el-radio-button label="简体中文" value="zh-CN" />
  279. <el-radio-button label="繁体中文" value="zh-HK" />
  280. <el-radio-button label="English" value="en-US" />
  281. </el-radio-group>
  282. <el-button-group>
  283. <el-button
  284. @click="onAppend({ key: '', label: '', children: langSelectedDataSource })"
  285. type="primary"
  286. text
  287. :icon="Plus"
  288. >
  289. </el-button>
  290. <el-button @click="loadSideBar" type="info" text :icon="Refresh"></el-button>
  291. </el-button-group>
  292. </div>
  293. <el-divider />
  294. <el-tree
  295. ref="tree$"
  296. :data="langSelectedDataSource"
  297. node-key="key"
  298. default-expand-all
  299. :expand-on-click-node="false"
  300. @node-click="onNodeClick"
  301. draggable
  302. :allow-drop="allowDrop"
  303. @node-drag-end="onDragEnd"
  304. >
  305. <template #default="{ node, data }">
  306. <Node
  307. :node="node"
  308. :data="data"
  309. @append="() => onAppend(data)"
  310. @remove="() => onRemove(node, data)"
  311. />
  312. </template>
  313. </el-tree>
  314. </div>
  315. <MdEditor
  316. v-loading="textLoading"
  317. :disabled="!currentOpenData"
  318. v-model="text"
  319. @save="onSave"
  320. @uploadImg="onUploadImg"
  321. />
  322. </div>
  323. <AppendModal
  324. v-model:show="showAppendModal"
  325. :contextData="appendContextData"
  326. :appendLoading="appendLoading"
  327. @ok="(formData) => resolveAppend?.(formData)"
  328. @cancel="() => rejectAppend?.()"
  329. />
  330. </template>
  331. <style lang="scss" scoped>
  332. .md-container {
  333. width: 100vw;
  334. height: 100dvh;
  335. display: flex;
  336. align-items: stretch;
  337. .md-editor {
  338. flex: 1;
  339. height: 100%;
  340. }
  341. .left {
  342. flex: 0 0 300px;
  343. padding: 10px;
  344. display: flex;
  345. flex-direction: column;
  346. align-items: stretch;
  347. .toolbar {
  348. display: flex;
  349. flex-direction: column;
  350. gap: 5px;
  351. }
  352. .el-tree {
  353. overflow: auto;
  354. flex: 1;
  355. :deep(.el-tree-node.is-current) {
  356. & > .el-tree-node__content {
  357. background-color: aqua;
  358. }
  359. }
  360. }
  361. }
  362. }
  363. </style>