app_user_repo.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. window.initUserRepoBrowser = async function initUserRepoBrowser({ resourceId }) {
  2. const refSelect = document.getElementById("refSelect");
  3. const reloadRepo = document.getElementById("reloadRepo");
  4. const treeEl = document.getElementById("tree");
  5. const fileContent = document.getElementById("fileContent");
  6. const filePlaceholder = document.getElementById("filePlaceholder");
  7. const breadcrumb = document.getElementById("breadcrumb");
  8. const downloadBtn = document.getElementById("downloadBtn");
  9. const repoModalBackdrop = document.getElementById("repoModalBackdrop");
  10. const repoModalTitle = document.getElementById("repoModalTitle");
  11. const repoModalClose = document.getElementById("repoModalClose");
  12. const repoModalBody = document.getElementById("repoModalBody");
  13. const repoModalFooter = document.getElementById("repoModalFooter");
  14. if (!refSelect || !reloadRepo || !treeEl || !fileContent || !breadcrumb || !downloadBtn) return;
  15. let currentRef = "";
  16. let currentPath = "";
  17. let canEditRepo = false;
  18. let refKinds = { branches: new Set(), tags: new Set() };
  19. let selectedFilePath = "";
  20. let selectedFileContent = "";
  21. const repoWriteActionsEnabled = false;
  22. function closeRepoModal() {
  23. if (!repoModalBackdrop) return;
  24. repoModalBackdrop.style.display = "none";
  25. if (repoModalTitle) repoModalTitle.textContent = "";
  26. if (repoModalBody) repoModalBody.innerHTML = "";
  27. if (repoModalFooter) repoModalFooter.innerHTML = "";
  28. }
  29. function openRepoModal(title, bodyNodes, footerNodes, icon = "ri-code-line") {
  30. if (!repoModalBackdrop || !repoModalTitle || !repoModalBody || !repoModalFooter) return;
  31. repoModalTitle.innerHTML = "";
  32. repoModalTitle.appendChild(el("i", { class: icon }));
  33. repoModalTitle.appendChild(document.createTextNode(title));
  34. repoModalBody.innerHTML = "";
  35. repoModalFooter.innerHTML = "";
  36. bodyNodes.forEach((n) => repoModalBody.appendChild(n));
  37. footerNodes.forEach((n) => repoModalFooter.appendChild(n));
  38. repoModalBackdrop.style.display = "";
  39. }
  40. if (repoModalBackdrop && repoModalBackdrop.dataset.repoBound !== "1") {
  41. if (repoModalClose) repoModalClose.addEventListener("click", closeRepoModal);
  42. repoModalBackdrop.addEventListener("click", (evt) => {
  43. if (evt.target === repoModalBackdrop) closeRepoModal();
  44. });
  45. repoModalBackdrop.dataset.repoBound = "1";
  46. }
  47. function isBranchRef(ref) {
  48. return refKinds.branches.has(ref);
  49. }
  50. function setBreadcrumb(path) {
  51. breadcrumb.innerHTML = "";
  52. const parts = path ? path.split("/") : [];
  53. const items = [{ name: "根目录", path: "" }];
  54. let acc = "";
  55. parts.forEach((p) => {
  56. acc = acc ? `${acc}/${p}` : p;
  57. items.push({ name: p, path: acc });
  58. });
  59. items.forEach((it, idx) => {
  60. const a = el("a", { href: "#" }, it.name);
  61. a.addEventListener("click", async (e) => {
  62. e.preventDefault();
  63. currentPath = it.path;
  64. await loadTree();
  65. });
  66. breadcrumb.appendChild(a);
  67. if (idx < items.length - 1) breadcrumb.appendChild(el("span", { class: "muted" }, "/"));
  68. });
  69. }
  70. async function loadRefs() {
  71. const refs = await apiFetch(`/resources/${resourceId}/repo/refs`);
  72. refSelect.innerHTML = "";
  73. refKinds = { branches: new Set(), tags: new Set() };
  74. const branchGroup = document.createElement("optgroup");
  75. branchGroup.label = "分支";
  76. (refs.branches || []).forEach((b) => {
  77. const name = (b.name || "").trim();
  78. if (!name) return;
  79. refKinds.branches.add(name);
  80. branchGroup.appendChild(el("option", { value: name }, name));
  81. });
  82. const tagGroup = document.createElement("optgroup");
  83. tagGroup.label = "标签";
  84. (refs.tags || []).forEach((t) => {
  85. const name = (t.name || "").trim();
  86. if (!name) return;
  87. refKinds.tags.add(name);
  88. tagGroup.appendChild(el("option", { value: name }, name));
  89. });
  90. if (branchGroup.children.length) refSelect.appendChild(branchGroup);
  91. if (tagGroup.children.length) refSelect.appendChild(tagGroup);
  92. currentRef = refSelect.value;
  93. }
  94. async function loadTree() {
  95. fileContent.textContent = "";
  96. if (filePlaceholder) filePlaceholder.style.display = "";
  97. selectedFilePath = "";
  98. selectedFileContent = "";
  99. setBreadcrumb(currentPath);
  100. treeEl.innerHTML = "";
  101. const params = new URLSearchParams();
  102. params.set("ref", currentRef);
  103. params.set("path", currentPath);
  104. const data = await apiFetch(`/resources/${resourceId}/repo/tree?${params.toString()}`);
  105. data.items.forEach((it) => {
  106. const rightText = String(it.path || "");
  107. const isLocked = it.type !== "dir" && it.guestAllowed === false;
  108. const rightNode = isLocked
  109. ? el(
  110. "div",
  111. { class: "muted tree-locked", style: "font-size: 0.85rem; display: flex; align-items: center; gap: 6px;" },
  112. el("i", { class: "ri-lock-2-line" }),
  113. "需登录"
  114. )
  115. : rightText && rightText !== it.name
  116. ? el("div", { class: "muted", style: "font-size: 0.85rem;" }, rightText)
  117. : null;
  118. const row = el(
  119. "div",
  120. { class: `card${isLocked ? " is-locked" : ""}` },
  121. el(
  122. "div",
  123. { style: "display: flex; align-items: center; gap: 8px; font-weight: 500;" },
  124. el("i", { class: it.type === "dir" ? "ri-folder-3-fill" : "ri-file-text-line", style: `font-size: 1.2rem; color: ${it.type === "dir" ? "#fbbf24" : "var(--muted)"};` }),
  125. it.name
  126. ),
  127. rightNode
  128. );
  129. row.addEventListener("click", async () => {
  130. if (it.type === "dir") {
  131. currentPath = it.path;
  132. await loadTree();
  133. return;
  134. }
  135. if (it.guestAllowed === false) {
  136. Swal.fire({
  137. title: '需要登录',
  138. text: '未登录仅可预览文档/配置等普通文本文件',
  139. icon: 'info',
  140. showCancelButton: true,
  141. confirmButtonText: '去登录',
  142. cancelButtonText: '取消',
  143. confirmButtonColor: 'var(--brand)'
  144. }).then((result) => {
  145. if (result.isConfirmed) {
  146. window.location.href = `/ui/login?next=${currentNextParam()}`;
  147. }
  148. });
  149. return;
  150. }
  151. const p = new URLSearchParams();
  152. p.set("ref", currentRef);
  153. p.set("path", it.path);
  154. try {
  155. const f = await apiFetch(`/resources/${resourceId}/repo/file?${p.toString()}`);
  156. fileContent.textContent = f.content;
  157. if (filePlaceholder) filePlaceholder.style.display = "none";
  158. selectedFilePath = it.path;
  159. selectedFileContent = f.content;
  160. } catch (e) {
  161. if (e.status === 401 && e.detail?.error === "login_required") {
  162. Swal.fire({
  163. title: '需要登录',
  164. text: '未登录仅可预览文档/配置等普通文本文件',
  165. icon: 'info',
  166. showCancelButton: true,
  167. confirmButtonText: '去登录',
  168. cancelButtonText: '取消',
  169. confirmButtonColor: 'var(--brand)'
  170. }).then((result) => {
  171. if (result.isConfirmed) {
  172. window.location.href = `/ui/login?next=${currentNextParam()}`;
  173. }
  174. });
  175. return;
  176. }
  177. fileContent.textContent = `无法预览:${e.detail?.error || e.status || "unknown"}`;
  178. if (filePlaceholder) filePlaceholder.style.display = "none";
  179. }
  180. });
  181. treeEl.appendChild(row);
  182. });
  183. }
  184. async function showCommits() {
  185. const p = new URLSearchParams();
  186. p.set("ref", currentRef);
  187. const focusPath = selectedFilePath || currentPath || "";
  188. if (focusPath) p.set("path", focusPath);
  189. p.set("limit", "20");
  190. const msg = el("div", { class: "muted" }, "加载中…");
  191. openRepoModal("提交历史", [msg], [el("button", { class: "btn", onclick: closeRepoModal }, "关闭")], "ri-history-line");
  192. try {
  193. const data = await apiFetch(`/resources/${resourceId}/repo/commits?${p.toString()}`);
  194. const items = data.items || [];
  195. if (!items.length) {
  196. msg.textContent = "没有找到提交记录";
  197. return;
  198. }
  199. msg.remove();
  200. const list = el("div", {});
  201. items.forEach((it) => {
  202. const sha = String(it.sha || "");
  203. list.appendChild(
  204. el(
  205. "div",
  206. { class: "card", style: "margin-bottom:12px; padding: 16px; border-left: 3px solid var(--brand); border-radius: 8px;" },
  207. el(
  208. "div",
  209. { style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;" },
  210. el("div", { style: "font-weight: 500; font-size: 1.05rem;" }, String(it.subject || "")),
  211. el("span", { class: "badge", style: "font-family: monospace; font-size: 0.85rem;" }, sha.slice(0, 7))
  212. ),
  213. el(
  214. "div",
  215. { class: "muted", style: "display: flex; align-items: center; gap: 8px; font-size: 0.9rem;" },
  216. el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-user-line" }), it.authorName || ""),
  217. el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-time-line" }), formatDateTime(it.authorDate))
  218. )
  219. )
  220. );
  221. });
  222. if (repoModalBody) repoModalBody.appendChild(list);
  223. } catch (e) {
  224. msg.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}${e.detail?.message ? `\n${e.detail.message}` : ""}`;
  225. }
  226. }
  227. if (downloadBtn) {
  228. const toolbar = downloadBtn.closest(".toolbar");
  229. if (toolbar && !document.getElementById("commitsBtn")) {
  230. const commitsBtn = el("button", { id: "commitsBtn", class: "btn", style: "border-radius: 8px; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-history-line" }), "提交历史");
  231. commitsBtn.addEventListener("click", showCommits);
  232. toolbar.insertBefore(commitsBtn, downloadBtn);
  233. }
  234. }
  235. if (refSelect.dataset.repoBound !== "1") {
  236. refSelect.addEventListener("change", async () => {
  237. currentRef = refSelect.value;
  238. currentPath = "";
  239. await loadTree();
  240. });
  241. reloadRepo.addEventListener("click", async () => {
  242. await loadRefs();
  243. currentPath = "";
  244. await loadTree();
  245. });
  246. refSelect.dataset.repoBound = "1";
  247. }
  248. try {
  249. await apiFetch("/admin/settings");
  250. canEditRepo = true;
  251. } catch (e) {
  252. canEditRepo = false;
  253. }
  254. if (canEditRepo && repoWriteActionsEnabled) {
  255. const toolbar = downloadBtn.closest(".toolbar");
  256. if (toolbar && !document.getElementById("repoWriteActions")) {
  257. const createBtn = el("button", { class: "btn", style: "border-radius: 8px; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-file-add-line" }), "新建文件");
  258. const editBtn = el("button", { class: "btn", style: "border-radius: 8px; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-edit-line" }), "在线编辑");
  259. const delBtn = el("button", { class: "btn btn-danger", style: "border-radius: 8px; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-delete-bin-line" }), "删除");
  260. function requireBranchOrToast() {
  261. if (isBranchRef(currentRef)) return true;
  262. Swal.fire({
  263. icon: 'warning',
  264. title: '操作受限',
  265. text: '仅支持在分支上进行编辑或提交操作'
  266. });
  267. return false;
  268. }
  269. function requireSelectedFileOrToast() {
  270. if (selectedFilePath) return true;
  271. Swal.fire({
  272. icon: 'info',
  273. title: '未选择文件',
  274. text: '请先在左侧目录结构中选择一个文件'
  275. });
  276. return false;
  277. }
  278. async function createFile() {
  279. if (!requireBranchOrToast()) return;
  280. const pathInput = el("input", { class: "input", placeholder: "例如:README.md 或 docs/intro.md" });
  281. const defaultPath = currentPath ? `${currentPath.replace(/\\/g, "/").replace(/\/+$/, "")}/new-file.txt` : "new-file.txt";
  282. pathInput.value = defaultPath;
  283. const msgInput = el("input", { class: "input", placeholder: "提交信息,例如:Add new file" });
  284. const ta = el("textarea", { class: "input", style: "min-height:260px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;" });
  285. const msg = el("div", { class: "muted" });
  286. const saveBtn = el("button", { class: "btn btn-primary" }, "提交");
  287. saveBtn.addEventListener("click", async () => {
  288. msg.textContent = "";
  289. saveBtn.disabled = true;
  290. try {
  291. const res = await apiFetch(`/resources/${resourceId}/repo/file`, {
  292. method: "POST",
  293. body: { ref: currentRef, path: pathInput.value.trim(), content: ta.value, message: msgInput.value.trim() },
  294. });
  295. msg.textContent = `提交成功:${String(res.commit || "").slice(0, 10)}`;
  296. await loadTree();
  297. setTimeout(closeRepoModal, 600);
  298. } catch (e) {
  299. msg.textContent = `提交失败:${e.detail?.error || e.status || "unknown"}${e.detail?.message ? `\n${e.detail.message}` : ""}`;
  300. } finally {
  301. saveBtn.disabled = false;
  302. }
  303. });
  304. openRepoModal(
  305. "新建文件",
  306. [
  307. el(
  308. "div",
  309. { style: "display: flex; flex-direction: column; gap: 16px;" },
  310. el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "文件路径"), pathInput),
  311. el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "提交信息"), msgInput),
  312. el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "文件内容"), ta),
  313. msg
  314. )
  315. ],
  316. [el("button", { class: "btn", onclick: closeRepoModal }, "取消"), saveBtn],
  317. "ri-file-add-line"
  318. );
  319. }
  320. async function editFile() {
  321. if (!requireBranchOrToast()) return;
  322. if (!requireSelectedFileOrToast()) return;
  323. const pathText = el("input", { class: "input", value: selectedFilePath, disabled: true, style: "background: #f1f5f9; color: var(--muted); cursor: not-allowed;" });
  324. const msgInput = el("input", { class: "input", placeholder: "提交信息,例如:Update README" });
  325. const ta = el("textarea", { class: "input", style: "min-height:320px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;" }, "");
  326. ta.value = selectedFileContent || "";
  327. const msg = el("div", { class: "muted" });
  328. const saveBtn = el("button", { class: "btn btn-primary" }, "提交");
  329. saveBtn.addEventListener("click", async () => {
  330. msg.textContent = "";
  331. saveBtn.disabled = true;
  332. try {
  333. const res = await apiFetch(`/resources/${resourceId}/repo/file`, {
  334. method: "PUT",
  335. body: { ref: currentRef, path: selectedFilePath, content: ta.value, message: msgInput.value.trim() },
  336. });
  337. selectedFileContent = ta.value;
  338. msg.textContent = `提交成功:${String(res.commit || "").slice(0, 10)}`;
  339. await loadTree();
  340. setTimeout(closeRepoModal, 600);
  341. } catch (e) {
  342. msg.textContent = `提交失败:${e.detail?.error || e.status || "unknown"}${e.detail?.message ? `\n${e.detail.message}` : ""}`;
  343. } finally {
  344. saveBtn.disabled = false;
  345. }
  346. });
  347. openRepoModal(
  348. "在线编辑",
  349. [
  350. el(
  351. "div",
  352. { style: "display: flex; flex-direction: column; gap: 16px;" },
  353. el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "文件路径"), pathText),
  354. el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "提交信息"), msgInput),
  355. el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "文件内容"), ta),
  356. msg
  357. )
  358. ],
  359. [el("button", { class: "btn", onclick: closeRepoModal }, "取消"), saveBtn],
  360. "ri-edit-line"
  361. );
  362. }
  363. async function deleteFile() {
  364. if (!requireBranchOrToast()) return;
  365. if (!requireSelectedFileOrToast()) return;
  366. Swal.fire({
  367. title: '确认删除?',
  368. text: `您即将删除文件:${selectedFilePath}`,
  369. icon: 'warning',
  370. input: 'text',
  371. inputPlaceholder: '提交信息,例如:Delete file',
  372. showCancelButton: true,
  373. confirmButtonColor: '#d33',
  374. cancelButtonColor: 'var(--border)',
  375. confirmButtonText: '<i class="ri-delete-bin-line"></i> 确认删除',
  376. cancelButtonText: '取消',
  377. showLoaderOnConfirm: true,
  378. customClass: {
  379. cancelButton: 'btn',
  380. confirmButton: 'btn btn-danger'
  381. },
  382. preConfirm: async (message) => {
  383. try {
  384. const res = await apiFetch(`/resources/${resourceId}/repo/file`, {
  385. method: "DELETE",
  386. body: { ref: currentRef, path: selectedFilePath, message: (message || "").trim() },
  387. });
  388. return res;
  389. } catch (e) {
  390. Swal.showValidationMessage(`删除失败:${e.detail?.error || e.status || "unknown"}${e.detail?.message ? `<br>${e.detail.message}` : ""}`);
  391. }
  392. },
  393. allowOutsideClick: () => !Swal.isLoading()
  394. }).then(async (result) => {
  395. if (result.isConfirmed) {
  396. selectedFilePath = "";
  397. selectedFileContent = "";
  398. Swal.fire({
  399. title: '删除成功!',
  400. text: `提交 ID:${String(result.value.commit || "").slice(0, 10)}`,
  401. icon: 'success',
  402. timer: 1500,
  403. showConfirmButton: false
  404. });
  405. await loadTree();
  406. }
  407. });
  408. }
  409. createBtn.addEventListener("click", createFile);
  410. editBtn.addEventListener("click", editFile);
  411. delBtn.addEventListener("click", deleteFile);
  412. const group = btnGroup(createBtn, editBtn, delBtn);
  413. group.id = "repoWriteActions";
  414. const commitsBtn = document.getElementById("commitsBtn");
  415. toolbar.insertBefore(group, commitsBtn || downloadBtn);
  416. }
  417. }
  418. try {
  419. breadcrumb.textContent = "加载中…";
  420. await loadRefs();
  421. await loadTree();
  422. } catch (e) {
  423. breadcrumb.textContent = "仓库加载失败";
  424. treeEl.innerHTML = "";
  425. treeEl.appendChild(el("div", { class: "card", style: "margin: 8px; padding: 16px; border-radius: 12px;" }, `加载失败:${e.detail?.error || e.status || "unknown"}`));
  426. fileContent.textContent = "";
  427. }
  428. };