@weibaohui/dsh-git-server 0.1.4 → 0.2.1

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.
package/client/bundle.js CHANGED
@@ -218,6 +218,24 @@ window.__ModuleLoader__.load({
218
218
  .dgs-md blockquote{border-left:3px solid var(--dsw-alias-border-l2);margin:8px 0;padding:2px 12px;color:var(--dsw-alias-label-secondary)}
219
219
  .dgs-md table{border-collapse:collapse}
220
220
  .dgs-md td,.dgs-md th{border:1px solid var(--dsw-alias-border-l2);padding:4px 10px}
221
+ /* 语法高亮(亮暗双主题可读配色) */
222
+ .tok-k{color:#cf222e;font-weight:500}
223
+ .tok-s{color:#1a7f37}
224
+ .tok-c{color:#6e7781;font-style:italic}
225
+ .tok-n{color:#0550ae}
226
+ .tok-b{color:#953800}
227
+ @media (prefers-color-scheme: dark){
228
+ .tok-k{color:#ff7b72}
229
+ .tok-s{color:#7ee787}
230
+ .tok-c{color:#8b949e}
231
+ .tok-n{color:#79c0ff}
232
+ .tok-b{color:#ffa657}
233
+ }
234
+ .dgs-hit{padding:4px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);cursor:pointer}
235
+ .dgs-hit:hover{background:var(--dsw-alias-interactive-bg-hover)}
236
+ .dgs-hit-code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-all}
237
+ .dgs-notif-dot{display:inline-block;min-width:16px;height:16px;line-height:16px;text-align:center;border-radius:999px;background:var(--dsw-alias-state-error-primary);color:#fff;font-size:10px;font-weight:700;padding:0 4px;margin-left:4px}
238
+ .dgs-guide{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;background:var(--dsw-alias-markdown-code-block);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px 14px;margin:6px 0;white-space:pre-wrap;word-break:break-all}
221
239
  </style>`
222
240
  document.head.appendChild(holder)
223
241
  }
@@ -247,25 +265,133 @@ window.__ModuleLoader__.load({
247
265
  }
248
266
  const nav = (hash) => { location.hash = hash }
249
267
 
268
+ // ── 语法高亮(零依赖:无 bundler,客户端不能 import,内置一个轻量 tokenizer) ──
269
+
270
+ function escapeHtml(s) {
271
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
272
+ }
273
+
274
+ const HL = (() => {
275
+ const KW = {
276
+ clike: 'abstract assert boolean break byte case catch char class const continue default do double else enum extends final finally float for goto if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while true false null',
277
+ js: 'const let var function return if else for while do switch case break continue class extends new delete typeof instanceof in of async await yield import export from default try catch finally throw this super static get set null undefined true false',
278
+ py: 'def class return if elif else for while import from as with try except finally raise lambda pass break continue global nonlocal yield assert async await in is not and or del print None True False self cls',
279
+ go: 'func return if else for range switch case break continue go defer select chan map struct interface package import const var type fallthrough default nil true false iota',
280
+ rs: 'fn let mut pub struct enum impl trait for while loop if else match return use mod crate self Self super const static type where async await move ref break continue in as dyn unsafe extern true false None Some Ok Err',
281
+ sh: 'if then else elif fi for while until do done case esac function return local export readonly declare shift eval exec source alias unalias set unset trap exit break continue in true false echo printf cd test',
282
+ sql: 'select from where insert into values update set delete join inner left right outer on group by order having limit offset as asc desc distinct union all create table alter drop index primary key foreign references not null default and or like in is between exists case when then else end',
283
+ }
284
+ const C = { line: '//[^\\n]*', block: '\\/\\*[\\s\\S]*?\\*\\/' }
285
+ const HASH = { line: '#[^\\n]*' }
286
+ const STR = ['"(?:\\\\.|[^"\\\\\\n])*"', "'(?:\\\\.|[^'\\\\\\n])*'", '`(?:\\\\.|[^`\\\\])*`']
287
+ const NUM = '\\b\\d[\\d_]*(?:\\.[\\d_]+)?(?:[eE][+-]?\\d+)?\\b'
288
+ const langs = {
289
+ js: { kw: KW.js, c: C }, mjs: { kw: KW.js, c: C }, cjs: { kw: KW.js, c: C }, jsx: { kw: KW.js, c: C },
290
+ ts: { kw: KW.js + ' interface type namespace declare readonly keyof infer never unknown any string number boolean', c: C },
291
+ tsx: { kw: KW.js + ' interface type namespace declare readonly keyof infer never unknown any string number boolean', c: C },
292
+ json: { kw: 'true false null', c: null },
293
+ py: { kw: KW.py, c: HASH }, rb: { kw: KW.py, c: HASH }, pl: { kw: KW.py, c: HASH },
294
+ go: { kw: KW.go, c: C },
295
+ rs: { kw: KW.rs, c: C },
296
+ java: { kw: KW.clike, c: C }, kt: { kw: KW.clike, c: C }, scala: { kw: KW.clike, c: C },
297
+ c: { kw: KW.clike, c: C }, h: { kw: KW.clike, c: C }, cpp: { kw: KW.clike, c: C }, cc: { kw: KW.clike, c: C }, cxx: { kw: KW.clike, c: C }, cs: { kw: KW.clike, c: C }, mm: { kw: KW.clike, c: C }, m: { kw: KW.clike, c: C },
298
+ sh: { kw: KW.sh, c: HASH }, bash: { kw: KW.sh, c: HASH }, zsh: { kw: KW.sh, c: HASH },
299
+ yaml: { kw: 'true false null yes no on off', c: HASH }, yml: { kw: 'true false null yes no on off', c: HASH },
300
+ toml: { kw: 'true false', c: HASH }, ini: { kw: 'true false', c: HASH }, cfg: { kw: 'true false', c: HASH },
301
+ sql: { kw: KW.sql, c: { line: '--[^\\n]*', block: C.block } },
302
+ css: { kw: '', c: { block: C.block } }, scss: { kw: '', c: C }, less: { kw: '', c: C },
303
+ dockerfile: { kw: 'FROM RUN CMD ENTRYPOINT COPY ADD WORKDIR EXPOSE ENV ARG VOLUME USER LABEL HEALTHCHECK ONBUILD SHELL STOPSIGNAL', c: HASH },
304
+ makefile: { kw: '', c: HASH },
305
+ vue: { kw: KW.js, c: C }, svelte: { kw: KW.js, c: C },
306
+ }
307
+ const byName = { dockerfile: 'dockerfile', makefile: 'makefile', 'cmakelists.txt': 'makefile' }
308
+ const cache = new Map()
309
+ function langFor(name) {
310
+ const lower = (name || '').toLowerCase()
311
+ const ext = byName[lower] || lower.split('.').pop() || ''
312
+ return langs[ext] || null
313
+ }
314
+ function regexFor(cfg) {
315
+ if (cache.has(cfg)) return cache.get(cfg)
316
+ const parts = []
317
+ if (cfg.c && cfg.c.block) parts.push(cfg.c.block)
318
+ if (cfg.c && cfg.c.line) parts.push(cfg.c.line)
319
+ parts.push(...STR, NUM)
320
+ if (cfg.kw) parts.push('\\b(?:' + cfg.kw.split(' ').join('|') + ')\\b')
321
+ const rx = new RegExp(parts.join('|'), 'g')
322
+ cache.set(cfg, rx)
323
+ return rx
324
+ }
325
+ return { langFor, regexFor }
326
+ })()
327
+
328
+ /** 源码 → 带 token 着色的 HTML(输入已转义)。 */
329
+ function highlightCode(text, filename) {
330
+ const cfg = HL.langFor(filename)
331
+ if (!cfg) return escapeHtml(text)
332
+ const rx = HL.regexFor(cfg)
333
+ let out = ''
334
+ let last = 0
335
+ for (const m of text.matchAll(rx)) {
336
+ const i = m.index
337
+ if (i > last) out += escapeHtml(text.slice(last, i))
338
+ const tok = m[0]
339
+ let cls = 'tok-n'
340
+ if (/^\/\/|^\/\*|^#|^--/.test(tok)) cls = 'tok-c'
341
+ else if (/^["'`]/.test(tok)) cls = 'tok-s'
342
+ else if (/^\d/.test(tok)) cls = 'tok-n'
343
+ else cls = 'tok-k'
344
+ out += '<span class="' + cls + '">' + escapeHtml(tok) + '</span>'
345
+ last = i + tok.length
346
+ }
347
+ if (last < text.length) out += escapeHtml(text.slice(last))
348
+ return out
349
+ }
350
+
351
+ /** 提交/评论文本里的 #123 → 点击跳工单(返回 React 子节点数组)。 */
352
+ function linkifyIssues(text, repo) {
353
+ const parts = String(text || '').split(/(#\d+)/g)
354
+ if (parts.length === 1) return text
355
+ return parts.map((p, i) => {
356
+ const m = /^#(\d+)$/.exec(p)
357
+ if (!m) return p
358
+ return h('a', {
359
+ key: i, style: { color: 'var(--dsw-alias-state-business-primary)', cursor: 'pointer' },
360
+ onClick: (e) => { e.stopPropagation(); nav('/r/' + repo.owner + '/' + repo.name + '/issues/' + m[1]) },
361
+ }, p)
362
+ })
363
+ }
364
+
365
+ function dirnameOf(p) {
366
+ const i = (p || '').lastIndexOf('/')
367
+ return i < 0 ? '' : p.slice(0, i)
368
+ }
369
+
370
+
250
371
  // ── 全屏管理页 ────────────────────────────────────────────────────────────
251
372
 
252
- function RepoBrowser({ repo, t, onBack }) {
253
- const [tab, setTab] = useState('files')
373
+ function RepoBrowser({ repo, t, onBack, deep }) {
374
+ const [tab, setTab] = useState(() => {
375
+ const k = deep && deep.tab
376
+ return k && ['files', 'issues', 'pulls', 'wiki', 'releases', 'settings', 'commits', 'branches', 'tags'].includes(k) ? k : 'files'
377
+ })
254
378
  const [prPreset, setPrPreset] = useState(null)
255
379
  const [issuesSub, setIssuesSub] = useState(null)
256
380
  const [ov, setOv] = useState(null)
257
- const [rev, setRev] = useState('')
381
+ const [rev, setRev] = useState(deep && deep.tab === 'src' ? deep.ref || '' : '')
258
382
  const [cloneUrl, setCloneUrl] = useState('')
259
383
  const [copied, setCopied] = useState(false)
260
384
  const [star, setStar] = useState(null)
261
385
  const [watch, setWatch] = useState(null)
262
386
  const [forkBusy, setForkBusy] = useState(false)
387
+ const [pop, setPop] = useState(null) // 'star' | 'watch' | 'fork' | null
388
+ const [forkList, setForkList] = useState(null)
263
389
  const [msg, setMsg] = useState('')
264
390
  useEffect(() => {
265
391
  api('GET', `/dsh/repos/${repo.owner}/${repo.name}/overview`).then((d) => {
266
392
  if (!d.defaultBranch) return
267
393
  setOv(d)
268
- setRev(d.defaultBranch)
394
+ setRev((cur) => cur || d.defaultBranch)
269
395
  })
270
396
  fetch(API + '/status').then((r) => r.json()).then((st) => {
271
397
  if (st && st.running) setCloneUrl('git clone http://' + location.hostname + ':' + (st.port || 3400) + '/' + repo.owner + '/' + repo.name + '.git')
@@ -296,15 +422,37 @@ window.__ModuleLoader__.load({
296
422
  h('span', { className: 'dgs-h1' }, repo.owner + '/' + repo.name),
297
423
  repo.private || (ov && ov.private) ? h('span', { className: 'dgs-badge pri' }, t('private')) : null,
298
424
  h('span', { style: { flex: 1 } }),
425
+ h('div', { style: { position: 'relative', display: 'flex', alignItems: 'center', gap: 10 } },
299
426
  star ? h('span', { className: 'dgs-labeled' },
300
427
  h('button', { className: 'dgs-btn ghost', onClick: () => toggle('star') }, star.on ? '★ Unstar' : '☆ Star'),
301
- h('span', { className: 'dgs-labeled-count' }, star.count)) : null,
428
+ h('span', { className: 'dgs-labeled-count', style: { cursor: 'pointer' }, title: '谁点了星标', onClick: () => setPop(pop === 'star' ? null : 'star') }, star.count)) : null,
302
429
  watch ? h('span', { className: 'dgs-labeled' },
303
430
  h('button', { className: 'dgs-btn ghost', onClick: () => toggle('watch') }, watch.on ? '👁 Unwatch' : '👁 Watch'),
304
- h('span', { className: 'dgs-labeled-count' }, watch.count)) : null,
305
- h('button', { className: 'dgs-btn ghost', disabled: forkBusy, onClick: fork, style: { display: 'inline-flex', alignItems: 'center', gap: 6 } },
306
- 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>' } }),
307
- 'Fork'),
431
+ h('span', { className: 'dgs-labeled-count', style: { cursor: 'pointer' }, title: '谁在关注', onClick: () => setPop(pop === 'watch' ? null : 'watch') }, watch.count)) : null,
432
+ h('span', { className: 'dgs-labeled' },
433
+ h('button', { className: 'dgs-btn ghost', disabled: forkBusy, onClick: fork, style: { display: 'inline-flex', alignItems: 'center', gap: 6 } },
434
+ 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>' } }),
435
+ 'Fork'),
436
+ h('span', { className: 'dgs-labeled-count', style: { cursor: 'pointer' }, title: '复刻列表', onClick: () => {
437
+ if (pop === 'fork') { setPop(null); return }
438
+ setPop('fork')
439
+ if (forkList === null) api('GET', `/dsh/repos/${repo.owner}/${repo.name}/forks`).then((d) => setForkList(Array.isArray(d) ? d : []))
440
+ } }, ov ? ov.numForks || 0 : '')),
441
+ pop ? h('div', { className: 'dgs-card', style: { position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 50, minWidth: 230, maxHeight: 280, overflowY: 'auto', margin: 0, padding: '10px 12px' } },
442
+ h('div', { style: { fontWeight: 700, fontSize: 12.5, marginBottom: 6 } },
443
+ pop === 'star' ? '星标用户' : pop === 'watch' ? '关注者' : '复刻(Fork)'),
444
+ pop === 'fork'
445
+ ? (forkList === null ? h('div', { className: 'dgs-sub' }, t('loading'))
446
+ : forkList.length === 0 ? h('div', { className: 'dgs-sub' }, '—')
447
+ : forkList.map((f) => h('div', { key: f.owner + '/' + f.name, style: { padding: '3px 0' } },
448
+ h('a', { className: 'dgs-sub', style: { color: 'var(--dsw-alias-state-business-primary)', cursor: 'pointer' }, onClick: () => { setPop(null); nav('/r/' + f.owner + '/' + f.name) } },
449
+ f.owner + '/' + f.name + (f.stars ? ' ★' + f.stars : '')))))
450
+ : ((pop === 'star' ? (star && star.users) : (watch && watch.users)) || []).length === 0
451
+ ? h('div', { className: 'dgs-sub' }, '—')
452
+ : ((pop === 'star' ? star.users : watch.users) || []).map((u) =>
453
+ h('div', { key: u, style: { padding: '3px 0' } },
454
+ h('a', { className: 'dgs-sub', style: { color: 'var(--dsw-alias-state-business-primary)', cursor: 'pointer' }, onClick: () => { setPop(null); nav('/u/' + u) } }, '@' + u))))
455
+ : null),
308
456
  ),
309
457
  msg ? h('div', { className: 'dgs-err' }, msg) : null,
310
458
  h('div', { className: 'dgs-tabs' },
@@ -335,19 +483,19 @@ window.__ModuleLoader__.load({
335
483
  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: '下载' }, '⭳'))
336
484
  : null))
337
485
  : null,
338
- tab === 'files' && h(FileTree, { repo, rev: rev || 'master', t, overview: ov, onOverview: setOv }),
486
+ tab === 'files' && h(FileTree, { repo, rev: rev || 'master', t, overview: ov, onOverview: setOv, cloneUrl, initialPath: deep && deep.tab === 'src' ? deep.filePath : '' }),
339
487
  tab === 'commits' && h(Commits, { repo, rev: rev || 'master', t }),
340
488
  tab === 'branches' && h(Branches, { repo, t, onNewPR: (head, base) => { setPrPreset({ head, base }); setTab('pulls') } }),
341
- tab === 'issues' && h(IssuesArea, { repo, t, initialSub: issuesSub || undefined }),
342
- tab === 'pulls' && h(Pulls, { repo, t, preset: prPreset, onPresetDone: () => setPrPreset(null), onGoIssuesSub: (k) => { setIssuesSub(k); setTab('issues') } }),
489
+ tab === 'issues' && h(IssuesArea, { repo, t, initialSub: issuesSub || undefined, initialIdx: deep && deep.tab === 'issues' && deep.arg ? Number(deep.arg) : undefined }),
490
+ tab === 'pulls' && h(Pulls, { repo, t, preset: prPreset, onPresetDone: () => setPrPreset(null), onGoIssuesSub: (k) => { setIssuesSub(k); setTab('issues') }, initialIdx: deep && deep.tab === 'pulls' && deep.arg ? Number(deep.arg) : undefined }),
343
491
  tab === 'wiki' && h(WikiView, { repo, t }),
344
492
  tab === 'releases' && h(Releases, { repo, t }),
345
493
  tab === 'settings' && h(RepoSettings, { repo, t }),
346
494
  )
347
495
  }
348
496
 
349
- function FileTree({ repo, rev, t, overview, onOverview }) {
350
- const [path, setPath] = useState('')
497
+ function FileTree({ repo, rev, t, overview, onOverview, initialPath, cloneUrl }) {
498
+ const [path, setPath] = useState(() => dirnameOf(initialPath || ''))
351
499
  const [entries, setEntries] = useState(null)
352
500
  const [file, setFile] = useState(null)
353
501
  const [err, setErr] = useState('')
@@ -361,6 +509,9 @@ window.__ModuleLoader__.load({
361
509
  const [editText, setEditText] = useState('')
362
510
  const [editMsg, setEditMsg] = useState('')
363
511
  const [histMode, setHistMode] = useState(null)
512
+ const [searchQ, setSearchQ] = useState('')
513
+ const [hits, setHits] = useState(null) // null=未搜索;[]=无结果
514
+ const [deepDone, setDeepDone] = useState(false)
364
515
  const load = useCallback((p) => {
365
516
  setFile(null); setEntries(null); setErr(''); setBlameOn(false); setEditing(false); setHistMode(null)
366
517
  api('GET', `/dsh/repos/${repo.owner}/${repo.name}/tree?ref=${encodeURIComponent(rev)}&path=${encodeURIComponent(p)}`)
@@ -376,20 +527,38 @@ window.__ModuleLoader__.load({
376
527
  if (p !== '') setReadme(null)
377
528
  }, [repo.owner, repo.name, rev])
378
529
  useEffect(() => { load(path) }, [load, path])
379
- const openEntry = (e) => {
380
- const p = e.path
381
- if (e.type === 'dir' || e.type === 'tree') { setPath(p); return }
382
- api('GET', `/repos/${repo.owner}/${repo.name}/raw?ref=${encodeURIComponent(rev)}&path=${encodeURIComponent(p)}`)
530
+ const openFile = (fp, name, size) => {
531
+ api('GET', `/repos/${repo.owner}/${repo.name}/raw?ref=${encodeURIComponent(rev)}&path=${encodeURIComponent(fp)}`)
383
532
  .then(async (d) => {
384
- const isMd = /\.md$/i.test(e.name)
533
+ if (!d.ok) { setPath(fp); setFile(null); return } // 目录型永久链接:落到目录
534
+ const isMd = /\.md$/i.test(name)
385
535
  let html = ''
386
- if (isMd && d.ok) {
536
+ if (isMd) {
387
537
  const r = await api('POST', '/dsh/markdown', { text: d.text })
388
538
  html = r.html || ''
389
539
  }
390
- setFile({ name: e.name, text: d.ok ? d.text : null, size: e.size, isMd, html })
540
+ setFile({ name, path: fp, text: d.text, size: size || 0, isMd, html })
391
541
  })
392
542
  }
543
+ const openEntry = (e) => {
544
+ if (e.type === 'dir' || e.type === 'tree') { setPath(e.path); return }
545
+ openFile(e.path, e.name, e.size)
546
+ }
547
+ // 深链直达:#/r/:o/:r/src/:ref/:path —— 打开指定文件(rev 由父组件同步)
548
+ useEffect(() => {
549
+ if (deepDone || !initialPath) return
550
+ setDeepDone(true)
551
+ openFile(initialPath, initialPath.split('/').pop(), 0)
552
+ }, [initialPath])
553
+ const doSearch = () => {
554
+ const q = searchQ.trim()
555
+ if (!q) return
556
+ setHits(null); setFile(null)
557
+ setMsg('')
558
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/search?q=${encodeURIComponent(q)}&ref=${encodeURIComponent(rev)}`)
559
+ .then((d) => { setHits(Array.isArray(d.matches) ? d.matches : []); if (d.error) setMsg(d.error) })
560
+ .catch(() => setMsg(t('loadFailed')))
561
+ }
393
562
  const startCreate = (mode) => {
394
563
  setNewFile(mode); setMsg('')
395
564
  setNf({ path: '', content: '', message: '' })
@@ -420,6 +589,11 @@ window.__ModuleLoader__.load({
420
589
  h('span', { className: 'dgs-crumb' + (i === arr.length - 1 ? ' cur' : ''), onClick: () => setPath(arr.slice(0, i + 1).join('/')) }, seg),
421
590
  i < arr.length - 1 ? h('span', { className: 'dgs-sub' }, '/') : null))),
422
591
  h('span', { style: { flex: 1 } }),
592
+ h('input', {
593
+ className: 'dgs-input', style: { maxWidth: 180, flex: 'none' }, placeholder: '搜索代码…',
594
+ value: searchQ, onChange: (e) => setSearchQ(e.target.value),
595
+ onKeyDown: (e) => { if (e.key === 'Enter') doSearch() },
596
+ }),
423
597
  h('button', { className: 'dgs-btn', onClick: () => startCreate('create') }, '新的文件'),
424
598
  h('button', { className: 'dgs-btn ghost', onClick: () => startCreate('upload') }, '上传文件')),
425
599
  msg ? h('div', { className: 'dgs-err' }, msg) : null,
@@ -458,15 +632,17 @@ window.__ModuleLoader__.load({
458
632
  h('span', { style: { fontWeight: 600, fontSize: 12.5 } }, '📄 ' + file.name + ' ' + (file.size ? fmtSize(file.size) : '')),
459
633
  h('span', { style: { flex: 1 } }),
460
634
  file.text !== null ? h('a', { className: 'dgs-sub', style: { cursor: 'pointer', marginRight: 12 }, onClick: () => {
461
- try { navigator.clipboard.writeText(location.origin + '/r/' + repo.owner + '/' + repo.name + '/src/' + encodeURIComponent(rev) + '/' + encodeURIComponent(path)) } catch {}
635
+ // 永久链接走面板的 hash 路由(旧网页 UI 已裁撤,裸路径链接是死的)
636
+ const link = location.origin + location.pathname + '#/r/' + repo.owner + '/' + repo.name + '/src/' + encodeURIComponent(rev) + '/' + (file.path || '').split('/').map(encodeURIComponent).join('/')
637
+ try { navigator.clipboard.writeText(link) } catch {}
462
638
  setMsg('✓ 已复制永久链接'); setTimeout(() => setMsg(''), 1500)
463
639
  } }, '永久链接') : null,
464
640
  file.text !== null ? h('a', { className: 'dgs-sub', style: { cursor: 'pointer', marginRight: 12 }, onClick: () => setHistMode(histMode === 'history' ? null : 'history') }, '文件历史') : null,
465
- 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,
641
+ 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(file.path || path)}`, target: '_blank', rel: 'noreferrer' }, '原始文件') : null,
466
642
  file.text !== null ? h('button', { className: 'dgs-btn ghost', style: { padding: '2px 8px' }, onClick: () => { setEditing(true); setEditText(file.text || '') } }, '✏️') : null,
467
643
  file.text !== null ? h('button', { className: 'dgs-btn danger', style: { padding: '2px 8px' }, onClick: async () => {
468
644
  if (!confirm('删除文件 ' + file.name + ' ?')) return
469
- const d = await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/files`, { path, branch: rev })
645
+ const d = await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/files`, { path: file.path || path, branch: rev })
470
646
  if (d && d.ok) { setFile(null); load(path) } else setMsg((d && d.error) || '删除失败')
471
647
  } }, '🗑️') : null,
472
648
  file.text !== null ? h('button', { className: 'dgs-btn ghost', style: { padding: '2px 8px' }, onClick: () => setBlameOn(!blameOn) }, 'Blame') : null),
@@ -478,20 +654,37 @@ window.__ModuleLoader__.load({
478
654
  h('button', { className: 'dgs-btn ghost', onClick: () => setEditing(false) }, '取消'),
479
655
  h('button', { className: 'dgs-btn', disabled: busy, onClick: async () => {
480
656
  setBusy(true); setMsg('')
481
- const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/files`, { path, branch: rev, content: editText, message: editMsg, overwrite: true })
657
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/files`, { path: file.path || path, branch: rev, content: editText, message: editMsg, overwrite: true })
482
658
  setBusy(false)
483
- if (d && d.ok) { setEditing(false); load(path) } else setMsg((d && d.error) || '保存失败')
659
+ if (d && d.ok) { setEditing(false); setFile(null); load(path) } else setMsg((d && d.error) || '保存失败')
484
660
  } }, '提交修改')))
485
- : histMode === 'history' ? h(FileHistory, { repo, rev, path })
661
+ : histMode === 'history' ? h(FileHistory, { repo, rev, path: file.path || path })
486
662
  : blameOn && file.text !== null
487
- ? h(BlameView, { repo, rev, path, text: file.text })
663
+ ? h(BlameView, { repo, rev, path: file.path || path, text: file.text })
488
664
  : file.isMd
489
665
  ? h('div', { className: 'dgs-md', style: { padding: '14px 16px' }, dangerouslySetInnerHTML: { __html: file.html || '' } })
490
- : h('pre', { className: 'dgs-file', style: { border: 'none', borderRadius: 0 } }, file.text === null ? t('emptyFile') : file.text)))
666
+ : file.text === null
667
+ ? h('pre', { className: 'dgs-file', style: { border: 'none', borderRadius: 0 } }, t('emptyFile'))
668
+ : h('pre', { className: 'dgs-file', style: { border: 'none', borderRadius: 0 }, dangerouslySetInnerHTML: { __html: highlightCode(file.text, file.name) } })))
669
+ : hits !== null ? renderHits()
491
670
  : entries === null ? h('div', { className: 'dgs-empty' }, t('loading'))
492
671
  : renderDir(),
493
672
  )
494
673
 
674
+ function renderHits() {
675
+ return h('div', null,
676
+ h('div', { className: 'dgs-row', style: { marginBottom: 8 } },
677
+ h('button', { className: 'dgs-btn ghost', onClick: () => setHits(null) }, t('back')),
678
+ h('span', { className: 'dgs-sub' }, '在 ' + rev + ' 中搜索「' + searchQ.trim() + '」')),
679
+ hits.length === 0 ? h('div', { className: 'dgs-empty' }, '—')
680
+ : hits.map((hit, i) =>
681
+ h('div', { key: i, className: 'dgs-hit', onClick: () => { setHits(null); setPath(dirnameOf(hit.path)); openFile(hit.path, hit.path.split('/').pop(), 0) } },
682
+ h('div', null,
683
+ h('span', { style: { color: 'var(--dsw-alias-state-business-primary)', fontWeight: 500, fontSize: 12.5 } }, hit.path),
684
+ h('span', { className: 'dgs-sub' }, ':' + hit.line)),
685
+ h('div', { className: 'dgs-hit-code' }, hit.text))))
686
+ }
687
+
495
688
  function renderDir() {
496
689
  const head = entries.find((e) => e.last)
497
690
  const rows = entries.map((e) =>
@@ -508,7 +701,9 @@ window.__ModuleLoader__.load({
508
701
  h('span', { style: { flex: 1 } }),
509
702
  h('span', { className: 'dgs-sub' }, timeAgo(head.last.date))) : null
510
703
  const table = entries.length === 0
511
- ? h('div', { className: 'dgs-empty' }, t('emptyDir'))
704
+ ? (path === '' && overview && overview.numCommits === 0
705
+ ? renderEmptyRepoGuide()
706
+ : h('div', { className: 'dgs-empty' }, t('emptyDir')))
512
707
  : h('div', null, headRow, h('table', { className: 'dgs-table' }, h('tbody', null, rows)))
513
708
  const readmeEl = (path === '' && overview && overview.readmeHtml)
514
709
  ? h('div', { className: 'dgs-card', style: { marginTop: 12 } },
@@ -517,9 +712,26 @@ window.__ModuleLoader__.load({
517
712
  : null
518
713
  return h('div', null, table, readmeEl)
519
714
  }
715
+
716
+ function renderEmptyRepoGuide() {
717
+ const remote = (cloneUrl || '').replace(/^git clone\s+/, '') || ('http://<服务器>:3400/' + repo.owner + '/' + repo.name + '.git')
718
+ const branch = (overview && overview.defaultBranch) || 'master'
719
+ const copyBtn = (text) => h('button', { className: 'dgs-btn ghost', style: { padding: '2px 10px', fontSize: 12 }, onClick: () => { try { navigator.clipboard.writeText(text) } catch {} } }, t('copy'))
720
+ return h('div', { className: 'dgs-card', style: { maxWidth: 660, margin: '24px auto' } },
721
+ h('div', { style: { fontWeight: 700, fontSize: 15, marginBottom: 6 } }, '这个仓库还是空的'),
722
+ h('div', { className: 'dgs-sub', style: { marginBottom: 12 } }, '克隆到本地开始开发,或把已有的本地仓库推送上来。git 凭据使用 dsh(user-management)的用户名和密码。'),
723
+ h('div', { className: 'dgs-row', style: { marginBottom: 4 } },
724
+ h('span', { style: { fontWeight: 600, fontSize: 12.5, flex: 1 } }, '克隆仓库'),
725
+ copyBtn('git clone ' + remote)),
726
+ h('div', { className: 'dgs-guide' }, 'git clone ' + remote),
727
+ h('div', { className: 'dgs-row', style: { margin: '12px 0 4px' } },
728
+ h('span', { style: { fontWeight: 600, fontSize: 12.5, flex: 1 } }, '推送已有仓库'),
729
+ copyBtn('git remote add origin ' + remote + '\ngit push -u origin ' + branch)),
730
+ h('div', { className: 'dgs-guide' }, 'git remote add origin ' + remote + '\ngit push -u origin ' + branch))
731
+ }
520
732
  }
521
733
 
522
- function IssuesArea({ repo, t, initialSub }) {
734
+ function IssuesArea({ repo, t, initialSub, initialIdx }) {
523
735
  const [sub, setSub] = useState(initialSub || 'list')
524
736
  const [labelJump, setLabelJump] = useState(null)
525
737
  const [msJump, setMsJump] = useState(null)
@@ -535,12 +747,12 @@ window.__ModuleLoader__.load({
535
747
  h('span', { style: { flex: 1 } }),
536
748
  sub === 'list' ? h('button', { className: 'dgs-btn', onClick: () => setCreateSignal((n) => n + 1) }, t('issueNew')) : null),
537
749
  sub === 'list' && h(Issues, { repo, t, presetLabel: labelJump, presetMilestone: msJump,
538
- onPresetDone: () => { setLabelJump(null); setMsJump(null) }, createSignal }),
750
+ onPresetDone: () => { setLabelJump(null); setMsJump(null) }, createSignal, initialIdx }),
539
751
  sub === 'labels' && h(LabelsManage, { repo, t, onFilterLabel: (lid) => { setLabelJump(lid); setSub('list') } }),
540
752
  sub === 'milestones' && h(MilestonesManage, { repo, t, onOpenMilestone: (m) => { setMsJump(m.id); setSub('list') } }))
541
753
  }
542
754
 
543
- function Issues({ repo, t, presetLabel, presetMilestone, onPresetDone, createSignal }) {
755
+ function Issues({ repo, t, presetLabel, presetMilestone, onPresetDone, createSignal, initialIdx }) {
544
756
  const [state, setState] = useState('open')
545
757
  const [flt, setFlt] = useState({ label: '', milestone: '', assignee: '' })
546
758
  useEffect(() => {
@@ -555,7 +767,7 @@ window.__ModuleLoader__.load({
555
767
  const [meta, setMeta] = useState({ labels: [], milestones: [], collabs: [] })
556
768
  const [creating, setCreating] = useState(false)
557
769
  useEffect(() => { if (createSignal) setCreating(true) }, [createSignal])
558
- const [openIdx, setOpenIdx] = useState(null)
770
+ const [openIdx, setOpenIdx] = useState(initialIdx ?? null)
559
771
  const reload = useCallback(() => {
560
772
  setList(null)
561
773
  const q = new URLSearchParams({ state, sort })
@@ -700,6 +912,8 @@ window.__ModuleLoader__.load({
700
912
  setComments(null)
701
913
  api('GET', `/dsh/repos/${repo.owner}/${repo.name}/issues/${idx}`).then((d) => setIssue(d && d.number ? d : null))
702
914
  api('GET', `/repos/${repo.owner}/${repo.name}/issues/${idx}/comments`).then((d) => setComments(d.comments || []))
915
+ // 打开详情即视为已读(通知中心据此消未读)
916
+ api('POST', `/dsh/repos/${repo.owner}/${repo.name}/issues/${idx}/read`).catch(() => {})
703
917
  }, [repo.owner, repo.name, idx])
704
918
  useEffect(reload, [reload])
705
919
  useEffect(() => {
@@ -729,7 +943,13 @@ window.__ModuleLoader__.load({
729
943
  comments === null ? h('div', { className: 'dgs-empty' }, t('loading'))
730
944
  : comments.length === 0 ? h('div', { className: 'dgs-empty' }, t('noIssues'))
731
945
  : comments.map((c) =>
732
- h('div', { key: c.id, className: 'dgs-comment' },
946
+ c.type === 2 // 提交关单事件(push 时 fixes #N 触发)
947
+ ? h('div', { key: c.id || ('ev' + c.created), className: 'dgs-row', style: { padding: '5px 4px', gap: 6 } },
948
+ h('span', null, '🔒'),
949
+ h('span', { className: 'dgs-sub' }, (c.user || '') + ' 通过提交 '),
950
+ h('code', { className: 'dgs-badge' }, (c.commit_sha || '').slice(0, 10) || '?'),
951
+ h('span', { className: 'dgs-sub' }, ' 关闭了此工单 · ' + fmtDate(c.created)))
952
+ : h('div', { key: c.id, className: 'dgs-comment' },
733
953
  h('div', { className: 'dgs-row', style: { marginBottom: 4 } },
734
954
  h('span', { className: 'dgs-sub' }, `${c.user || ''} · ${fmtDate(c.created)}`),
735
955
  h('span', { style: { flex: 1 } }),
@@ -746,7 +966,7 @@ window.__ModuleLoader__.load({
746
966
  await api('PATCH', `/repos/${repo.owner}/${repo.name}/issues/comments/${c.id}`, { body: editC.text })
747
967
  setEditC(null); reload()
748
968
  } }, t('save'))))
749
- : h('div', { style: { whiteSpace: 'pre-wrap' } }, c.body))),
969
+ : h('div', { style: { whiteSpace: 'pre-wrap' } }, linkifyIssues(c.body, repo)))),
750
970
  h('div', { className: 'dgs-card', style: { margin: '12px 0' } },
751
971
  h('textarea', { className: 'dgs-input', placeholder: t('commentPlaceholder'), value: text, onChange: (e) => setText(e.target.value) }),
752
972
  h('div', { className: 'dgs-row', style: { marginTop: 8 } },
@@ -829,7 +1049,7 @@ window.__ModuleLoader__.load({
829
1049
  h('table', { className: 'dgs-table' },
830
1050
  h('tbody', null, shown.map((c, i) =>
831
1051
  h('tr', { key: i, style: { cursor: 'pointer' }, onClick: () => setSel(c.sha) },
832
- h('td', null, h('div', { style: { fontWeight: 500 } }, (c.message || '').split('\n')[0]),
1052
+ h('td', null, h('div', { style: { fontWeight: 500 } }, linkifyIssues((c.message || '').split('\n')[0], repo)),
833
1053
  h('div', { className: 'dgs-sub' }, c.author)),
834
1054
  h('td', { className: 'dgs-sub', style: { textAlign: 'right', whiteSpace: 'nowrap' } }, (c.sha || '').slice(0, 10)),
835
1055
  h('td', { className: 'dgs-sub', style: { textAlign: 'right', whiteSpace: 'nowrap', width: 100 } }, timeAgo(c.date)))))),
@@ -849,7 +1069,7 @@ window.__ModuleLoader__.load({
849
1069
  return h('div', null,
850
1070
  h('div', { className: 'dgs-row', style: { marginBottom: 10 } },
851
1071
  h('button', { className: 'dgs-btn ghost', onClick: onBack }, t('back')),
852
- h('span', { style: { fontWeight: 600 } }, (d.message || '').split('\n')[0]),
1072
+ h('span', { style: { fontWeight: 600 } }, linkifyIssues((d.message || '').split('\n')[0], repo)),
853
1073
  h('span', { className: 'dgs-sub' }, (d.sha || '').slice(0, 10))),
854
1074
  h('div', { className: 'dgs-sub', style: { marginBottom: 8 } },
855
1075
  `${(d.author && d.author.name) || ''} · ${fmtDate(when)}`),
@@ -943,8 +1163,9 @@ window.__ModuleLoader__.load({
943
1163
  b.name !== def ? h('button', { className: 'dgs-btn ghost', style: { marginLeft: 8, padding: '2px 8px' }, onClick: () => onNewPR && onNewPR(b.name, def) }, '+ PR') : null,
944
1164
  b.name !== def ? h('button', { className: 'dgs-btn danger', style: { marginLeft: 4, padding: '2px 8px' }, onClick: async () => {
945
1165
  if (!confirm('删除分支 ' + b.name + ' ?')) return
946
- await api('DELETE', `/repos/${repo.owner}/${repo.name}/branches/${encodeURIComponent(b.name)}`)
947
- reload()
1166
+ const d = await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/branches/${encodeURIComponent(b.name)}`)
1167
+ if (d && d.ok) reload()
1168
+ else alert((d && d.error) || '删除失败')
948
1169
  } }, '删') : null))))),
949
1170
  h('div', { style: { fontWeight: 700, margin: '18px 0 8px' } }, '标签'),
950
1171
  tags === null ? h('div', { className: 'dgs-empty' }, t('loading'))
@@ -1199,19 +1420,33 @@ window.__ModuleLoader__.load({
1199
1420
  const [form, setForm] = useState({ description: '', private: false, website: '' })
1200
1421
  const [collabs, setCollabs] = useState([])
1201
1422
  const [addName, setAddName] = useState('')
1423
+ const [addMode, setAddMode] = useState('write')
1202
1424
  const [hooks, setHooks] = useState(null)
1203
1425
  const [hookUrl, setHookUrl] = useState('')
1426
+ const [hookDeliveries, setHookDeliveries] = useState({}) // id → rows | 'loading'
1427
+ const [prots, setProts] = useState(null)
1428
+ const [protBranch, setProtBranch] = useState('')
1429
+ const [branchList, setBranchList] = useState([])
1430
+ const [mirrorInfo, setMirrorInfo] = useState(null)
1431
+ const [syncBusy, setSyncBusy] = useState(false)
1204
1432
  const [msg, setMsg] = useState('')
1205
1433
  const [busy, setBusy] = useState(false)
1206
1434
  const loadCollabs = () => api('GET', `/dsh/repos/${repo.owner}/${repo.name}/collaborators`).then((d) => setCollabs(Array.isArray(d) ? d : []))
1207
1435
  const loadHooks = () => api('GET', `/dsh/repos/${repo.owner}/${repo.name}/hooks`).then((d) => setHooks(Array.isArray(d) ? d : []))
1436
+ const loadProts = () => api('GET', `/dsh/repos/${repo.owner}/${repo.name}/protections`).then((d) => setProts(Array.isArray(d) ? d : []))
1208
1437
  useEffect(() => {
1209
1438
  api('GET', `/dsh/repos/${repo.owner}/${repo.name}`).then((d) => {
1210
- if (d && d.name) { setInfo(d); setForm({ description: d.description || '', private: !!d.private, website: d.website || '' }) }
1439
+ if (d && d.name) {
1440
+ setInfo(d)
1441
+ setForm({ description: d.description || '', private: !!d.private, website: d.website || '' })
1442
+ if (d.isMirror) setMirrorInfo(d.mirror || { address: '', updatedAt: 0, nextAt: 0 })
1443
+ }
1211
1444
  else setMsg((d && d.error) || t('loadFailed'))
1212
1445
  })
1213
1446
  loadCollabs()
1214
1447
  loadHooks()
1448
+ loadProts()
1449
+ api('GET', `/repos/${repo.owner}/${repo.name}/branches`).then((d) => setBranchList((d && d.branches) || []))
1215
1450
  }, [repo.owner, repo.name])
1216
1451
  if (info === null && !msg) return h('div', { className: 'dgs-empty' }, t('loading'))
1217
1452
  const navItem = (k, label) => h('span', { key: k, className: 'item' + (sub === k ? ' active' : ''), onClick: () => setSub(k) }, label)
@@ -1236,19 +1471,33 @@ window.__ModuleLoader__.load({
1236
1471
  setBusy(false)
1237
1472
  d && d.ok ? setMsg('✓ 已保存') : setMsg((d && d.error) || 'failed')
1238
1473
  } }, '更新设置')))
1474
+ const collabModeName = { 1: '只读', 2: '可写', 3: '管理' }
1239
1475
  const collab = h('div', { className: 'dgs-card' },
1240
1476
  h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '协作者'),
1241
1477
  h('div', { className: 'dgs-row' },
1242
1478
  h('input', { className: 'dgs-input', style: { maxWidth: 200 }, placeholder: '用户名', value: addName, onChange: (e) => setAddName(e.target.value) }),
1479
+ h('select', { className: 'dgs-input', style: { maxWidth: 110, flex: 'none', width: 'auto' }, value: addMode, onChange: (e) => setAddMode(e.target.value) },
1480
+ h('option', { value: 'read' }, '只读'),
1481
+ h('option', { value: 'write' }, '可写'),
1482
+ h('option', { value: 'admin' }, '管理')),
1243
1483
  h('button', { className: 'dgs-btn', disabled: !addName.trim(),
1244
1484
  onClick: async () => {
1245
- const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/collaborators/${encodeURIComponent(addName.trim())}`)
1485
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/collaborators/${encodeURIComponent(addName.trim())}`, { mode: addMode })
1246
1486
  if (d && d.ok !== false && !d.error) { setAddName(''); setMsg(''); loadCollabs() } else setMsg((d && d.error) || 'failed')
1247
1487
  } }, '+ 添加')),
1248
1488
  collabs.length === 0 ? h('div', { className: 'dgs-sub', style: { marginTop: 8 } }, '—') : null,
1249
1489
  collabs.map((u) => h('div', { key: u.name, className: 'dgs-row', style: { marginTop: 6 } },
1250
1490
  h('a', { className: 'dgs-name', href: '#/u/' + u.name }, u.name),
1251
1491
  h('span', { style: { flex: 1 } }),
1492
+ h('select', { className: 'dgs-input', style: { maxWidth: 96, flex: 'none', width: 'auto', padding: '3px 6px', fontSize: 12 }, value: String(u.mode || 2),
1493
+ onChange: async (e) => {
1494
+ const mode = { 1: 'read', 2: 'write', 3: 'admin' }[e.target.value] || 'write'
1495
+ await api('PATCH', `/dsh/repos/${repo.owner}/${repo.name}/collaborators/${encodeURIComponent(u.name)}`, { mode })
1496
+ loadCollabs()
1497
+ } },
1498
+ h('option', { value: '1' }, '只读'),
1499
+ h('option', { value: '2' }, '可写'),
1500
+ h('option', { value: '3' }, '管理')),
1252
1501
  h('button', { className: 'dgs-btn danger', onClick: async () => {
1253
1502
  await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/collaborators/${encodeURIComponent(u.name)}`)
1254
1503
  loadCollabs()
@@ -1261,16 +1510,77 @@ window.__ModuleLoader__.load({
1261
1510
  const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/hooks`, { url: hookUrl.trim() })
1262
1511
  if (d && !d.error) { setHookUrl(''); loadHooks() } else setMsg((d && d.error) || 'failed')
1263
1512
  } }, '+ 添加')),
1264
- (hooks || []).map((hk) => h('div', { key: hk.id, className: 'dgs-row', style: { marginTop: 6 } },
1265
- h('span', { className: 'dgs-badge' + (hk.is_active ? ' ok' : ' closed') }, hk.is_active ? '启用' : '停用'),
1266
- h('span', { className: 'dgs-sub', style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' } }, hk.url),
1267
- h('button', { className: 'dgs-btn ghost', onClick: async () => {
1268
- await api('PATCH', `/dsh/repos/${repo.owner}/${repo.name}/hooks/${hk.id}`, { active: !hk.is_active }); loadHooks()
1269
- } }, hk.is_active ? '停用' : '启用'),
1270
- h('button', { className: 'dgs-btn danger', onClick: async () => {
1271
- await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/hooks/${hk.id}`); loadHooks()
1272
- } }, t('delete')))),
1513
+ (hooks || []).map((hk) => h('div', { key: hk.id, style: { marginTop: 6 } },
1514
+ h('div', { className: 'dgs-row' },
1515
+ h('span', { className: 'dgs-badge' + (hk.is_active ? ' ok' : ' closed') }, hk.is_active ? '启用' : '停用'),
1516
+ hk.last_status === 1 ? h('span', { className: 'dgs-badge ok', title: '最近一次投递成功' }, '✓ 投递成功')
1517
+ : hk.last_status === 2 ? h('span', { className: 'dgs-badge closed', title: '最近一次投递失败' }, '✗ 投递失败') : null,
1518
+ h('span', { className: 'dgs-sub', style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' } }, hk.url),
1519
+ h('button', { className: 'dgs-btn ghost', onClick: async () => {
1520
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/hooks/${hk.id}/test`)
1521
+ if (d && d.ok) { setMsg('✓ 已发送测试事件(push)'); setTimeout(() => setMsg(''), 1600); setTimeout(loadHooks, 1200) }
1522
+ else setMsg((d && d.error) || 'failed')
1523
+ } }, '测试'),
1524
+ h('button', { className: 'dgs-btn ghost', onClick: async () => {
1525
+ if (hookDeliveries[hk.id]) { setHookDeliveries({ ...hookDeliveries, [hk.id]: undefined }); return }
1526
+ setHookDeliveries({ ...hookDeliveries, [hk.id]: 'loading' })
1527
+ const d = await api('GET', `/dsh/repos/${repo.owner}/${repo.name}/hooks/${hk.id}/deliveries`)
1528
+ setHookDeliveries((m) => ({ ...m, [hk.id]: Array.isArray(d) ? d : [] }))
1529
+ } }, '记录'),
1530
+ h('button', { className: 'dgs-btn ghost', onClick: async () => {
1531
+ await api('PATCH', `/dsh/repos/${repo.owner}/${repo.name}/hooks/${hk.id}`, { active: !hk.is_active }); loadHooks()
1532
+ } }, hk.is_active ? '停用' : '启用'),
1533
+ h('button', { className: 'dgs-btn danger', onClick: async () => {
1534
+ await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/hooks/${hk.id}`); loadHooks()
1535
+ } }, t('delete'))),
1536
+ Array.isArray(hookDeliveries[hk.id]) ? h('div', { style: { margin: '4px 0 4px 24px' } },
1537
+ hookDeliveries[hk.id].length === 0 ? h('div', { className: 'dgs-sub' }, '暂无投递记录')
1538
+ : hookDeliveries[hk.id].map((dv) =>
1539
+ h('div', { key: dv.id, className: 'dgs-row', style: { padding: '2px 0', gap: 8 } },
1540
+ h('span', { className: 'dgs-badge' + (dv.ok ? ' ok' : ' closed') }, dv.ok ? '成功' : '失败'),
1541
+ h('span', { className: 'dgs-sub' }, '#' + dv.id + ' · ' + dv.event + (dv.status ? ' · HTTP ' + dv.status : '')),
1542
+ dv.err ? h('span', { className: 'dgs-sub', style: { color: 'var(--dsw-alias-state-error-primary)' } }, dv.err) : null)))
1543
+ : hookDeliveries[hk.id] === 'loading' ? h('div', { className: 'dgs-sub', style: { margin: '4px 0 4px 24px' } }, t('loading')) : null)),
1273
1544
  hooks !== null && hooks.length === 0 ? h('div', { className: 'dgs-sub', style: { marginTop: 6 } }, '—') : null)
1545
+ const protsCard = h('div', { className: 'dgs-card' },
1546
+ h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '分支保护'),
1547
+ h('div', { className: 'dgs-sub', style: { marginBottom: 10 } }, '受保护的分支不可删除,也不接受强推(非快进推送会被拒绝)。合并 PR、网页编辑等正常快进操作不受影响。'),
1548
+ h('div', { className: 'dgs-row' },
1549
+ h('select', { className: 'dgs-input', style: { maxWidth: 220, flex: 'none', width: 'auto' }, value: protBranch, onChange: (e) => setProtBranch(e.target.value) },
1550
+ h('option', { value: '' }, '选择分支…'),
1551
+ branchList.map((b) => h('option', { key: b.name, value: b.name }, b.name + (info && b.name === info.defaultBranch ? '(默认)' : '')))),
1552
+ h('button', { className: 'dgs-btn', disabled: !protBranch, onClick: async () => {
1553
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/protections`, { branch: protBranch, protected: true })
1554
+ if (d && d.ok) { setProtBranch(''); loadProts() } else setMsg((d && d.error) || 'failed')
1555
+ } }, '+ 保护')),
1556
+ prots === null ? h('div', { className: 'dgs-sub', style: { marginTop: 8 } }, t('loading'))
1557
+ : prots.length === 0 ? h('div', { className: 'dgs-sub', style: { marginTop: 8 } }, '—')
1558
+ : prots.map((p) => h('div', { key: p.branch, className: 'dgs-row', style: { marginTop: 6 } },
1559
+ h('span', { className: 'dgs-badge ok' }, '🔒 ' + p.branch),
1560
+ h('span', { style: { flex: 1 } }),
1561
+ h('button', { className: 'dgs-btn danger', onClick: async () => {
1562
+ await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/protections`, { branch: p.branch, protected: false })
1563
+ loadProts()
1564
+ } }, '解除保护'))))
1565
+ const mirrorCard = mirrorInfo ? h('div', { className: 'dgs-card' },
1566
+ h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '镜像同步'),
1567
+ h('div', { className: 'dgs-row' },
1568
+ h('span', { className: 'dgs-sub', style: { minWidth: 70 } }, '上游地址'),
1569
+ h('span', { style: { fontFamily: 'ui-monospace,monospace', fontSize: 12.5, wordBreak: 'break-all' } }, mirrorInfo.address || '—')),
1570
+ h('div', { className: 'dgs-row' },
1571
+ h('span', { className: 'dgs-sub', style: { minWidth: 70 } }, '上次同步'),
1572
+ h('span', { className: 'dgs-sub' }, mirrorInfo.updatedAt ? timeAgo(mirrorInfo.updatedAt) : '从未'),
1573
+ h('span', { className: 'dgs-sub' }, mirrorInfo.nextAt ? '· 下次 ' + timeAgo(mirrorInfo.nextAt).replace('之前', '后') : '')),
1574
+ h('div', { className: 'dgs-row', style: { marginTop: 8 } },
1575
+ h('button', { className: 'dgs-btn', disabled: syncBusy, onClick: async () => {
1576
+ setSyncBusy(true); setMsg('')
1577
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/mirror-sync`)
1578
+ setSyncBusy(false)
1579
+ if (d && d.ok) {
1580
+ setMirrorInfo({ ...mirrorInfo, updatedAt: d.updatedAt, nextAt: d.nextAt })
1581
+ setMsg('✓ 同步完成')
1582
+ } else setMsg((d && d.error) || '同步失败')
1583
+ } }, syncBusy ? '同步中…(拉取上游可能需要数十秒)' : '立即同步'))) : null
1274
1584
  const danger = h('div', { className: 'dgs-card', style: { marginTop: 12, borderColor: 'var(--dsw-alias-state-error-primary)55' } },
1275
1585
  h('div', { style: { fontWeight: 700, marginBottom: 8, color: 'var(--dsw-alias-state-error-primary)' } }, '危险区域'),
1276
1586
  h('div', { className: 'dgs-row' },
@@ -1302,17 +1612,19 @@ window.__ModuleLoader__.load({
1302
1612
  h('div', { className: 'dgs-settings-nav' },
1303
1613
  navItem('basic', '基本设置'),
1304
1614
  navItem('collab', '管理协作者'),
1615
+ navItem('prot', '分支保护'),
1305
1616
  navItem('hooks', '管理 Web 钩子'),
1617
+ mirrorInfo ? navItem('mirror', '镜像同步') : null,
1306
1618
  navItem('danger', '危险区域')),
1307
1619
  h('div', { style: { flex: 1, minWidth: 0 } },
1308
- sub === 'basic' ? basic : sub === 'collab' ? collab : sub === 'hooks' ? hooksCard : danger)))
1620
+ sub === 'basic' ? basic : sub === 'collab' ? collab : sub === 'prot' ? protsCard : sub === 'hooks' ? hooksCard : sub === 'mirror' ? mirrorCard : danger)))
1309
1621
  }
1310
1622
 
1311
1623
  // ── PR(列表 + 详情 + 合并 + 评论复用 issue 通道) ────────────────────────
1312
1624
 
1313
- function Pulls({ repo, t, preset, onPresetDone, onGoIssuesSub }) {
1625
+ function Pulls({ repo, t, preset, onPresetDone, onGoIssuesSub, initialIdx }) {
1314
1626
  const [list, setList] = useState(null)
1315
- const [openIdx, setOpenIdx] = useState(null)
1627
+ const [openIdx, setOpenIdx] = useState(initialIdx ?? null)
1316
1628
  const [creating, setCreating] = useState(false)
1317
1629
  const [state, setState] = useState('open')
1318
1630
  const [flt, setFlt] = useState({ label: '', milestone: '', assignee: '' })
@@ -1483,6 +1795,11 @@ window.__ModuleLoader__.load({
1483
1795
  const [allMs, setAllMs] = useState([])
1484
1796
  const [collabs, setCollabs] = useState([])
1485
1797
  const [mergeMsg, setMergeMsg] = useState('')
1798
+ const [reviews, setReviews] = useState([])
1799
+ const [reviewText, setReviewText] = useState('')
1800
+ const loadReviews = useCallback(() => {
1801
+ api('GET', `/dsh/repos/${repo.owner}/${repo.name}/pulls/${idx}/reviews`).then((d) => setReviews(Array.isArray(d) ? d : []))
1802
+ }, [repo.owner, repo.name, idx])
1486
1803
  useEffect(() => {
1487
1804
  api('GET', `/dsh/repos/${repo.owner}/${repo.name}/pulls`).then((d) => {
1488
1805
  const all = Array.isArray(d) ? d : (d.data || d.pulls || [])
@@ -1492,6 +1809,8 @@ window.__ModuleLoader__.load({
1492
1809
  })
1493
1810
  api('GET', `/repos/${repo.owner}/${repo.name}/issues/${idx}/comments`).then((d) => setComments(d.comments || []))
1494
1811
  api('GET', `/dsh/repos/${repo.owner}/${repo.name}/issues/${idx}`).then((d) => setIssue(d && d.number ? d : null))
1812
+ api('POST', `/dsh/repos/${repo.owner}/${repo.name}/issues/${idx}/read`).catch(() => {})
1813
+ loadReviews()
1495
1814
  api('GET', `/dsh/repos/${repo.owner}/${repo.name}/labels`).then((d) => setAllLabels(Array.isArray(d) ? d : []))
1496
1815
  api('GET', `/dsh/repos/${repo.owner}/${repo.name}/milestones`).then((d) => setAllMs(Array.isArray(d) ? d : []))
1497
1816
  api('GET', `/dsh/repos/${repo.owner}/${repo.name}/collaborators`).then((d) => setCollabs(Array.isArray(d) ? d : []))
@@ -1532,13 +1851,37 @@ window.__ModuleLoader__.load({
1532
1851
  onChange: (e) => patchIssue({ assignee: e.target.value }) },
1533
1852
  h('option', { value: '' }, '未指派成员'),
1534
1853
  collabs.map((u) => h('option', { key: u.name, value: u.name }, u.name)))),
1854
+ h('div', { className: 'dgs-side-block' },
1855
+ h('div', { className: 'dgs-side-title' }, '评审'),
1856
+ reviews.length === 0 ? h('div', { className: 'dgs-sub' }, '暂无评审') : null,
1857
+ reviews.map((rv) => h('div', { key: rv.user, className: 'dgs-row', style: { padding: '3px 0', gap: 6 } },
1858
+ h('span', { style: { color: rv.approved ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontWeight: 600, fontSize: 12.5 } }, rv.approved ? '✓' : '✗'),
1859
+ h('span', { style: { fontSize: 12.5 } }, rv.user),
1860
+ h('span', { className: 'dgs-sub' }, rv.approved ? '通过' : '请求修改')))),
1535
1861
  h('div', { className: 'dgs-side-block' },
1536
1862
  h('div', { className: 'dgs-side-title' }, (issue && issue.participants || 1) + ' 名参与者'),
1537
1863
  h('div', { className: 'dgs-sub' }, pr.author || '')))
1864
+ const approvals = reviews.filter((rv) => rv.approved).length
1865
+ const submitReview = async (approved) => {
1866
+ setBusy(true)
1867
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/pulls/${idx}/reviews`, { approved, content: reviewText.trim() })
1868
+ setBusy(false)
1869
+ if (d && d.ok) {
1870
+ setReviewText('')
1871
+ loadReviews()
1872
+ if (reviewText.trim()) api('GET', `/repos/${repo.owner}/${repo.name}/issues/${idx}/comments`).then((d2) => setComments(d2.comments || []))
1873
+ } else setMsg((d && d.error) || 'failed')
1874
+ }
1538
1875
  const mergeBox = pr.state === 'open' ? h('div', { className: 'dgs-card', style: { margin: '10px 0', borderColor: 'var(--dsw-alias-state-success-primary)55' } },
1539
1876
  h('div', { className: 'dgs-row', style: { gap: 8 } },
1540
1877
  h('span', { style: { color: 'var(--dsw-alias-state-success-primary)', fontSize: 16 } }, '⑂'),
1541
- h('span', { style: { color: 'var(--dsw-alias-state-success-primary)', fontSize: 13 } }, '该合并请求可以进行自动合并操作。')),
1878
+ h('span', { style: { color: 'var(--dsw-alias-state-success-primary)', fontSize: 13 } }, '该合并请求可以进行自动合并操作。'),
1879
+ approvals > 0 ? h('span', { className: 'dgs-badge ok' }, approvals + ' 个评审通过') : null),
1880
+ h('div', { style: { margin: '10px 0', borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: 10 } },
1881
+ h('textarea', { className: 'dgs-input', style: { minHeight: 48 }, placeholder: '评审意见(可选,会同时出现在对话中)', value: reviewText, onChange: (e) => setReviewText(e.target.value) }),
1882
+ h('div', { className: 'dgs-row', style: { marginTop: 6 } },
1883
+ h('button', { className: 'dgs-btn ghost', disabled: busy, onClick: () => submitReview(true) }, '✓ 通过评审'),
1884
+ h('button', { className: 'dgs-btn ghost', disabled: busy, onClick: () => submitReview(false) }, '✗ 请求修改'))),
1542
1885
  h('div', { style: { margin: '10px 0' } },
1543
1886
  h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 6, alignItems: 'center', margin: '4px 0' } },
1544
1887
  h('input', { type: 'radio', name: 'mergeStyle', checked: mergeStyle === 'merge', onChange: () => setMergeStyle('merge') }), '创建一个新的合并提交'),
@@ -1570,7 +1913,7 @@ window.__ModuleLoader__.load({
1570
1913
  (comments || []).map((c) =>
1571
1914
  h('div', { key: c.id, className: 'dgs-comment' },
1572
1915
  h('div', { className: 'dgs-sub', style: { marginBottom: 4 } }, `${c.user || ''} 评论于 ${fmtDate(c.created)}`),
1573
- h('div', { style: { whiteSpace: 'pre-wrap' } }, c.body || '这个人很懒,什么都没留下。'))),
1916
+ h('div', { style: { whiteSpace: 'pre-wrap' } }, c.body ? linkifyIssues(c.body, repo) : '这个人很懒,什么都没留下。'))),
1574
1917
  h('div', { className: 'dgs-card', style: { margin: '10px 0' } },
1575
1918
  h('textarea', { className: 'dgs-input', placeholder: t('commentPlaceholder'), value: text, onChange: (e) => setText(e.target.value) }),
1576
1919
  h('div', { className: 'dgs-row', style: { marginTop: 8 } },
@@ -1588,7 +1931,7 @@ window.__ModuleLoader__.load({
1588
1931
  : h('table', { className: 'dgs-table' },
1589
1932
  h('tbody', null, cmp.commits.map((c) =>
1590
1933
  h('tr', { key: c.sha, style: { cursor: 'pointer' }, onClick: () => setSelCommit(c.sha) },
1591
- h('td', null, h('div', { style: { fontWeight: 500 } }, (c.message || '').split('\n')[0]),
1934
+ h('td', null, h('div', { style: { fontWeight: 500 } }, linkifyIssues((c.message || '').split('\n')[0], repo)),
1592
1935
  h('div', { className: 'dgs-sub' }, c.author)),
1593
1936
  h('td', { className: 'dgs-sub', style: { textAlign: 'right' } },
1594
1937
  h('a', { style: { color: 'var(--dsw-alias-state-business-primary)', cursor: 'pointer' } }, (c.sha || '').slice(0, 10)))))))
@@ -1809,16 +2152,46 @@ window.__ModuleLoader__.load({
1809
2152
  r.prerelease ? h('span', { className: 'dgs-badge pri' }, '预发布') : null,
1810
2153
  h('span', { style: { fontWeight: 600 } }, r.title),
1811
2154
  h('span', { style: { flex: 1 } }),
1812
- h('a', { className: 'dgs-sub', style: { cursor: 'pointer' }, onClick: () => { setEditId(r.id); setEditForm({ title: r.title || '', note: r.noteRaw || '' }) } }, '(编辑)')),
2155
+ h('a', { className: 'dgs-sub', style: { cursor: 'pointer' }, onClick: () => { setEditId(r.id); setEditForm({ title: r.title || '', note: r.noteRaw || '' }) } }, '(编辑)'),
2156
+ h('a', { className: 'dgs-sub', style: { cursor: 'pointer', color: 'var(--dsw-alias-state-error-primary)' }, onClick: async () => {
2157
+ if (!confirm('删除发版 ' + r.tag + '(git 标签保留)?')) return
2158
+ const d = await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/releases/${r.id}`)
2159
+ if (d && d.ok) reload(); else setMsg((d && d.error) || '删除失败')
2160
+ } }, '(删除)')),
1813
2161
  h('div', { className: 'dgs-sub', style: { marginTop: 4 } },
1814
2162
  (r.author || '') + ' · ' + timeAgo((r.createdAt || 0) * 1000) + ' 发布 · ' +
1815
2163
  (r.behind ? '在该版本发布之后已有 ' + r.behind + ' 次代码提交到 ' + (r.target || '默认') + ' 分支' : '暂无后续提交')),
1816
2164
  r.noteHtml ? h('div', { className: 'dgs-md', style: { marginTop: 8 }, dangerouslySetInnerHTML: { __html: r.noteHtml } }) : null,
1817
2165
  h('div', { style: { marginTop: 10 } },
1818
- h('div', { style: { fontWeight: 600, fontSize: 13, marginBottom: 4 } }, '下载附件'),
2166
+ h('div', { className: 'dgs-row', style: { marginBottom: 4 } },
2167
+ h('div', { style: { fontWeight: 600, fontSize: 13 } }, '下载附件'),
2168
+ h('span', { style: { flex: 1 } }),
2169
+ h('label', { className: 'dgs-sub', style: { cursor: 'pointer' } }, '⭱ 上传附件',
2170
+ h('input', { type: 'file', style: { display: 'none' }, onChange: async (e) => {
2171
+ const f = e.target.files && e.target.files[0]
2172
+ e.target.value = ''
2173
+ if (!f) return
2174
+ if (f.size > 25 * 1024 * 1024) { setMsg('附件超过 25MB 上限'); return }
2175
+ const bytes = new Uint8Array(await f.arrayBuffer())
2176
+ let bin = ''
2177
+ for (let i = 0; i < bytes.length; i += 32768) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 32768))
2178
+ setMsg('')
2179
+ const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/releases/${r.id}/assets`, { name: f.name, contentBase64: btoa(bin) })
2180
+ if (d && d.ok) reload(); else setMsg((d && d.error) || '上传失败')
2181
+ } }))),
1819
2182
  h('div', { className: 'dgs-row', style: { gap: 8 } },
1820
2183
  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)'),
1821
- 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)')))))),
2184
+ 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)')),
2185
+ (r.assets || []).length === 0 ? null
2186
+ : h('div', { style: { marginTop: 6 } }, r.assets.map((a) =>
2187
+ h('div', { key: a.id, className: 'dgs-row', style: { gap: 8, padding: '2px 0' } },
2188
+ h('a', { className: 'dgs-sub', style: { color: 'var(--dsw-alias-state-business-primary)' }, href: `/dsh-git-server/api/dsh/repos/${repo.owner}/${repo.name}/attachments/${a.id}`, download: a.name }, '📦 ' + a.name),
2189
+ h('span', { className: 'dgs-sub' }, fmtSize(a.size)),
2190
+ h('a', { className: 'dgs-sub', style: { cursor: 'pointer', color: 'var(--dsw-alias-state-error-primary)' }, onClick: async () => {
2191
+ if (!confirm('删除附件 ' + a.name + ' ?')) return
2192
+ await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/attachments/${a.id}`)
2193
+ reload()
2194
+ } }, '删除')))))))),
1822
2195
  (plainTags && plainTags.length > 0) ? h('div', { style: { marginTop: 16 } },
1823
2196
  h('div', { style: { fontWeight: 700, margin: '4px 0 8px' } }, '未发版的标签'),
1824
2197
  plainTags.map((tg) => h('div', { key: tg, className: 'dgs-issue' },
@@ -1950,20 +2323,44 @@ window.__ModuleLoader__.load({
1950
2323
 
1951
2324
  function UserProfile({ name, t }) {
1952
2325
  const [profile, setProfile] = useState(null)
1953
- useEffect(() => {
2326
+ const [showList, setShowList] = useState(null) // 'followers' | 'following' | null
2327
+ const [followBusy, setFollowBusy] = useState(false)
2328
+ const reload = useCallback(() => {
1954
2329
  api('GET', '/dsh/users/' + encodeURIComponent(name)).then((d) => setProfile(d.data || d))
1955
2330
  }, [name])
2331
+ useEffect(() => { reload() }, [reload])
1956
2332
  if (profile === null) return h('div', { className: 'dgs-empty' }, t('loading'))
2333
+ const followBtn = profile.isSelf === false ? h('button', {
2334
+ className: 'dgs-btn' + (profile.isFollowing ? ' ghost' : ''), style: { marginTop: 10, width: '100%' }, disabled: followBusy,
2335
+ onClick: async () => {
2336
+ setFollowBusy(true)
2337
+ const d = await api('POST', '/dsh/users/' + encodeURIComponent(name) + '/follow')
2338
+ setFollowBusy(false)
2339
+ if (d && typeof d.on === 'boolean') reload()
2340
+ },
2341
+ }, profile.isFollowing ? '取消关注' : '+ 关注') : null
2342
+ const countBtn = (k, n, label) => h('a', {
2343
+ className: 'dgs-sub', style: { cursor: 'pointer', color: showList === k ? 'var(--dsw-alias-state-business-primary)' : undefined },
2344
+ onClick: () => setShowList(showList === k ? null : k),
2345
+ }, `${n} ${label}`)
2346
+ const listNames = showList === 'followers' ? profile.followers : showList === 'following' ? profile.following : []
1957
2347
  return h('div', { className: 'dgs-settings-layout' },
1958
2348
  h('div', { className: 'dgs-settings-nav', style: { width: 230 } },
1959
2349
  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 } },
1960
2350
  (name || '?').slice(0, 1).toUpperCase()),
1961
2351
  h('div', { className: 'dgs-h1', style: { fontSize: 17 } }, name),
1962
2352
  profile.isAdmin ? h('span', { className: 'dgs-badge ok', style: { marginTop: 4 } }, 'admin') : null,
2353
+ followBtn,
1963
2354
  h('div', { className: 'dgs-sub', style: { marginTop: 10 } }, profile.email ? '✉ ' + profile.email : ''),
1964
2355
  h('div', { className: 'dgs-sub', style: { marginTop: 4 } }, '🕐 加入于 ' + (profile.created ? new Date(profile.created * 1000).toLocaleDateString() : '')),
1965
2356
  h('div', { className: 'dgs-sub', style: { marginTop: 10 } },
1966
- `${profile.followers.length} 关注者 - ${profile.following.length} 关注中`)),
2357
+ countBtn('followers', profile.followers.length, '关注者'),
2358
+ ' - ',
2359
+ countBtn('following', profile.following.length, '关注中')),
2360
+ showList ? h('div', { style: { marginTop: 8, borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: 8 } },
2361
+ listNames.length === 0 ? h('div', { className: 'dgs-sub' }, '—')
2362
+ : listNames.map((u) => h('div', { key: u, style: { padding: '3px 0' } },
2363
+ h('a', { className: 'dgs-sub', style: { color: 'var(--dsw-alias-state-business-primary)', cursor: 'pointer' }, onClick: () => { setProfile(null); setShowList(null); nav('/u/' + u) } }, '@' + u)))) : null),
1967
2364
  h('div', { style: { flex: 1, minWidth: 0 } },
1968
2365
  h('div', { style: { fontWeight: 700, margin: '4px 0 10px' } }, '仓库'),
1969
2366
  profile.repos.length === 0 ? h('div', { className: 'dgs-empty' }, '—') : null,
@@ -2063,6 +2460,69 @@ window.__ModuleLoader__.load({
2063
2460
  sub === 'panel' ? panel : sub === 'users' ? usersTab : sub === 'repos' ? reposTab : orgsTab))
2064
2461
  }
2065
2462
 
2463
+ // ── 通知中心 ───────────────────────────────────────────────────────────────
2464
+
2465
+ function Notifications({ t }) {
2466
+ const [data, setData] = useState(null)
2467
+ const reload = useCallback(() => {
2468
+ api('GET', '/dsh/notifications').then((d) => setData(d && Array.isArray(d.items) ? d : { items: [], unread: 0 }))
2469
+ }, [])
2470
+ useEffect(() => { reload() }, [reload])
2471
+ const reasonLabel = { mentioned: '@ 提及了你', assigned: '指派给你', poster: '你发起的', comment: '有新评论' }
2472
+ const open = (n) => {
2473
+ api('POST', `/dsh/repos/${n.repoOwner}/${n.repoName}/issues/${n.index}/read`).catch(() => {})
2474
+ nav('/r/' + n.repoOwner + '/' + n.repoName + '/' + (n.isPull ? 'pulls' : 'issues') + '/' + n.index)
2475
+ }
2476
+ return h('div', null,
2477
+ h('div', { className: 'dgs-row', style: { margin: '8px 0 12px' } },
2478
+ h('span', { style: { fontWeight: 700 } }, '未读通知' + (data && data.unread ? '(' + data.unread + ')' : '')),
2479
+ h('span', { style: { flex: 1 } }),
2480
+ h('button', { className: 'dgs-btn ghost', disabled: !data || !data.unread, onClick: async () => { await api('POST', '/dsh/notifications/read-all'); reload() } }, '全部标记已读')),
2481
+ data === null ? h('div', { className: 'dgs-empty' }, t('loading'))
2482
+ : data.items.length === 0 ? h('div', { className: 'dgs-empty' }, '没有未读通知 🎉')
2483
+ : data.items.map((n) =>
2484
+ h('div', { key: n.repoOwner + '/' + n.repoName + '#' + n.index, className: 'dgs-issue', style: { cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }, onClick: () => open(n) },
2485
+ h('span', { className: 'dgs-issue-num' + (n.isClosed ? ' closed' : '') }, '#' + n.index),
2486
+ h('div', { style: { flex: 1, minWidth: 0 } },
2487
+ h('div', null, h('span', { className: 'dgs-issue-title' }, n.title), n.isPull ? h('span', { className: 'dgs-badge', style: { marginLeft: 6 } }, 'PR') : null),
2488
+ h('div', { className: 'dgs-sub', style: { marginTop: 2 } }, n.repoOwner + '/' + n.repoName + ' · ' + (reasonLabel[n.reason] || '有更新') + ' · ' + timeAgo(n.updatedAt))),
2489
+ h('span', { className: 'dgs-sub', style: { flex: 'none' } }, '›'))))
2490
+ }
2491
+
2492
+ // ── 我的工单 / 我的 PR(跨仓库聚合) ────────────────────────────────────────
2493
+
2494
+ function MyIssues({ t, isPull }) {
2495
+ const [filter, setFilter] = useState('all')
2496
+ const [state, setState] = useState('open')
2497
+ const [list, setList] = useState(null)
2498
+ useEffect(() => {
2499
+ setList(null)
2500
+ api('GET', `/dsh/my/issues?type=${isPull ? 'pulls' : 'issues'}&state=${state}&filter=${filter}`)
2501
+ .then((d) => setList(Array.isArray(d) ? d : []))
2502
+ }, [isPull, state, filter])
2503
+ const fBtn = (k, label) => h('button', { key: k, className: 'dgs-subtab' + (filter === k ? ' active' : ''), onClick: () => setFilter(k) }, label)
2504
+ return h('div', null,
2505
+ h('div', { className: 'dgs-row', style: { margin: '8px 0 12px' } },
2506
+ h('button', { className: 'dgs-pill' + (state === 'open' ? ' active' : ''), onClick: () => setState('open') }, '⊘ ' + t('openState')),
2507
+ h('button', { className: 'dgs-pill closed' + (state === 'closed' ? ' active' : ''), onClick: () => setState('closed') }, '✓ ' + t('closedState')),
2508
+ h('span', { style: { flex: 1 } }),
2509
+ h('div', { className: 'dgs-subtabs', style: { margin: 0 } },
2510
+ fBtn('all', '与我相关'), fBtn('created', '我创建的'), fBtn('assigned', '指派给我的'))),
2511
+ list === null ? h('div', { className: 'dgs-empty' }, t('loading'))
2512
+ : list.length === 0 ? h('div', { className: 'dgs-empty' }, '—')
2513
+ : list.map((i) =>
2514
+ h('div', {
2515
+ key: i.repoOwner + '/' + i.repoName + '#' + i.index, className: 'dgs-issue', style: { cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 },
2516
+ onClick: () => nav('/r/' + i.repoOwner + '/' + i.repoName + '/' + (isPull ? 'pulls' : 'issues') + '/' + i.index),
2517
+ },
2518
+ h('span', { className: 'dgs-issue-num' + (i.state === 'open' ? '' : ' closed') }, '#' + i.index),
2519
+ h('div', { style: { flex: 1, minWidth: 0 } },
2520
+ h('div', { className: 'dgs-issue-title' }, i.title),
2521
+ h('div', { className: 'dgs-sub', style: { marginTop: 2 } },
2522
+ i.repoOwner + '/' + i.repoName + ' · ' + (i.author || '') + ' · ' + timeAgo(i.updatedAt) + (i.assignee ? ' · @' + i.assignee : ''))),
2523
+ h('span', { className: 'dgs-sub', style: { flex: 'none' } }, '💬 ' + (i.comments || 0)))))
2524
+ }
2525
+
2066
2526
  // ── 主页(仓库列表 + 建仓) ────────────────────────────────────────────────
2067
2527
 
2068
2528
  function GitPage({ onClose, t, standalone }) {
@@ -2072,6 +2532,8 @@ window.__ModuleLoader__.load({
2072
2532
  const [err, setErr] = useState('')
2073
2533
  const [name, setName] = useState('')
2074
2534
  const [isPrivate, setIsPrivate] = useState(false)
2535
+ const [createMode, setCreateMode] = useState('new') // new | import
2536
+ const [importForm, setImportForm] = useState({ url: '', mirror: false })
2075
2537
  const [busy, setBusy] = useState(false)
2076
2538
  const [toast, setToast] = useState('')
2077
2539
  const reload = useCallback(() => {
@@ -2112,28 +2574,70 @@ window.__ModuleLoader__.load({
2112
2574
  return () => document.removeEventListener('click', onClickRow, true)
2113
2575
  }, [standalone, onClose])
2114
2576
  const notify = (m) => { setToast(m); setTimeout(() => setToast(''), 1600) }
2577
+ const [unread, setUnread] = useState(0)
2578
+ useEffect(() => {
2579
+ api('GET', '/dsh/notifications/count').then((d) => { if (d && typeof d.unread === 'number') setUnread(d.unread) }).catch(() => {})
2580
+ }, [route])
2115
2581
  const seg = route.split('/').filter(Boolean)
2116
2582
  const view = seg[0] || 'repos'
2117
- const repoRoute = view === 'r' && seg[1] && seg[2] ? { owner: seg[1], name: seg[2] } : null
2583
+ const deepTab = seg[3] ? decodeURIComponent(seg[3]) : ''
2584
+ // 深链:#/r/:o/:r 之外支持 /issues/:idx /pulls/:idx /src/:ref/:path...
2585
+ const repoRoute = view === 'r' && seg[1] && seg[2] ? {
2586
+ owner: seg[1], name: seg[2],
2587
+ deep: {
2588
+ tab: deepTab,
2589
+ ref: deepTab === 'src' ? decodeURIComponent(seg[4] || '') : '',
2590
+ arg: deepTab && deepTab !== 'src' ? decodeURIComponent(seg[4] || '') : '',
2591
+ filePath: deepTab === 'src' && seg.length > 5 ? seg.slice(5).map((s) => decodeURIComponent(s)).join('/') : '',
2592
+ },
2593
+ } : null
2118
2594
  const body = err ? h('div', { className: 'dgs-card dgs-err' }, err,
2119
2595
  h('button', { className: 'dgs-btn ghost', style: { marginLeft: 12 }, onClick: reload }, t('retry')))
2120
- : repoRoute ? h(RepoBrowser, { repo: repoRoute, t, onBack: () => nav('/repos') })
2596
+ : repoRoute ? h(RepoBrowser, { key: route, repo: repoRoute, t, deep: repoRoute.deep, onBack: () => nav('/repos') })
2121
2597
  : view === 'explore' ? h(Explore, { t })
2122
2598
  : view === 'orgs' ? h(Orgs, { t })
2123
2599
  : view === 'org' && seg[1] ? h(OrgView, { name: seg[1], t })
2124
2600
  : view === 'u' && seg[1] ? h(UserProfile, { name: decodeURIComponent(seg[1]), t })
2125
2601
  : view === 'admin' ? h(Admin, { t })
2602
+ : view === 'notifications' ? h(Notifications, { t })
2603
+ : view === 'myissues' ? h(MyIssues, { t, isPull: false })
2604
+ : view === 'mypulls' ? h(MyIssues, { t, isPull: true })
2126
2605
  : me === null ? h('div', { className: 'dgs-empty' }, t('loading'))
2127
2606
  : h('div', null,
2128
2607
  h('div', { className: 'dgs-card' },
2129
2608
  h('div', { className: 'dgs-row' },
2130
2609
  h('span', { style: { fontWeight: 700 } }, t('newRepo')),
2610
+ h('div', { className: 'dgs-subtabs', style: { margin: 0 } },
2611
+ h('button', { className: 'dgs-subtab' + (createMode === 'new' ? ' active' : ''), onClick: () => setCreateMode('new') }, '新建'),
2612
+ h('button', { className: 'dgs-subtab' + (createMode === 'import' ? ' active' : ''), onClick: () => setCreateMode('import') }, '导入'))),
2613
+ createMode === 'new' ? h('div', { className: 'dgs-row', style: { marginTop: 8 } },
2131
2614
  h('input', { className: 'dgs-input', placeholder: t('repoName'), value: name,
2132
2615
  onChange: (e) => setName(e.target.value),
2133
2616
  onKeyDown: (e) => { if (e.key === 'Enter' && name.trim()) create() } }),
2134
2617
  h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 4, alignItems: 'center' } },
2135
2618
  h('input', { type: 'checkbox', checked: isPrivate, onChange: (e) => setIsPrivate(e.target.checked) }), t('private')),
2136
- h('button', { className: 'dgs-btn', disabled: busy || !name.trim(), onClick: create }, t('create')))),
2619
+ h('button', { className: 'dgs-btn', disabled: busy || !name.trim(), onClick: create }, t('create')))
2620
+ : h('div', null,
2621
+ h('div', { className: 'dgs-row', style: { marginTop: 8 } },
2622
+ h('input', {
2623
+ className: 'dgs-input', placeholder: 'https://github.com/owner/repo.git', value: importForm.url,
2624
+ onChange: (e) => {
2625
+ const url = e.target.value
2626
+ setImportForm({ ...importForm, url })
2627
+ if (!name) {
2628
+ const m = /\/([^/]+?)(?:\.git)?\/?$/.exec(url.trim())
2629
+ if (m) setName(m[1])
2630
+ }
2631
+ },
2632
+ })),
2633
+ h('div', { className: 'dgs-row', style: { marginTop: 8 } },
2634
+ h('input', { className: 'dgs-input', placeholder: t('repoName'), value: name, onChange: (e) => setName(e.target.value) }),
2635
+ h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 4, alignItems: 'center' } },
2636
+ h('input', { type: 'checkbox', checked: importForm.mirror, onChange: (e) => setImportForm({ ...importForm, mirror: e.target.checked }) }), '镜像同步'),
2637
+ h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 4, alignItems: 'center' } },
2638
+ h('input', { type: 'checkbox', checked: isPrivate, onChange: (e) => setIsPrivate(e.target.checked) }), t('private')),
2639
+ h('button', { className: 'dgs-btn', disabled: busy || !name.trim() || !importForm.url.trim(), onClick: importRepo }, busy ? '导入中…' : '导入')),
2640
+ h('div', { className: 'dgs-sub', style: { marginTop: 6 } }, '私有仓库可在地址里带凭据:https://user:token@host/owner/repo.git;勾选「镜像同步」后每小时自动拉取上游更新。'))),
2137
2641
  h('div', { style: { height: 10 } }),
2138
2642
  h('div', { style: { fontWeight: 700, margin: '4px 0 10px' } }, `${t('myRepos')}(${me.repos.length})`),
2139
2643
  me.repos.length === 0 ? h('div', { className: 'dgs-empty' }, t('emptyRepo'))
@@ -2154,7 +2658,7 @@ window.__ModuleLoader__.load({
2154
2658
  h('div', { className: 'dgs-row' }, title, badge, h('span', { style: { flex: 1 } }), stats, del), desc)
2155
2659
  }
2156
2660
 
2157
- const navItems = [['repos', t('myRepos')], ['explore', '探索'], ['orgs', '组织'], ['admin', '管理']]
2661
+ const navItems = [['repos', t('myRepos')], ['myissues', '工单'], ['mypulls', 'PR'], ['notifications', '通知'], ['explore', '探索'], ['orgs', '组织'], ['admin', '管理']]
2158
2662
  return h('div', { className: 'dgs-page' },
2159
2663
  h('div', { className: 'dgs-head' },
2160
2664
  h('span', { className: 'dgs-h1' }, t('title')),
@@ -2162,7 +2666,9 @@ window.__ModuleLoader__.load({
2162
2666
  navItems.map(([k, label]) =>
2163
2667
  h('button', { key: k, className: 'dgs-btn ghost',
2164
2668
  style: view === k ? { color: 'var(--dsw-alias-state-business-primary)', borderColor: 'var(--dsw-alias-state-business-primary)55' } : undefined,
2165
- onClick: () => nav('/' + k) }, label))),
2669
+ onClick: () => nav('/' + k) },
2670
+ label,
2671
+ k === 'notifications' && unread > 0 ? h('span', { className: 'dgs-notif-dot' }, unread > 99 ? '99+' : unread) : null))),
2166
2672
  h('button', { className: 'dgs-close', onClick: onClose, title: 'Esc' }, '✕')),
2167
2673
  h('div', { className: 'dgs-body' }, body),
2168
2674
  toast ? h('div', { className: 'dgs-toast' }, toast) : null)
@@ -2173,6 +2679,20 @@ window.__ModuleLoader__.load({
2173
2679
  setBusy(false)
2174
2680
  if (d.ok) { setName(''); notify('✓ ' + name); reload() } else notify(d.error || 'failed')
2175
2681
  }
2682
+
2683
+ async function importRepo() {
2684
+ setBusy(true)
2685
+ const d = await api('POST', '/dsh/migrate', {
2686
+ cloneAddr: importForm.url.trim(), name: name.trim(), private: isPrivate, mirror: importForm.mirror,
2687
+ })
2688
+ setBusy(false)
2689
+ if (d && d.ok) {
2690
+ const repoName = name
2691
+ setName(''); setImportForm({ url: '', mirror: false })
2692
+ notify('✓ ' + repoName)
2693
+ nav('/r/' + d.owner + '/' + d.name)
2694
+ } else notify((d && d.error) || 'failed')
2695
+ }
2176
2696
  }
2177
2697
 
2178
2698
  // ── 设置节(服务配置,无 iframe) ──────────────────────────────────────────
@@ -2336,9 +2856,17 @@ window.__ModuleLoader__.load({
2336
2856
  entry.addEventListener('click', () => openGitPage(t))
2337
2857
  const stats = entry.querySelector('.dsh-git-entry-stats')
2338
2858
  const refreshStats = () => {
2859
+ let repos = ''
2339
2860
  fetch(API + '/me').then((r) => r.json()).then((d) => {
2340
- if (stats && d && Array.isArray(d.repos)) stats.textContent = String(d.repos.length)
2341
- }).catch(() => {})
2861
+ if (d && Array.isArray(d.repos)) repos = String(d.repos.length)
2862
+ }).catch(() => {}).then(() =>
2863
+ fetch(API + '/dsh/notifications/count').then((r) => r.json()).then((d) => {
2864
+ if (!stats) return
2865
+ const unread = d && typeof d.unread === 'number' ? d.unread : 0
2866
+ stats.textContent = repos + (unread > 0 ? ' · ' + unread + ' 未读' : '')
2867
+ if (unread > 0) stats.style.color = 'var(--dsw-alias-state-error-primary)'
2868
+ else stats.style.color = ''
2869
+ }).catch(() => { if (stats) stats.textContent = repos }))
2342
2870
  }
2343
2871
  refreshStats()
2344
2872
  const poll = setInterval(refreshStats, 30000)