index.vue 12 KB

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