index.vue 13 KB

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