index.vue 13 KB

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