index.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. <script setup lang="ts">
  2. import { ref, onMounted, computed, nextTick, onUnmounted, provide } 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 { Plus, Refresh } from "@element-plus/icons-vue";
  14. import Node from "./Node.vue";
  15. import AppendModal from "./AppendModal.vue";
  16. import type { TreeData } from "./Tree";
  17. import { ElNotification, type ElTree } from "element-plus";
  18. import { MdEditor } from "md-editor-v3";
  19. import { s3ContentsToTree, getTreeFlatten } from "@/utils/s3Helper";
  20. import type ETNode from "element-plus/es/components/tree/src/model/node";
  21. import type { DragEvents } from "element-plus/es/components/tree/src/model/useDragNode";
  22. import type {
  23. AllowDropType,
  24. NodeDropType,
  25. } from "element-plus/es/components/tree/src/tree.type";
  26. import VideoToolBarItem from './VideoToolBarItem.vue'
  27. import path from "path-browserify";
  28. const s3 = new S3Client({
  29. credentials: {
  30. accessKeyId: import.meta.env.VITE_AWS_S3_ACCESS_KEY_ID,
  31. secretAccessKey: import.meta.env.VITE_AWS_S3_SECRET_ACCESS_KEY,
  32. },
  33. region: import.meta.env.VITE_AWS_S3_REGION,
  34. });
  35. provide('s3', s3)
  36. const SIDEBAR_SORTED_MAP_KEY = "SIDEBAR_SORTED_MAP.json";
  37. const tree$ = ref<InstanceType<typeof ElTree>>();
  38. const editor$ = ref<InstanceType<typeof MdEditor>>();
  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<
  48. Partial<Record<"zh-CN" | "zh-HK" | "en-US", TreeData[]>>
  49. >({});
  50. const langSelectedDataSource = computed(() => {
  51. return langDataSource.value[langSelect.value];
  52. });
  53. const dataSource = ref<unknown[]>([]);
  54. const sideLoading = ref(false);
  55. const loadS3DocsListObjects = async () => {
  56. const command = new ListObjectsCommand({
  57. Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
  58. });
  59. const result = await s3.send(command);
  60. return result.Contents!;
  61. };
  62. const loadS3SideBarSort = async (lang) => {
  63. try {
  64. const command = new GetObjectCommand({
  65. Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
  66. Key: `${lang}::${SIDEBAR_SORTED_MAP_KEY}`,
  67. ResponseCacheControl: "no-cache",
  68. });
  69. const file = await s3.send(command);
  70. return JSON.parse(await file.Body?.transformToString()!);
  71. } catch (e) {
  72. console.warn(e);
  73. }
  74. return {};
  75. };
  76. const loadSideBar = async () => {
  77. sideLoading.value = true;
  78. dataSource.value = await loadS3DocsListObjects();
  79. const allLangs: ("zh-CN" | "zh-HK" | "en-US")[] = ["zh-CN", "zh-HK", "en-US"];
  80. await Promise.all(
  81. allLangs.map(async (lang: "zh-CN" | "zh-HK" | "en-US") => {
  82. const prefix = {
  83. "zh-CN": "docs/",
  84. "zh-HK": "zh-HK/docs/",
  85. "en-US": "en-US/docs/",
  86. }[lang];
  87. const sideBarSortMap = await loadS3SideBarSort(lang);
  88. const filtered = dataSource.value
  89. .filter((cont) => cont.Key.startsWith(prefix))
  90. .map((cont) => ({
  91. ...cont,
  92. Key: cont.Key.replace(new RegExp(`^${prefix}`), ""),
  93. }))
  94. .sort((a, b) => sideBarSortMap[a.Key] - sideBarSortMap[b.Key]);
  95. // TODO sort
  96. langDataSource.value[lang] = s3ContentsToTree(
  97. filtered,
  98. {},
  99. (r, label, i, a, thisContent) => ({
  100. isDir: a.length !== i + 1,
  101. ...(a.length === i + 1 ? thisContent : {}),
  102. })
  103. );
  104. })
  105. );
  106. sideLoading.value = false;
  107. };
  108. onMounted(() => {
  109. loadSideBar();
  110. });
  111. const currentOpenData = ref<TreeData>();
  112. const onNodeClick = async (data: TreeData, node) => {
  113. if (data.isDir) {
  114. return;
  115. }
  116. textLoading.value = true;
  117. const command = new GetObjectCommand({
  118. Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
  119. Key: `${langKeyPrefix.value}${data.key}`,
  120. ResponseCacheControl: "no-cache",
  121. });
  122. const file = await s3.send(command);
  123. text.value = await file.Body?.transformToString()!;
  124. currentOpenData.value = data;
  125. textLoading.value = false;
  126. };
  127. const text = ref("");
  128. const textLoading = ref(false);
  129. provide('editorLoading', textLoading)
  130. const onUploadImg = async (
  131. files: File[],
  132. callback: (
  133. urls: string[] | { url: string; alt: string; title: string }[]
  134. ) => void
  135. ) => {
  136. let result = [];
  137. for (const file of files) {
  138. try {
  139. const key = `${uuid4()}::${file.name}`;
  140. const command = new PutObjectCommand({
  141. Bucket: import.meta.env.VITE_DOCS_MEDIA_BUCKET,
  142. Key: key,
  143. Body: file,
  144. ACL: "public-read",
  145. });
  146. const res = await s3.send(command);
  147. result.push({
  148. url: `https://${
  149. import.meta.env.VITE_DOCS_MEDIA_BUCKET
  150. }.s3.amazonaws.com/${key}`,
  151. alt: file.name,
  152. title: file.name,
  153. });
  154. } catch (e) {
  155. console.error(e);
  156. ElNotification.error(`${file.name} 上传失败`);
  157. }
  158. }
  159. callback(result);
  160. };
  161. const onSave = async () => {
  162. if (!currentOpenData.value) {
  163. ElNotification.info("请先选择文件");
  164. return;
  165. }
  166. textLoading.value = true;
  167. try {
  168. const command = new PutObjectCommand({
  169. Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
  170. Key: `${langKeyPrefix.value}${currentOpenData.value?.key}`,
  171. Body: text.value,
  172. });
  173. await s3.send(command);
  174. ElNotification.success(`${currentOpenData.value?.key} 保存成功`);
  175. } catch (e) {
  176. console.error(e);
  177. ElNotification.error(`${currentOpenData.value?.key} 保存失败`);
  178. } finally {
  179. textLoading.value = false;
  180. }
  181. };
  182. let resolveAppend: Function | undefined;
  183. let rejectAppend: Function | undefined;
  184. const showAppendModal = ref(false);
  185. const appendLoading = ref(false);
  186. const appendContextData = ref();
  187. const onAppend = async (data: TreeData) => {
  188. const modalConfirmPromise = new Promise((resolve, reject) => {
  189. resolveAppend = resolve;
  190. rejectAppend = reject;
  191. });
  192. appendContextData.value = data;
  193. showAppendModal.value = true;
  194. let filename, isDir;
  195. try {
  196. ({ filename, isDir } = await modalConfirmPromise);
  197. } catch (e) {
  198. return;
  199. }
  200. try {
  201. appendLoading.value = true;
  202. // const key = `${langKeyPrefix.value}${data.key ? `${data.key}/` : ''}${filename}`
  203. const key = path.join(langKeyPrefix.value, data.key ?? "", filename);
  204. if (!isDir) {
  205. const command = new PutObjectCommand({
  206. Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
  207. Key: key,
  208. Body: "",
  209. });
  210. await s3.send(command);
  211. }
  212. const newChild: TreeData = {
  213. key: `${data.key ? `${data.key}/` : ""}${filename}`,
  214. label: filename,
  215. children: [],
  216. isDir,
  217. };
  218. if (!data.children) {
  219. data.children = [];
  220. }
  221. data.children.push(newChild);
  222. } catch (e) {
  223. console.error(e);
  224. ElNotification.error("添加失败");
  225. } finally {
  226. appendLoading.value = false;
  227. showAppendModal.value = false;
  228. }
  229. if (!isDir) {
  230. saveSideBarSort();
  231. }
  232. };
  233. const onRemove = async (node: ETNode, data: TreeData) => {
  234. const parent = node.parent;
  235. const children = parent.data.children || parent.data;
  236. const index = children.findIndex((d) => d.key === data.key);
  237. sideLoading.value = true;
  238. try {
  239. const leafDatas = getTreeFlatten(data);
  240. const command = new DeleteObjectsCommand({
  241. Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
  242. Delete: {
  243. Objects: leafDatas.map((d) => ({
  244. Key: `${langKeyPrefix.value}${d.key}`,
  245. })),
  246. Quiet: false,
  247. },
  248. });
  249. await s3.send(command);
  250. children.splice(index, 1);
  251. } catch (e) {
  252. console.error(e);
  253. ElNotification.error("删除失败");
  254. } finally {
  255. sideLoading.value = false;
  256. }
  257. };
  258. const saveSideBarSort = async () => {
  259. sideLoading.value = true;
  260. const sortedNodes = getTreeFlatten(
  261. { children: tree$.value!.data } as TreeData,
  262. "preorder",
  263. "all"
  264. );
  265. const sortedKeyMap = sortedNodes.reduce(
  266. (a, v, i) => ({ ...a, [path.join(...v.__path__!)]: i }),
  267. {}
  268. );
  269. const command = new PutObjectCommand({
  270. Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
  271. Key: `${langSelect.value}::${SIDEBAR_SORTED_MAP_KEY}`,
  272. Body: JSON.stringify(sortedKeyMap),
  273. });
  274. await s3.send(command);
  275. sideLoading.value = false;
  276. };
  277. const onDragEnd = async (
  278. draggingNode: ETNode,
  279. dropNode: ETNode,
  280. dropType: NodeDropType,
  281. ev: DragEvents
  282. ) => {
  283. if (dropType === "none") {
  284. return;
  285. }
  286. sideLoading.value = true;
  287. const copySourceNodes = getTreeFlatten(draggingNode.data);
  288. const waitForDelete = [];
  289. for (const sourceNode of copySourceNodes) {
  290. if (sourceNode.isDir) {
  291. continue;
  292. }
  293. const copySource = path.join(langKeyPrefix.value, sourceNode.key);
  294. const copyTarget = path.join(
  295. langKeyPrefix.value,
  296. dropType === "inner" ? dropNode.data.key : dropNode.parent.data.key ?? "",
  297. path.basename(draggingNode.data.key),
  298. path.relative(draggingNode.data.key, sourceNode.key)
  299. );
  300. if (copySource === copyTarget) {
  301. continue;
  302. }
  303. try {
  304. const command = new CopyObjectCommand({
  305. Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
  306. CopySource: encodeURIComponent(
  307. `/${import.meta.env.VITE_DOCS_LIST_BUCKET}/${copySource}`
  308. ),
  309. Key: copyTarget,
  310. });
  311. await s3.send(command);
  312. waitForDelete.push({ Key: copySource });
  313. } catch (e) {
  314. console.error(e);
  315. console.warn(`${copySource} 复制失败`);
  316. }
  317. }
  318. if (waitForDelete.length) {
  319. const command = new DeleteObjectsCommand({
  320. Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
  321. Delete: {
  322. Objects: waitForDelete,
  323. Quiet: false,
  324. },
  325. });
  326. await s3.send(command);
  327. }
  328. sideLoading.value = false;
  329. nextTick(async () => {
  330. await saveSideBarSort();
  331. await loadSideBar();
  332. });
  333. };
  334. const allowDrop = (draggingNode: Node, dropNode: Node, type: AllowDropType) => {
  335. return !(!dropNode.data.isDir && type === "inner");
  336. };
  337. let observer: InstanceType<typeof MutationObserver>
  338. const callback = (mutationList: MutationRecord[]) => {
  339. const preview$ = ( mutationList.at(-1)?.target as HTMLElement ).querySelector('#helpdocs-editor-preview')
  340. preview$?.classList.remove('md-editor-preview')
  341. };
  342. onMounted(() => {
  343. const el = document.querySelector('#helpdocs-editor')
  344. if (el) {
  345. observer = new MutationObserver(callback)
  346. observer.observe(el, { childList: true, subtree: true })
  347. }
  348. })
  349. onUnmounted(() => {
  350. observer && observer.disconnect()
  351. })
  352. </script>
  353. <template>
  354. <div class="md-container">
  355. <div class="left" v-loading="sideLoading">
  356. <div class="toolbar">
  357. <el-segmented
  358. v-model="langSelect"
  359. :options="[
  360. { label: '简体中文', value: 'zh-CN' },
  361. { label: '繁体中文', value: 'zh-HK' },
  362. { label: 'English', value: 'en-US' },
  363. ]"
  364. />
  365. <el-button-group>
  366. <el-button
  367. @click="onAppend({ key: '', label: '', children: langSelectedDataSource })"
  368. type="primary"
  369. text
  370. :icon="Plus"
  371. >
  372. </el-button>
  373. <el-button @click="loadSideBar" type="info" text :icon="Refresh"></el-button>
  374. </el-button-group>
  375. </div>
  376. <el-divider />
  377. <el-tree
  378. ref="tree$"
  379. :data="langSelectedDataSource"
  380. node-key="key"
  381. default-expand-all
  382. :expand-on-click-node="false"
  383. @node-click="onNodeClick"
  384. draggable
  385. :allow-drop="allowDrop"
  386. @node-drag-end="onDragEnd"
  387. >
  388. <template #default="{ node, data }">
  389. <Node
  390. :node="node"
  391. :data="data"
  392. @append="() => onAppend(data)"
  393. @remove="() => onRemove(node, data)"
  394. />
  395. </template>
  396. </el-tree>
  397. </div>
  398. <MdEditor
  399. ref="editor$"
  400. editorId="helpdocs-editor"
  401. v-model="text"
  402. v-loading="textLoading"
  403. :disabled="!currentOpenData"
  404. :toolbars="[
  405. 'bold',
  406. 'underline',
  407. 'italic',
  408. 'strikeThrough',
  409. '-',
  410. 'title',
  411. 'sub',
  412. 'sup',
  413. 'quote',
  414. 'unorderedList',
  415. 'orderedList',
  416. 'task',
  417. '-',
  418. 'codeRow',
  419. 'code',
  420. 'link',
  421. 'image',
  422. 0,
  423. 'table',
  424. 'mermaid',
  425. 'katex',
  426. '-',
  427. 'revoke',
  428. 'next',
  429. 'save',
  430. '=',
  431. 'prettier',
  432. 'pageFullscreen',
  433. 'fullscreen',
  434. 'preview',
  435. 'previewOnly',
  436. 'htmlPreview',
  437. 'catalog',
  438. ]"
  439. previewTheme="custom"
  440. class="vp-doc"
  441. @save="onSave"
  442. @uploadImg="onUploadImg"
  443. >
  444. <template #defToolbars>
  445. <VideoToolBarItem />
  446. </template>
  447. </MdEditor>
  448. </div>
  449. <AppendModal
  450. v-model:show="showAppendModal"
  451. :contextData="appendContextData"
  452. :appendLoading="appendLoading"
  453. @ok="(formData) => resolveAppend?.(formData)"
  454. @cancel="() => rejectAppend?.()"
  455. />
  456. </template>
  457. <style src="md-editor-v3/lib/style.css"></style>
  458. <style lang="scss" scoped>
  459. .md-container {
  460. width: 100vw;
  461. height: 100dvh;
  462. display: flex;
  463. align-items: stretch;
  464. .md-editor {
  465. flex: 1;
  466. height: 100%;
  467. }
  468. .left {
  469. flex: 0 0 300px;
  470. padding: 10px;
  471. display: flex;
  472. flex-direction: column;
  473. align-items: stretch;
  474. .toolbar {
  475. display: flex;
  476. flex-direction: column;
  477. gap: 5px;
  478. }
  479. .el-tree {
  480. overflow: auto;
  481. flex: 1;
  482. :deep(.el-tree-node.is-current) {
  483. & > .el-tree-node__content {
  484. background-color: aqua;
  485. }
  486. }
  487. }
  488. }
  489. }
  490. </style>