@weibaohui/dsh-git-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/README.md +43 -0
  2. package/client/bundle.js +2356 -0
  3. package/client/index.js +2342 -0
  4. package/cordis.patch.yml +11 -0
  5. package/package.json +70 -0
  6. package/server/dist/api/v1.js +2029 -0
  7. package/server/dist/authx/password.js +43 -0
  8. package/server/dist/authx/um.js +157 -0
  9. package/server/dist/conf.js +323 -0
  10. package/server/dist/context.js +701 -0
  11. package/server/dist/db/actions.js +202 -0
  12. package/server/dist/db/db.js +838 -0
  13. package/server/dist/dshapi.js +1416 -0
  14. package/server/dist/editorconfig.js +104 -0
  15. package/server/dist/gitx/git.js +580 -0
  16. package/server/dist/gitx/http.js +232 -0
  17. package/server/dist/gitx/paths.js +28 -0
  18. package/server/dist/gitx/service.js +251 -0
  19. package/server/dist/gosprintf.js +167 -0
  20. package/server/dist/hook.js +53 -0
  21. package/server/dist/index.js +112 -0
  22. package/server/dist/lfsx.js +265 -0
  23. package/server/dist/mailer.js +127 -0
  24. package/server/dist/markup.js +317 -0
  25. package/server/dist/metrics.js +18 -0
  26. package/server/dist/mirror.js +203 -0
  27. package/server/dist/multipart.js +33 -0
  28. package/server/dist/repox.js +99 -0
  29. package/server/dist/router.js +185 -0
  30. package/server/dist/serv.js +43 -0
  31. package/server/dist/server.js +382 -0
  32. package/server/dist/sshx/dispatch.js +160 -0
  33. package/server/dist/sshx/lfs-transfer.js +289 -0
  34. package/server/dist/sshx/server.js +107 -0
  35. package/server/dist/toolx.js +108 -0
  36. package/server/dist/twofactor.js +160 -0
  37. package/server/dist/webapi.js +377 -0
  38. package/server/dist/webhook.js +128 -0
  39. package/server/package.json +34 -0
  40. package/server/public/img/avatar_default.png +0 -0
  41. package/server/tsconfig.json +19 -0
  42. package/server/vendored-conf/app.ini +538 -0
  43. package/server/vendored-conf/gitignore/Actionscript +11 -0
  44. package/server/vendored-conf/gitignore/Ada +5 -0
  45. package/server/vendored-conf/gitignore/Agda +1 -0
  46. package/server/vendored-conf/gitignore/Android +32 -0
  47. package/server/vendored-conf/gitignore/Anjuta +3 -0
  48. package/server/vendored-conf/gitignore/AppEngine +2 -0
  49. package/server/vendored-conf/gitignore/AppceleratorTitanium +3 -0
  50. package/server/vendored-conf/gitignore/ArchLinuxPackages +13 -0
  51. package/server/vendored-conf/gitignore/Archives +27 -0
  52. package/server/vendored-conf/gitignore/Autotools +14 -0
  53. package/server/vendored-conf/gitignore/BricxCC +4 -0
  54. package/server/vendored-conf/gitignore/C +32 -0
  55. package/server/vendored-conf/gitignore/C Sharp +108 -0
  56. package/server/vendored-conf/gitignore/C++ +28 -0
  57. package/server/vendored-conf/gitignore/CFWheels +12 -0
  58. package/server/vendored-conf/gitignore/CMake +6 -0
  59. package/server/vendored-conf/gitignore/CUDA +6 -0
  60. package/server/vendored-conf/gitignore/CakePHP +13 -0
  61. package/server/vendored-conf/gitignore/ChefCookbook +9 -0
  62. package/server/vendored-conf/gitignore/Cloud9 +3 -0
  63. package/server/vendored-conf/gitignore/CodeIgniter +6 -0
  64. package/server/vendored-conf/gitignore/CodeKit +3 -0
  65. package/server/vendored-conf/gitignore/CommonLisp +3 -0
  66. package/server/vendored-conf/gitignore/Composer +6 -0
  67. package/server/vendored-conf/gitignore/Concrete5 +3 -0
  68. package/server/vendored-conf/gitignore/Coq +3 -0
  69. package/server/vendored-conf/gitignore/CraftCMS +3 -0
  70. package/server/vendored-conf/gitignore/DM +5 -0
  71. package/server/vendored-conf/gitignore/Dart +15 -0
  72. package/server/vendored-conf/gitignore/DartEditor +2 -0
  73. package/server/vendored-conf/gitignore/Delphi +57 -0
  74. package/server/vendored-conf/gitignore/Dreamweaver +3 -0
  75. package/server/vendored-conf/gitignore/Drupal +36 -0
  76. package/server/vendored-conf/gitignore/EPiServer +4 -0
  77. package/server/vendored-conf/gitignore/Eagle +36 -0
  78. package/server/vendored-conf/gitignore/Eclipse +39 -0
  79. package/server/vendored-conf/gitignore/EiffelStudio +2 -0
  80. package/server/vendored-conf/gitignore/Elisp +5 -0
  81. package/server/vendored-conf/gitignore/Elixir +4 -0
  82. package/server/vendored-conf/gitignore/Emacs +32 -0
  83. package/server/vendored-conf/gitignore/Ensime +4 -0
  84. package/server/vendored-conf/gitignore/Erlang +10 -0
  85. package/server/vendored-conf/gitignore/Espresso +1 -0
  86. package/server/vendored-conf/gitignore/ExpressionEngine +19 -0
  87. package/server/vendored-conf/gitignore/ExtJs +4 -0
  88. package/server/vendored-conf/gitignore/Fancy +2 -0
  89. package/server/vendored-conf/gitignore/Finale +13 -0
  90. package/server/vendored-conf/gitignore/FlexBuilder +3 -0
  91. package/server/vendored-conf/gitignore/ForceDotCom +4 -0
  92. package/server/vendored-conf/gitignore/FuelPHP +2 -0
  93. package/server/vendored-conf/gitignore/GWT +28 -0
  94. package/server/vendored-conf/gitignore/Gcov +5 -0
  95. package/server/vendored-conf/gitignore/GitBook +16 -0
  96. package/server/vendored-conf/gitignore/Go +24 -0
  97. package/server/vendored-conf/gitignore/Gradle +8 -0
  98. package/server/vendored-conf/gitignore/Grails +33 -0
  99. package/server/vendored-conf/gitignore/Haskell +16 -0
  100. package/server/vendored-conf/gitignore/IGORPro +5 -0
  101. package/server/vendored-conf/gitignore/IPythonNotebook +2 -0
  102. package/server/vendored-conf/gitignore/Idris +2 -0
  103. package/server/vendored-conf/gitignore/JDeveloper +13 -0
  104. package/server/vendored-conf/gitignore/Java +12 -0
  105. package/server/vendored-conf/gitignore/Jboss +19 -0
  106. package/server/vendored-conf/gitignore/Jekyll +3 -0
  107. package/server/vendored-conf/gitignore/JetBrains +46 -0
  108. package/server/vendored-conf/gitignore/Joomla +525 -0
  109. package/server/vendored-conf/gitignore/KDevelop4 +2 -0
  110. package/server/vendored-conf/gitignore/Kate +3 -0
  111. package/server/vendored-conf/gitignore/KiCAD +13 -0
  112. package/server/vendored-conf/gitignore/Kohana +2 -0
  113. package/server/vendored-conf/gitignore/LabVIEW +16 -0
  114. package/server/vendored-conf/gitignore/Laravel +4 -0
  115. package/server/vendored-conf/gitignore/Lazarus +30 -0
  116. package/server/vendored-conf/gitignore/Leiningen +12 -0
  117. package/server/vendored-conf/gitignore/LemonStand +21 -0
  118. package/server/vendored-conf/gitignore/LibreOffice +2 -0
  119. package/server/vendored-conf/gitignore/Lilypond +6 -0
  120. package/server/vendored-conf/gitignore/Linux +7 -0
  121. package/server/vendored-conf/gitignore/Lithium +2 -0
  122. package/server/vendored-conf/gitignore/Lua +41 -0
  123. package/server/vendored-conf/gitignore/LyX +4 -0
  124. package/server/vendored-conf/gitignore/Magento +126 -0
  125. package/server/vendored-conf/gitignore/Matlab +16 -0
  126. package/server/vendored-conf/gitignore/Maven +9 -0
  127. package/server/vendored-conf/gitignore/Mercurial +6 -0
  128. package/server/vendored-conf/gitignore/Mercury +13 -0
  129. package/server/vendored-conf/gitignore/MetaProgrammingSystem +16 -0
  130. package/server/vendored-conf/gitignore/MicrosoftOffice +10 -0
  131. package/server/vendored-conf/gitignore/ModelSim +23 -0
  132. package/server/vendored-conf/gitignore/Momentics +8 -0
  133. package/server/vendored-conf/gitignore/MonoDevelop +8 -0
  134. package/server/vendored-conf/gitignore/Nanoc +10 -0
  135. package/server/vendored-conf/gitignore/NetBeans +8 -0
  136. package/server/vendored-conf/gitignore/Nim +1 -0
  137. package/server/vendored-conf/gitignore/Ninja +2 -0
  138. package/server/vendored-conf/gitignore/Node +28 -0
  139. package/server/vendored-conf/gitignore/NotepadPP +2 -0
  140. package/server/vendored-conf/gitignore/OCaml +20 -0
  141. package/server/vendored-conf/gitignore/Objective-C +43 -0
  142. package/server/vendored-conf/gitignore/Opa +13 -0
  143. package/server/vendored-conf/gitignore/OpenCart +11 -0
  144. package/server/vendored-conf/gitignore/OracleForms +8 -0
  145. package/server/vendored-conf/gitignore/Packer +5 -0
  146. package/server/vendored-conf/gitignore/Perl +20 -0
  147. package/server/vendored-conf/gitignore/Phalcon +2 -0
  148. package/server/vendored-conf/gitignore/PhpStorm +63 -0
  149. package/server/vendored-conf/gitignore/PlayFramework +17 -0
  150. package/server/vendored-conf/gitignore/Plone +18 -0
  151. package/server/vendored-conf/gitignore/Prestashop +25 -0
  152. package/server/vendored-conf/gitignore/Processing +7 -0
  153. package/server/vendored-conf/gitignore/Python +58 -0
  154. package/server/vendored-conf/gitignore/Qooxdoo +5 -0
  155. package/server/vendored-conf/gitignore/Qt +34 -0
  156. package/server/vendored-conf/gitignore/R +16 -0
  157. package/server/vendored-conf/gitignore/ROS +47 -0
  158. package/server/vendored-conf/gitignore/Rails +35 -0
  159. package/server/vendored-conf/gitignore/Redcar +1 -0
  160. package/server/vendored-conf/gitignore/Redis +3 -0
  161. package/server/vendored-conf/gitignore/RhodesRhomobile +9 -0
  162. package/server/vendored-conf/gitignore/Ruby +36 -0
  163. package/server/vendored-conf/gitignore/Rust +11 -0
  164. package/server/vendored-conf/gitignore/SBT +9 -0
  165. package/server/vendored-conf/gitignore/SCons +2 -0
  166. package/server/vendored-conf/gitignore/SVN +1 -0
  167. package/server/vendored-conf/gitignore/Sass +2 -0
  168. package/server/vendored-conf/gitignore/Scala +17 -0
  169. package/server/vendored-conf/gitignore/Scrivener +7 -0
  170. package/server/vendored-conf/gitignore/Sdcc +8 -0
  171. package/server/vendored-conf/gitignore/SeamGen +26 -0
  172. package/server/vendored-conf/gitignore/SketchUp +1 -0
  173. package/server/vendored-conf/gitignore/SlickEdit +11 -0
  174. package/server/vendored-conf/gitignore/Stella +12 -0
  175. package/server/vendored-conf/gitignore/SublimeText +14 -0
  176. package/server/vendored-conf/gitignore/SugarCRM +25 -0
  177. package/server/vendored-conf/gitignore/Swift +43 -0
  178. package/server/vendored-conf/gitignore/Symfony +37 -0
  179. package/server/vendored-conf/gitignore/SymphonyCMS +6 -0
  180. package/server/vendored-conf/gitignore/SynopsysVCS +36 -0
  181. package/server/vendored-conf/gitignore/Tags +14 -0
  182. package/server/vendored-conf/gitignore/TeX +136 -0
  183. package/server/vendored-conf/gitignore/TextMate +3 -0
  184. package/server/vendored-conf/gitignore/Textpattern +11 -0
  185. package/server/vendored-conf/gitignore/TortoiseGit +2 -0
  186. package/server/vendored-conf/gitignore/TurboGears2 +20 -0
  187. package/server/vendored-conf/gitignore/Typo3 +20 -0
  188. package/server/vendored-conf/gitignore/Umbraco +17 -0
  189. package/server/vendored-conf/gitignore/Unity +21 -0
  190. package/server/vendored-conf/gitignore/UnrealEngine +76 -0
  191. package/server/vendored-conf/gitignore/VVVV +6 -0
  192. package/server/vendored-conf/gitignore/Vagrant +1 -0
  193. package/server/vendored-conf/gitignore/Vim +6 -0
  194. package/server/vendored-conf/gitignore/VirtualEnv +10 -0
  195. package/server/vendored-conf/gitignore/VisualStudio +217 -0
  196. package/server/vendored-conf/gitignore/VisualStudioCode +2 -0
  197. package/server/vendored-conf/gitignore/Waf +3 -0
  198. package/server/vendored-conf/gitignore/WebMethods +14 -0
  199. package/server/vendored-conf/gitignore/WebStorm +63 -0
  200. package/server/vendored-conf/gitignore/Windows +18 -0
  201. package/server/vendored-conf/gitignore/WordPress +18 -0
  202. package/server/vendored-conf/gitignore/Xcode +23 -0
  203. package/server/vendored-conf/gitignore/XilinxISE +57 -0
  204. package/server/vendored-conf/gitignore/Xojo +11 -0
  205. package/server/vendored-conf/gitignore/Yeoman +6 -0
  206. package/server/vendored-conf/gitignore/Yii +6 -0
  207. package/server/vendored-conf/gitignore/ZendFramework +20 -0
  208. package/server/vendored-conf/gitignore/Zephir +26 -0
  209. package/server/vendored-conf/gitignore/macOS +25 -0
  210. package/server/vendored-conf/label/Default +7 -0
  211. package/server/vendored-conf/license/Abstyles License +10 -0
  212. package/server/vendored-conf/license/Academic Free License v1.1 +22 -0
  213. package/server/vendored-conf/license/Academic Free License v1.2 +27 -0
  214. package/server/vendored-conf/license/Academic Free License v2.0 +43 -0
  215. package/server/vendored-conf/license/Academic Free License v2.1 +44 -0
  216. package/server/vendored-conf/license/Academic Free License v3.0 +42 -0
  217. package/server/vendored-conf/license/Affero General Public License v1.0 +84 -0
  218. package/server/vendored-conf/license/Apache License 1.0 +19 -0
  219. package/server/vendored-conf/license/Apache License 1.1 +20 -0
  220. package/server/vendored-conf/license/Apache License 2.0 +72 -0
  221. package/server/vendored-conf/license/Artistic License 1.0 +48 -0
  222. package/server/vendored-conf/license/Artistic License 2.0 +84 -0
  223. package/server/vendored-conf/license/BSD 2-clause License +8 -0
  224. package/server/vendored-conf/license/BSD 3-clause License +10 -0
  225. package/server/vendored-conf/license/BSD 4-clause License +13 -0
  226. package/server/vendored-conf/license/Creative Commons CC0 1.0 Universal +39 -0
  227. package/server/vendored-conf/license/Eclipse Public License 1.0 +72 -0
  228. package/server/vendored-conf/license/Educational Community License v1.0 +22 -0
  229. package/server/vendored-conf/license/Educational Community License v2.0 +97 -0
  230. package/server/vendored-conf/license/GNU Affero General Public License v3.0 +241 -0
  231. package/server/vendored-conf/license/GNU Free Documentation License v1.1 +118 -0
  232. package/server/vendored-conf/license/GNU Free Documentation License v1.2 +129 -0
  233. package/server/vendored-conf/license/GNU Free Documentation License v1.3 +148 -0
  234. package/server/vendored-conf/license/GNU General Public License v1.0 +99 -0
  235. package/server/vendored-conf/license/GNU General Public License v2.0 +116 -0
  236. package/server/vendored-conf/license/GNU General Public License v3.0 +231 -0
  237. package/server/vendored-conf/license/GNU Lesser General Public License v2.1 +174 -0
  238. package/server/vendored-conf/license/GNU Lesser General Public License v3.0 +70 -0
  239. package/server/vendored-conf/license/GNU Library General Public License v2.0 +173 -0
  240. package/server/vendored-conf/license/ISC license +6 -0
  241. package/server/vendored-conf/license/MIT License +8 -0
  242. package/server/vendored-conf/license/Mozilla Public License 1.0 +122 -0
  243. package/server/vendored-conf/license/Mozilla Public License 1.1 +142 -0
  244. package/server/vendored-conf/license/Mozilla Public License 2.0 +143 -0
  245. package/server/vendored-conf/readme/Default +3 -0
  246. package/src/dsh-host.js +168 -0
  247. package/src/index.js +682 -0
  248. package/src/runner.js +364 -0
@@ -0,0 +1,2356 @@
1
+ /* Generated from client/index.js by scripts/build-client.mjs — do not edit by hand.
2
+ * Regenerate with: npm run build:client
3
+ */
4
+ window.__ModuleLoader__.load({
5
+ id: "@weibaohui/dsh-git-server",
6
+ factory: (require) => {
7
+ var module = { exports: {} }
8
+ var exports = module.exports
9
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" })
10
+ var React = require("react")
11
+ /**
12
+ * @weibaohui/dsh-git-server — Browser half(原生 dsh 插件 UI,无 iframe/桥接)。
13
+ *
14
+ * 两个面:
15
+ * 1. sidebar.footer.action「Git」→ 全屏管理页(仓库列表/建仓/删除 +
16
+ * 仓库浏览:文件树/文件内容/提交/分支/工单[列表/新建/评论/开关])。
17
+ * 2. settings.section「Git 服务器」→ 服务配置(启停/端口/数据目录/兜底密码)。
18
+ *
19
+ * 数据通道:宿主同源路由 /dsh-git-server/api/*(dsh 登录门禁保护,
20
+ * 宿主按 dsh 会话解析操作者并以其身份代理 kernel API)。
21
+ * 主题:直接用 --dsw-alias-* token(dsh 原生,亮暗自动跟随)。
22
+ */
23
+
24
+ let __React = null
25
+ try { __React = require('react') } catch {}
26
+ if (!__React || typeof __React.createElement !== 'function') {
27
+ __React = {
28
+ createElement(type, props, ...kids) {
29
+ return { type, props: props || {}, kids: kids.flat(9).filter(k => k !== null && k !== undefined && k !== false && k !== true || true) }
30
+ },
31
+ useState(init) { const v = [typeof init === 'function' ? init() : init]; return [v[0], x => { v[0] = typeof x === 'function' ? x(v[0]) : x }] },
32
+ useEffect() {}, useMemo(fn) { return fn() }, useCallback(fn) { return fn }, useRef(v = null) { return { current: v } },
33
+ }
34
+ }
35
+ const { createElement: h, useState, useEffect, useCallback } = __React
36
+
37
+ // ── locale ────────────────────────────────────────────────────────────────
38
+
39
+ const NS = 'dshGitServer'
40
+ const ZH = {
41
+ nav: 'Git', title: '代码仓库',
42
+ myRepos: '我的仓库', newRepo: '新建仓库', repoName: '仓库名', private: '私有',
43
+ create: '创建', delete: '删除', deleteConfirm: '确定删除仓库', open: '打开',
44
+ clone: '克隆地址', copy: '复制', copied: '已复制',
45
+ files: '文件', commits: '提交', branches: '分支', issues: '工单',
46
+ emptyRepo: '还没有仓库,先建一个', back: '‹ 返回',
47
+ issueNew: '新工单', issueTitle: '标题', issueBody: '描述', submit: '提交',
48
+ comment: '评论', commentPlaceholder: '说点什么…', close: '关闭工单', reopen: '重新打开',
49
+ noIssues: '暂无工单', openState: '开启', closedState: '已关闭',
50
+ loading: '加载中…', loadFailed: '加载失败', retry: '重试',
51
+ emptyDir: '空目录', emptyFile: '(二进制或空文件)',
52
+ tags: '标签', pulls: 'PR', wiki: 'Wiki', releases: '发版',
53
+ readme: 'README', noPulls: '暂无 PR', merged: '已合并', merge: '合并',
54
+ mergeConfirm: '确认合并此 PR?', headToBase: '→', newWiki: '新页面',
55
+ edit: '编辑', save: '保存', wikiTitle: '页面名',
56
+ newRelease: '发新版本', tag: 'Tag', relTitle: '标题', relNote: '说明', target: '目标分支/提交',
57
+ noReleases: '暂无发版', noWiki: '暂无页面——创建第一个', deletePage: '删除页面',
58
+ prMerged: '已合并', prOpen: '开启中', prClosed: '已关闭',
59
+ labels: '标签管理', milestones: '里程碑', settings: '设置',
60
+ }
61
+ const EN = {
62
+ nav: 'Git', title: 'Repositories',
63
+ myRepos: 'My repositories', newRepo: 'New repository', repoName: 'Repository name', private: 'Private',
64
+ create: 'Create', delete: 'Delete', deleteConfirm: 'Delete repository', open: 'Open',
65
+ clone: 'Clone URL', copy: 'Copy', copied: 'Copied',
66
+ files: 'Files', commits: 'Commits', branches: 'Branches', issues: 'Issues',
67
+ emptyRepo: 'No repositories yet — create one', back: '‹ Back',
68
+ issueNew: 'New issue', issueTitle: 'Title', issueBody: 'Description', submit: 'Submit',
69
+ comment: 'Comment', commentPlaceholder: 'Say something…', close: 'Close issue', reopen: 'Reopen',
70
+ noIssues: 'No issues', openState: 'Open', closedState: 'Closed',
71
+ loading: 'Loading…', loadFailed: 'Failed to load', retry: 'Retry',
72
+ emptyDir: 'Empty directory', emptyFile: '(binary or empty)',
73
+ tags: 'Tags', pulls: 'PRs', wiki: 'Wiki', releases: 'Releases',
74
+ readme: 'README', noPulls: 'No pull requests', merged: 'Merged', merge: 'Merge',
75
+ mergeConfirm: 'Merge this PR?', headToBase: '→', newWiki: 'New page',
76
+ edit: 'Edit', save: 'Save', wikiTitle: 'Page name',
77
+ newRelease: 'New release', tag: 'Tag', relTitle: 'Title', relNote: 'Notes', target: 'Target',
78
+ noReleases: 'No releases', noWiki: 'No pages yet — create one', deletePage: 'Delete page',
79
+ prMerged: 'Merged', prOpen: 'Open', prClosed: 'Closed',
80
+ labels: 'Labels', milestones: 'Milestones', settings: 'Settings',
81
+ }
82
+
83
+ // ── styles(dsw token 原生) ───────────────────────────────────────────────
84
+
85
+ function ensureStyles() {
86
+ if (typeof document === 'undefined' || document.getElementById('dgs-styles')) return
87
+ const holder = document.createElement('div')
88
+ holder.id = 'dgs-styles'
89
+ holder.style.display = 'none'
90
+ holder.innerHTML = `<style>
91
+ .dgs-page{position:fixed;inset:0;z-index:2147483000;display:flex;flex-direction:column;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);font-size:14px}
92
+ .dgs-head{display:flex;align-items:center;gap:12px;padding:12px 28px;border-bottom:1px solid var(--dsw-alias-border-l2);flex:none}
93
+ .dgs-h1{font-size:16px;font-weight:700;margin:0}
94
+ .dgs-close{margin-left:auto;cursor:pointer;border:none;background:transparent;color:var(--dsw-alias-label-secondary);font-size:18px;padding:4px 8px;border-radius:8px}
95
+ .dgs-close:hover{background:var(--dsw-alias-interactive-bg-hover)}
96
+ .dgs-body{box-sizing:border-box;flex:1;overflow-x:hidden;overflow-y:auto;padding:20px 28px 32px;max-width:1156px;width:100%;margin:0 auto}
97
+ .dgs-card{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:14px 16px;margin-bottom:10px}
98
+ .dgs-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
99
+ .dgs-repo{cursor:pointer;transition:border-color .12s}
100
+ .dgs-repo:hover{border-color:var(--dsw-alias-state-business-primary)}
101
+ .dgs-name{font-weight:600;font-size:15px;color:var(--dsw-alias-label-primary);text-decoration:none}
102
+ .dgs-name:hover{text-decoration:underline}
103
+ .dgs-badge{font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary)}
104
+ .dgs-badge.pri{color:#f59e0b;border-color:#f59e0b55}
105
+ .dgs-badge.ok{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)44}
106
+ .dgs-badge.closed{color:var(--dsw-alias-label-tertiary)}
107
+ .dgs-labeled{display:inline-flex;align-items:stretch;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;overflow:hidden}
108
+ .dgs-labeled .dgs-btn{border:none;border-radius:0}
109
+ .dgs-labeled-count{display:inline-flex;align-items:center;padding:0 10px;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary);border-left:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2)}
110
+ .dgs-sub{font-size:12px;color:var(--dsw-alias-label-tertiary)}
111
+ .dgs-btn{cursor:pointer;border:none;border-radius:8px;padding:6px 14px;font-size:13px;font-weight:500;background:var(--dsw-alias-state-business-primary);color:#fff}
112
+ .dgs-btn:hover{filter:brightness(1.1)}
113
+ .dgs-btn:disabled{opacity:.5;cursor:default}
114
+ .dgs-btn.ghost{background:transparent;color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l2)}
115
+ .dgs-btn.ghost:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);filter:none}
116
+ .dgs-btn.danger{background:transparent;color:var(--dsw-alias-state-error-primary);border:1px solid var(--dsw-alias-state-error-primary)55}
117
+ .dgs-input{flex:1;min-width:160px;padding:7px 11px;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;outline:none}
118
+ .dgs-input:focus{border-color:var(--dsw-alias-state-business-primary)}
119
+ textarea.dgs-input{min-height:72px;resize:vertical}
120
+ /* 卡片直属的输入控件不在 flex 行内,flex:1 失效——显式撑满 */
121
+ textarea.dgs-input,.dgs-card>.dgs-input{width:100%;box-sizing:border-box;flex:none}
122
+ .dgs-subtabs{display:flex;gap:6px;margin-bottom:14px}
123
+ .dgs-subtab{cursor:pointer;border:none;border-radius:999px;padding:4px 14px;font-size:12.5px;font-weight:500;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}
124
+ .dgs-subtab.active{background:var(--dsw-alias-state-business-primary);color:#fff}
125
+ .dgs-stats{display:flex;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;overflow:hidden;margin-bottom:8px}
126
+ .dgs-stats-item{flex:1;display:flex;align-items:center;justify-content:center;gap:6px;padding:10px 0;cursor:pointer;color:var(--dsw-alias-label-secondary);border-right:1px solid var(--dsw-alias-border-l2)}
127
+ .dgs-stats-item:last-child{border-right:none}
128
+ .dgs-stats-item:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
129
+ .dgs-stats-num{font-weight:700;color:var(--dsw-alias-label-primary)}
130
+ .dgs-pill{cursor:pointer;border:1px solid var(--dsw-alias-state-success-primary)55;background:transparent;color:var(--dsw-alias-state-success-primary);border-radius:6px;padding:4px 12px;font-size:12px;font-weight:500}
131
+ .dgs-pill.active{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent)}
132
+ .dgs-pill.closed{border-color:var(--dsw-alias-state-error-primary)55;color:var(--dsw-alias-state-error-primary)}
133
+ .dgs-pill.closed.active{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}
134
+ .dgs-issue-num{flex:none;width:34px;height:34px;border-radius:6px;background:var(--dsw-alias-state-success-primary);color:#fff;display:inline-flex;align-items:center;justify-content:center;font-size:11px;font-weight:700}
135
+ .dgs-issue-num.closed{background:var(--dsw-alias-label-tertiary)}
136
+ .dgs-issue-title{font-weight:500;color:var(--dsw-alias-state-business-primary)}
137
+ .dgs-tabs{display:flex;flex-wrap:wrap;gap:4px;border-bottom:1px solid var(--dsw-alias-border-l2);margin-bottom:14px}
138
+ .dgs-tab{cursor:pointer;border:none;background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:500;padding:8px 14px;border-radius:8px 8px 0 0;border-bottom:2px solid transparent}
139
+ .dgs-tab.active{color:var(--dsw-alias-state-business-primary);border-bottom-color:var(--dsw-alias-state-business-primary)}
140
+ .dgs-table{width:100%;border-collapse:collapse}
141
+ .dgs-table td{padding:7px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:13px}
142
+ .dgs-table tr:hover td{background:var(--dsw-alias-interactive-bg-hover)}
143
+ .dgs-file{white-space:pre-wrap;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;line-height:1.7;background:var(--dsw-alias-markdown-code-block);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:14px;overflow:auto;max-height:60vh}
144
+ .dgs-diff-line{display:flex;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:20px;padding:0 8px}
145
+ .dgs-diff-line.hunk{color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-2);margin:2px 0}
146
+ .dgs-diff-line.add{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent)}
147
+ .dgs-diff-line.del{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)}
148
+ .dgs-diff-no{flex:none;width:44px;text-align:right;padding-right:10px;color:var(--dsw-alias-label-tertiary);user-select:none}
149
+ .dgs-diff-stat{font-size:12px;font-weight:600;color:var(--dsw-alias-state-success-primary)}
150
+ .dgs-diff-stat.del{color:var(--dsw-alias-state-error-primary);margin-left:8px}
151
+ .dgs-crumbs{display:flex;gap:6px;align-items:center;font-size:13px;margin-bottom:10px;flex-wrap:wrap}
152
+ .dgs-crumb{color:var(--dsw-alias-label-secondary);cursor:pointer}
153
+ .dgs-crumb:hover{color:var(--dsw-alias-state-business-primary)}
154
+ .dgs-crumb.cur{color:var(--dsw-alias-label-primary);cursor:default;font-weight:500}
155
+ .dgs-issue{border-bottom:1px solid var(--dsw-alias-border-l1);padding:10px 4px}
156
+ .dgs-issue-layout{display:flex;gap:18px;align-items:flex-start}
157
+ .dgs-issue-main{flex:1;min-width:0}
158
+ .dgs-issue-side{width:230px;flex:none}
159
+ .dgs-side-block{border-bottom:1px solid var(--dsw-alias-border-l2);padding:10px 0}
160
+ .dgs-side-title{font-weight:700;font-size:12.5px;margin-bottom:4px}
161
+ .dgs-settings-layout{display:flex;gap:20px;align-items:flex-start}
162
+ .dgs-settings-nav{width:180px;flex:none;border-right:1px solid var(--dsw-alias-border-l2);padding-right:12px}
163
+ .dgs-settings-nav .item{display:block;padding:7px 10px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--dsw-alias-label-secondary)}
164
+ .dgs-settings-nav .item.active{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);font-weight:600}
165
+ .dgs-settings-nav .item:hover{background:var(--dsw-alias-interactive-bg-hover)}
166
+ .dgs-comment{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:10px 12px;margin:8px 0}
167
+ .dgs-empty{color:var(--dsw-alias-label-tertiary);text-align:center;padding:40px 0;font-size:13px}
168
+ .dgs-err{color:var(--dsw-alias-state-error-primary);font-size:13px}
169
+ .dgs-ico{margin-right:8px;opacity:.75}
170
+ .dgs-toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);background:var(--dsw-alias-toast-bg);color:#fff;border-radius:8px;padding:8px 16px;font-size:12px;z-index:2147483600}
171
+ .dgs-settings{max-width:720px;display:flex;flex-direction:column;gap:12px;padding:12px 16px 24px;color:var(--dsw-alias-label-primary)}
172
+ .dgs-settings .dgs-card{margin-bottom:0}
173
+ .dgs-settings label{font-size:13px;color:var(--dsw-alias-label-secondary);min-width:120px}
174
+ .dgs-md{font-size:13.5px;line-height:1.75}
175
+ .dgs-md h1,.dgs-md h2,.dgs-md h3{margin:14px 0 8px;color:var(--dsw-alias-label-primary)}
176
+ .dgs-md p{margin:8px 0}
177
+ .dgs-md pre{background:var(--dsw-alias-markdown-code-block);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px;overflow:auto;font-size:12.5px}
178
+ .dgs-md code{background:var(--dsw-alias-markdown-inline-code);border-radius:4px;padding:1px 5px;font-size:12.5px}
179
+ .dgs-md pre code{background:transparent;padding:0}
180
+ .dgs-md a{color:var(--dsw-alias-state-business-primary)}
181
+ .dgs-md img{max-width:100%}
182
+ .dgs-md blockquote{border-left:3px solid var(--dsw-alias-border-l2);margin:8px 0;padding:2px 12px;color:var(--dsw-alias-label-secondary)}
183
+ .dgs-md table{border-collapse:collapse}
184
+ .dgs-md td,.dgs-md th{border:1px solid var(--dsw-alias-border-l2);padding:4px 10px}
185
+ </style>`
186
+ document.head.appendChild(holder)
187
+ }
188
+
189
+ // ── data helpers ──────────────────────────────────────────────────────────
190
+
191
+ const API = '/dsh-git-server/api'
192
+ async function api(method, path, body) {
193
+ const res = await fetch(API + path, {
194
+ method,
195
+ headers: body !== undefined ? { 'Content-Type': 'application/json' } : {},
196
+ body: body !== undefined ? JSON.stringify(body) : undefined,
197
+ })
198
+ try { return await res.json() } catch { return { ok: false, error: 'http ' + res.status } }
199
+ }
200
+
201
+ // ── hash 路由 ──────────────────────────────────────────────────────────────
202
+
203
+ function useHashRoute() {
204
+ const [route, setRoute] = useState(() => location.hash.slice(1) || '/')
205
+ useEffect(() => {
206
+ const on = () => setRoute(location.hash.slice(1) || '/')
207
+ window.addEventListener('hashchange', on)
208
+ return () => window.removeEventListener('hashchange', on)
209
+ }, [])
210
+ return route
211
+ }
212
+ const nav = (hash) => { location.hash = hash }
213
+
214
+ // ── 全屏管理页 ────────────────────────────────────────────────────────────
215
+
216
+ function RepoBrowser({ repo, t, onBack }) {
217
+ const [tab, setTab] = useState('files')
218
+ const [prPreset, setPrPreset] = useState(null)
219
+ const [issuesSub, setIssuesSub] = useState(null)
220
+ const [ov, setOv] = useState(null)
221
+ const [rev, setRev] = useState('')
222
+ const [cloneUrl, setCloneUrl] = useState('')
223
+ const [copied, setCopied] = useState(false)
224
+ const [star, setStar] = useState(null)
225
+ const [watch, setWatch] = useState(null)
226
+ const [forkBusy, setForkBusy] = useState(false)
227
+ const [msg, setMsg] = useState('')
228
+ useEffect(() => {
229
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/overview`).then((d) => {
230
+ if (!d.defaultBranch) return
231
+ setOv(d)
232
+ setRev(d.defaultBranch)
233
+ })
234
+ fetch(API + '/status').then((r) => r.json()).then((st) => {
235
+ if (st && st.running) setCloneUrl('git clone http://' + location.hostname + ':' + (st.port || 3400) + '/' + repo.owner + '/' + repo.name + '.git')
236
+ }).catch(() => {})
237
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/star`).then((d) => d.count !== undefined && setStar(d))
238
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/watch`).then((d) => d.count !== undefined && setWatch(d))
239
+ }, [repo.owner, repo.name])
240
+ const toggle = async (kind) => {
241
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/${kind}`)
242
+ if (d.on !== undefined) (kind === 'star' ? setStar : setWatch)({ on: d.on, count: d.count })
243
+ }
244
+ const fork = async () => {
245
+ setForkBusy(true); setMsg('')
246
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/fork`)
247
+ setForkBusy(false)
248
+ if (d.ok) nav('/r/' + d.owner + '/' + d.name)
249
+ else setMsg(d.error || 'fork failed')
250
+ }
251
+ const branchSel = ov && ov.branches && ov.branches.length
252
+ ? h('select', {
253
+ className: 'dgs-input', style: { flex: 'none', width: 'auto', padding: '4px 8px' },
254
+ value: rev, onChange: (e) => setRev(e.target.value),
255
+ }, ov.branches.map((b) => h('option', { key: b, value: b }, b)))
256
+ : null
257
+ return h('div', null,
258
+ h('div', { className: 'dgs-row', style: { marginBottom: 8 } },
259
+ h('button', { className: 'dgs-btn ghost', onClick: onBack }, t('back')),
260
+ h('span', { className: 'dgs-h1' }, repo.owner + '/' + repo.name),
261
+ repo.private || (ov && ov.private) ? h('span', { className: 'dgs-badge pri' }, t('private')) : null,
262
+ h('span', { style: { flex: 1 } }),
263
+ star ? h('span', { className: 'dgs-labeled' },
264
+ h('button', { className: 'dgs-btn ghost', onClick: () => toggle('star') }, star.on ? '★ Unstar' : '☆ Star'),
265
+ h('span', { className: 'dgs-labeled-count' }, star.count)) : null,
266
+ watch ? h('span', { className: 'dgs-labeled' },
267
+ h('button', { className: 'dgs-btn ghost', onClick: () => toggle('watch') }, watch.on ? '👁 Unwatch' : '👁 Watch'),
268
+ h('span', { className: 'dgs-labeled-count' }, watch.count)) : null,
269
+ h('button', { className: 'dgs-btn ghost', disabled: forkBusy, onClick: fork, style: { display: 'inline-flex', alignItems: 'center', gap: 6 } },
270
+ h('span', { style: { display: 'inline-flex', lineHeight: 0 }, dangerouslySetInnerHTML: { __html: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="4" cy="3" r="1.7" fill="currentColor" stroke="none"/><circle cx="12" cy="3" r="1.7" fill="currentColor" stroke="none"/><circle cx="8" cy="13" r="1.7" fill="currentColor" stroke="none"/><path d="M4 4.7v2.8c0 .6.4 1 1 1h2.2M12 4.7v2.8c0 .6-.4 1-1 1H8.8M8 8.5v2.8"/></svg>' } }),
271
+ 'Fork'),
272
+ ),
273
+ msg ? h('div', { className: 'dgs-err' }, msg) : null,
274
+ h('div', { className: 'dgs-tabs' },
275
+ ['files', 'issues', 'pulls', 'wiki', 'releases', 'settings'].map((k) =>
276
+ h('button', { key: k, className: 'dgs-tab' + (tab === k ? ' active' : ''), onClick: () => setTab(k) }, t(k)))),
277
+ (tab === 'commits' || tab === 'branches' || tab === 'tags') ? h('div', { className: 'dgs-tabs' },
278
+ h('button', { className: 'dgs-tab', onClick: () => setTab('files') }, t('files')),
279
+ h('button', { className: 'dgs-tab active' }, tab === 'commits' ? t('commits') : tab === 'branches' ? t('branches') : '标签'))
280
+ : null,
281
+ ov && tab === 'files' ? h('div', { style: { margin: '0 0 10px' } },
282
+ ov.description ? h('div', { className: 'dgs-sub', style: { marginBottom: 8 } }, ov.description) : h('div', { className: 'dgs-sub', style: { marginBottom: 8, fontStyle: 'italic' } }, '暂无描述'),
283
+ h('div', { className: 'dgs-stats' },
284
+ h('a', { className: 'dgs-stats-item', onClick: () => setTab('commits') },
285
+ h('span', { className: 'dgs-stats-num' }, '⎇ ' + (ov.numCommits || 0)), ' 提交历史'),
286
+ h('a', { className: 'dgs-stats-item', onClick: () => setTab('branches') },
287
+ h('span', { className: 'dgs-stats-num' }, '⑂ ' + (ov.numBranches || 0)), ' 代码分支'),
288
+ h('a', { className: 'dgs-stats-item', onClick: () => setTab('releases') },
289
+ h('span', { className: 'dgs-stats-num' }, '◎ ' + (ov.numReleases || 0)), ' 版本发布')),
290
+ h('div', { className: 'dgs-row', style: { margin: '8px 0 0' } },
291
+ branchSel,
292
+ h('span', { style: { flex: 1 } }),
293
+ cloneUrl ? h('span', { className: 'dgs-labeled' },
294
+ h('span', { className: 'dgs-labeled-count', style: { borderLeft: 'none', borderRight: '1px solid var(--dsw-alias-border-l2)' } }, 'HTTP'),
295
+ h('button', { className: 'dgs-btn ghost', style: { borderRadius: 0, padding: '4px 8px', fontFamily: 'ui-monospace,monospace', fontSize: 12, maxWidth: 340, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, onClick: () => {
296
+ try { navigator.clipboard.writeText(cloneUrl) } catch {}
297
+ setCopied(true); setTimeout(() => setCopied(false), 1600)
298
+ }, title: cloneUrl }, copied ? t('copied') : cloneUrl.replace('git clone ', '')),
299
+ h('a', { className: 'dgs-btn ghost', style: { borderRadius: '0 8px 8px 0', borderLeft: 'none' }, href: `/dsh-git-server/api/dsh/repos/${repo.owner}/${repo.name}/archive/${encodeURIComponent(rev || 'master')}.zip`, download: `${repo.name}-${rev}.zip`, title: '下载' }, '⭳'))
300
+ : null))
301
+ : null,
302
+ tab === 'files' && h(FileTree, { repo, rev: rev || 'master', t, overview: ov, onOverview: setOv }),
303
+ tab === 'commits' && h(Commits, { repo, rev: rev || 'master', t }),
304
+ tab === 'branches' && h(Branches, { repo, t, onNewPR: (head, base) => { setPrPreset({ head, base }); setTab('pulls') } }),
305
+ tab === 'issues' && h(IssuesArea, { repo, t, initialSub: issuesSub || undefined }),
306
+ tab === 'pulls' && h(Pulls, { repo, t, preset: prPreset, onPresetDone: () => setPrPreset(null), onGoIssuesSub: (k) => { setIssuesSub(k); setTab('issues') } }),
307
+ tab === 'wiki' && h(WikiView, { repo, t }),
308
+ tab === 'releases' && h(Releases, { repo, t }),
309
+ tab === 'settings' && h(RepoSettings, { repo, t }),
310
+ )
311
+ }
312
+
313
+ function FileTree({ repo, rev, t, overview, onOverview }) {
314
+ const [path, setPath] = useState('')
315
+ const [entries, setEntries] = useState(null)
316
+ const [file, setFile] = useState(null)
317
+ const [err, setErr] = useState('')
318
+ const [readme, setReadme] = useState(null)
319
+ const [blameOn, setBlameOn] = useState(false)
320
+ const [newFile, setNewFile] = useState(null) // {mode:'create'|'upload'}
321
+ const [nf, setNf] = useState({ path: '', content: '', message: '' })
322
+ const [busy, setBusy] = useState(false)
323
+ const [msg, setMsg] = useState('')
324
+ const [editing, setEditing] = useState(false)
325
+ const [editText, setEditText] = useState('')
326
+ const [editMsg, setEditMsg] = useState('')
327
+ const [histMode, setHistMode] = useState(null)
328
+ const load = useCallback((p) => {
329
+ setFile(null); setEntries(null); setErr(''); setBlameOn(false); setEditing(false); setHistMode(null)
330
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/tree?ref=${encodeURIComponent(rev)}&path=${encodeURIComponent(p)}`)
331
+ .then((d) => {
332
+ const entries = (d.data && d.data.entries) || d.entries
333
+ if (entries) setEntries(entries)
334
+ else setErr((d && d.error) || t('loadFailed'))
335
+ })
336
+ .catch(() => setErr(t('loadFailed')))
337
+ if (p === '' && overview === undefined) {
338
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/overview`).then((d) => d.defaultBranch && onOverview(d))
339
+ }
340
+ if (p !== '') setReadme(null)
341
+ }, [repo.owner, repo.name, rev])
342
+ useEffect(() => { load(path) }, [load, path])
343
+ const openEntry = (e) => {
344
+ const p = e.path
345
+ if (e.type === 'dir' || e.type === 'tree') { setPath(p); return }
346
+ api('GET', `/repos/${repo.owner}/${repo.name}/raw?ref=${encodeURIComponent(rev)}&path=${encodeURIComponent(p)}`)
347
+ .then(async (d) => {
348
+ const isMd = /\.md$/i.test(e.name)
349
+ let html = ''
350
+ if (isMd && d.ok) {
351
+ const r = await api('POST', '/dsh/markdown', { text: d.text })
352
+ html = r.html || ''
353
+ }
354
+ setFile({ name: e.name, text: d.ok ? d.text : null, size: e.size, isMd, html })
355
+ })
356
+ }
357
+ const startCreate = (mode) => {
358
+ setNewFile(mode); setMsg('')
359
+ setNf({ path: '', content: '', message: '' })
360
+ }
361
+ const submitFile = async () => {
362
+ setBusy(true); setMsg('')
363
+ const full = (path ? path + '/' : '') + nf.path.trim()
364
+ const body = { path: full, branch: rev, message: nf.message }
365
+ if (newFile === 'upload') {
366
+ if (!nf.content) { setBusy(false); setMsg('请选择文件'); return }
367
+ body.contentBase64 = btoa(unescape(encodeURIComponent(nf.content)))
368
+ body.overwrite = true
369
+ } else {
370
+ if (!nf.path.trim()) { setBusy(false); setMsg('文件名必填'); return }
371
+ body.content = nf.content
372
+ }
373
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/files`, body)
374
+ setBusy(false)
375
+ if (d && d.ok) { setNewFile(null); load(path) }
376
+ else setMsg((d && d.error) || 'failed')
377
+ }
378
+ return h('div', null,
379
+ h('div', { className: 'dgs-row', style: { marginBottom: 8 } },
380
+ h('div', { className: 'dgs-crumbs', style: { margin: 0 } },
381
+ h('span', { className: 'dgs-crumb' + (path ? '' : ' cur'), style: { cursor: path ? 'pointer' : 'default' }, onClick: () => setPath('') }, rev),
382
+ path.split('/').filter(Boolean).map((seg, i, arr) =>
383
+ h('span', { key: i },
384
+ h('span', { className: 'dgs-crumb' + (i === arr.length - 1 ? ' cur' : ''), onClick: () => setPath(arr.slice(0, i + 1).join('/')) }, seg),
385
+ i < arr.length - 1 ? h('span', { className: 'dgs-sub' }, '/') : null))),
386
+ h('span', { style: { flex: 1 } }),
387
+ h('button', { className: 'dgs-btn', onClick: () => startCreate('create') }, '新的文件'),
388
+ h('button', { className: 'dgs-btn ghost', onClick: () => startCreate('upload') }, '上传文件')),
389
+ msg ? h('div', { className: 'dgs-err' }, msg) : null,
390
+ newFile ? h('div', { className: 'dgs-card', style: { margin: '8px 0' } },
391
+ h('div', { className: 'dgs-row', style: { marginBottom: 8 } },
392
+ h('span', { style: { fontWeight: 700 } }, newFile === 'create' ? '新建文件' : '上传文件'),
393
+ h('span', { style: { flex: 1 } }),
394
+ h('button', { className: 'dgs-btn ghost', onClick: () => setNewFile(null) }, t('back'))),
395
+ h('div', { className: 'dgs-row' },
396
+ h('span', { className: 'dgs-sub' }, (path || '') + '/'),
397
+ h('input', { className: 'dgs-input', placeholder: newFile === 'create' ? '文件名(如 docs/guide.md)' : '目标路径', value: nf.path, onChange: (e) => setNf({ ...nf, path: e.target.value }) })),
398
+ newFile === 'create' ? h('textarea', { className: 'dgs-input', style: { marginTop: 8, minHeight: 160 }, placeholder: '文件内容', value: nf.content, onChange: (e) => setNf({ ...nf, content: e.target.value }) })
399
+ : h('input', { type: 'file', style: { marginTop: 8 }, onChange: async (e) => {
400
+ const f = e.target.files && e.target.files[0]
401
+ if (!f) return
402
+ if (!nf.path) setNf((x) => ({ ...x, path: f.name }))
403
+ setNf((x) => ({ ...x, content: 'BIN:' + f.name }))
404
+ const buf = await f.arrayBuffer()
405
+ let bin = ''
406
+ new Uint8Array(buf).forEach((b) => { bin += String.fromCharCode(b) })
407
+ setNf((x) => ({ ...x, content: btoa(bin) }))
408
+ } }),
409
+ h('div', { className: 'dgs-row', style: { marginTop: 8 } },
410
+ h('input', { className: 'dgs-input', placeholder: '提交信息(可选)', value: nf.message, onChange: (e) => setNf({ ...nf, message: e.target.value }) })),
411
+ h('div', { className: 'dgs-row', style: { marginTop: 8, justifyContent: 'flex-end' } },
412
+ h('button', { className: 'dgs-btn', disabled: busy || !nf.path.trim(), onClick: submitFile }, '提交修改')))
413
+ : null,
414
+ err ? h('div', { className: 'dgs-err' }, err) : null,
415
+ file ? h('div', null,
416
+ h('div', { className: 'dgs-row', style: { margin: '8px 0 10px' } },
417
+ h('button', { className: 'dgs-btn ghost', onClick: () => setFile(null) }, '⑂ ' + rev),
418
+ h('span', { className: 'dgs-sub' }, repo.name + ' /'),
419
+ h('span', { style: { fontWeight: 600 } }, file.name)),
420
+ h('div', { className: 'dgs-card', style: { padding: 0, overflow: 'hidden' } },
421
+ h('div', { className: 'dgs-row', style: { padding: '8px 12px', borderBottom: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-2)' } },
422
+ h('span', { style: { fontWeight: 600, fontSize: 12.5 } }, '📄 ' + file.name + ' ' + (file.size ? fmtSize(file.size) : '')),
423
+ h('span', { style: { flex: 1 } }),
424
+ file.text !== null ? h('a', { className: 'dgs-sub', style: { cursor: 'pointer', marginRight: 12 }, onClick: () => {
425
+ try { navigator.clipboard.writeText(location.origin + '/r/' + repo.owner + '/' + repo.name + '/src/' + encodeURIComponent(rev) + '/' + encodeURIComponent(path)) } catch {}
426
+ setMsg('✓ 已复制永久链接'); setTimeout(() => setMsg(''), 1500)
427
+ } }, '永久链接') : null,
428
+ file.text !== null ? h('a', { className: 'dgs-sub', style: { cursor: 'pointer', marginRight: 12 }, onClick: () => setHistMode(histMode === 'history' ? null : 'history') }, '文件历史') : null,
429
+ file.text !== null ? h('a', { className: 'dgs-sub', style: { marginRight: 12 }, href: `/dsh-git-server/api/repos/${repo.owner}/${repo.name}/raw?ref=${encodeURIComponent(rev)}&path=${encodeURIComponent(path)}`, target: '_blank', rel: 'noreferrer' }, '原始文件') : null,
430
+ file.text !== null ? h('button', { className: 'dgs-btn ghost', style: { padding: '2px 8px' }, onClick: () => { setEditing(true); setEditText(file.text || '') } }, '✏️') : null,
431
+ file.text !== null ? h('button', { className: 'dgs-btn danger', style: { padding: '2px 8px' }, onClick: async () => {
432
+ if (!confirm('删除文件 ' + file.name + ' ?')) return
433
+ const d = await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/files`, { path, branch: rev })
434
+ if (d && d.ok) { setFile(null); load(path) } else setMsg((d && d.error) || '删除失败')
435
+ } }, '🗑️') : null,
436
+ file.text !== null ? h('button', { className: 'dgs-btn ghost', style: { padding: '2px 8px' }, onClick: () => setBlameOn(!blameOn) }, 'Blame') : null),
437
+ editing ? h('div', { style: { padding: 12 } },
438
+ h('textarea', { className: 'dgs-input', style: { minHeight: '40vh', fontFamily: 'ui-monospace,monospace' }, value: editText, onChange: (e) => setEditText(e.target.value) }),
439
+ h('div', { className: 'dgs-row', style: { marginTop: 8 } },
440
+ h('input', { className: 'dgs-input', placeholder: '提交信息(可选)', value: editMsg, onChange: (e) => setEditMsg(e.target.value) }),
441
+ h('span', { style: { flex: 1 } }),
442
+ h('button', { className: 'dgs-btn ghost', onClick: () => setEditing(false) }, '取消'),
443
+ h('button', { className: 'dgs-btn', disabled: busy, onClick: async () => {
444
+ setBusy(true); setMsg('')
445
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/files`, { path, branch: rev, content: editText, message: editMsg, overwrite: true })
446
+ setBusy(false)
447
+ if (d && d.ok) { setEditing(false); load(path) } else setMsg((d && d.error) || '保存失败')
448
+ } }, '提交修改')))
449
+ : histMode === 'history' ? h(FileHistory, { repo, rev, path })
450
+ : blameOn && file.text !== null
451
+ ? h(BlameView, { repo, rev, path, text: file.text })
452
+ : file.isMd
453
+ ? h('div', { className: 'dgs-md', style: { padding: '14px 16px' }, dangerouslySetInnerHTML: { __html: file.html || '' } })
454
+ : h('pre', { className: 'dgs-file', style: { border: 'none', borderRadius: 0 } }, file.text === null ? t('emptyFile') : file.text)))
455
+ : entries === null ? h('div', { className: 'dgs-empty' }, t('loading'))
456
+ : renderDir(),
457
+ )
458
+
459
+ function renderDir() {
460
+ const head = entries.find((e) => e.last)
461
+ const rows = entries.map((e) =>
462
+ h('tr', { key: e.path, style: { cursor: 'pointer' }, onClick: () => openEntry(e) },
463
+ h('td', { style: { width: 24 } }, h('span', { className: 'dgs-ico' }, e.type === 'dir' || e.type === 'tree' ? '📁' : '📄')),
464
+ h('td', null, h('div', null, e.name),
465
+ e.last ? h('div', { className: 'dgs-sub' }, (e.last.msg || '')) : null,
466
+ e.last ? h('div', { className: 'dgs-sub', style: { marginTop: 1 } }, timeAgo(e.last.date)) : null),
467
+ h('td', { className: 'dgs-sub', style: { textAlign: 'right', width: 70 } }, e.type === 'blob' ? fmtSize(e.size) : '')))
468
+ const headRow = head ? h('div', { className: 'dgs-row', style: { padding: '8px 0 10px', borderBottom: '1px solid var(--dsw-alias-border-l2)', marginBottom: 4 } },
469
+ h('span', { style: { width: 26, height: 26, borderRadius: 6, background: 'var(--dsw-alias-bg-layer-2)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, color: 'var(--dsw-alias-label-secondary)' } }, (head.last.author || '?').slice(0, 1).toUpperCase()),
470
+ h('span', { className: 'dgs-badge' }, head.last.sha),
471
+ h('span', { style: { fontWeight: 500 } }, head.last.msg),
472
+ h('span', { style: { flex: 1 } }),
473
+ h('span', { className: 'dgs-sub' }, timeAgo(head.last.date))) : null
474
+ const table = entries.length === 0
475
+ ? h('div', { className: 'dgs-empty' }, t('emptyDir'))
476
+ : h('div', null, headRow, h('table', { className: 'dgs-table' }, h('tbody', null, rows)))
477
+ const readmeEl = (path === '' && overview && overview.readmeHtml)
478
+ ? h('div', { className: 'dgs-card', style: { marginTop: 12 } },
479
+ h('div', { style: { fontWeight: 700, marginBottom: 8 } }, t('readme')),
480
+ h('div', { className: 'dgs-md', dangerouslySetInnerHTML: { __html: overview.readmeHtml } }))
481
+ : null
482
+ return h('div', null, table, readmeEl)
483
+ }
484
+ }
485
+
486
+ function IssuesArea({ repo, t, initialSub }) {
487
+ const [sub, setSub] = useState(initialSub || 'list')
488
+ const [labelJump, setLabelJump] = useState(null)
489
+ const [msJump, setMsJump] = useState(null)
490
+ const subLink = (k, label) => h('a', { key: k, style: { cursor: 'pointer', marginRight: 18, fontSize: 13,
491
+ color: sub === k ? 'var(--dsw-alias-state-business-primary)' : 'var(--dsw-alias-label-secondary)',
492
+ fontWeight: sub === k ? 600 : 400 },
493
+ onClick: () => setSub(k) }, label)
494
+ const [createSignal, setCreateSignal] = useState(0)
495
+ return h('div', null,
496
+ h('div', { className: 'dgs-row', style: { margin: '0 0 12px' } },
497
+ subLink('labels', '标签管理'),
498
+ subLink('milestones', '里程碑'),
499
+ h('span', { style: { flex: 1 } }),
500
+ sub === 'list' ? h('button', { className: 'dgs-btn', onClick: () => setCreateSignal((n) => n + 1) }, t('issueNew')) : null),
501
+ sub === 'list' && h(Issues, { repo, t, presetLabel: labelJump, presetMilestone: msJump,
502
+ onPresetDone: () => { setLabelJump(null); setMsJump(null) }, createSignal }),
503
+ sub === 'labels' && h(LabelsManage, { repo, t, onFilterLabel: (lid) => { setLabelJump(lid); setSub('list') } }),
504
+ sub === 'milestones' && h(MilestonesManage, { repo, t, onOpenMilestone: (m) => { setMsJump(m.id); setSub('list') } }))
505
+ }
506
+
507
+ function Issues({ repo, t, presetLabel, presetMilestone, onPresetDone, createSignal }) {
508
+ const [state, setState] = useState('open')
509
+ const [flt, setFlt] = useState({ label: '', milestone: '', assignee: '' })
510
+ useEffect(() => {
511
+ if (presetLabel || presetMilestone) {
512
+ setFlt((f) => ({ ...f, label: presetLabel ? String(presetLabel) : f.label, milestone: presetMilestone ? String(presetMilestone) : f.milestone }))
513
+ onPresetDone && onPresetDone()
514
+ }
515
+ }, [presetLabel, presetMilestone])
516
+ const [sort, setSort] = useState('latest')
517
+ const [search, setSearch] = useState('')
518
+ const [list, setList] = useState(null)
519
+ const [meta, setMeta] = useState({ labels: [], milestones: [], collabs: [] })
520
+ const [creating, setCreating] = useState(false)
521
+ useEffect(() => { if (createSignal) setCreating(true) }, [createSignal])
522
+ const [openIdx, setOpenIdx] = useState(null)
523
+ const reload = useCallback(() => {
524
+ setList(null)
525
+ const q = new URLSearchParams({ state, sort })
526
+ if (flt.label) q.set('label', flt.label)
527
+ if (flt.milestone) q.set('milestone', flt.milestone)
528
+ if (flt.assignee) q.set('assignee', flt.assignee)
529
+ if (search.trim()) q.set('search', search.trim())
530
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/issues?` + q).then((d) => setList(Array.isArray(d) ? d : (d.issues || [])))
531
+ }, [repo.owner, repo.name, state, flt, sort, search])
532
+ useEffect(reload, [reload])
533
+ useEffect(() => {
534
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/labels`).then((d) => setMeta((m) => ({ ...m, labels: Array.isArray(d) ? d : [] })))
535
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/milestones`).then((d) => setMeta((m) => ({ ...m, milestones: Array.isArray(d) ? d : [] })))
536
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/collaborators`).then((d) => setMeta((m) => ({ ...m, collabs: Array.isArray(d) ? d : [] })))
537
+ }, [repo.owner, repo.name])
538
+ if (openIdx !== null) return h(IssueDetail, { repo, idx: openIdx, t, onBack: () => { setOpenIdx(null); reload() } })
539
+ const fltSel = (key, items, blank) => h('select', {
540
+ className: 'dgs-input', style: { maxWidth: 150, flex: 'none', width: 'auto', padding: '5px 8px' },
541
+ value: flt[key], onChange: (e) => setFlt({ ...flt, [key]: e.target.value }),
542
+ }, h('option', { value: '' }, blank),
543
+ items.map((x) => h('option', { key: x.id || x.name, value: String(x.id || x.name) }, x.title || x.name)))
544
+ return h('div', null,
545
+ h('div', { className: 'dgs-row', style: { margin: '8px 0 12px' } },
546
+ h('button', { className: 'dgs-pill' + (state === 'open' ? ' active' : ''), onClick: () => setState('open') }, '⊘ ' + t('openState')),
547
+ h('button', { className: 'dgs-pill' + (state === 'closed' ? ' active closed' : ''), onClick: () => setState('closed') }, '✓ ' + t('closedState'))),
548
+ creating ? h(NewIssue, { repo, t, onDone: () => { setCreating(false); reload() } }) : null,
549
+ h('div', { className: 'dgs-row', style: { margin: '0 0 12px' } },
550
+ fltSel('label', meta.labels, '标签'),
551
+ fltSel('milestone', meta.milestones, '里程碑'),
552
+ fltSel('assignee', meta.collabs, '负责人'),
553
+ h('select', { className: 'dgs-input', style: { maxWidth: 150, flex: 'none', width: 'auto', padding: '5px 8px' }, value: sort, onChange: (e) => setSort(e.target.value) },
554
+ [['latest', '最新'], ['oldest', '最旧'], ['recentupdate', '最近更新'], ['leastupdate', '最久未更新'], ['mostcomment', '最多评论'], ['leastcomment', '最少评论']].map(([v, label]) =>
555
+ h('option', { key: v, value: v }, '排序: ' + label))),
556
+ h('input', { className: 'dgs-input', style: { maxWidth: 180, flex: 'none' }, placeholder: '搜索标题/内容', value: search, onChange: (e) => setSearch(e.target.value) }),
557
+ (flt.label || flt.milestone || flt.assignee || search) ? h('button', { className: 'dgs-btn ghost', onClick: () => { setFlt({ label: '', milestone: '', assignee: '' }); setSearch('') } }, '清除') : null),
558
+ list === null ? h('div', { className: 'dgs-empty' }, t('loading'))
559
+ : list.length === 0 ? h('div', { className: 'dgs-empty' }, t('noIssues'))
560
+ : h('div', null, list.map((i) => issueRow(i)))
561
+ )
562
+
563
+ function issueRow(i) {
564
+ const badge = h('span', { className: 'dgs-issue-num' + (i.state === 'open' ? '' : ' closed') }, '#' + i.number)
565
+ const title = h('span', { className: 'dgs-issue-title' }, i.title)
566
+ const labels = (i.labels || []).map((l) =>
567
+ h('span', { key: l.id, className: 'dgs-badge', style: { background: (l.color || '#70c24a') + '33', borderColor: l.color || '#70c24a' } }, l.name))
568
+ const meta = h('div', { className: 'dgs-row', style: { marginTop: 4, gap: 6 } },
569
+ h('span', { className: 'dgs-sub' }, '由 ' + (i.user || '') + ' 于 ' + timeAgo(i.updatedAt) + ' 创建'),
570
+ i.milestone ? h('span', { className: 'dgs-badge' }, '◆ ' + i.milestone.title) : null,
571
+ i.assignee ? h('span', { className: 'dgs-badge' }, '@' + i.assignee) : null)
572
+ return h('div', {
573
+ key: i.number, className: 'dgs-issue', style: { cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 },
574
+ onClick: () => setOpenIdx(i.number),
575
+ }, badge,
576
+ h('div', { style: { flex: 1, minWidth: 0 } },
577
+ h('div', { className: 'dgs-row', style: { gap: 6 } }, title, labels),
578
+ meta),
579
+ h('span', { className: 'dgs-sub', style: { flex: 'none' } }, '💬 ' + (i.comments || 0)))
580
+ }
581
+ }
582
+
583
+ function NewIssue({ repo, t, onDone }) {
584
+ const [title, setTitle] = useState('')
585
+ const [body, setBody] = useState('')
586
+ const [busy, setBusy] = useState(false)
587
+ const [err, setErr] = useState('')
588
+ const [preview, setPreview] = useState(false)
589
+ const [previewHtml, setPreviewHtml] = useState('')
590
+ const [labels, setLabels] = useState([])
591
+ const [ms, setMs] = useState([])
592
+ const [collabs, setCollabs] = useState([])
593
+ const [sel, setSel] = useState({ labels: new Set(), milestone: '', assignee: '' })
594
+ useEffect(() => {
595
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/labels`).then((d) => setLabels(Array.isArray(d) ? d : []))
596
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/milestones`).then((d) => setMs(Array.isArray(d) ? d : []))
597
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/collaborators`).then((d) => setCollabs(Array.isArray(d) ? d : []))
598
+ }, [repo.owner, repo.name])
599
+ const submit = async () => {
600
+ setBusy(true); setErr('')
601
+ const d = await api('POST', `/repos/${repo.owner}/${repo.name}/issues`, {
602
+ title, body,
603
+ labels: [...sel.labels].map(Number),
604
+ milestone: sel.milestone ? Number(sel.milestone) : undefined,
605
+ assignee: sel.assignee || undefined,
606
+ })
607
+ setBusy(false)
608
+ d.ok ? onDone() : setErr(d.error || 'failed')
609
+ }
610
+ const toggleLabel = (id) => setSel((x) => {
611
+ const next = new Set(x.labels)
612
+ next.has(id) ? next.delete(id) : next.add(id)
613
+ return { ...x, labels: next }
614
+ })
615
+ const side = h('div', null,
616
+ h('div', { className: 'dgs-side-block' },
617
+ h('div', { className: 'dgs-side-title' }, '标签 ⚙'),
618
+ labels.length === 0 ? h('div', { className: 'dgs-sub' }, '未选择标签') : null,
619
+ labels.map((l) => h('label', { key: l.id, className: 'dgs-sub', style: { display: 'flex', gap: 6, alignItems: 'center', margin: '4px 0', cursor: 'pointer' } },
620
+ h('input', { type: 'checkbox', checked: sel.labels.has(l.id), onChange: () => toggleLabel(l.id) }),
621
+ h('span', { className: 'dgs-badge', style: { background: (l.color || '#70c24a') + '33' } }, l.name)))),
622
+ h('div', { className: 'dgs-side-block' },
623
+ h('div', { className: 'dgs-side-title' }, '里程碑 ⚙'),
624
+ h('select', { className: 'dgs-input', style: { width: '100%' }, value: sel.milestone, onChange: (e) => setSel({ ...sel, milestone: e.target.value }) },
625
+ h('option', { value: '' }, '未选择里程碑'),
626
+ ms.map((m) => h('option', { key: m.id, value: m.id }, m.title + (m.closed ? ' ✓' : ''))))),
627
+ h('div', { className: 'dgs-side-block' },
628
+ h('div', { className: 'dgs-side-title' }, '指派成员 ⚙'),
629
+ h('select', { className: 'dgs-input', style: { width: '100%' }, value: sel.assignee, onChange: (e) => setSel({ ...sel, assignee: e.target.value }) },
630
+ h('option', { value: '' }, '未指派成员'),
631
+ collabs.map((u) => h('option', { key: u.name, value: u.name }, u.name)))))
632
+ return h('div', { className: 'dgs-card', style: { margin: '10px 0' } },
633
+ h('div', { className: 'dgs-issue-layout' },
634
+ h('div', { className: 'dgs-issue-main' },
635
+ h('input', { className: 'dgs-input', placeholder: '标题', value: title, onChange: (e) => setTitle(e.target.value) }),
636
+ h('div', { className: 'dgs-row', style: { margin: '8px 0 4px' } },
637
+ h('button', { className: 'dgs-subtab' + (!preview ? ' active' : ''), onClick: () => setPreview(false) }, '内容编辑'),
638
+ h('button', { className: 'dgs-subtab' + (preview ? ' active' : ''), onClick: () => {
639
+ setPreview(true)
640
+ api('POST', '/dsh/markdown', { text: body }).then((d) => setPreviewHtml(d.html || ''))
641
+ } }, '效果预览')),
642
+ preview ? h('div', { className: 'dgs-md', style: { minHeight: 160, border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8, padding: 12 } },
643
+ body ? h('div', { dangerouslySetInnerHTML: { __html: previewHtml } }) : h('span', { className: 'dgs-sub' }, '暂无内容'))
644
+ : h('textarea', { className: 'dgs-input', style: { minHeight: 160 }, placeholder: '内容', value: body, onChange: (e) => setBody(e.target.value) }),
645
+ err ? h('div', { className: 'dgs-err' }, err) : null,
646
+ h('div', { className: 'dgs-row', style: { marginTop: 10, justifyContent: 'flex-end' } },
647
+ h('button', { className: 'dgs-btn', disabled: busy || !title.trim(), onClick: submit }, '创建工单'))),
648
+ h('div', { className: 'dgs-issue-side' }, side)))
649
+ }
650
+
651
+ function IssueDetail({ repo, idx, t, onBack }) {
652
+ const [issue, setIssue] = useState(null)
653
+ const [comments, setComments] = useState(null)
654
+ const [text, setText] = useState('')
655
+ const [busy, setBusy] = useState(false)
656
+ const [editC, setEditC] = useState(null)
657
+ const [editing, setEditing] = useState(false)
658
+ const [draft, setDraft] = useState({ title: '', body: '' })
659
+ const [allLabels, setAllLabels] = useState([])
660
+ const [allMs, setAllMs] = useState([])
661
+ const [collabs, setCollabs] = useState([])
662
+ const [msg, setMsg] = useState('')
663
+ const reload = useCallback(() => {
664
+ setComments(null)
665
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/issues/${idx}`).then((d) => setIssue(d && d.number ? d : null))
666
+ api('GET', `/repos/${repo.owner}/${repo.name}/issues/${idx}/comments`).then((d) => setComments(d.comments || []))
667
+ }, [repo.owner, repo.name, idx])
668
+ useEffect(reload, [reload])
669
+ useEffect(() => {
670
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/labels`).then((d) => setAllLabels(Array.isArray(d) ? d : []))
671
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/milestones`).then((d) => setAllMs(Array.isArray(d) ? d : []))
672
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/collaborators`).then((d) => setCollabs(Array.isArray(d) ? d : []))
673
+ }, [repo.owner, repo.name])
674
+ if (!issue) return h('div', { className: 'dgs-empty' }, t('loading'))
675
+ const patch = async (fields) => {
676
+ setBusy(true); setMsg('')
677
+ const d = await api('PATCH', `/dsh/repos/${repo.owner}/${repo.name}/issues/${idx}`, fields)
678
+ setBusy(false)
679
+ if (d && d.ok) { setEditing(false); reload() } else setMsg((d && d.error) || 'failed')
680
+ }
681
+ const main = h('div', null,
682
+ editing ? h('div', { className: 'dgs-card', style: { margin: '10px 0' } },
683
+ h('input', { className: 'dgs-input', value: draft.title, onChange: (e) => setDraft({ ...draft, title: e.target.value }) }),
684
+ h('textarea', { className: 'dgs-input', style: { marginTop: 8 }, value: draft.body, onChange: (e) => setDraft({ ...draft, body: e.target.value }) }),
685
+ h('div', { className: 'dgs-row', style: { marginTop: 8 } },
686
+ h('button', { className: 'dgs-btn', disabled: busy || !draft.title.trim(), onClick: () => patch({ title: draft.title, body: draft.body }) }, t('save'))))
687
+ : h('div', { className: 'dgs-card', style: { margin: '10px 0' } },
688
+ issue.body ? h('div', { style: { whiteSpace: 'pre-wrap' } }, issue.body) : h('div', { className: 'dgs-sub' }, '暂无内容'),
689
+ h('div', { className: 'dgs-row', style: { marginTop: 10, flexWrap: 'wrap' } },
690
+ (issue.labels || []).map((l) => h('span', { key: l.id, className: 'dgs-badge', style: { background: (l.color || '#70c24a') + '33' } }, l.name)),
691
+ issue.milestone ? h('span', { className: 'dgs-badge' }, '◆ ' + issue.milestone.title) : null,
692
+ issue.assignee ? h('span', { className: 'dgs-badge' }, '@' + issue.assignee) : null)),
693
+ comments === null ? h('div', { className: 'dgs-empty' }, t('loading'))
694
+ : comments.length === 0 ? h('div', { className: 'dgs-empty' }, t('noIssues'))
695
+ : comments.map((c) =>
696
+ h('div', { key: c.id, className: 'dgs-comment' },
697
+ h('div', { className: 'dgs-row', style: { marginBottom: 4 } },
698
+ h('span', { className: 'dgs-sub' }, `${c.user || ''} · ${fmtDate(c.created)}`),
699
+ h('span', { style: { flex: 1 } }),
700
+ c.id ? h('button', { className: 'dgs-btn ghost', style: { padding: '1px 8px' }, onClick: () => { setEditC({ id: c.id, text: c.body }) } }, '编辑') : null,
701
+ c.id ? h('button', { className: 'dgs-btn danger', style: { padding: '1px 8px' }, onClick: async () => {
702
+ if (!confirm('删除此评论?')) return
703
+ await api('DELETE', `/repos/${repo.owner}/${repo.name}/issues/comments/${c.id}`)
704
+ reload()
705
+ } }, '删') : null),
706
+ editC && editC.id === c.id ? h('div', null,
707
+ h('textarea', { className: 'dgs-input', value: editC.text, onChange: (e) => setEditC({ ...editC, text: e.target.value }) }),
708
+ h('div', { className: 'dgs-row', style: { marginTop: 4 } },
709
+ h('button', { className: 'dgs-btn', onClick: async () => {
710
+ await api('PATCH', `/repos/${repo.owner}/${repo.name}/issues/comments/${c.id}`, { body: editC.text })
711
+ setEditC(null); reload()
712
+ } }, t('save'))))
713
+ : h('div', { style: { whiteSpace: 'pre-wrap' } }, c.body))),
714
+ h('div', { className: 'dgs-card', style: { margin: '12px 0' } },
715
+ h('textarea', { className: 'dgs-input', placeholder: t('commentPlaceholder'), value: text, onChange: (e) => setText(e.target.value) }),
716
+ h('div', { className: 'dgs-row', style: { marginTop: 8 } },
717
+ h('button', { className: 'dgs-btn', disabled: busy || !text.trim(),
718
+ onClick: async () => {
719
+ setBusy(true)
720
+ await api('POST', `/repos/${repo.owner}/${repo.name}/issues/${idx}/comments`, { body: text })
721
+ setText(''); setBusy(false); reload()
722
+ } }, t('comment')))))
723
+ const side = h('div', null,
724
+ h('div', { className: 'dgs-side-block' },
725
+ h('div', { className: 'dgs-side-title' }, '标签 ⚙'),
726
+ allLabels.length === 0 ? h('div', { className: 'dgs-sub' }, '未选择标签') : null,
727
+ allLabels.map((l) => h('label', { key: l.id, className: 'dgs-sub', style: { display: 'flex', gap: 6, alignItems: 'center', margin: '4px 0', cursor: 'pointer' } },
728
+ h('input', { type: 'checkbox', checked: (issue.labels || []).some((x) => x.id === l.id),
729
+ onChange: (e) => {
730
+ const cur = new Set((issue.labels || []).map((x) => x.id))
731
+ if (e.target.checked) cur.add(l.id); else cur.delete(l.id)
732
+ patch({ labels: [...cur] })
733
+ } }),
734
+ h('span', { className: 'dgs-badge', style: { background: (l.color || '#70c24a') + '33' } }, l.name)))),
735
+ h('div', { className: 'dgs-side-block' },
736
+ h('div', { className: 'dgs-side-title' }, '里程碑 ⚙'),
737
+ h('select', { className: 'dgs-input', style: { width: '100%' }, value: issue.milestone ? issue.milestone.id : '',
738
+ onChange: (e) => patch({ milestone: Number(e.target.value) || 0 }) },
739
+ h('option', { value: '' }, '未选择里程碑'),
740
+ allMs.map((m) => h('option', { key: m.id, value: m.id }, m.title + (m.closed ? ' ✓' : ''))))),
741
+ h('div', { className: 'dgs-side-block' },
742
+ h('div', { className: 'dgs-side-title' }, '指派成员 ⚙'),
743
+ h('select', { className: 'dgs-input', style: { width: '100%' }, value: issue.assignee || '',
744
+ onChange: (e) => patch({ assignee: e.target.value }) },
745
+ h('option', { value: '' }, '未指派成员'),
746
+ collabs.map((u) => h('option', { key: u.name, value: u.name }, u.name)))),
747
+ h('div', { className: 'dgs-side-block' },
748
+ h('div', { className: 'dgs-side-title' }, (issue.participants || 1) + ' 名参与者'),
749
+ h('div', { className: 'dgs-sub' }, issue.author || '')))
750
+ return h('div', null,
751
+ h('div', { className: 'dgs-row', style: { marginBottom: 10 } },
752
+ h('button', { className: 'dgs-btn ghost', onClick: onBack }, t('back')),
753
+ h('span', { style: { fontWeight: 600 } }, '#' + idx + ' ' + issue.title),
754
+ h('span', { className: 'dgs-badge' + (issue.state === 'open' ? ' ok' : ' closed') }, issue.state === 'open' ? t('openState') : t('closedState')),
755
+ h('span', { style: { flex: 1 } }),
756
+ h('button', { className: 'dgs-btn ghost', onClick: () => { setDraft({ title: issue.title, body: issue.body }); setEditing(!editing) } }, t('edit')),
757
+ h('button', { className: 'dgs-btn danger', disabled: busy,
758
+ onClick: async () => {
759
+ setBusy(true)
760
+ await api('PATCH', `/repos/${repo.owner}/${repo.name}/issues/${idx}`, { state: issue.state === 'open' ? 'closed' : 'open' })
761
+ setBusy(false); reload()
762
+ } }, issue.state === 'open' ? t('close') : t('reopen'))),
763
+ msg ? h('div', { className: 'dgs-err' }, msg) : null,
764
+ h('div', { className: 'dgs-issue-layout' },
765
+ h('div', { className: 'dgs-issue-main' }, main),
766
+ h('div', { className: 'dgs-issue-side' }, side)))
767
+ }
768
+
769
+ function Commits({ repo, rev, t }) {
770
+ const [list, setList] = useState(null)
771
+ const [sel, setSel] = useState(null)
772
+ const [page, setPage] = useState(1)
773
+ const [full, setFull] = useState(false)
774
+ const [q, setQ] = useState('')
775
+ useEffect(() => { setPage(1); setQ('') }, [rev])
776
+ useEffect(() => {
777
+ setList(null)
778
+ api('GET', `/repos/${repo.owner}/${repo.name}/commits?ref=${encodeURIComponent(rev)}&page=${page}&pageSize=30`)
779
+ .then((d) => {
780
+ const l = d.commits || []
781
+ setList(l)
782
+ setFull(l.length < 30)
783
+ })
784
+ }, [repo.owner, repo.name, rev, page])
785
+ if (sel) return h(CommitDetail, { repo, sha: sel, t, onBack: () => setSel(null) })
786
+ if (list === null) return h('div', { className: 'dgs-empty' }, t('loading'))
787
+ if (list.length === 0) return h('div', { className: 'dgs-empty' }, t('emptyDir'))
788
+ const shown = q.trim() ? list.filter((c) => (c.message || '').toLowerCase().includes(q.trim().toLowerCase()) || (c.author || '').toLowerCase().includes(q.trim().toLowerCase())) : list
789
+ return h('div', null,
790
+ h('div', { className: 'dgs-row', style: { margin: '0 0 10px' } },
791
+ h('input', { className: 'dgs-input', style: { maxWidth: 260, flex: 'none' }, placeholder: '搜索提交历史', value: q, onChange: (e) => setQ(e.target.value) })),
792
+ shown.length === 0 ? h('div', { className: 'dgs-empty' }, '—') : null,
793
+ h('table', { className: 'dgs-table' },
794
+ h('tbody', null, shown.map((c, i) =>
795
+ h('tr', { key: i, style: { cursor: 'pointer' }, onClick: () => setSel(c.sha) },
796
+ h('td', null, h('div', { style: { fontWeight: 500 } }, (c.message || '').split('\n')[0]),
797
+ h('div', { className: 'dgs-sub' }, c.author)),
798
+ h('td', { className: 'dgs-sub', style: { textAlign: 'right', whiteSpace: 'nowrap' } }, (c.sha || '').slice(0, 10)),
799
+ h('td', { className: 'dgs-sub', style: { textAlign: 'right', whiteSpace: 'nowrap', width: 100 } }, timeAgo(c.date)))))),
800
+ h('div', { className: 'dgs-row', style: { marginTop: 10, justifyContent: 'center' } },
801
+ h('button', { className: 'dgs-btn ghost', disabled: page <= 1, onClick: () => setPage(page - 1) }, '‹ 较新'),
802
+ h('span', { className: 'dgs-sub' }, '第 ' + page + ' 页'),
803
+ h('button', { className: 'dgs-btn ghost', disabled: full, onClick: () => setPage(page + 1) }, '较旧 ›')))
804
+ }
805
+
806
+ function CommitDetail({ repo, sha, t, onBack }) {
807
+ const [d, setD] = useState(null)
808
+ useEffect(() => {
809
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/commits/${sha}`).then((x) => x && x.sha ? setD(x) : setD(x || {}))
810
+ }, [repo.owner, repo.name, sha])
811
+ if (!d || !d.sha) return h('div', { className: 'dgs-empty' }, t('loading'))
812
+ const when = d.committer && d.committer.when
813
+ return h('div', null,
814
+ h('div', { className: 'dgs-row', style: { marginBottom: 10 } },
815
+ h('button', { className: 'dgs-btn ghost', onClick: onBack }, t('back')),
816
+ h('span', { style: { fontWeight: 600 } }, (d.message || '').split('\n')[0]),
817
+ h('span', { className: 'dgs-sub' }, (d.sha || '').slice(0, 10))),
818
+ h('div', { className: 'dgs-sub', style: { marginBottom: 8 } },
819
+ `${(d.author && d.author.name) || ''} · ${fmtDate(when)}`),
820
+ h(DiffView, { patch: d.files || '' }))
821
+ }
822
+
823
+ // unified diff → 结构化渲染(文件分块 / 行号 / +绿 -红 / hunk 灰 / 每文件增删统计)
824
+ function DiffView({ patch }) {
825
+ const files = []
826
+ let cur = null
827
+ let oldNo = 0; let newNo = 0
828
+ for (const line of patch.split('\n')) {
829
+ if (line.startsWith('diff --git ')) {
830
+ cur = { name: line.replace(/^diff --git a\/(\S+) b\/.*/, '$1'), header: [], stats: { add: 0, del: 0 }, lines: [] }
831
+ files.push(cur); oldNo = 0; newNo = 0
832
+ continue
833
+ }
834
+ if (!cur) continue
835
+ if (line.startsWith('@@')) {
836
+ const m = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
837
+ if (m) { oldNo = Number(m[1]); newNo = Number(m[2]) }
838
+ cur.lines.push({ kind: 'hunk', text: line }); continue
839
+ }
840
+ if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ') ||
841
+ line.startsWith('new file mode') || line.startsWith('deleted file mode') ||
842
+ line.startsWith('old mode') || line.startsWith('new mode') || line.startsWith('similarity ') ||
843
+ line.startsWith('rename from') || line.startsWith('rename to')) { cur.header.push(line); continue }
844
+ if (line.startsWith('+')) { cur.stats.add++; cur.lines.push({ kind: 'add', old: '', new: newNo++, text: line.slice(1) }); continue }
845
+ if (line.startsWith('-')) { cur.stats.del++; cur.lines.push({ kind: 'del', old: oldNo++, new: '', text: line.slice(1) }); continue }
846
+ if (line.startsWith(' ')) { cur.lines.push({ kind: 'ctx', old: oldNo++, new: newNo++, text: line.slice(1) }); continue }
847
+ if (line === '') { cur.lines.push({ kind: 'ctx', old: oldNo++, new: newNo++, text: '' }); continue }
848
+ cur.lines.push({ kind: 'ctx', text: line })
849
+ }
850
+ if (files.length === 0) return h('div', { className: 'dgs-empty' }, '—')
851
+ return h('div', null, files.map((f, i) =>
852
+ h('div', { key: i, className: 'dgs-card', style: { padding: 0, overflow: 'hidden', marginBottom: 12 } },
853
+ h('div', { className: 'dgs-row', style: { padding: '8px 12px', borderBottom: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-2)' } },
854
+ h('span', { style: { fontWeight: 600, fontSize: 12.5 } }, f.name),
855
+ h('span', { style: { flex: 1 } }),
856
+ h('span', { className: 'dgs-diff-stat' }, '+' + f.stats.add),
857
+ h('span', { className: 'dgs-diff-stat del' }, '-' + f.stats.del)),
858
+ h('div', { style: { overflowX: 'auto' } }, f.lines.map((l, j) => {
859
+ if (l.kind === 'hunk') return h('div', { key: j, className: 'dgs-diff-line hunk' }, l.text)
860
+ return h('div', { key: j, className: 'dgs-diff-line ' + l.kind },
861
+ h('span', { className: 'dgs-diff-no' }, l.old || ''),
862
+ h('span', { className: 'dgs-diff-no' }, l.new || ''),
863
+ h('span', { className: 'dgs-diff-code', style: { whiteSpace: 'pre' } }, l.text))
864
+ })))))
865
+ }
866
+ function Branches({ repo, t, onNewPR }) {
867
+ const [view, setView] = useState('overview')
868
+ const [list, setList] = useState(null)
869
+ const [tags, setTags] = useState(null)
870
+ const [rels, setRels] = useState([])
871
+ const [def, setDef] = useState('')
872
+ const reload = () => api('GET', `/repos/${repo.owner}/${repo.name}/branches`).then((d) => setList(d.branches || []))
873
+ useEffect(() => {
874
+ reload()
875
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/overview`).then((d) => setDef(d.defaultBranch || ''))
876
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/tags`).then((d) => setTags(Array.isArray(d) ? d : []))
877
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/releases`).then((d) => setRels(Array.isArray(d) ? d : (d.data || [])))
878
+ }, [repo.owner, repo.name])
879
+ if (list === null) return h('div', { className: 'dgs-empty' }, t('loading'))
880
+ const tabBtn = (k, label) => h('button', { className: 'dgs-subtab' + (view === k ? ' active' : ''), onClick: () => setView(k) }, label)
881
+ const active = list.filter((b) => b.name !== def)
882
+ return h('div', null,
883
+ h('div', { className: 'dgs-subtabs' }, tabBtn('overview', '概况'), tabBtn('all', '所有分支')),
884
+ view === 'overview' ? h('div', null,
885
+ h('div', { className: 'dgs-card', style: { marginBottom: 12 } },
886
+ h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '默认分支'),
887
+ h('div', { className: 'dgs-row' },
888
+ h('span', { className: 'dgs-badge' }, def || 'master'),
889
+ h('span', { className: 'dgs-sub', style: { flex: 1 } }, ''),
890
+ h('button', { className: 'dgs-btn ghost', onClick: () => setView('all') }, '更改默认分支'))),
891
+ h('div', { className: 'dgs-card' },
892
+ h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '活跃分支'),
893
+ active.length === 0 ? h('div', { className: 'dgs-sub' }, '—')
894
+ : active.map((b) => h('div', { key: b.name, className: 'dgs-row', style: { padding: '4px 0' } },
895
+ h('a', { className: 'dgs-name', style: { cursor: 'pointer' }, onClick: () => onNewPR && onNewPR(b.name, def) }, b.name),
896
+ h('span', { className: 'dgs-sub', style: { flex: 1 } }, ''),
897
+ h('span', { className: 'dgs-sub' }, (b.sha || '').slice(0, 10))))))
898
+ : h('div', null,
899
+ h('table', { className: 'dgs-table' },
900
+ h('tbody', null, list.map((b) =>
901
+ h('tr', { key: b.name },
902
+ h('td', { style: { fontWeight: 500 } },
903
+ b.name,
904
+ b.name === def ? h('span', { className: 'dgs-badge ok', style: { marginLeft: 8 } }, '默认') : null),
905
+ h('td', { className: 'dgs-sub', style: { textAlign: 'right' } },
906
+ (b.sha || '').slice(0, 10),
907
+ b.name !== def ? h('button', { className: 'dgs-btn ghost', style: { marginLeft: 8, padding: '2px 8px' }, onClick: () => onNewPR && onNewPR(b.name, def) }, '+ PR') : null,
908
+ b.name !== def ? h('button', { className: 'dgs-btn danger', style: { marginLeft: 4, padding: '2px 8px' }, onClick: async () => {
909
+ if (!confirm('删除分支 ' + b.name + ' ?')) return
910
+ await api('DELETE', `/repos/${repo.owner}/${repo.name}/branches/${encodeURIComponent(b.name)}`)
911
+ reload()
912
+ } }, '删') : null))))),
913
+ h('div', { style: { fontWeight: 700, margin: '18px 0 8px' } }, '标签'),
914
+ tags === null ? h('div', { className: 'dgs-empty' }, t('loading'))
915
+ : tags.length === 0 ? h('div', { className: 'dgs-empty' }, '—')
916
+ : h('table', { className: 'dgs-table' },
917
+ h('tbody', null, tags.map((tg) => {
918
+ const released = rels.some((r) => r.tag === tg.name)
919
+ return h('tr', { key: tg.name },
920
+ h('td', { style: { fontWeight: 500 } },
921
+ '◎ ' + tg.name,
922
+ released ? h('span', { className: 'dgs-badge ok', style: { marginLeft: 8 } }, '已发版') : null),
923
+ h('td', { className: 'dgs-sub' }, tg.msg || ''),
924
+ h('td', { className: 'dgs-sub', style: { textAlign: 'right', whiteSpace: 'nowrap' } },
925
+ (tg.sha || '') + ' · ' + timeAgo(tg.date)))
926
+ }))))
927
+ )
928
+ }
929
+
930
+ // ── 标签 ───────────────────────────────────────────────────────────────────
931
+
932
+ function Tags({ repo, ov, t }) {
933
+ if (!ov) return h('div', { className: 'dgs-empty' }, t('loading'))
934
+ return ov.tags.length === 0 ? h('div', { className: 'dgs-empty' }, '—')
935
+ : h('table', { className: 'dgs-table' },
936
+ h('tbody', null, ov.tags.map((name) =>
937
+ h('tr', { key: name },
938
+ h('td', { style: { fontWeight: 500 } }, name),
939
+ h('td', { className: 'dgs-sub', style: { textAlign: 'right' } }, t('tags'))))))
940
+ }
941
+
942
+ // ── Blame ──────────────────────────────────────────────────────────────────
943
+
944
+ function BlameView({ repo, rev, path, text }) {
945
+ const [lines, setLines] = useState(null)
946
+ useEffect(() => {
947
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/blame?ref=${encodeURIComponent(rev)}&path=${encodeURIComponent(path)}`)
948
+ .then((d) => setLines(d.lines || []))
949
+ }, [repo.owner, repo.name, rev, path])
950
+ if (lines === null) return h('div', { className: 'dgs-empty' }, '…')
951
+ const codeLines = (text || '').split('\n')
952
+ return h('table', { className: 'dgs-table' },
953
+ h('tbody', null, codeLines.map((code, i) => {
954
+ const bl = lines.find((l) => l.line === i + 1) || {}
955
+ return h('tr', { key: i },
956
+ h('td', { className: 'dgs-sub', style: { textAlign: 'right', width: 34 } }, String(i + 1)),
957
+ h('td', { className: 'dgs-sub', style: { width: 170, whiteSpace: 'nowrap' } },
958
+ bl.author ? `${bl.author} · ${(bl.sha || '').slice(0, 7)}` : ''),
959
+ h('td', null, h('span', { style: { whiteSpace: 'pre-wrap', fontSize: 12.5 } }, code)))
960
+ })))
961
+ }
962
+
963
+ // ── 文件历史 ───────────────────────────────────────────────────────────────
964
+
965
+ function FileHistory({ repo, rev, path }) {
966
+ const [list, setList] = useState(null)
967
+ useEffect(() => {
968
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/file-history?ref=${encodeURIComponent(rev)}&path=${encodeURIComponent(path)}`)
969
+ .then((d) => setList(d.commits || []))
970
+ }, [repo.owner, repo.name, rev, path])
971
+ if (list === null) return h('div', { className: 'dgs-empty' }, '…')
972
+ return h('table', { className: 'dgs-table' },
973
+ h('tbody', null, list.length === 0
974
+ ? h('tr', null, h('td', { className: 'dgs-sub' }, '—'))
975
+ : list.map((cm) =>
976
+ h('tr', { key: cm.sha },
977
+ h('td', { className: 'dgs-sub', style: { width: 70 } }, cm.sha),
978
+ h('td', null, cm.msg || ''),
979
+ h('td', { className: 'dgs-sub', style: { textAlign: 'right', whiteSpace: 'nowrap' } },
980
+ (cm.author || '') + ' · ' + timeAgo(cm.date))))))
981
+ }
982
+
983
+ // ── 标签管理 ───────────────────────────────────────────────────────────────
984
+
985
+ function LabelsManage({ repo, t, onFilterLabel }) {
986
+ const [list, setList] = useState(null)
987
+ const [creating, setCreating] = useState(false)
988
+ const [editId, setEditId] = useState(null)
989
+ const [form, setForm] = useState({ name: '', color: '#70c24a' })
990
+ const [msg, setMsg] = useState('')
991
+ const reload = () => api('GET', `/dsh/repos/${repo.owner}/${repo.name}/labels`).then((d) => setList(Array.isArray(d) ? d : []))
992
+ useEffect(() => { reload() }, [repo.owner, repo.name])
993
+ const save = async () => {
994
+ const d = editId
995
+ ? await api('PATCH', `/dsh/repos/${repo.owner}/${repo.name}/labels/${editId}`, form)
996
+ : await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/labels`, form)
997
+ if (d && d.ok !== false && !d.error) {
998
+ setForm({ name: '', color: '#70c24a' }); setCreating(false); setEditId(null); reload()
999
+ } else setMsg((d && d.error) || 'failed')
1000
+ }
1001
+ const startCreate = () => { setCreating(!creating); setEditId(null); setForm({ name: '', color: '#70c24a' }) }
1002
+ const startEdit = (l) => { setEditId(l.id); setCreating(false); setForm({ name: l.name, color: l.color || '#70c24a' }) }
1003
+ const header = h('div', { className: 'dgs-row', style: { margin: '8px 0 12px' } },
1004
+ h('span', { style: { fontWeight: 700 } }, list ? list.length + ' 个标签' : ''),
1005
+ h('span', { style: { flex: 1 } }),
1006
+ h('button', { className: 'dgs-btn', onClick: startCreate }, '创建标签'))
1007
+ const formCard = (creating || editId !== null)
1008
+ ? h('div', { className: 'dgs-card', style: { marginBottom: 12 } },
1009
+ h('div', { className: 'dgs-row' },
1010
+ h('input', { className: 'dgs-input', style: { maxWidth: 160 }, placeholder: '标签名称', value: form.name, onChange: (e) => setForm({ ...form, name: e.target.value }) }),
1011
+ h('input', { type: 'color', value: form.color, onChange: (e) => setForm({ ...form, color: e.target.value }), style: { width: 36, height: 30, padding: 0, border: 'none', background: 'none' } }),
1012
+ h('span', { style: { flex: 1 } }),
1013
+ h('button', { className: 'dgs-btn', disabled: !form.name.trim(), onClick: save }, editId !== null ? '保存' : '创建标签')))
1014
+ : null
1015
+ const rows = (list || []).map((l) =>
1016
+ h('div', { key: l.id, className: 'dgs-issue' },
1017
+ h('div', { className: 'dgs-row' },
1018
+ h('span', { className: 'dgs-badge', style: { background: (l.color || '#70c24a') + '33', borderColor: l.color || '#70c24a' } }, l.name),
1019
+ h('span', { style: { flex: 1 } }),
1020
+ h('a', { className: 'dgs-sub', style: { cursor: 'pointer' }, onClick: () => onFilterLabel && onFilterLabel(l.id) }, (l.openIssues || 0) + ' 个开启的工单'),
1021
+ h('button', { className: 'dgs-btn ghost', onClick: () => startEdit(l) }, '编辑'),
1022
+ h('button', { className: 'dgs-btn danger', onClick: async () => {
1023
+ await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/labels/${l.id}`); reload()
1024
+ } }, t('delete')))))
1025
+ return h('div', null,
1026
+ header,
1027
+ msg ? h('div', { className: 'dgs-err' }, msg) : null,
1028
+ formCard,
1029
+ list === null ? h('div', { className: 'dgs-empty' }, t('loading'))
1030
+ : list.length === 0 ? h('div', { className: 'dgs-card', style: { maxWidth: 520, margin: '30px auto', textAlign: 'center' } },
1031
+ h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '加载预定义的标签模板'),
1032
+ h('div', { className: 'dgs-sub', style: { marginBottom: 14 } }, '此仓库还未创建任何标签。可以通过上方的「创建标签」创建一个新的标签或加载一组预定义的标签。'),
1033
+ h('div', { className: 'dgs-row', style: { justifyContent: 'center' } },
1034
+ h('select', { className: 'dgs-input', style: { maxWidth: 240, flex: 'none' } },
1035
+ h('option', { value: 'default' }, 'Default'),
1036
+ h('option', { value: 'enterprise' }, 'Enterprise')),
1037
+ h('button', { className: 'dgs-btn', onClick: async (e) => {
1038
+ const existing = new Set((list || []).map((l) => l.name.toLowerCase()))
1039
+ const defaults = [
1040
+ { name: 'bug', color: '#e25444' }, { name: 'duplicate', color: '#b7b7b7' },
1041
+ { name: 'enhancement', color: '#70c24a' }, { name: 'help wanted', color: '#c8c8ff' },
1042
+ { name: 'invalid', color: '#fef2c0' }, { name: 'question', color: '#d876e3' },
1043
+ { name: 'wontfix', color: '#ffffff' },
1044
+ ]
1045
+ for (const tpl of defaults) {
1046
+ if (existing.has(tpl.name.toLowerCase())) continue
1047
+ await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/labels`, tpl)
1048
+ }
1049
+ reload()
1050
+ } }, '加载标签模板')))
1051
+ : rows)
1052
+ }
1053
+
1054
+ // ── 里程碑管理 ─────────────────────────────────────────────────────────────
1055
+
1056
+ function MilestonesManage({ repo, t, onOpenMilestone }) {
1057
+ const [list, setList] = useState(null)
1058
+ const [showClosed, setShowClosed] = useState(false)
1059
+ const [creating, setCreating] = useState(false)
1060
+ const [editId, setEditId] = useState(null)
1061
+ const [form, setForm] = useState({ title: '', due: '', description: '' })
1062
+ const [busy, setBusy] = useState(false)
1063
+ const [msg, setMsg] = useState('')
1064
+ const [openMs, setOpenMs] = useState(null)
1065
+ const [msIssues, setMsIssues] = useState(null)
1066
+ const reload = () => api('GET', `/dsh/repos/${repo.owner}/${repo.name}/milestones`).then((d) => setList(Array.isArray(d) ? d : []))
1067
+ useEffect(() => { reload() }, [repo.owner, repo.name])
1068
+ const toggleMs = (m) => {
1069
+ if (onOpenMilestone) onOpenMilestone(m)
1070
+ else if (openMs === m.id) { setOpenMs(null); setMsIssues(null) }
1071
+ else {
1072
+ setOpenMs(m.id); setMsIssues(null)
1073
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/issues?milestone=${m.id}&state=all`).then((d) => setMsIssues(Array.isArray(d) ? d : []))
1074
+ }
1075
+ }
1076
+ const startCreate = () => {
1077
+ setCreating(!creating); setEditId(null)
1078
+ setForm({ title: '', due: '', description: '' })
1079
+ }
1080
+ const startEdit = (m) => {
1081
+ setEditId(m.id); setCreating(false)
1082
+ setForm({ title: m.title, due: m.due ? new Date(m.due * 1000).toISOString().slice(0, 10) : '', description: m.description || '' })
1083
+ }
1084
+ const saveForm = async () => {
1085
+ setBusy(true); setMsg('')
1086
+ const body = { title: form.title.trim(), description: form.description }
1087
+ if (form.due) body.due = form.due
1088
+ const d = editId
1089
+ ? await api('PATCH', `/dsh/repos/${repo.owner}/${repo.name}/milestones/${editId}`, body)
1090
+ : await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/milestones`, body)
1091
+ setBusy(false)
1092
+ if (d && d.ok !== false && !d.error) {
1093
+ setCreating(false); setEditId(null); setForm({ title: '', due: '', description: '' }); reload()
1094
+ } else setMsg((d && d.error) || 'failed')
1095
+ }
1096
+ const shown = (list || []).filter((m) => showClosed ? m.closed : !m.closed)
1097
+ const openCount = (list || []).filter((m) => !m.closed).length
1098
+ const closedCount = (list || []).filter((m) => m.closed).length
1099
+ return h('div', null,
1100
+ h('div', { className: 'dgs-row', style: { margin: '8px 0 12px' } },
1101
+ h('button', { className: 'dgs-btn ghost', onClick: () => setShowClosed(false) }, '开放里程碑 (' + openCount + ')'),
1102
+ h('button', { className: 'dgs-btn ghost', onClick: () => setShowClosed(true) }, '已关闭里程碑 (' + closedCount + ')'),
1103
+ h('span', { style: { flex: 1 } }),
1104
+ h('button', { className: 'dgs-btn', onClick: startCreate }, '+ 创建里程碑')),
1105
+ msg ? h('div', { className: 'dgs-err' }, msg) : null,
1106
+ (creating || editId !== null) ? h('div', { className: 'dgs-card', style: { margin: '10px 0' } },
1107
+ h('div', { className: 'dgs-row' },
1108
+ h('span', { style: { fontWeight: 700 } }, editId !== null ? '编辑里程碑' : '创建里程碑'),
1109
+ h('span', { style: { flex: 1 } }),
1110
+ h('button', { className: 'dgs-btn ghost', onClick: () => { setCreating(false); setEditId(null) } }, t('back'))),
1111
+ h('div', { style: { margin: '8px 0 0' } },
1112
+ h('input', { className: 'dgs-input', placeholder: '里程碑标题', value: form.title, onChange: (e) => setForm({ ...form, title: e.target.value }) }),
1113
+ h('div', { className: 'dgs-row', style: { marginTop: 8 } },
1114
+ h('span', { className: 'dgs-sub', style: { minWidth: 60 } }, '截止日期'),
1115
+ h('input', { type: 'date', className: 'dgs-input', style: { maxWidth: 180, flex: 'none' }, value: form.due, onChange: (e) => setForm({ ...form, due: e.target.value }) }),
1116
+ form.due ? h('a', { className: 'dgs-sub', style: { cursor: 'pointer' }, onClick: () => setForm({ ...form, due: '' }) }, '清除') : null),
1117
+ h('textarea', { className: 'dgs-input', style: { marginTop: 8, minHeight: 60 }, placeholder: '描述', value: form.description, onChange: (e) => setForm({ ...form, description: e.target.value }) }),
1118
+ h('div', { className: 'dgs-row', style: { marginTop: 8 } },
1119
+ h('button', { className: 'dgs-btn', disabled: busy || !form.title.trim(), onClick: saveForm }, editId !== null ? t('save') : t('submit')))))
1120
+ : null,
1121
+ list === null ? h('div', { className: 'dgs-empty' }, t('loading'))
1122
+ : shown.length === 0 ? h('div', { className: 'dgs-empty' }, '—')
1123
+ : shown.map((m) => {
1124
+ const total = (m.open || 0) + (m.closedIssues || 0)
1125
+ const pct = total ? Math.round((m.closedIssues || 0) * 100 / total) : 0
1126
+ const overdue = m.due && !m.closed && m.due * 1000 < Date.now()
1127
+ return h('div', { key: m.id, className: 'dgs-issue' },
1128
+ h('div', { className: 'dgs-row', style: { alignItems: 'flex-start' } },
1129
+ h('div', { style: { flex: 2, minWidth: 0 } },
1130
+ h('span', { style: { fontWeight: 600, cursor: 'pointer' }, onClick: () => toggleMs(m) }, '◆ ' + m.title),
1131
+ m.description ? h('div', { className: 'dgs-sub', style: { marginTop: 2 } }, m.description) : null,
1132
+ m.due ? h('div', { className: 'dgs-sub', style: { marginTop: 2, color: overdue ? 'var(--dsw-alias-state-error-primary)' : undefined } },
1133
+ '截止 ' + fmtDate(m.due * 1000) + (overdue ? ' · 已逾期' : '')) : null),
1134
+ h('div', { style: { flex: 1, minWidth: 170 } },
1135
+ h('div', { style: { height: 8, background: 'var(--dsw-alias-bg-layer-2)', borderRadius: 8, overflow: 'hidden', marginBottom: 4 } },
1136
+ h('div', { style: { width: pct + '%', height: '100%', background: 'var(--dsw-alias-state-business-primary)' } })),
1137
+ h('div', { className: 'dgs-sub' }, pct + '% 完成 · ' + (m.closedIssues || 0) + ' 已关闭 · ' + (m.open || 0) + ' 开放')),
1138
+ h('div', { className: 'dgs-row', style: { flex: 'none' } },
1139
+ h('button', { className: 'dgs-btn ghost', onClick: () => startEdit(m) }, t('edit')),
1140
+ h('button', { className: 'dgs-btn ghost', onClick: async () => {
1141
+ await api('PATCH', `/dsh/repos/${repo.owner}/${repo.name}/milestones/${m.id}`, { closed: !m.closed }); reload()
1142
+ } }, m.closed ? '重新打开' : '关闭里程碑'),
1143
+ h('button', { className: 'dgs-btn danger', onClick: async () => {
1144
+ if (!confirm('删除里程碑 ' + m.title + ' ?')) return
1145
+ await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/milestones/${m.id}`)
1146
+ if (openMs === m.id) { setOpenMs(null); setMsIssues(null) }
1147
+ reload()
1148
+ } }, t('delete')))),
1149
+ openMs === m.id ? h('div', { style: { margin: '6px 0 2px 24px' } },
1150
+ msIssues === null ? h('span', { className: 'dgs-sub' }, t('loading'))
1151
+ : msIssues.length === 0 ? h('span', { className: 'dgs-sub' }, t('noIssues'))
1152
+ : msIssues.map((i) => h('div', { key: i.number, className: 'dgs-row', style: { padding: '2px 0' } },
1153
+ h('span', { className: 'dgs-badge' + (i.state === 'open' ? ' ok' : ' closed') }, '#' + i.number),
1154
+ h('span', { style: { cursor: 'pointer' }, onClick: () => nav('/r/' + repo.owner + '/' + repo.name) }, i.title)))) : null)
1155
+ }))
1156
+ }
1157
+
1158
+ // ── 仓库设置(基础信息 + 协作者) ──────────────────────────────────────────
1159
+
1160
+ function RepoSettings({ repo, t }) {
1161
+ const [sub, setSub] = useState('basic')
1162
+ const [info, setInfo] = useState(null)
1163
+ const [form, setForm] = useState({ description: '', private: false, website: '' })
1164
+ const [collabs, setCollabs] = useState([])
1165
+ const [addName, setAddName] = useState('')
1166
+ const [hooks, setHooks] = useState(null)
1167
+ const [hookUrl, setHookUrl] = useState('')
1168
+ const [msg, setMsg] = useState('')
1169
+ const [busy, setBusy] = useState(false)
1170
+ const loadCollabs = () => api('GET', `/dsh/repos/${repo.owner}/${repo.name}/collaborators`).then((d) => setCollabs(Array.isArray(d) ? d : []))
1171
+ const loadHooks = () => api('GET', `/dsh/repos/${repo.owner}/${repo.name}/hooks`).then((d) => setHooks(Array.isArray(d) ? d : []))
1172
+ useEffect(() => {
1173
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}`).then((d) => {
1174
+ if (d && d.name) { setInfo(d); setForm({ description: d.description || '', private: !!d.private, website: d.website || '' }) }
1175
+ else setMsg((d && d.error) || t('loadFailed'))
1176
+ })
1177
+ loadCollabs()
1178
+ loadHooks()
1179
+ }, [repo.owner, repo.name])
1180
+ if (info === null && !msg) return h('div', { className: 'dgs-empty' }, t('loading'))
1181
+ const navItem = (k, label) => h('span', { key: k, className: 'item' + (sub === k ? ' active' : ''), onClick: () => setSub(k) }, label)
1182
+ const basic = h('div', { className: 'dgs-card', style: { marginBottom: 12 } },
1183
+ h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '基本设置'),
1184
+ h('div', { className: 'dgs-row' },
1185
+ h('span', { className: 'dgs-sub', style: { minWidth: 70 } }, '仓库描述'),
1186
+ h('input', { className: 'dgs-input', value: form.description, onChange: (e) => setForm({ ...form, description: e.target.value }) })),
1187
+ h('div', { className: 'dgs-sub', style: { margin: '2px 0 0 78px', fontSize: 11 } }, '最多 512 个字符,剩余 ' + (512 - (form.description || '').length)),
1188
+ h('div', { className: 'dgs-row' },
1189
+ h('span', { className: 'dgs-sub', style: { minWidth: 70 } }, '官方网站'),
1190
+ h('input', { className: 'dgs-input', placeholder: 'https://…', value: form.website, onChange: (e) => setForm({ ...form, website: e.target.value }) })),
1191
+ h('div', { className: 'dgs-row' },
1192
+ h('span', { className: 'dgs-sub', style: { minWidth: 70 } }, '可见性'),
1193
+ h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 4, alignItems: 'center' } },
1194
+ h('input', { type: 'checkbox', checked: form.private, onChange: (e) => setForm({ ...form, private: e.target.checked }) }), '该仓库为私有的')),
1195
+ h('div', { className: 'dgs-row', style: { marginTop: 8 } },
1196
+ h('button', { className: 'dgs-btn', disabled: busy,
1197
+ onClick: async () => {
1198
+ setBusy(true); setMsg('')
1199
+ const d = await api('PATCH', `/dsh/repos/${repo.owner}/${repo.name}`, form)
1200
+ setBusy(false)
1201
+ d && d.ok ? setMsg('✓ 已保存') : setMsg((d && d.error) || 'failed')
1202
+ } }, '更新设置')))
1203
+ const collab = h('div', { className: 'dgs-card' },
1204
+ h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '协作者'),
1205
+ h('div', { className: 'dgs-row' },
1206
+ h('input', { className: 'dgs-input', style: { maxWidth: 200 }, placeholder: '用户名', value: addName, onChange: (e) => setAddName(e.target.value) }),
1207
+ h('button', { className: 'dgs-btn', disabled: !addName.trim(),
1208
+ onClick: async () => {
1209
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/collaborators/${encodeURIComponent(addName.trim())}`)
1210
+ if (d && d.ok !== false && !d.error) { setAddName(''); setMsg(''); loadCollabs() } else setMsg((d && d.error) || 'failed')
1211
+ } }, '+ 添加')),
1212
+ collabs.length === 0 ? h('div', { className: 'dgs-sub', style: { marginTop: 8 } }, '—') : null,
1213
+ collabs.map((u) => h('div', { key: u.name, className: 'dgs-row', style: { marginTop: 6 } },
1214
+ h('a', { className: 'dgs-name', href: '#/u/' + u.name }, u.name),
1215
+ h('span', { style: { flex: 1 } }),
1216
+ h('button', { className: 'dgs-btn danger', onClick: async () => {
1217
+ await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/collaborators/${encodeURIComponent(u.name)}`)
1218
+ loadCollabs()
1219
+ } }, t('delete')))))
1220
+ const hooksCard = h('div', { className: 'dgs-card' },
1221
+ h('div', { style: { fontWeight: 700, marginBottom: 8 } }, 'Webhook'),
1222
+ h('div', { className: 'dgs-row' },
1223
+ h('input', { className: 'dgs-input', placeholder: 'https://…', value: hookUrl, onChange: (e) => setHookUrl(e.target.value) }),
1224
+ h('button', { className: 'dgs-btn', disabled: !hookUrl.trim(), onClick: async () => {
1225
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/hooks`, { url: hookUrl.trim() })
1226
+ if (d && !d.error) { setHookUrl(''); loadHooks() } else setMsg((d && d.error) || 'failed')
1227
+ } }, '+ 添加')),
1228
+ (hooks || []).map((hk) => h('div', { key: hk.id, className: 'dgs-row', style: { marginTop: 6 } },
1229
+ h('span', { className: 'dgs-badge' + (hk.is_active ? ' ok' : ' closed') }, hk.is_active ? '启用' : '停用'),
1230
+ h('span', { className: 'dgs-sub', style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' } }, hk.url),
1231
+ h('button', { className: 'dgs-btn ghost', onClick: async () => {
1232
+ await api('PATCH', `/dsh/repos/${repo.owner}/${repo.name}/hooks/${hk.id}`, { active: !hk.is_active }); loadHooks()
1233
+ } }, hk.is_active ? '停用' : '启用'),
1234
+ h('button', { className: 'dgs-btn danger', onClick: async () => {
1235
+ await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/hooks/${hk.id}`); loadHooks()
1236
+ } }, t('delete')))),
1237
+ hooks !== null && hooks.length === 0 ? h('div', { className: 'dgs-sub', style: { marginTop: 6 } }, '—') : null)
1238
+ const danger = h('div', { className: 'dgs-card', style: { marginTop: 12, borderColor: 'var(--dsw-alias-state-error-primary)55' } },
1239
+ h('div', { style: { fontWeight: 700, marginBottom: 8, color: 'var(--dsw-alias-state-error-primary)' } }, '危险区域'),
1240
+ h('div', { className: 'dgs-row' },
1241
+ h('span', { className: 'dgs-sub', style: { flex: 1 } }, '转移仓库所有权(组织/用户)'),
1242
+ h('button', { className: 'dgs-btn ghost', onClick: async () => {
1243
+ const org = prompt('转移到组织名(留空 = 当前用户)', '')
1244
+ if (org === null) return
1245
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/transfer`, { org: org || undefined })
1246
+ setMsg(d && d.ok ? '已转移' : (d && d.error) || '转移失败')
1247
+ } }, '转移仓库')),
1248
+ h('div', { className: 'dgs-row' },
1249
+ h('span', { className: 'dgs-sub', style: { flex: 1 } }, '清除 Wiki 数据(删除所有 Wiki 页面)'),
1250
+ h('button', { className: 'dgs-btn danger', onClick: async () => {
1251
+ if (!confirm('确定清除该仓库的全部 Wiki 页面?')) return
1252
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/wiki-wipe`)
1253
+ setMsg(d && d.ok ? '已清除' : (d && d.error) || '清除失败')
1254
+ } }, '清除 Wiki 数据')),
1255
+ h('div', { className: 'dgs-row' },
1256
+ h('span', { className: 'dgs-sub', style: { flex: 1 } }, '删除此仓库(含所有工单/PR/Wiki)'),
1257
+ h('button', { className: 'dgs-btn danger', onClick: async () => {
1258
+ if (!confirm('确定删除仓库 ' + repo.name + ' ?此操作不可恢复')) return
1259
+ const d = await api('DELETE', '/repos/' + repo.owner + '/' + repo.name)
1260
+ if (d && d.ok) nav('/repos')
1261
+ else setMsg((d && d.error) || '删除失败')
1262
+ } }, t('delete') + ' ' + repo.name)))
1263
+ return h('div', null,
1264
+ msg ? h('div', { className: 'dgs-err' }, msg) : null,
1265
+ h('div', { className: 'dgs-settings-layout' },
1266
+ h('div', { className: 'dgs-settings-nav' },
1267
+ navItem('basic', '基本设置'),
1268
+ navItem('collab', '管理协作者'),
1269
+ navItem('hooks', '管理 Web 钩子'),
1270
+ navItem('danger', '危险区域')),
1271
+ h('div', { style: { flex: 1, minWidth: 0 } },
1272
+ sub === 'basic' ? basic : sub === 'collab' ? collab : sub === 'hooks' ? hooksCard : danger)))
1273
+ }
1274
+
1275
+ // ── PR(列表 + 详情 + 合并 + 评论复用 issue 通道) ────────────────────────
1276
+
1277
+ function Pulls({ repo, t, preset, onPresetDone, onGoIssuesSub }) {
1278
+ const [list, setList] = useState(null)
1279
+ const [openIdx, setOpenIdx] = useState(null)
1280
+ const [creating, setCreating] = useState(false)
1281
+ const [state, setState] = useState('open')
1282
+ const [flt, setFlt] = useState({ label: '', milestone: '', assignee: '' })
1283
+ const [sort, setSort] = useState('latest')
1284
+ const [meta, setMeta] = useState({ labels: [], milestones: [], collabs: [] })
1285
+ const [branches, setBranches] = useState([])
1286
+ const [form, setForm] = useState({ title: '', head: '', base: '', body: '' })
1287
+ const [sel, setSel] = useState({ labels: new Set(), milestone: '', assignee: '' })
1288
+ const [msg, setMsg] = useState('')
1289
+ const [busy, setBusy] = useState(false)
1290
+ const [preview, setPreview] = useState(null)
1291
+ const [previewMode, setPreviewMode] = useState(false)
1292
+ const [previewHtml, setPreviewHtml] = useState('')
1293
+ const reload = useCallback(() => {
1294
+ setList(null)
1295
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/pulls`).then((d) => setList(Array.isArray(d) ? d : (d.data || d.pulls || [])))
1296
+ }, [repo.owner, repo.name])
1297
+ useEffect(reload, [reload])
1298
+ useEffect(() => {
1299
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/overview`).then((d) => {
1300
+ setBranches(d.branches || [])
1301
+ setForm((f) => ({ ...f, base: d.defaultBranch || 'master' }))
1302
+ })
1303
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/labels`).then((d) => setMeta((m) => ({ ...m, labels: Array.isArray(d) ? d : [] })))
1304
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/milestones`).then((d) => setMeta((m) => ({ ...m, milestones: Array.isArray(d) ? d : [] })))
1305
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/collaborators`).then((d) => setMeta((m) => ({ ...m, collabs: Array.isArray(d) ? d : [] })))
1306
+ }, [repo.owner, repo.name])
1307
+ useEffect(() => {
1308
+ if (!creating || !form.head || !form.base || form.head === form.base) { setPreview(null); return }
1309
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/compare?base=${encodeURIComponent(form.base)}&head=${encodeURIComponent(form.head)}`)
1310
+ .then((d) => setPreview(d && d.commits ? d : null))
1311
+ }, [creating, form.head, form.base, repo.owner, repo.name])
1312
+ useEffect(() => {
1313
+ if (preset) {
1314
+ setCreating(true)
1315
+ setForm((f) => ({ ...f, head: preset.head, base: preset.base || f.base }))
1316
+ onPresetDone && onPresetDone()
1317
+ }
1318
+ }, [preset])
1319
+ if (openIdx !== null) return h(PullDetail, { repo, idx: openIdx, t, onBack: () => { setOpenIdx(null); reload() } })
1320
+ if (list === null) return h('div', { className: 'dgs-empty' }, t('loading'))
1321
+ const openCount = list.filter((x) => x.state === 'open').length
1322
+ const closedCount = list.filter((x) => x.state !== 'open').length
1323
+ let shown = list.filter((x) => state === 'open' ? x.state === 'open' : x.state !== 'open')
1324
+ if (flt.label) shown = shown.filter((x) => (x.labels || []).some((l) => String(l.id) === String(flt.label)))
1325
+ if (flt.milestone) shown = shown.filter((x) => x.milestone && String(x.milestone.id) === String(flt.milestone))
1326
+ if (flt.assignee) shown = shown.filter((x) => x.assignee === flt.assignee)
1327
+ shown = [...shown].sort((a, b) => {
1328
+ const at = (a.updatedAt || 0) * 1000; const bt = (b.updatedAt || 0) * 1000
1329
+ return sort === 'latest' ? bt - at : sort === 'oldest' ? at - bt : (b.comments || 0) - (a.comments || 0)
1330
+ })
1331
+ const fltSel = (key, items, blank) => h('select', {
1332
+ key: key, className: 'dgs-input', style: { maxWidth: 130, flex: 'none', width: 'auto', padding: '5px 8px', marginLeft: 8 },
1333
+ value: flt[key], onChange: (e) => setFlt({ ...flt, [key]: e.target.value }),
1334
+ }, h('option', { value: '' }, blank),
1335
+ items.map((x) => h('option', { key: x.id || x.name, value: String(x.id || x.name) }, x.title || x.name)))
1336
+ const row = (x) => h('div', { key: x.index, className: 'dgs-issue', style: { cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }, onClick: () => setOpenIdx(x.index) },
1337
+ h('span', { className: 'dgs-issue-num' + (x.state !== 'open' ? ' closed' : '') }, '#' + x.index),
1338
+ h('div', { style: { flex: 1, minWidth: 0 } },
1339
+ h('div', { className: 'dgs-row', style: { gap: 6 } },
1340
+ h('span', { className: 'dgs-issue-title' }, x.title),
1341
+ (x.labels || []).map((l) => h('span', { key: l.id, className: 'dgs-badge', style: { background: (l.color || '#70c24a') + '33', borderColor: l.color || '#70c24a' } }, l.name)),
1342
+ x.milestone ? h('span', { className: 'dgs-badge' }, '◆ ' + x.milestone.title) : null,
1343
+ x.state === 'merged' ? h('span', { className: 'dgs-badge' }, '已合并') : null),
1344
+ h('div', { className: 'dgs-sub', style: { marginTop: 2 } },
1345
+ '由 ' + (x.author || '') + ' 于 ' + timeAgo((x.updatedAt || 0) * 1000 || x.updatedAt) + ' 发起 · ' + x.head + ' → ' + x.base)),
1346
+ h('span', { className: 'dgs-sub', style: { flex: 'none' } }, (x.comments != null ? '💬 ' + x.comments : '')))
1347
+ const toggleLabel = (id) => setSel((x) => {
1348
+ const next = new Set(x.labels)
1349
+ next.has(id) ? next.delete(id) : next.add(id)
1350
+ return { ...x, labels: next }
1351
+ })
1352
+ const createSide = h('div', null,
1353
+ h('div', { className: 'dgs-side-block' },
1354
+ h('div', { className: 'dgs-side-title' }, '标签 ⚙'),
1355
+ meta.labels.length === 0 ? h('div', { className: 'dgs-sub' }, '未选择标签') : null,
1356
+ meta.labels.map((l) => h('label', { key: l.id, className: 'dgs-sub', style: { display: 'flex', gap: 6, alignItems: 'center', margin: '4px 0', cursor: 'pointer' } },
1357
+ h('input', { type: 'checkbox', checked: sel.labels.has(l.id), onChange: () => toggleLabel(l.id) }),
1358
+ h('span', { className: 'dgs-badge', style: { background: (l.color || '#70c24a') + '33' } }, l.name)))),
1359
+ h('div', { className: 'dgs-side-block' },
1360
+ h('div', { className: 'dgs-side-title' }, '里程碑 ⚙'),
1361
+ h('select', { className: 'dgs-input', style: { width: '100%' }, value: sel.milestone, onChange: (e) => setSel({ ...sel, milestone: e.target.value }) },
1362
+ h('option', { value: '' }, '未选择里程碑'),
1363
+ meta.milestones.map((m) => h('option', { key: m.id, value: m.id }, m.title + (m.closed ? ' ✓' : ''))))),
1364
+ h('div', { className: 'dgs-side-block' },
1365
+ h('div', { className: 'dgs-side-title' }, '指派成员 ⚙'),
1366
+ h('select', { className: 'dgs-input', style: { width: '100%' }, value: sel.assignee, onChange: (e) => setSel({ ...sel, assignee: e.target.value }) },
1367
+ h('option', { value: '' }, '未指派成员'),
1368
+ meta.collabs.map((u) => h('option', { key: u.name, value: u.name }, u.name)))))
1369
+ const createForm = h('div', { className: 'dgs-card', style: { margin: '10px 0' } },
1370
+ h('div', { className: 'dgs-row', style: { marginBottom: 8 } },
1371
+ h('span', { style: { fontWeight: 700 } }, '对比文件变化'),
1372
+ h('span', { style: { flex: 1 } }),
1373
+ h('button', { className: 'dgs-btn ghost', onClick: () => setCreating(false) }, t('back'))),
1374
+ h('div', { className: 'dgs-sub', style: { marginBottom: 8 } }, '对比两个分支间的文件变化并发起一个合并请求。'),
1375
+ h('div', { className: 'dgs-issue-layout' },
1376
+ h('div', { className: 'dgs-issue-main' },
1377
+ h('div', { className: 'dgs-row', style: { marginBottom: 8 } },
1378
+ h('select', { className: 'dgs-input', style: { maxWidth: 170, flex: 'none', width: 'auto' }, value: form.base, onChange: (e) => setForm({ ...form, base: e.target.value }) },
1379
+ branches.map((b) => h('option', { key: b, value: b }, '基准分支: ' + b))),
1380
+ h('span', { className: 'dgs-sub' }, '…'),
1381
+ h('select', { className: 'dgs-input', style: { maxWidth: 170, flex: 'none', width: 'auto' }, value: form.head, onChange: (e) => setForm({ ...form, head: e.target.value }) },
1382
+ h('option', { value: '' }, '对比分支…'),
1383
+ branches.map((b) => h('option', { key: b, value: b }, '对比分支: ' + b)))),
1384
+ h('input', { className: 'dgs-input', placeholder: '标题', value: form.title, onChange: (e) => setForm({ ...form, title: e.target.value }) }),
1385
+ h('div', { className: 'dgs-row', style: { margin: '8px 0 4px' } },
1386
+ h('button', { className: 'dgs-subtab' + (!previewMode ? ' active' : ''), onClick: () => setPreviewMode(false) }, '内容编辑'),
1387
+ h('button', { className: 'dgs-subtab' + (previewMode ? ' active' : ''), onClick: () => {
1388
+ setPreviewMode(true)
1389
+ api('POST', '/dsh/markdown', { text: form.body }).then((d) => setPreviewHtml(d.html || ''))
1390
+ } }, '效果预览')),
1391
+ previewMode ? h('div', { className: 'dgs-md', style: { minHeight: 120, border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8, padding: 12 } },
1392
+ form.body ? h('div', { dangerouslySetInnerHTML: { __html: previewHtml } }) : h('span', { className: 'dgs-sub' }, '暂无内容'))
1393
+ : h('textarea', { className: 'dgs-input', style: { minHeight: 120 }, placeholder: '内容', value: form.body, onChange: (e) => setForm({ ...form, body: e.target.value }) }),
1394
+ preview ? h('div', { className: 'dgs-card', style: { margin: '8px 0', padding: 10 } },
1395
+ h('div', { className: 'dgs-sub', style: { marginBottom: 6 } }, `${preview.commits.length} commits`),
1396
+ h('pre', { className: 'dgs-file' }, preview.diffStat || '—')) : null,
1397
+ h('div', { className: 'dgs-row', style: { marginTop: 10, justifyContent: 'flex-end' } },
1398
+ h('button', { className: 'dgs-btn', disabled: busy || !form.title.trim() || !form.head,
1399
+ onClick: async () => {
1400
+ setBusy(true); setMsg('')
1401
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/pulls`, {
1402
+ ...form,
1403
+ labels: [...sel.labels].map(Number),
1404
+ milestone: sel.milestone ? Number(sel.milestone) : undefined,
1405
+ assignee: sel.assignee || undefined,
1406
+ })
1407
+ setBusy(false)
1408
+ if (d.ok) { setCreating(false); setForm({ title: '', head: '', base: form.base, body: '' }); setSel({ labels: new Set(), milestone: '', assignee: '' }); reload() }
1409
+ else setMsg(d.error || 'failed')
1410
+ } }, '创建合并请求'))),
1411
+ h('div', { className: 'dgs-issue-side' }, createSide)))
1412
+ return h('div', null,
1413
+ h('div', { className: 'dgs-row', style: { margin: '0 0 12px' } },
1414
+ h('a', { style: { cursor: 'pointer', marginRight: 18, fontSize: 13, color: 'var(--dsw-alias-label-secondary)' }, onClick: () => { if (onGoIssuesSub) onGoIssuesSub('labels') } }, '标签管理'),
1415
+ h('a', { style: { cursor: 'pointer', fontSize: 13, color: 'var(--dsw-alias-label-secondary)' }, onClick: () => { if (onGoIssuesSub) onGoIssuesSub('milestones') } }, '里程碑'),
1416
+ h('span', { style: { flex: 1 } }),
1417
+ h('button', { className: 'dgs-btn', onClick: () => setCreating(!creating) }, '创建合并请求')),
1418
+ h('div', { className: 'dgs-row', style: { margin: '0 0 12px' } },
1419
+ h('button', { className: 'dgs-pill' + (state === 'open' ? ' active' : ''), onClick: () => setState('open') }, '⊘ ' + openCount + ' 个开启中'),
1420
+ h('button', { className: 'dgs-pill closed' + (state === 'closed' ? ' active' : ''), onClick: () => setState('closed') }, '✓ ' + closedCount + ' 个已关闭'),
1421
+ h('span', { style: { flex: 1 } }),
1422
+ fltSel('label', meta.labels, '标签筛选'),
1423
+ fltSel('milestone', meta.milestones, '里程碑筛选'),
1424
+ fltSel('assignee', meta.collabs, '指派人筛选'),
1425
+ h('select', { key: 'sort', className: 'dgs-input', style: { maxWidth: 130, flex: 'none', width: 'auto', padding: '5px 8px', marginLeft: 8 }, value: sort, onChange: (e) => setSort(e.target.value) },
1426
+ [['latest', '最新'], ['oldest', '最旧'], ['mostcomment', '最多评论']].map(([v, label]) => h('option', { key: v, value: v }, '排序: ' + label)))),
1427
+ msg ? h('div', { className: 'dgs-err' }, msg) : null,
1428
+ creating ? createForm : null,
1429
+ shown.length === 0 && !creating ? h('div', { className: 'dgs-empty' }, t('noPulls')) : null,
1430
+ shown.map(row))
1431
+ }
1432
+
1433
+ function PullDetail({ repo, idx, t, onBack }) {
1434
+ const [pr, setPr] = useState(null)
1435
+ const [comments, setComments] = useState(null)
1436
+ const [text, setText] = useState('')
1437
+ const [busy, setBusy] = useState(false)
1438
+ const [msg, setMsg] = useState('')
1439
+ const [mergeStyle, setMergeStyle] = useState('merge')
1440
+ const [tab, setTab] = useState('conv')
1441
+ const [cmp, setCmp] = useState(null)
1442
+ const [selCommit, setSelCommit] = useState(null)
1443
+ const [issue, setIssue] = useState(null)
1444
+ const [editing, setEditing] = useState(false)
1445
+ const [draft, setDraft] = useState({ title: '', body: '' })
1446
+ const [allLabels, setAllLabels] = useState([])
1447
+ const [allMs, setAllMs] = useState([])
1448
+ const [collabs, setCollabs] = useState([])
1449
+ const [mergeMsg, setMergeMsg] = useState('')
1450
+ useEffect(() => {
1451
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/pulls`).then((d) => {
1452
+ const all = Array.isArray(d) ? d : (d.data || d.pulls || [])
1453
+ const found = all.find((x) => x.index === idx) || null
1454
+ setPr(found)
1455
+ if (found) api('GET', `/dsh/repos/${repo.owner}/${repo.name}/compare?base=${encodeURIComponent(found.base)}&head=${encodeURIComponent(found.head)}`).then((c) => c && c.commits && setCmp(c))
1456
+ })
1457
+ api('GET', `/repos/${repo.owner}/${repo.name}/issues/${idx}/comments`).then((d) => setComments(d.comments || []))
1458
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/issues/${idx}`).then((d) => setIssue(d && d.number ? d : null))
1459
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/labels`).then((d) => setAllLabels(Array.isArray(d) ? d : []))
1460
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/milestones`).then((d) => setAllMs(Array.isArray(d) ? d : []))
1461
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/collaborators`).then((d) => setCollabs(Array.isArray(d) ? d : []))
1462
+ }, [repo.owner, repo.name, idx])
1463
+ const patchIssue = async (fields) => {
1464
+ setBusy(true)
1465
+ await api('PATCH', `/dsh/repos/${repo.owner}/${repo.name}/issues/${idx}`, fields)
1466
+ setBusy(false)
1467
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/issues/${idx}`).then((d) => setIssue(d && d.number ? d : null))
1468
+ }
1469
+
1470
+ if (pr === null) return h('div', { className: 'dgs-empty' }, t('loading'))
1471
+ const headRef = (pr.headUser || repo.owner) + '/' + pr.head
1472
+ const baseRef = repo.owner + '/' + pr.base
1473
+ const side = h('div', null,
1474
+ h('div', { className: 'dgs-side-block' },
1475
+ h('div', { className: 'dgs-side-title' }, '标签 ⚙'),
1476
+ !issue ? h('div', { className: 'dgs-sub' }, '…') : allLabels.length === 0 ? h('div', { className: 'dgs-sub' }, '未选择标签') : null,
1477
+ issue && allLabels.map((l) => h('label', { key: l.id, className: 'dgs-sub', style: { display: 'flex', gap: 6, alignItems: 'center', margin: '4px 0', cursor: 'pointer' } },
1478
+ h('input', { type: 'checkbox', checked: (issue.labels || []).some((x) => x.id === l.id),
1479
+ onChange: (e) => {
1480
+ const cur = new Set((issue.labels || []).map((x) => x.id))
1481
+ if (e.target.checked) cur.add(l.id); else cur.delete(l.id)
1482
+ patchIssue({ labels: [...cur] })
1483
+ } }),
1484
+ h('span', { className: 'dgs-badge', style: { background: (l.color || '#70c24a') + '33' } }, l.name)))),
1485
+ h('div', { className: 'dgs-side-block' },
1486
+ h('div', { className: 'dgs-side-title' }, '里程碑 ⚙'),
1487
+ !issue ? h('div', { className: 'dgs-sub' }, '…')
1488
+ : h('select', { className: 'dgs-input', style: { width: '100%' }, value: issue.milestone ? issue.milestone.id : '',
1489
+ onChange: (e) => patchIssue({ milestone: Number(e.target.value) || 0 }) },
1490
+ h('option', { value: '' }, '未选择里程碑'),
1491
+ allMs.map((m) => h('option', { key: m.id, value: m.id }, m.title + (m.closed ? ' ✓' : ''))))),
1492
+ h('div', { className: 'dgs-side-block' },
1493
+ h('div', { className: 'dgs-side-title' }, '指派成员 ⚙'),
1494
+ !issue ? h('div', { className: 'dgs-sub' }, '…')
1495
+ : h('select', { className: 'dgs-input', style: { width: '100%' }, value: issue.assignee || '',
1496
+ onChange: (e) => patchIssue({ assignee: e.target.value }) },
1497
+ h('option', { value: '' }, '未指派成员'),
1498
+ collabs.map((u) => h('option', { key: u.name, value: u.name }, u.name)))),
1499
+ h('div', { className: 'dgs-side-block' },
1500
+ h('div', { className: 'dgs-side-title' }, (issue && issue.participants || 1) + ' 名参与者'),
1501
+ h('div', { className: 'dgs-sub' }, pr.author || '')))
1502
+ const mergeBox = pr.state === 'open' ? h('div', { className: 'dgs-card', style: { margin: '10px 0', borderColor: 'var(--dsw-alias-state-success-primary)55' } },
1503
+ h('div', { className: 'dgs-row', style: { gap: 8 } },
1504
+ h('span', { style: { color: 'var(--dsw-alias-state-success-primary)', fontSize: 16 } }, '⑂'),
1505
+ h('span', { style: { color: 'var(--dsw-alias-state-success-primary)', fontSize: 13 } }, '该合并请求可以进行自动合并操作。')),
1506
+ h('div', { style: { margin: '10px 0' } },
1507
+ h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 6, alignItems: 'center', margin: '4px 0' } },
1508
+ h('input', { type: 'radio', name: 'mergeStyle', checked: mergeStyle === 'merge', onChange: () => setMergeStyle('merge') }), '创建一个新的合并提交'),
1509
+ h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 6, alignItems: 'center', margin: '4px 0' } },
1510
+ h('input', { type: 'radio', name: 'mergeStyle', checked: mergeStyle === 'squash', onChange: () => setMergeStyle('squash') }), '压缩提交并合并'),
1511
+ h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 6, alignItems: 'center', margin: '4px 0' } },
1512
+ h('input', { type: 'radio', name: 'mergeStyle', checked: mergeStyle === 'rebase', onChange: () => setMergeStyle('rebase') }), '变基并合并')),
1513
+ h('div', { className: 'dgs-sub', style: { margin: '6px 0' } }, '提交说明:'),
1514
+ h('textarea', { className: 'dgs-input', style: { minHeight: 60 }, value: mergeMsg, onChange: (e) => setMergeMsg(e.target.value) }),
1515
+ h('div', { className: 'dgs-row', style: { marginTop: 10 } },
1516
+ h('button', { className: 'dgs-btn', disabled: busy,
1517
+ onClick: async () => {
1518
+ if (!confirm(t('mergeConfirm'))) return
1519
+ setBusy(true); setMsg('')
1520
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/pulls/${idx}/merge`, { style: mergeStyle, message: mergeMsg })
1521
+ setBusy(false)
1522
+ d.ok !== false && !d.error ? onBack() : setMsg(d.error || 'merge failed')
1523
+ } }, '⇆ 合并请求'),
1524
+ h('button', { className: 'dgs-btn danger', disabled: busy,
1525
+ onClick: async () => {
1526
+ if (!confirm('不合并直接关闭此 PR?')) return
1527
+ setBusy(true)
1528
+ await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/pulls/${idx}/close`)
1529
+ setBusy(false); onBack()
1530
+ } }, '关闭')))
1531
+ : h('div', { className: 'dgs-card', style: { margin: '10px 0' } },
1532
+ h('span', { className: 'dgs-badge' }, pr.state === 'merged' ? '已合并' : '已关闭'))
1533
+ const conv = h('div', null,
1534
+ (comments || []).map((c) =>
1535
+ h('div', { key: c.id, className: 'dgs-comment' },
1536
+ h('div', { className: 'dgs-sub', style: { marginBottom: 4 } }, `${c.user || ''} 评论于 ${fmtDate(c.created)}`),
1537
+ h('div', { style: { whiteSpace: 'pre-wrap' } }, c.body || '这个人很懒,什么都没留下。'))),
1538
+ h('div', { className: 'dgs-card', style: { margin: '10px 0' } },
1539
+ h('textarea', { className: 'dgs-input', placeholder: t('commentPlaceholder'), value: text, onChange: (e) => setText(e.target.value) }),
1540
+ h('div', { className: 'dgs-row', style: { marginTop: 8 } },
1541
+ h('button', { className: 'dgs-btn', disabled: busy || !text.trim(),
1542
+ onClick: async () => {
1543
+ setBusy(true)
1544
+ await api('POST', `/repos/${repo.owner}/${repo.name}/issues/${idx}/comments`, { body: text })
1545
+ setText(''); setBusy(false)
1546
+ api('GET', `/repos/${repo.owner}/${repo.name}/issues/${idx}/comments`).then((d) => setComments(d.comments || []))
1547
+ } }, t('comment')))))
1548
+ const commitsTab = selCommit
1549
+ ? h(CommitDetail, { repo, sha: selCommit, t, onBack: () => setSelCommit(null) })
1550
+ : cmp === null ? h('div', { className: 'dgs-empty' }, t('loading'))
1551
+ : cmp.commits.length === 0 ? h('div', { className: 'dgs-empty' }, '—')
1552
+ : h('table', { className: 'dgs-table' },
1553
+ h('tbody', null, cmp.commits.map((c) =>
1554
+ h('tr', { key: c.sha, style: { cursor: 'pointer' }, onClick: () => setSelCommit(c.sha) },
1555
+ h('td', null, h('div', { style: { fontWeight: 500 } }, (c.message || '').split('\n')[0]),
1556
+ h('div', { className: 'dgs-sub' }, c.author)),
1557
+ h('td', { className: 'dgs-sub', style: { textAlign: 'right' } },
1558
+ h('a', { style: { color: 'var(--dsw-alias-state-business-primary)', cursor: 'pointer' } }, (c.sha || '').slice(0, 10)))))))
1559
+ const filesTab = cmp === null ? h('div', { className: 'dgs-empty' }, t('loading'))
1560
+ : cmp.diff ? h(DiffView, { patch: cmp.diff })
1561
+ : h('div', { className: 'dgs-empty' }, '无差异')
1562
+ const tabBtn = (k, label, count) => h('button', { key: k, className: 'dgs-subtab' + (tab === k ? ' active' : ''), onClick: () => setTab(k) },
1563
+ label + (count !== undefined && count !== null ? ' ' + count : ''))
1564
+ return h('div', null,
1565
+ h('div', { className: 'dgs-row', style: { marginBottom: 6 } },
1566
+ h('button', { className: 'dgs-btn ghost', onClick: onBack }, t('back')),
1567
+ h('span', { className: 'dgs-h1', style: { fontSize: 17 } }, '#' + idx + ' ' + pr.title),
1568
+ h('span', { style: { flex: 1 } }),
1569
+ h('button', { className: 'dgs-btn ghost', onClick: () => {
1570
+ if (!issue) return
1571
+ setDraft({ title: issue.title, body: issue.body })
1572
+ setEditing(!editing)
1573
+ } }, '编辑')),
1574
+ h('div', { className: 'dgs-row', style: { marginBottom: 10 } },
1575
+ h('span', { className: 'dgs-issue-num' }, pr.state === 'open' ? '开启中' : pr.state === 'merged' ? '已合并' : '已关闭'),
1576
+ h('span', { className: 'dgs-sub' },
1577
+ (pr.author || '') + ' 请求将 ' + (cmp ? cmp.commits.length : 'N') + ' 次代码提交从 ' + headRef + ' 合并至 ' + baseRef)),
1578
+ msg ? h('div', { className: 'dgs-err' }, msg) : null,
1579
+ editing && issue ? h('div', { className: 'dgs-card', style: { margin: '10px 0' } },
1580
+ h('input', { className: 'dgs-input', value: draft.title, onChange: (e) => setDraft({ ...draft, title: e.target.value }) }),
1581
+ h('textarea', { className: 'dgs-input', style: { marginTop: 8, minHeight: 100 }, value: draft.body, onChange: (e) => setDraft({ ...draft, body: e.target.value }) }),
1582
+ h('div', { className: 'dgs-row', style: { marginTop: 8, justifyContent: 'flex-end' } },
1583
+ h('button', { className: 'dgs-btn ghost', onClick: () => setEditing(false) }, '取消'),
1584
+ h('button', { className: 'dgs-btn', disabled: busy || !draft.title.trim(),
1585
+ onClick: async () => { await patchIssue({ title: draft.title, body: draft.body }); setEditing(false) } }, '保存'))) : null,
1586
+ h('div', { className: 'dgs-subtabs', style: { margin: '8px 0 12px' } },
1587
+ tabBtn('conv', '对话内容', (comments || []).length),
1588
+ tabBtn('commits', '代码提交', cmp ? cmp.commits.length : ''),
1589
+ tabBtn('files', '文件变动', cmp && cmp.diff ? '1' : '0')),
1590
+ h('div', { className: 'dgs-issue-layout' },
1591
+ h('div', { className: 'dgs-issue-main' },
1592
+ tab === 'conv' ? h('div', null, conv, mergeBox) : tab === 'commits' ? commitsTab : filesTab),
1593
+ h('div', { className: 'dgs-issue-side' }, side)))
1594
+ }
1595
+
1596
+ function WikiView({ repo, t }) {
1597
+ const [pages, setPages] = useState(null)
1598
+ const [page, setPage] = useState(null) // {name, html, content}
1599
+ const [hist, setHist] = useState(null)
1600
+ const [histMeta, setHistMeta] = useState(null)
1601
+ const [editing, setEditing] = useState(false)
1602
+ const [draft, setDraft] = useState('')
1603
+ const [creating, setCreating] = useState(false)
1604
+ const [form, setForm] = useState({ title: '', content: '' })
1605
+ const [busy, setBusy] = useState(false)
1606
+ const [msg, setMsg] = useState('')
1607
+ const reload = useCallback(() => {
1608
+ setPages(null)
1609
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/wiki`).then((d) => setPages(d.pages || []))
1610
+ }, [repo.owner, repo.name])
1611
+ useEffect(reload, [reload])
1612
+ const openPage = (name) => {
1613
+ setEditing(false); setCreating(false); setMsg(''); setHist(null); setHistMeta(null)
1614
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/wiki/${encodeURIComponent(name)}`)
1615
+ .then((d) => d.html !== undefined ? setPage(d) : setPage({ name, missing: true, content: '', html: '' }))
1616
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/wiki/${encodeURIComponent(name)}/commits`)
1617
+ .then((d) => { if (Array.isArray(d) && d.length) setHistMeta({ author: d[0].author, date: d[0].date }) })
1618
+ }
1619
+ return h('div', null,
1620
+ h('div', { className: 'dgs-row', style: { margin: '8px 0 12px' } },
1621
+ h('span', { style: { fontWeight: 700 } }, t('wiki')),
1622
+ h('span', { style: { flex: 1 } }),
1623
+ h('button', { className: 'dgs-btn', onClick: () => {
1624
+ if (creating) { setCreating(false); return }
1625
+ setCreating(true); setEditing(false); setPage(null); setForm({ title: '', content: '' })
1626
+ } }, '+ ' + t('newWiki'))),
1627
+ msg ? h('div', { className: 'dgs-err' }, msg) : null,
1628
+ creating ? h('div', { className: 'dgs-card', style: { margin: '10px 0' } },
1629
+ h('input', { className: 'dgs-input', placeholder: t('wikiTitle'), value: form.title, onChange: (e) => setForm({ ...form, title: e.target.value }) }),
1630
+ h('textarea', { className: 'dgs-input', style: { marginTop: 8, minHeight: '40vh' }, placeholder: '内容(Markdown)', value: form.content, onChange: (e) => setForm({ ...form, content: e.target.value }) }),
1631
+ h('div', { className: 'dgs-row', style: { marginTop: 8 } },
1632
+ h('button', { className: 'dgs-btn', disabled: busy || !form.title.trim(),
1633
+ onClick: async () => {
1634
+ setBusy(true); setMsg('')
1635
+ const name = form.title.trim()
1636
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/wiki/${encodeURIComponent(name)}`, { content: form.content })
1637
+ setBusy(false)
1638
+ if (d.ok) { setCreating(false); reload(); openPage(name) } else setMsg(d.error || 'save failed')
1639
+ } }, t('submit'))))
1640
+ : h('div', { className: 'dgs-row', style: { alignItems: 'flex-start' } },
1641
+ h('div', { style: { width: 180, flex: 'none' } },
1642
+ pages === null ? h('div', { className: 'dgs-sub' }, t('loading'))
1643
+ : pages.length === 0 ? h('div', { className: 'dgs-sub' }, t('noWiki'))
1644
+ : h('div', null, pages.map((p) =>
1645
+ h('div', { key: p.name, className: 'dgs-crumb', style: { padding: '3px 0' }, onClick: () => openPage(p.name) }, p.name)))),
1646
+ h('div', { style: { flex: 1, minWidth: 0, paddingLeft: 16 } },
1647
+ page === null ? h('div', { className: 'dgs-card', style: { margin: '40px auto', maxWidth: 460, textAlign: 'center', padding: '36px 20px' } },
1648
+ h('div', { style: { fontSize: 34, marginBottom: 10 } }, '📖'),
1649
+ h('div', { style: { fontWeight: 700, fontSize: 16, marginBottom: 8 } }, '欢迎使用 Wiki!'),
1650
+ h('div', { className: 'dgs-sub' }, 'Wiki 是用于共同协作文档的地方,清晰的文档可以帮助其他人深入了解您的项目。'),
1651
+ pages && pages.length ? h('div', { className: 'dgs-sub', style: { marginTop: 10 } }, '← 从左侧选择页面') : null)
1652
+ : editing ? h('div', null,
1653
+ h('div', { className: 'dgs-row', style: { margin: '4px 0 8px' } },
1654
+ h('span', { style: { fontWeight: 600 } }, page.name + '.md'),
1655
+ h('span', { style: { flex: 1 } }),
1656
+ h('button', { className: 'dgs-btn ghost', onClick: () => page.html ? setEditing(false) : setPage(null) }, t('back')),
1657
+ h('button', { className: 'dgs-btn', disabled: busy,
1658
+ onClick: async () => {
1659
+ setBusy(true); setMsg('')
1660
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/wiki/${encodeURIComponent(page.name)}`, { content: draft })
1661
+ setBusy(false)
1662
+ if (d.ok) { setEditing(false); reload(); openPage(page.name) } else setMsg(d.error || 'save failed')
1663
+ } }, t('save'))),
1664
+ h('textarea', { className: 'dgs-input', style: { minHeight: '50vh' }, value: draft, onChange: (e) => setDraft(e.target.value) }))
1665
+ : page.missing ? h('div', { className: 'dgs-empty' }, '404')
1666
+ : h('div', null,
1667
+ h('div', { className: 'dgs-row', style: { margin: '4px 0 8px' } },
1668
+ h('div', null,
1669
+ h('span', { style: { fontWeight: 600 } }, page.name),
1670
+ histMeta ? h('div', { className: 'dgs-sub', style: { marginTop: 2 } }, (histMeta.author || '') + ' 于 ' + timeAgo(histMeta.date) + ' 修改了此页面') : null),
1671
+ h('span', { style: { flex: 1 } }),
1672
+ h('button', { className: 'dgs-btn ghost', onClick: () => { setDraft(page.content || ''); setEditing(true) } }, t('edit')),
1673
+ h('button', { className: 'dgs-btn ghost', onClick: () => {
1674
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/wiki/${encodeURIComponent(page.name)}/commits`).then((d) => setHist(Array.isArray(d) ? d : []))
1675
+ } }, '历史'),
1676
+ h('button', { className: 'dgs-btn danger', onClick: async () => {
1677
+ if (!confirm('删除页面 ' + page.name + ' ?')) return
1678
+ await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/wiki/${encodeURIComponent(page.name)}`)
1679
+ setPage(null); reload()
1680
+ } }, t('delete'))),
1681
+ hist ? h('div', { className: 'dgs-card', style: { margin: '8px 0', padding: '8px 14px' } },
1682
+ hist.length === 0 ? h('span', { className: 'dgs-sub' }, '—')
1683
+ : hist.map((cm, i) => h('div', { key: i, className: 'dgs-row', style: { padding: '2px 0' } },
1684
+ h('span', { className: 'dgs-sub', style: { minWidth: 64 } }, (cm.sha || '').slice(0, 7)),
1685
+ h('span', { style: { flex: 1 } }, cm.message || ''),
1686
+ h('span', { className: 'dgs-sub' }, (cm.author || '') + ' · ' + fmtDate(cm.date))))) : null,
1687
+ h('div', { className: 'dgs-card' },
1688
+ h('div', { className: 'dgs-md', dangerouslySetInnerHTML: { __html: page.html } }))))))
1689
+ }
1690
+
1691
+ // ── 发版(列表 + 创建) ────────────────────────────────────────────────────
1692
+
1693
+ function Releases({ repo, t }) {
1694
+ const [list, setList] = useState(null)
1695
+ const [creating, setCreating] = useState(false)
1696
+ const [form, setForm] = useState({ tag: '', title: '', note: '', target: '' })
1697
+ const [editId, setEditId] = useState(null)
1698
+ const [editForm, setEditForm] = useState({ title: '', note: '' })
1699
+ const [busy, setBusy] = useState(false)
1700
+ const [msg, setMsg] = useState('')
1701
+ const [branches, setBranches] = useState([])
1702
+ const [plainTags, setPlainTags] = useState(null)
1703
+ const reload = useCallback(() => {
1704
+ setList(null)
1705
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/releases`).then((d) => {
1706
+ setList(Array.isArray(d) ? d : (d.data || []))
1707
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/overview`).then((ov) => {
1708
+ setBranches(ov.branches || [])
1709
+ const released = new Set((Array.isArray(d) ? d : (d.data || [])).map((r) => r.tag))
1710
+ setPlainTags((ov.tags || []).filter((tg) => !released.has(tg)))
1711
+ })
1712
+ })
1713
+ }, [repo.owner, repo.name])
1714
+ useEffect(reload, [reload])
1715
+ return h('div', null,
1716
+ h('div', { className: 'dgs-row', style: { margin: '8px 0 12px' } },
1717
+ h('span', { style: { fontWeight: 700 } }, t('releases')),
1718
+ h('span', { style: { flex: 1 } }),
1719
+ h('button', { className: 'dgs-btn', onClick: () => setCreating(!creating) }, '+ ' + t('newRelease'))),
1720
+ msg ? h('div', { className: 'dgs-err' }, msg) : null,
1721
+ creating ? h('div', { className: 'dgs-card', style: { margin: '10px 0' } },
1722
+ h('div', { className: 'dgs-row' },
1723
+ h('span', { style: { fontWeight: 700 } }, '发布新版本'),
1724
+ h('span', { style: { flex: 1 } }),
1725
+ h('button', { className: 'dgs-btn ghost', onClick: () => setCreating(false) }, t('back'))),
1726
+ h('div', { className: 'dgs-row', style: { margin: '8px 0 0' } },
1727
+ h('input', { className: 'dgs-input', style: { maxWidth: 180 }, placeholder: '标签名(如 v0.1.0)', value: form.tag, onChange: (e) => setForm({ ...form, tag: e.target.value }) }),
1728
+ h('select', { className: 'dgs-input', style: { maxWidth: 180, flex: 'none', width: 'auto' }, value: form.target, onChange: (e) => setForm({ ...form, target: e.target.value }) },
1729
+ h('option', { value: '' }, '基于分支:默认分支'),
1730
+ branches.map((b) => h('option', { key: b, value: b }, b))),
1731
+ h('input', { className: 'dgs-input', placeholder: t('relTitle'), value: form.title, onChange: (e) => setForm({ ...form, title: e.target.value }) })),
1732
+ h('div', { className: 'dgs-sub', style: { margin: '6px 0 0' } }, '标签不存在时会基于所选分支自动创建 git 标签'),
1733
+ h('textarea', { className: 'dgs-input', placeholder: t('relNote'), value: form.note, onChange: (e) => setForm({ ...form, note: e.target.value }) }),
1734
+ h('div', { className: 'dgs-row', style: { marginTop: 8 } },
1735
+ h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 4, alignItems: 'center' } },
1736
+ h('input', { type: 'checkbox', checked: form.prerelease, onChange: (e) => setForm({ ...form, prerelease: e.target.checked }) }), '预发布版本'),
1737
+ h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 4, alignItems: 'center' } },
1738
+ h('input', { type: 'checkbox', checked: form.draft, onChange: (e) => setForm({ ...form, draft: e.target.checked }) }), '存为草稿'),
1739
+ h('span', { style: { flex: 1 } }),
1740
+ h('button', { className: 'dgs-btn', disabled: busy || !form.tag.trim(),
1741
+ onClick: async () => {
1742
+ setBusy(true); setMsg('')
1743
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/releases`, form)
1744
+ setBusy(false)
1745
+ if (d.ok) { setCreating(false); setForm({ tag: '', title: '', note: '', target: '', prerelease: false, draft: false }); reload() }
1746
+ else setMsg(d.error || 'failed')
1747
+ } }, t('submit')))) : null,
1748
+ list === null ? h('div', { className: 'dgs-empty' }, t('loading'))
1749
+ : list.length === 0 ? h('div', { className: 'dgs-empty' }, t('noReleases'))
1750
+ : list.map((r) =>
1751
+ editId === r.id ? h('div', { key: r.id, className: 'dgs-card' },
1752
+ h('div', { className: 'dgs-row' },
1753
+ h('span', { className: 'dgs-badge ok' }, r.tag),
1754
+ h('span', { className: 'dgs-sub' }, '编辑发版'),
1755
+ h('span', { style: { flex: 1 } }),
1756
+ h('button', { className: 'dgs-btn ghost', onClick: () => setEditId(null) }, t('back'))),
1757
+ h('div', { style: { margin: '8px 0' } },
1758
+ h('input', { className: 'dgs-input', value: editForm.title, onChange: (e) => setEditForm({ ...editForm, title: e.target.value }) }),
1759
+ h('textarea', { className: 'dgs-input', style: { marginTop: 8 }, value: editForm.note, onChange: (e) => setEditForm({ ...editForm, note: e.target.value }) })),
1760
+ h('button', { className: 'dgs-btn', disabled: busy, onClick: async () => {
1761
+ setBusy(true)
1762
+ const d = await api('PATCH', `/dsh/repos/${repo.owner}/${repo.name}/releases/${r.id}`, editForm)
1763
+ setBusy(false)
1764
+ if (d && d.ok) { setEditId(null); reload() } else setMsg((d && d.error) || 'failed')
1765
+ } }, t('save')))
1766
+ : h('div', { key: r.id, className: 'dgs-card', style: { display: 'flex', gap: 16 } },
1767
+ h('div', { style: { flex: 'none', width: 110, paddingTop: 2 } },
1768
+ h('div', { style: { fontWeight: 600 } }, '◎ ' + r.tag),
1769
+ h('div', { className: 'dgs-sub', style: { marginTop: 4 } }, r.sha || '')),
1770
+ h('div', { style: { flex: 1, minWidth: 0 } },
1771
+ h('div', { className: 'dgs-row' },
1772
+ r.draft ? h('span', { className: 'dgs-badge' }, '草稿') : null,
1773
+ r.prerelease ? h('span', { className: 'dgs-badge pri' }, '预发布') : null,
1774
+ h('span', { style: { fontWeight: 600 } }, r.title),
1775
+ h('span', { style: { flex: 1 } }),
1776
+ h('a', { className: 'dgs-sub', style: { cursor: 'pointer' }, onClick: () => { setEditId(r.id); setEditForm({ title: r.title || '', note: r.noteRaw || '' }) } }, '(编辑)')),
1777
+ h('div', { className: 'dgs-sub', style: { marginTop: 4 } },
1778
+ (r.author || '') + ' · ' + timeAgo((r.createdAt || 0) * 1000) + ' 发布 · ' +
1779
+ (r.behind ? '在该版本发布之后已有 ' + r.behind + ' 次代码提交到 ' + (r.target || '默认') + ' 分支' : '暂无后续提交')),
1780
+ r.noteHtml ? h('div', { className: 'dgs-md', style: { marginTop: 8 }, dangerouslySetInnerHTML: { __html: r.noteHtml } }) : null,
1781
+ h('div', { style: { marginTop: 10 } },
1782
+ h('div', { style: { fontWeight: 600, fontSize: 13, marginBottom: 4 } }, '下载附件'),
1783
+ h('div', { className: 'dgs-row', style: { gap: 8 } },
1784
+ h('a', { className: 'dgs-sub', href: `/dsh-git-server/api/dsh/repos/${repo.owner}/${repo.name}/archive/${encodeURIComponent(r.tag)}.zip`, download: `${repo.name}-${r.tag}.zip` }, '源代码 (ZIP)'),
1785
+ h('a', { className: 'dgs-sub', href: `/dsh-git-server/api/dsh/repos/${repo.owner}/${repo.name}/archive/${encodeURIComponent(r.tag)}.tar.gz`, download: `${repo.name}-${r.tag}.tar.gz` }, '源代码 (TAR.GZ)')))))),
1786
+ (plainTags && plainTags.length > 0) ? h('div', { style: { marginTop: 16 } },
1787
+ h('div', { style: { fontWeight: 700, margin: '4px 0 8px' } }, '未发版的标签'),
1788
+ plainTags.map((tg) => h('div', { key: tg, className: 'dgs-issue' },
1789
+ h('span', { className: 'dgs-badge' }, tg),
1790
+ h('span', { style: { flex: 1 } }),
1791
+ h('button', { className: 'dgs-btn ghost', onClick: () => {
1792
+ setCreating(true); setEditId(null); setForm({ tag: tg, title: '', note: '', target: '' })
1793
+ } }, '+ 为此标签创建发版')))) : null)
1794
+ }
1795
+
1796
+ // ── 探索 ───────────────────────────────────────────────────────────────────
1797
+
1798
+ function Explore({ t }) {
1799
+ const [q, setQ] = useState('')
1800
+ const [tab, setTab] = useState('repos')
1801
+ const [repos, setRepos] = useState(null)
1802
+ const [users, setUsers] = useState(null)
1803
+ const load = useCallback(() => {
1804
+ api('GET', '/dsh/explore/repos?q=' + encodeURIComponent(q)).then((d) => setRepos(Array.isArray(d) ? d : (d.data || [])))
1805
+ api('GET', '/dsh/explore/users?q=' + encodeURIComponent(q)).then((d) => setUsers(Array.isArray(d) ? d : (d.data || [])))
1806
+ }, [q])
1807
+ useEffect(() => { const tm = setTimeout(load, 300); return () => clearTimeout(tm) }, [q])
1808
+ const sideItem = (k, label) => h('span', {
1809
+ key: k, className: 'item' + (tab === k ? ' active' : ''), onClick: () => setTab(k),
1810
+ style: { display: 'block', padding: '7px 10px', borderRadius: 8, cursor: 'pointer', fontSize: 13,
1811
+ color: tab === k ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-secondary)',
1812
+ fontWeight: tab === k ? 600 : 400, background: tab === k ? 'var(--dsw-alias-interactive-bg-hover)' : 'transparent' },
1813
+ }, label)
1814
+ return h('div', { className: 'dgs-settings-layout' },
1815
+ h('div', { className: 'dgs-settings-nav' }, sideItem('repos', '仓库'), sideItem('users', '用户')),
1816
+ h('div', { style: { flex: 1, minWidth: 0 } },
1817
+ h('div', { className: 'dgs-row', style: { marginBottom: 12 } },
1818
+ h('input', { className: 'dgs-input', placeholder: '搜索仓库 / 用户', value: q, onChange: (e) => setQ(e.target.value) }),
1819
+ h('button', { className: 'dgs-btn', onClick: load }, '搜索')),
1820
+ tab === 'repos' && (repos === null ? h('div', { className: 'dgs-empty' }, t('loading'))
1821
+ : repos.length === 0 ? h('div', { className: 'dgs-empty' }, '—')
1822
+ : repos.map((r) =>
1823
+ h('div', { key: r.owner + '/' + r.name, className: 'dgs-card dgs-repo', onClick: () => nav('/r/' + r.owner + '/' + r.name), style: { cursor: 'pointer' } },
1824
+ h('div', { className: 'dgs-row' },
1825
+ h('span', { className: 'dgs-name' }, r.owner + '/' + r.name),
1826
+ h('span', { style: { flex: 1 } }),
1827
+ h('span', { className: 'dgs-sub' }, '★ ' + (r.stars || 0) + ' ⑂ ' + (r.forks || 0))),
1828
+ r.description ? h('div', { className: 'dgs-sub' }, r.description) : null,
1829
+ h('div', { className: 'dgs-sub' }, r.updated ? '最后更新于 ' + timeAgo(r.updated * 1000) : '')))),
1830
+ tab === 'users' && (users === null ? h('div', { className: 'dgs-empty' }, t('loading'))
1831
+ : users.length === 0 ? h('div', { className: 'dgs-empty' }, '—')
1832
+ : users.map((u) =>
1833
+ h('div', { key: u.name, className: 'dgs-card dgs-repo', onClick: () => nav('/u/' + u.name), style: { cursor: 'pointer' } },
1834
+ h('div', { className: 'dgs-row' },
1835
+ h('span', { className: 'dgs-name' }, u.name),
1836
+ u.full_name ? h('span', { className: 'dgs-sub', style: { marginLeft: 8 } }, u.full_name) : null),
1837
+ h('div', { className: 'dgs-sub' }, '关注者 - 关注中'))))))
1838
+ }
1839
+
1840
+ // ── 组织 ───────────────────────────────────────────────────────────────────
1841
+
1842
+ function Orgs({ t }) {
1843
+ const [list, setList] = useState(null)
1844
+ const [name, setName] = useState('')
1845
+ const [msg, setMsg] = useState('')
1846
+ const reload = () => api('GET', '/dsh/orgs').then((d) => setList(d.data || d || []))
1847
+ useEffect(() => { reload() }, [])
1848
+ return h('div', null,
1849
+ h('div', { className: 'dgs-row', style: { margin: '8px 0 12px' } },
1850
+ h('span', { style: { fontWeight: 700 } }, '我的组织'),
1851
+ h('input', { className: 'dgs-input', style: { maxWidth: 200 }, placeholder: '新组织名', value: name, onChange: (e) => setName(e.target.value) }),
1852
+ h('button', { className: 'dgs-btn', disabled: !name.trim(),
1853
+ onClick: async () => {
1854
+ const d = await api('POST', '/dsh/orgs', { name })
1855
+ d.ok ? (setMsg(''), reload()) : setMsg(d.error || 'failed')
1856
+ } }, '创建')),
1857
+ msg ? h('div', { className: 'dgs-err' }, msg) : null,
1858
+ list === null ? h('div', { className: 'dgs-empty' }, t('loading'))
1859
+ : list.length === 0 ? h('div', { className: 'dgs-empty' }, '—')
1860
+ : list.map((o) =>
1861
+ h('div', { key: o.name || o, className: 'dgs-card' },
1862
+ h('a', { className: 'dgs-name', href: '#/org/' + (o.name || o) }, o.name || o))))
1863
+ }
1864
+
1865
+ function OrgView({ name, t }) {
1866
+ const [org, setOrg] = useState(null)
1867
+ const [form, setForm] = useState({ fullName: '', description: '' })
1868
+ const [busy, setBusy] = useState(false)
1869
+ const [msg, setMsg] = useState('')
1870
+ const reload = useCallback(() => {
1871
+ api('GET', '/dsh/orgs/' + encodeURIComponent(name)).then((d) => {
1872
+ const o = d.data || d
1873
+ if (o && o.name) { setOrg(o); setForm({ fullName: o.fullName || '', description: o.description || '' }) }
1874
+ else setMsg((d && d.error) || t('loadFailed'))
1875
+ })
1876
+ }, [name])
1877
+ useEffect(() => { reload() }, [reload])
1878
+ if (org === null) return h('div', { className: 'dgs-empty' }, t('loading'))
1879
+ return h('div', null,
1880
+ h('div', { className: 'dgs-row', style: { marginBottom: 12 } },
1881
+ h('span', { className: 'dgs-h1' }, name),
1882
+ org.fullName ? h('span', { className: 'dgs-sub' }, org.fullName) : null),
1883
+ msg ? h('div', { className: 'dgs-err' }, msg) : null,
1884
+ h('div', { className: 'dgs-card' },
1885
+ h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '成员'),
1886
+ (org.members || []).map((m) =>
1887
+ h('div', { key: m.name, className: 'dgs-row' },
1888
+ h('a', { className: 'dgs-name', href: '#/u/' + m.name }, m.name),
1889
+ m.owner ? h('span', { className: 'dgs-badge' }, 'owner') : null))),
1890
+ h('div', { className: 'dgs-card' },
1891
+ h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '仓库'),
1892
+ (org.repos || []).length === 0 ? h('div', { className: 'dgs-sub' }, '—')
1893
+ : org.repos.map((r) =>
1894
+ h('div', { key: r.name }, h('a', { className: 'dgs-name', href: '#/r/' + name + '/' + r.name }, r.name)))),
1895
+ h('div', { className: 'dgs-card' },
1896
+ h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '组织设置'),
1897
+ h('div', { className: 'dgs-row' },
1898
+ h('span', { className: 'dgs-sub', style: { minWidth: 50 } }, '名称'),
1899
+ h('input', { className: 'dgs-input', value: form.fullName, onChange: (e) => setForm({ ...form, fullName: e.target.value }) })),
1900
+ h('div', { className: 'dgs-row' },
1901
+ h('span', { className: 'dgs-sub', style: { minWidth: 50 } }, '描述'),
1902
+ h('input', { className: 'dgs-input', value: form.description, onChange: (e) => setForm({ ...form, description: e.target.value }) })),
1903
+ h('div', { className: 'dgs-row', style: { marginTop: 8 } },
1904
+ h('button', { className: 'dgs-btn', disabled: busy,
1905
+ onClick: async () => {
1906
+ setBusy(true); setMsg('')
1907
+ const d = await api('PATCH', '/dsh/orgs/' + encodeURIComponent(name), form)
1908
+ setBusy(false)
1909
+ d && d.ok ? (setMsg('✓ 已保存'), reload()) : setMsg((d && d.error) || 'failed')
1910
+ } }, t('save')))))
1911
+ }
1912
+
1913
+ // ── 用户 profile ───────────────────────────────────────────────────────────
1914
+
1915
+ function UserProfile({ name, t }) {
1916
+ const [profile, setProfile] = useState(null)
1917
+ useEffect(() => {
1918
+ api('GET', '/dsh/users/' + encodeURIComponent(name)).then((d) => setProfile(d.data || d))
1919
+ }, [name])
1920
+ if (profile === null) return h('div', { className: 'dgs-empty' }, t('loading'))
1921
+ return h('div', { className: 'dgs-settings-layout' },
1922
+ h('div', { className: 'dgs-settings-nav', style: { width: 230 } },
1923
+ h('div', { style: { width: 64, height: 64, borderRadius: 10, background: 'var(--dsw-alias-bg-layer-2)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 26, fontWeight: 700, color: 'var(--dsw-alias-label-secondary)', marginBottom: 10 } },
1924
+ (name || '?').slice(0, 1).toUpperCase()),
1925
+ h('div', { className: 'dgs-h1', style: { fontSize: 17 } }, name),
1926
+ profile.isAdmin ? h('span', { className: 'dgs-badge ok', style: { marginTop: 4 } }, 'admin') : null,
1927
+ h('div', { className: 'dgs-sub', style: { marginTop: 10 } }, profile.email ? '✉ ' + profile.email : ''),
1928
+ h('div', { className: 'dgs-sub', style: { marginTop: 4 } }, '🕐 加入于 ' + (profile.created ? new Date(profile.created * 1000).toLocaleDateString() : '')),
1929
+ h('div', { className: 'dgs-sub', style: { marginTop: 10 } },
1930
+ `${profile.followers.length} 关注者 - ${profile.following.length} 关注中`)),
1931
+ h('div', { style: { flex: 1, minWidth: 0 } },
1932
+ h('div', { style: { fontWeight: 700, margin: '4px 0 10px' } }, '仓库'),
1933
+ profile.repos.length === 0 ? h('div', { className: 'dgs-empty' }, '—') : null,
1934
+ profile.repos.map((r) =>
1935
+ h('div', { key: r.name, className: 'dgs-card dgs-repo', onClick: () => nav('/r/' + r.owner + '/' + r.name), style: { cursor: 'pointer' } },
1936
+ h('div', { className: 'dgs-row' },
1937
+ h('span', { className: 'dgs-name' }, r.owner + '/' + r.name),
1938
+ h('span', { style: { flex: 1 } }),
1939
+ h('span', { className: 'dgs-sub' }, '★ ' + (r.stars || 0) + ' ⑂ ' + (r.forks || 0))),
1940
+ r.description ? h('div', { className: 'dgs-sub' }, r.description) : null,
1941
+ h('div', { className: 'dgs-sub' }, r.updated ? '最后更新于 ' + timeAgo(r.updated * 1000) : ''))),
1942
+ h('div', { style: { fontWeight: 700, margin: '14px 0 10px' } }, '动态'),
1943
+ profile.activity.length === 0 ? h('div', { className: 'dgs-empty' }, '—') : null,
1944
+ profile.activity.map((a, i) =>
1945
+ h('div', { key: i, className: 'dgs-card', style: { padding: '8px 14px' } },
1946
+ h('div', { className: 'dgs-row' },
1947
+ h('span', { style: { flex: 1 } }, activityText(a)),
1948
+ h('span', { className: 'dgs-sub' }, timeAgo(a.date * 1000)))))))
1949
+ }
1950
+
1951
+ // ── 管理面板 ───────────────────────────────────────────────────────────────
1952
+
1953
+ function Admin({ t }) {
1954
+ const [sub, setSub] = useState('panel')
1955
+ const [info, setInfo] = useState(null)
1956
+ const [data, setData] = useState(null)
1957
+ const [op, setOp] = useState('')
1958
+ const [opMsg, setOpMsg] = useState('')
1959
+ const [busy, setBusy] = useState(false)
1960
+ useEffect(() => {
1961
+ api('GET', '/dsh/admin/sysinfo').then((d) => setInfo(d))
1962
+ api('GET', '/dsh/admin/summary').then((d) => setData((x) => ({ ...(x || {}), summary: d })))
1963
+ api('GET', '/dsh/admin/users').then((d) => setData((x) => ({ ...(x || {}), users: d })))
1964
+ api('GET', '/dsh/admin/repos').then((d) => setData((x) => ({ ...(x || {}), repos: d })))
1965
+ api('GET', '/dsh/admin/orgs').then((d) => setData((x) => ({ ...(x || {}), orgs: d })))
1966
+ }, [])
1967
+ const navItem = (k, label) => h('span', { key: k, className: 'item' + (sub === k ? ' active' : ''), onClick: () => setSub(k) }, label)
1968
+ const card = (title, ...children) => h('div', { className: 'dgs-card', style: { marginBottom: 12 } },
1969
+ h('div', { style: { fontWeight: 700, marginBottom: 8, paddingBottom: 8, borderBottom: '1px solid var(--dsw-alias-border-l2)' } }, title),
1970
+ ...children)
1971
+ const kv = (k, v) => h('div', { key: k, className: 'dgs-row' },
1972
+ h('span', { style: { flex: 1 } }, k), h('span', { className: 'dgs-sub' }, String(v)))
1973
+ const summary = data && data.summary
1974
+ const panel = h('div', null,
1975
+ info ? card('构建信息',
1976
+ kv('应用程序版本', info.version),
1977
+ kv('Git 版本', info.gitVersion),
1978
+ kv('Node 版本', info.nodeVersion),
1979
+ kv('服务运行时间', Math.floor(info.uptimeSec / 3600) + ' 小时 ' + Math.floor((info.uptimeSec % 3600) / 60) + ' 分钟')) : null,
1980
+ summary ? card('应用统计数据',
1981
+ h('div', { className: 'dgs-sub' },
1982
+ `数据库统计:${summary.users} 位用户,${summary.orgs} 个组织,${summary.repos} 个仓库,${summary.issues} 张工单,${summary.pulls} 个合并请求。`)) : null,
1983
+ info ? card('系统监视状态',
1984
+ kv('RSS 内存', (info.mem.rss / 1048576).toFixed(1) + ' MB'),
1985
+ kv('Heap 已用', (info.mem.heapUsed / 1048576).toFixed(1) + ' MB'),
1986
+ kv('Heap 总量', (info.mem.heapTotal / 1048576).toFixed(1) + ' MB')) : null,
1987
+ card('管理员操作',
1988
+ h('div', { className: 'dgs-row' },
1989
+ h('select', { className: 'dgs-input', value: op, onChange: (e) => setOp(e.target.value) },
1990
+ h('option', { value: '' }, '请选择要运行的操作'),
1991
+ h('option', { value: 'sync_hooks' }, '同步所有仓库的 Git 钩子'),
1992
+ h('option', { value: 'gc' }, '对所有仓库运行垃圾回收 (git gc)')),
1993
+ h('button', { className: 'dgs-btn', disabled: busy || !op, onClick: async () => {
1994
+ setBusy(true); setOpMsg('')
1995
+ const d = await api('POST', '/dsh/admin/ops', { op })
1996
+ setBusy(false)
1997
+ setOpMsg(d && d.ok ? `✓ 完成(${d.n} 个仓库)` : (d && d.error) || '失败')
1998
+ } }, '执行')),
1999
+ opMsg ? h('div', { className: 'dgs-sub', style: { marginTop: 8 } }, opMsg) : null))
2000
+ const usersTab = data && data.users ? card('用户管理',
2001
+ h('table', { className: 'dgs-table' },
2002
+ h('tbody', null, data.users.map((u) =>
2003
+ h('tr', { key: u.name },
2004
+ h('td', null, h('a', { className: 'dgs-name', href: '#/u/' + u.name }, u.name), u.isAdmin ? h('span', { className: 'dgs-badge ok', style: { marginLeft: 8 } }, 'admin') : null),
2005
+ h('td', { className: 'dgs-sub' }, u.email || ''),
2006
+ h('td', { className: 'dgs-sub', style: { textAlign: 'right' } }, timeAgo((u.created || 0) * 1000))))))) : h('div', { className: 'dgs-empty' }, t('loading'))
2007
+ const reposTab = data && data.repos ? card('仓库管理',
2008
+ h('table', { className: 'dgs-table' },
2009
+ h('tbody', null, data.repos.map((r) =>
2010
+ h('tr', { key: r.name },
2011
+ h('td', null, h('a', { className: 'dgs-name', href: '#/r/' + r.name }, r.name), r.private ? h('span', { className: 'dgs-badge pri', style: { marginLeft: 8 } }, '私有') : null),
2012
+ h('td', { className: 'dgs-sub', style: { textAlign: 'right' } }, '★ ' + r.stars)))))) : h('div', { className: 'dgs-empty' }, t('loading'))
2013
+ const orgsTab = data && data.orgs ? card('组织管理',
2014
+ data.orgs.length === 0 ? h('div', { className: 'dgs-sub' }, '—')
2015
+ : h('table', { className: 'dgs-table' },
2016
+ h('tbody', null, data.orgs.map((o) =>
2017
+ h('tr', { key: o.name },
2018
+ h('td', null, h('a', { className: 'dgs-name', href: '#/org/' + o.name }, o.name), o.fullName ? h('span', { className: 'dgs-sub', style: { marginLeft: 8 } }, o.fullName) : null),
2019
+ h('td', { className: 'dgs-sub', style: { textAlign: 'right' } }, timeAgo(o.created * 1000))))))) : h('div', { className: 'dgs-empty' }, t('loading'))
2020
+ return h('div', { className: 'dgs-settings-layout' },
2021
+ h('div', { className: 'dgs-settings-nav' },
2022
+ navItem('panel', '控制面板'),
2023
+ navItem('users', '用户管理'),
2024
+ navItem('repos', '仓库管理'),
2025
+ navItem('orgs', '组织管理')),
2026
+ h('div', { style: { flex: 1, minWidth: 0 } },
2027
+ sub === 'panel' ? panel : sub === 'users' ? usersTab : sub === 'repos' ? reposTab : orgsTab))
2028
+ }
2029
+
2030
+ // ── 主页(仓库列表 + 建仓) ────────────────────────────────────────────────
2031
+
2032
+ function GitPage({ onClose, t }) {
2033
+ useEffect(ensureStyles, [])
2034
+ const route = useHashRoute()
2035
+ const [me, setMe] = useState(null)
2036
+ const [err, setErr] = useState('')
2037
+ const [name, setName] = useState('')
2038
+ const [isPrivate, setIsPrivate] = useState(false)
2039
+ const [busy, setBusy] = useState(false)
2040
+ const [toast, setToast] = useState('')
2041
+ const reload = useCallback(() => {
2042
+ setMe(null); setErr('')
2043
+ api('GET', '/me').then((d) => d.ok ? setMe(d) : setErr(d.error || 'failed'))
2044
+ }, [])
2045
+ useEffect(reload, [reload])
2046
+ useEffect(() => {
2047
+ const onKey = (e) => { if (e.key === 'Escape') onClose() }
2048
+ window.addEventListener('keydown', onKey)
2049
+ return () => window.removeEventListener('keydown', onKey)
2050
+ }, [onClose])
2051
+ const notify = (m) => { setToast(m); setTimeout(() => setToast(''), 1600) }
2052
+ const seg = route.split('/').filter(Boolean)
2053
+ const view = seg[0] || 'repos'
2054
+ const repoRoute = view === 'r' && seg[1] && seg[2] ? { owner: seg[1], name: seg[2] } : null
2055
+ const body = err ? h('div', { className: 'dgs-card dgs-err' }, err,
2056
+ h('button', { className: 'dgs-btn ghost', style: { marginLeft: 12 }, onClick: reload }, t('retry')))
2057
+ : repoRoute ? h(RepoBrowser, { repo: repoRoute, t, onBack: () => nav('/repos') })
2058
+ : view === 'explore' ? h(Explore, { t })
2059
+ : view === 'orgs' ? h(Orgs, { t })
2060
+ : view === 'org' && seg[1] ? h(OrgView, { name: seg[1], t })
2061
+ : view === 'u' && seg[1] ? h(UserProfile, { name: decodeURIComponent(seg[1]), t })
2062
+ : view === 'admin' ? h(Admin, { t })
2063
+ : me === null ? h('div', { className: 'dgs-empty' }, t('loading'))
2064
+ : h('div', null,
2065
+ h('div', { className: 'dgs-card' },
2066
+ h('div', { className: 'dgs-row' },
2067
+ h('span', { style: { fontWeight: 700 } }, t('newRepo')),
2068
+ h('input', { className: 'dgs-input', placeholder: t('repoName'), value: name,
2069
+ onChange: (e) => setName(e.target.value),
2070
+ onKeyDown: (e) => { if (e.key === 'Enter' && name.trim()) create() } }),
2071
+ h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 4, alignItems: 'center' } },
2072
+ h('input', { type: 'checkbox', checked: isPrivate, onChange: (e) => setIsPrivate(e.target.checked) }), t('private')),
2073
+ h('button', { className: 'dgs-btn', disabled: busy || !name.trim(), onClick: create }, t('create')))),
2074
+ h('div', { style: { height: 10 } }),
2075
+ h('div', { style: { fontWeight: 700, margin: '4px 0 10px' } }, `${t('myRepos')}(${me.repos.length})`),
2076
+ me.repos.length === 0 ? h('div', { className: 'dgs-empty' }, t('emptyRepo'))
2077
+ : h('div', null, me.repos.map((r) => repoCardEl(r))))
2078
+
2079
+ function repoCardEl(r) {
2080
+ const title = h('a', { className: 'dgs-name', onClick: (e) => { e.stopPropagation(); nav('/r/' + r.owner + '/' + r.name) } },
2081
+ r.fullName || (r.owner + '/' + r.name))
2082
+ const badge = r.private ? h('span', { className: 'dgs-badge pri' }, t('private')) : null
2083
+ const stats = h('span', { className: 'dgs-sub' }, '★ ' + r.stars + ' · ' + t('issues') + ' ' + r.issues)
2084
+ const del = h('button', { className: 'dgs-btn danger', onClick: (e) => {
2085
+ e.stopPropagation()
2086
+ if (!confirm(t('deleteConfirm') + ' ' + r.name + ' ?')) return
2087
+ api('DELETE', '/repos/' + r.owner + '/' + r.name).then((d) => { d.ok ? reload() : notify(d.error || 'failed') })
2088
+ } }, t('delete'))
2089
+ const desc = r.description ? h('div', { className: 'dgs-sub' }, r.description) : null
2090
+ return h('div', { key: r.id, className: 'dgs-card dgs-repo', onClick: () => nav('/r/' + r.owner + '/' + r.name) },
2091
+ h('div', { className: 'dgs-row' }, title, badge, h('span', { style: { flex: 1 } }), stats, del), desc)
2092
+ }
2093
+
2094
+ const navItems = [['repos', t('myRepos')], ['explore', '探索'], ['orgs', '组织'], ['admin', '管理']]
2095
+ return h('div', { className: 'dgs-page' },
2096
+ h('div', { className: 'dgs-head' },
2097
+ h('span', { className: 'dgs-h1' }, t('title')),
2098
+ h('div', { className: 'dgs-row', style: { marginLeft: 8 } },
2099
+ navItems.map(([k, label]) =>
2100
+ h('button', { key: k, className: 'dgs-btn ghost',
2101
+ style: view === k ? { color: 'var(--dsw-alias-state-business-primary)', borderColor: 'var(--dsw-alias-state-business-primary)55' } : undefined,
2102
+ onClick: () => nav('/' + k) }, label))),
2103
+ h('button', { className: 'dgs-close', onClick: onClose, title: 'Esc' }, '✕')),
2104
+ h('div', { className: 'dgs-body' }, body),
2105
+ toast ? h('div', { className: 'dgs-toast' }, toast) : null)
2106
+
2107
+ async function create() {
2108
+ setBusy(true)
2109
+ const d = await api('POST', '/repos', { name, private: isPrivate, autoInit: true })
2110
+ setBusy(false)
2111
+ if (d.ok) { setName(''); notify('✓ ' + name); reload() } else notify(d.error || 'failed')
2112
+ }
2113
+ }
2114
+
2115
+ // ── 设置节(服务配置,无 iframe) ──────────────────────────────────────────
2116
+
2117
+ function SettingsSection({ t }) {
2118
+ const [status, setStatus] = useState(null)
2119
+ const [form, setForm] = useState({ enabled: false, host: '127.0.0.1', port: 3400, dataDir: '' })
2120
+ const [busy, setBusy] = useState(false)
2121
+ const [msg, setMsg] = useState('')
2122
+ useEffect(() => {
2123
+ fetch(API + '/status').then((r) => r.json()).then((d) => {
2124
+ setStatus(d)
2125
+ setForm({ enabled: !!d.enabled, host: d.host || '127.0.0.1', port: d.port || 3400, dataDir: d.dataDir || '' })
2126
+ }).catch(() => {})
2127
+ }, [])
2128
+ const save = async () => {
2129
+ setBusy(true); setMsg('…')
2130
+ try {
2131
+ const res = await fetch(API + '/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) })
2132
+ const d = await res.json()
2133
+ setStatus(d); setMsg('')
2134
+ } catch (e) { setMsg(String(e)) } finally { setBusy(false) }
2135
+ }
2136
+ const field = (k) => ({ value: form[k], onChange: (e) => setForm({ ...form, [k]: e.target.value }) })
2137
+ return h('div', { className: 'dgs-settings' },
2138
+ h('div', { className: 'dgs-card' },
2139
+ h('div', { className: 'dgs-h1' }, t('title')),
2140
+ h('div', { className: 'dgs-row' },
2141
+ h('label', null, '启用'),
2142
+ h('input', { type: 'checkbox', checked: form.enabled, onChange: (e) => setForm({ ...form, enabled: e.target.checked }) })),
2143
+ h('div', { className: 'dgs-row' },
2144
+ h('label', null, '监听地址'), h('input', { className: 'dgs-input', ...field('host') })),
2145
+ h('div', { className: 'dgs-row' },
2146
+ h('label', null, '端口'), h('input', { className: 'dgs-input', type: 'number', ...field('port') })),
2147
+ h('div', { className: 'dgs-row' },
2148
+ h('label', null, '数据目录'), h('input', { className: 'dgs-input', placeholder: '~/.dsh/dsh-git-server/data', ...field('dataDir') })),
2149
+ status && status.umAvailable !== 'ok' ? h('div', { className: 'dgs-err' }, 'user-management 服务不可用(回退兜底账号)') : null,
2150
+ msg ? h('div', { className: 'dgs-sub' }, msg) : null,
2151
+ h('div', { className: 'dgs-row', style: { marginTop: 8 } },
2152
+ h('button', { className: 'dgs-btn', disabled: busy, onClick: save }, '保存'))))
2153
+ }
2154
+
2155
+ // ── 侧栏导航条目(工艺库/知识库下方,dsh-kb 同款 DOM 注入)───────────────
2156
+
2157
+ const GIT_ENTRY_ATTR = 'data-dsh-git-entry'
2158
+
2159
+ let gitPageHost = null
2160
+ function closeGitPage() {
2161
+ if (!gitPageHost) return
2162
+ try { gitPageHost.root.unmount() } catch {}
2163
+ try { gitPageHost.el.remove() } catch {}
2164
+ gitPageHost = null
2165
+ }
2166
+ function openGitPage(t) {
2167
+ if (gitPageHost) { closeGitPage(); return }
2168
+ const el = document.createElement('div')
2169
+ document.body.appendChild(el)
2170
+ const root = require('react-dom/client').createRoot(el)
2171
+ gitPageHost = { el, root }
2172
+ root.render(h(GitPage, { onClose: closeGitPage, t }))
2173
+ }
2174
+
2175
+ function gitSidebarRoot() {
2176
+ const column = document.querySelector('[data-pane="sidebar"], [class*="sidebarCol"], .dshDesktopUpstreamSidebar, .dshDesktopSidebarSurface')
2177
+ if (column === null) return undefined
2178
+ const logoOwner = column.querySelector('[class*="logoRow"]') && column.querySelector('[class*="logoRow"]').parentElement
2179
+ return logoOwner || (column.firstElementChild || undefined)
2180
+ }
2181
+
2182
+ function gitNewSessionButton(root) {
2183
+ const nested = root.querySelector('button[class*="newSession"]')
2184
+ if (nested) return nested
2185
+ for (const child of root.children) {
2186
+ if (child instanceof HTMLButtonElement && !child.matches('[' + GIT_ENTRY_ATTR + ']')) return child
2187
+ }
2188
+ return Array.from(root.querySelectorAll('button')).find((b) => !b.matches('[' + GIT_ENTRY_ATTR + ']') && /新会话|新建会话|new session/i.test(b.textContent || ''))
2189
+ }
2190
+
2191
+ function placeGitEntry(root, entry) {
2192
+ const button = gitNewSessionButton(root)
2193
+ if (!button) return false
2194
+ if (entry.parentElement !== root) {
2195
+ const family = Array.from(root.children).filter((el) => el instanceof HTMLElement
2196
+ && el.matches('[data-dsh-prc-entry],[data-dsh-atb-entry],[data-dsh-taskboard-entry],[data-dsh-ssh-entry],[data-dsh-kb-entry],[' + GIT_ENTRY_ATTR + ']'))
2197
+ if (family.length > 0) {
2198
+ const last = family[family.length - 1]
2199
+ last.parentElement.insertBefore(entry, last.nextSibling)
2200
+ } else {
2201
+ const row = button.closest('[class*="logoRow"]')
2202
+ const base = (row && row.parentElement === root) ? row : button
2203
+ root.insertBefore(entry, base.nextSibling)
2204
+ }
2205
+ }
2206
+ return true
2207
+ }
2208
+
2209
+ function mountGitSidebarEntry(t) {
2210
+ let style = document.getElementById('dsh-git-sidebar-style')
2211
+ if (!style) {
2212
+ style = document.createElement('style')
2213
+ style.id = 'dsh-git-sidebar-style'
2214
+ style.textContent = `
2215
+ .dsh-git-entry{display:flex;align-items:center;gap:8px;width:100%;height:34px;padding:0 10px;margin:2px 0 8px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary,var(--dsw-text-primary,inherit));font:inherit;font-size:13px;cursor:pointer;text-align:left}
2216
+ .dsh-git-entry:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 8%,transparent)}
2217
+ .dsh-git-entry .dsh-git-entry-icon{flex:none;line-height:1}
2218
+ .dsh-git-entry .dsh-git-entry-stats{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:11px;color:var(--dsw-alias-label-secondary,var(--dsw-text-secondary,gray));font-variant-numeric:tabular-nums;white-space:nowrap}
2219
+ .dsh-git-entry .dsh-git-entry-dot{width:6px;height:6px;border-radius:6px;background:var(--dsw-alias-state-success-primary);display:inline-block}
2220
+ [data-sidebar-collapsed] .dsh-git-entry,[class*="_collapsed"] .dsh-git-entry{width:36px;height:36px;min-width:36px;margin:0 0 12px;padding:0;justify-content:center;gap:0;text-align:center}
2221
+ [data-sidebar-collapsed] .dsh-git-entry .dsh-git-entry-label,[data-sidebar-collapsed] .dsh-git-entry .dsh-git-entry-stats,[class*="_collapsed"] .dsh-git-entry .dsh-git-entry-label,[class*="_collapsed"] .dsh-git-entry .dsh-git-entry-stats{display:none}
2222
+ `
2223
+ document.head.appendChild(style)
2224
+ }
2225
+ const entry = document.createElement('button')
2226
+ entry.type = 'button'
2227
+ entry.setAttribute(GIT_ENTRY_ATTR, '')
2228
+ entry.className = 'dsh-git-entry'
2229
+ entry.title = '代码仓库 — 仓库 / 工单 / PR / Wiki / 发版'
2230
+ entry.innerHTML = '<span class="dsh-git-entry-icon">📦</span><span class="dsh-git-entry-label">代码仓库</span><span class="dsh-git-entry-stats"></span>'
2231
+ entry.addEventListener('click', () => openGitPage(t))
2232
+ const stats = entry.querySelector('.dsh-git-entry-stats')
2233
+ const refreshStats = () => {
2234
+ fetch(API + '/me').then((r) => r.json()).then((d) => {
2235
+ if (stats && d && Array.isArray(d.repos)) stats.textContent = String(d.repos.length)
2236
+ }).catch(() => {})
2237
+ }
2238
+ refreshStats()
2239
+ const poll = setInterval(refreshStats, 30000)
2240
+ let root
2241
+ let placed = false
2242
+ const rootObserver = new MutationObserver(() => {
2243
+ if (!root || !root.isConnected) { placed = false; tryPlace(); return }
2244
+ if (!root.contains(entry)) placed = placeGitEntry(root, entry)
2245
+ })
2246
+ const tryPlace = () => {
2247
+ if (root && !root.isConnected) { rootObserver.disconnect(); root = undefined; placed = false }
2248
+ if (placed) { if (document.body.contains(entry)) return; rootObserver.disconnect(); root = undefined; placed = false }
2249
+ root = root || gitSidebarRoot()
2250
+ if (!root) return
2251
+ placed = placeGitEntry(root, entry)
2252
+ if (placed) rootObserver.observe(root, { childList: true })
2253
+ }
2254
+ const waitObserver = new MutationObserver(() => tryPlace())
2255
+ waitObserver.observe(document.body, { childList: true, subtree: true })
2256
+ const retry = setInterval(tryPlace, 2000)
2257
+ tryPlace()
2258
+ return () => {
2259
+ clearInterval(retry)
2260
+ clearInterval(poll)
2261
+ waitObserver.disconnect()
2262
+ rootObserver.disconnect()
2263
+ try { entry.remove() } catch {}
2264
+ closeGitPage()
2265
+ }
2266
+ }
2267
+
2268
+ // ── utils ─────────────────────────────────────────────────────────────────
2269
+
2270
+ function activityText(a) {
2271
+ const repo = a.repo || ''
2272
+ const ref = a.ref || ''
2273
+ const m = {
2274
+ 1: '创建了仓库 ' + repo, 2: '重命名仓库 ' + repo, 3: '点赞了仓库 ' + repo, 4: '关注了仓库 ' + repo,
2275
+ 5: '推送提交到 ' + repo + (ref ? '(' + ref + ')' : ''), 6: '创建了工单 · ' + (a.content || ''),
2276
+ 7: '发起合并请求 · ' + (a.content || ''), 8: '转移仓库 ' + repo, 9: '发布标签 ' + ref,
2277
+ 10: '评论了工单 · ' + (a.content || ''), 11: '合并了合并请求 · ' + (a.content || ''),
2278
+ 12: '关闭了工单 · ' + (a.content || ''), 13: '重开工单 · ' + (a.content || ''),
2279
+ 14: '关闭了合并请求 · ' + (a.content || ''), 15: '重开合并请求 · ' + (a.content || ''),
2280
+ 16: '创建分支 ' + ref + ' 于 ' + repo, 17: '删除分支 ' + ref + ' 于 ' + repo,
2281
+ 18: '删除标签 ' + ref, 19: '复刻了仓库 ' + repo, 20: '同步镜像 ' + repo,
2282
+ }
2283
+ return m[a.type] || ('活动于 ' + repo)
2284
+ }
2285
+
2286
+ function fmtSize(n) {
2287
+ if (n == null) return ''
2288
+ if (n < 1024) return n + ' B'
2289
+ if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'
2290
+ return (n / 1048576).toFixed(1) + ' MB'
2291
+ }
2292
+ function fmtDate(s) {
2293
+ if (!s) return ''
2294
+ try { return new Date(s).toLocaleString() } catch { return s }
2295
+ }
2296
+ function timeAgo(ms) {
2297
+ const t = typeof ms === 'string' ? new Date(ms).getTime() : ms
2298
+ if (!t || Number.isNaN(t)) return ''
2299
+ const sec = Math.max(0, Math.floor((Date.now() - t) / 1000))
2300
+ if (sec < 60) return '刚刚'
2301
+ if (sec < 3600) return Math.floor(sec / 60) + ' 分钟之前'
2302
+ if (sec < 86400) return Math.floor(sec / 3600) + ' 小时之前'
2303
+ if (sec < 86400 * 30) return Math.floor(sec / 86400) + ' 天之前'
2304
+ if (sec < 86400 * 365) return Math.floor(sec / 86400 / 30) + ' 个月之前'
2305
+ return Math.floor(sec / 86400 / 365) + ' 年之前'
2306
+ }
2307
+
2308
+ // ── plugin plane ──────────────────────────────────────────────────────────
2309
+
2310
+ const CLIENT_NAME = '@weibaohui/dsh-git-server'
2311
+
2312
+ module.exports = {
2313
+ name: CLIENT_NAME,
2314
+ inject: ['slots', 'locale'],
2315
+ __internals: { NS, ZH, EN, GitPage, SettingsSection },
2316
+ __boot(container, opts = {}) {
2317
+ ensureStyles()
2318
+ let t = opts.t || ((key) => ZH[key] ?? EN[key] ?? key)
2319
+ const root = require('react-dom/client').createRoot(container)
2320
+ root.render(h(GitPage, { onClose: () => {}, t }))
2321
+ return root
2322
+ },
2323
+ apply(ctx) {
2324
+ let t = (key) => ZH[key] ?? EN[key] ?? key
2325
+ try {
2326
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
2327
+ ctx.locale.register(NS, 'zh', ZH)
2328
+ ctx.locale.register(NS, 'en', EN)
2329
+ const bound = typeof ctx.locale.bind === 'function' ? ctx.locale.bind(NS) : null
2330
+ if (bound) t = (key) => bound(key) || (ZH[key] ?? EN[key] ?? key)
2331
+ }
2332
+ } catch (e) { try { console.error('[dsh-git-server] locale init:', e) } catch {} }
2333
+
2334
+ ctx.effect(() => mountGitSidebarEntry(t), 'dsh-git-server: sidebar entry')
2335
+
2336
+ ctx.effect(() => {
2337
+ try {
2338
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
2339
+ name: 'settings.section',
2340
+ id: CLIENT_NAME,
2341
+ order: 97,
2342
+ locale: NS,
2343
+ label: () => t('title'),
2344
+ inject: () => ({}),
2345
+ }, function SettingsSectionSlot() {
2346
+ useEffect(ensureStyles, [])
2347
+ return h(SettingsSection, { t })
2348
+ }))
2349
+ } catch (e) { (globalThis.__skErrors = globalThis.__skErrors || []).push('settings:' + (e && e.message)); throw e }
2350
+ }, 'dsh-git-server: settings section')
2351
+ },
2352
+ }
2353
+
2354
+ return module.exports
2355
+ }
2356
+ })