@weibaohui/dsh-git-server 0.1.4 → 0.2.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.
- package/README.md +70 -2
- package/client/bundle.js +597 -69
- package/client/index.js +597 -69
- package/package.json +1 -1
- package/server/dist/api/v1.js +71 -3
- package/server/dist/dshapi.js +558 -66
- package/server/dist/gitx/git.js +39 -3
- package/server/dist/gitx/service.js +4 -3
- package/server/dist/hook.js +31 -2
- package/server/dist/notify.js +55 -0
- package/src/index.js +12 -1
package/client/index.js
CHANGED
|
@@ -208,6 +208,24 @@ textarea.dgs-input,.dgs-card>.dgs-input{width:100%;box-sizing:border-box;flex:no
|
|
|
208
208
|
.dgs-md blockquote{border-left:3px solid var(--dsw-alias-border-l2);margin:8px 0;padding:2px 12px;color:var(--dsw-alias-label-secondary)}
|
|
209
209
|
.dgs-md table{border-collapse:collapse}
|
|
210
210
|
.dgs-md td,.dgs-md th{border:1px solid var(--dsw-alias-border-l2);padding:4px 10px}
|
|
211
|
+
/* 语法高亮(亮暗双主题可读配色) */
|
|
212
|
+
.tok-k{color:#cf222e;font-weight:500}
|
|
213
|
+
.tok-s{color:#1a7f37}
|
|
214
|
+
.tok-c{color:#6e7781;font-style:italic}
|
|
215
|
+
.tok-n{color:#0550ae}
|
|
216
|
+
.tok-b{color:#953800}
|
|
217
|
+
@media (prefers-color-scheme: dark){
|
|
218
|
+
.tok-k{color:#ff7b72}
|
|
219
|
+
.tok-s{color:#7ee787}
|
|
220
|
+
.tok-c{color:#8b949e}
|
|
221
|
+
.tok-n{color:#79c0ff}
|
|
222
|
+
.tok-b{color:#ffa657}
|
|
223
|
+
}
|
|
224
|
+
.dgs-hit{padding:4px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);cursor:pointer}
|
|
225
|
+
.dgs-hit:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
226
|
+
.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}
|
|
227
|
+
.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}
|
|
228
|
+
.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}
|
|
211
229
|
</style>`
|
|
212
230
|
document.head.appendChild(holder)
|
|
213
231
|
}
|
|
@@ -237,25 +255,133 @@ function useHashRoute() {
|
|
|
237
255
|
}
|
|
238
256
|
const nav = (hash) => { location.hash = hash }
|
|
239
257
|
|
|
258
|
+
// ── 语法高亮(零依赖:无 bundler,客户端不能 import,内置一个轻量 tokenizer) ──
|
|
259
|
+
|
|
260
|
+
function escapeHtml(s) {
|
|
261
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const HL = (() => {
|
|
265
|
+
const KW = {
|
|
266
|
+
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',
|
|
267
|
+
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',
|
|
268
|
+
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',
|
|
269
|
+
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',
|
|
270
|
+
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',
|
|
271
|
+
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',
|
|
272
|
+
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',
|
|
273
|
+
}
|
|
274
|
+
const C = { line: '//[^\\n]*', block: '\\/\\*[\\s\\S]*?\\*\\/' }
|
|
275
|
+
const HASH = { line: '#[^\\n]*' }
|
|
276
|
+
const STR = ['"(?:\\\\.|[^"\\\\\\n])*"', "'(?:\\\\.|[^'\\\\\\n])*'", '`(?:\\\\.|[^`\\\\])*`']
|
|
277
|
+
const NUM = '\\b\\d[\\d_]*(?:\\.[\\d_]+)?(?:[eE][+-]?\\d+)?\\b'
|
|
278
|
+
const langs = {
|
|
279
|
+
js: { kw: KW.js, c: C }, mjs: { kw: KW.js, c: C }, cjs: { kw: KW.js, c: C }, jsx: { kw: KW.js, c: C },
|
|
280
|
+
ts: { kw: KW.js + ' interface type namespace declare readonly keyof infer never unknown any string number boolean', c: C },
|
|
281
|
+
tsx: { kw: KW.js + ' interface type namespace declare readonly keyof infer never unknown any string number boolean', c: C },
|
|
282
|
+
json: { kw: 'true false null', c: null },
|
|
283
|
+
py: { kw: KW.py, c: HASH }, rb: { kw: KW.py, c: HASH }, pl: { kw: KW.py, c: HASH },
|
|
284
|
+
go: { kw: KW.go, c: C },
|
|
285
|
+
rs: { kw: KW.rs, c: C },
|
|
286
|
+
java: { kw: KW.clike, c: C }, kt: { kw: KW.clike, c: C }, scala: { kw: KW.clike, c: C },
|
|
287
|
+
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 },
|
|
288
|
+
sh: { kw: KW.sh, c: HASH }, bash: { kw: KW.sh, c: HASH }, zsh: { kw: KW.sh, c: HASH },
|
|
289
|
+
yaml: { kw: 'true false null yes no on off', c: HASH }, yml: { kw: 'true false null yes no on off', c: HASH },
|
|
290
|
+
toml: { kw: 'true false', c: HASH }, ini: { kw: 'true false', c: HASH }, cfg: { kw: 'true false', c: HASH },
|
|
291
|
+
sql: { kw: KW.sql, c: { line: '--[^\\n]*', block: C.block } },
|
|
292
|
+
css: { kw: '', c: { block: C.block } }, scss: { kw: '', c: C }, less: { kw: '', c: C },
|
|
293
|
+
dockerfile: { kw: 'FROM RUN CMD ENTRYPOINT COPY ADD WORKDIR EXPOSE ENV ARG VOLUME USER LABEL HEALTHCHECK ONBUILD SHELL STOPSIGNAL', c: HASH },
|
|
294
|
+
makefile: { kw: '', c: HASH },
|
|
295
|
+
vue: { kw: KW.js, c: C }, svelte: { kw: KW.js, c: C },
|
|
296
|
+
}
|
|
297
|
+
const byName = { dockerfile: 'dockerfile', makefile: 'makefile', 'cmakelists.txt': 'makefile' }
|
|
298
|
+
const cache = new Map()
|
|
299
|
+
function langFor(name) {
|
|
300
|
+
const lower = (name || '').toLowerCase()
|
|
301
|
+
const ext = byName[lower] || lower.split('.').pop() || ''
|
|
302
|
+
return langs[ext] || null
|
|
303
|
+
}
|
|
304
|
+
function regexFor(cfg) {
|
|
305
|
+
if (cache.has(cfg)) return cache.get(cfg)
|
|
306
|
+
const parts = []
|
|
307
|
+
if (cfg.c && cfg.c.block) parts.push(cfg.c.block)
|
|
308
|
+
if (cfg.c && cfg.c.line) parts.push(cfg.c.line)
|
|
309
|
+
parts.push(...STR, NUM)
|
|
310
|
+
if (cfg.kw) parts.push('\\b(?:' + cfg.kw.split(' ').join('|') + ')\\b')
|
|
311
|
+
const rx = new RegExp(parts.join('|'), 'g')
|
|
312
|
+
cache.set(cfg, rx)
|
|
313
|
+
return rx
|
|
314
|
+
}
|
|
315
|
+
return { langFor, regexFor }
|
|
316
|
+
})()
|
|
317
|
+
|
|
318
|
+
/** 源码 → 带 token 着色的 HTML(输入已转义)。 */
|
|
319
|
+
function highlightCode(text, filename) {
|
|
320
|
+
const cfg = HL.langFor(filename)
|
|
321
|
+
if (!cfg) return escapeHtml(text)
|
|
322
|
+
const rx = HL.regexFor(cfg)
|
|
323
|
+
let out = ''
|
|
324
|
+
let last = 0
|
|
325
|
+
for (const m of text.matchAll(rx)) {
|
|
326
|
+
const i = m.index
|
|
327
|
+
if (i > last) out += escapeHtml(text.slice(last, i))
|
|
328
|
+
const tok = m[0]
|
|
329
|
+
let cls = 'tok-n'
|
|
330
|
+
if (/^\/\/|^\/\*|^#|^--/.test(tok)) cls = 'tok-c'
|
|
331
|
+
else if (/^["'`]/.test(tok)) cls = 'tok-s'
|
|
332
|
+
else if (/^\d/.test(tok)) cls = 'tok-n'
|
|
333
|
+
else cls = 'tok-k'
|
|
334
|
+
out += '<span class="' + cls + '">' + escapeHtml(tok) + '</span>'
|
|
335
|
+
last = i + tok.length
|
|
336
|
+
}
|
|
337
|
+
if (last < text.length) out += escapeHtml(text.slice(last))
|
|
338
|
+
return out
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** 提交/评论文本里的 #123 → 点击跳工单(返回 React 子节点数组)。 */
|
|
342
|
+
function linkifyIssues(text, repo) {
|
|
343
|
+
const parts = String(text || '').split(/(#\d+)/g)
|
|
344
|
+
if (parts.length === 1) return text
|
|
345
|
+
return parts.map((p, i) => {
|
|
346
|
+
const m = /^#(\d+)$/.exec(p)
|
|
347
|
+
if (!m) return p
|
|
348
|
+
return h('a', {
|
|
349
|
+
key: i, style: { color: 'var(--dsw-alias-state-business-primary)', cursor: 'pointer' },
|
|
350
|
+
onClick: (e) => { e.stopPropagation(); nav('/r/' + repo.owner + '/' + repo.name + '/issues/' + m[1]) },
|
|
351
|
+
}, p)
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function dirnameOf(p) {
|
|
356
|
+
const i = (p || '').lastIndexOf('/')
|
|
357
|
+
return i < 0 ? '' : p.slice(0, i)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
240
361
|
// ── 全屏管理页 ────────────────────────────────────────────────────────────
|
|
241
362
|
|
|
242
|
-
function RepoBrowser({ repo, t, onBack }) {
|
|
243
|
-
const [tab, setTab] = useState(
|
|
363
|
+
function RepoBrowser({ repo, t, onBack, deep }) {
|
|
364
|
+
const [tab, setTab] = useState(() => {
|
|
365
|
+
const k = deep && deep.tab
|
|
366
|
+
return k && ['files', 'issues', 'pulls', 'wiki', 'releases', 'settings', 'commits', 'branches', 'tags'].includes(k) ? k : 'files'
|
|
367
|
+
})
|
|
244
368
|
const [prPreset, setPrPreset] = useState(null)
|
|
245
369
|
const [issuesSub, setIssuesSub] = useState(null)
|
|
246
370
|
const [ov, setOv] = useState(null)
|
|
247
|
-
const [rev, setRev] = useState('')
|
|
371
|
+
const [rev, setRev] = useState(deep && deep.tab === 'src' ? deep.ref || '' : '')
|
|
248
372
|
const [cloneUrl, setCloneUrl] = useState('')
|
|
249
373
|
const [copied, setCopied] = useState(false)
|
|
250
374
|
const [star, setStar] = useState(null)
|
|
251
375
|
const [watch, setWatch] = useState(null)
|
|
252
376
|
const [forkBusy, setForkBusy] = useState(false)
|
|
377
|
+
const [pop, setPop] = useState(null) // 'star' | 'watch' | 'fork' | null
|
|
378
|
+
const [forkList, setForkList] = useState(null)
|
|
253
379
|
const [msg, setMsg] = useState('')
|
|
254
380
|
useEffect(() => {
|
|
255
381
|
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/overview`).then((d) => {
|
|
256
382
|
if (!d.defaultBranch) return
|
|
257
383
|
setOv(d)
|
|
258
|
-
setRev(d.defaultBranch)
|
|
384
|
+
setRev((cur) => cur || d.defaultBranch)
|
|
259
385
|
})
|
|
260
386
|
fetch(API + '/status').then((r) => r.json()).then((st) => {
|
|
261
387
|
if (st && st.running) setCloneUrl('git clone http://' + location.hostname + ':' + (st.port || 3400) + '/' + repo.owner + '/' + repo.name + '.git')
|
|
@@ -286,15 +412,37 @@ function RepoBrowser({ repo, t, onBack }) {
|
|
|
286
412
|
h('span', { className: 'dgs-h1' }, repo.owner + '/' + repo.name),
|
|
287
413
|
repo.private || (ov && ov.private) ? h('span', { className: 'dgs-badge pri' }, t('private')) : null,
|
|
288
414
|
h('span', { style: { flex: 1 } }),
|
|
415
|
+
h('div', { style: { position: 'relative', display: 'flex', alignItems: 'center', gap: 10 } },
|
|
289
416
|
star ? h('span', { className: 'dgs-labeled' },
|
|
290
417
|
h('button', { className: 'dgs-btn ghost', onClick: () => toggle('star') }, star.on ? '★ Unstar' : '☆ Star'),
|
|
291
|
-
h('span', { className: 'dgs-labeled-count' }, star.count)) : null,
|
|
418
|
+
h('span', { className: 'dgs-labeled-count', style: { cursor: 'pointer' }, title: '谁点了星标', onClick: () => setPop(pop === 'star' ? null : 'star') }, star.count)) : null,
|
|
292
419
|
watch ? h('span', { className: 'dgs-labeled' },
|
|
293
420
|
h('button', { className: 'dgs-btn ghost', onClick: () => toggle('watch') }, watch.on ? '👁 Unwatch' : '👁 Watch'),
|
|
294
|
-
h('span', { className: 'dgs-labeled-count' }, watch.count)) : null,
|
|
295
|
-
h('
|
|
296
|
-
h('
|
|
297
|
-
|
|
421
|
+
h('span', { className: 'dgs-labeled-count', style: { cursor: 'pointer' }, title: '谁在关注', onClick: () => setPop(pop === 'watch' ? null : 'watch') }, watch.count)) : null,
|
|
422
|
+
h('span', { className: 'dgs-labeled' },
|
|
423
|
+
h('button', { className: 'dgs-btn ghost', disabled: forkBusy, onClick: fork, style: { display: 'inline-flex', alignItems: 'center', gap: 6 } },
|
|
424
|
+
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>' } }),
|
|
425
|
+
'Fork'),
|
|
426
|
+
h('span', { className: 'dgs-labeled-count', style: { cursor: 'pointer' }, title: '复刻列表', onClick: () => {
|
|
427
|
+
if (pop === 'fork') { setPop(null); return }
|
|
428
|
+
setPop('fork')
|
|
429
|
+
if (forkList === null) api('GET', `/dsh/repos/${repo.owner}/${repo.name}/forks`).then((d) => setForkList(Array.isArray(d) ? d : []))
|
|
430
|
+
} }, ov ? ov.numForks || 0 : '')),
|
|
431
|
+
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' } },
|
|
432
|
+
h('div', { style: { fontWeight: 700, fontSize: 12.5, marginBottom: 6 } },
|
|
433
|
+
pop === 'star' ? '星标用户' : pop === 'watch' ? '关注者' : '复刻(Fork)'),
|
|
434
|
+
pop === 'fork'
|
|
435
|
+
? (forkList === null ? h('div', { className: 'dgs-sub' }, t('loading'))
|
|
436
|
+
: forkList.length === 0 ? h('div', { className: 'dgs-sub' }, '—')
|
|
437
|
+
: forkList.map((f) => h('div', { key: f.owner + '/' + f.name, style: { padding: '3px 0' } },
|
|
438
|
+
h('a', { className: 'dgs-sub', style: { color: 'var(--dsw-alias-state-business-primary)', cursor: 'pointer' }, onClick: () => { setPop(null); nav('/r/' + f.owner + '/' + f.name) } },
|
|
439
|
+
f.owner + '/' + f.name + (f.stars ? ' ★' + f.stars : '')))))
|
|
440
|
+
: ((pop === 'star' ? (star && star.users) : (watch && watch.users)) || []).length === 0
|
|
441
|
+
? h('div', { className: 'dgs-sub' }, '—')
|
|
442
|
+
: ((pop === 'star' ? star.users : watch.users) || []).map((u) =>
|
|
443
|
+
h('div', { key: u, style: { padding: '3px 0' } },
|
|
444
|
+
h('a', { className: 'dgs-sub', style: { color: 'var(--dsw-alias-state-business-primary)', cursor: 'pointer' }, onClick: () => { setPop(null); nav('/u/' + u) } }, '@' + u))))
|
|
445
|
+
: null),
|
|
298
446
|
),
|
|
299
447
|
msg ? h('div', { className: 'dgs-err' }, msg) : null,
|
|
300
448
|
h('div', { className: 'dgs-tabs' },
|
|
@@ -325,19 +473,19 @@ function RepoBrowser({ repo, t, onBack }) {
|
|
|
325
473
|
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: '下载' }, '⭳'))
|
|
326
474
|
: null))
|
|
327
475
|
: null,
|
|
328
|
-
tab === 'files' && h(FileTree, { repo, rev: rev || 'master', t, overview: ov, onOverview: setOv }),
|
|
476
|
+
tab === 'files' && h(FileTree, { repo, rev: rev || 'master', t, overview: ov, onOverview: setOv, cloneUrl, initialPath: deep && deep.tab === 'src' ? deep.filePath : '' }),
|
|
329
477
|
tab === 'commits' && h(Commits, { repo, rev: rev || 'master', t }),
|
|
330
478
|
tab === 'branches' && h(Branches, { repo, t, onNewPR: (head, base) => { setPrPreset({ head, base }); setTab('pulls') } }),
|
|
331
|
-
tab === 'issues' && h(IssuesArea, { repo, t, initialSub: issuesSub || undefined }),
|
|
332
|
-
tab === 'pulls' && h(Pulls, { repo, t, preset: prPreset, onPresetDone: () => setPrPreset(null), onGoIssuesSub: (k) => { setIssuesSub(k); setTab('issues') } }),
|
|
479
|
+
tab === 'issues' && h(IssuesArea, { repo, t, initialSub: issuesSub || undefined, initialIdx: deep && deep.tab === 'issues' && deep.arg ? Number(deep.arg) : undefined }),
|
|
480
|
+
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 }),
|
|
333
481
|
tab === 'wiki' && h(WikiView, { repo, t }),
|
|
334
482
|
tab === 'releases' && h(Releases, { repo, t }),
|
|
335
483
|
tab === 'settings' && h(RepoSettings, { repo, t }),
|
|
336
484
|
)
|
|
337
485
|
}
|
|
338
486
|
|
|
339
|
-
function FileTree({ repo, rev, t, overview, onOverview }) {
|
|
340
|
-
const [path, setPath] = useState('')
|
|
487
|
+
function FileTree({ repo, rev, t, overview, onOverview, initialPath, cloneUrl }) {
|
|
488
|
+
const [path, setPath] = useState(() => dirnameOf(initialPath || ''))
|
|
341
489
|
const [entries, setEntries] = useState(null)
|
|
342
490
|
const [file, setFile] = useState(null)
|
|
343
491
|
const [err, setErr] = useState('')
|
|
@@ -351,6 +499,9 @@ function FileTree({ repo, rev, t, overview, onOverview }) {
|
|
|
351
499
|
const [editText, setEditText] = useState('')
|
|
352
500
|
const [editMsg, setEditMsg] = useState('')
|
|
353
501
|
const [histMode, setHistMode] = useState(null)
|
|
502
|
+
const [searchQ, setSearchQ] = useState('')
|
|
503
|
+
const [hits, setHits] = useState(null) // null=未搜索;[]=无结果
|
|
504
|
+
const [deepDone, setDeepDone] = useState(false)
|
|
354
505
|
const load = useCallback((p) => {
|
|
355
506
|
setFile(null); setEntries(null); setErr(''); setBlameOn(false); setEditing(false); setHistMode(null)
|
|
356
507
|
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/tree?ref=${encodeURIComponent(rev)}&path=${encodeURIComponent(p)}`)
|
|
@@ -366,20 +517,38 @@ function FileTree({ repo, rev, t, overview, onOverview }) {
|
|
|
366
517
|
if (p !== '') setReadme(null)
|
|
367
518
|
}, [repo.owner, repo.name, rev])
|
|
368
519
|
useEffect(() => { load(path) }, [load, path])
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
if (e.type === 'dir' || e.type === 'tree') { setPath(p); return }
|
|
372
|
-
api('GET', `/repos/${repo.owner}/${repo.name}/raw?ref=${encodeURIComponent(rev)}&path=${encodeURIComponent(p)}`)
|
|
520
|
+
const openFile = (fp, name, size) => {
|
|
521
|
+
api('GET', `/repos/${repo.owner}/${repo.name}/raw?ref=${encodeURIComponent(rev)}&path=${encodeURIComponent(fp)}`)
|
|
373
522
|
.then(async (d) => {
|
|
374
|
-
|
|
523
|
+
if (!d.ok) { setPath(fp); setFile(null); return } // 目录型永久链接:落到目录
|
|
524
|
+
const isMd = /\.md$/i.test(name)
|
|
375
525
|
let html = ''
|
|
376
|
-
if (isMd
|
|
526
|
+
if (isMd) {
|
|
377
527
|
const r = await api('POST', '/dsh/markdown', { text: d.text })
|
|
378
528
|
html = r.html || ''
|
|
379
529
|
}
|
|
380
|
-
setFile({ name:
|
|
530
|
+
setFile({ name, path: fp, text: d.text, size: size || 0, isMd, html })
|
|
381
531
|
})
|
|
382
532
|
}
|
|
533
|
+
const openEntry = (e) => {
|
|
534
|
+
if (e.type === 'dir' || e.type === 'tree') { setPath(e.path); return }
|
|
535
|
+
openFile(e.path, e.name, e.size)
|
|
536
|
+
}
|
|
537
|
+
// 深链直达:#/r/:o/:r/src/:ref/:path —— 打开指定文件(rev 由父组件同步)
|
|
538
|
+
useEffect(() => {
|
|
539
|
+
if (deepDone || !initialPath) return
|
|
540
|
+
setDeepDone(true)
|
|
541
|
+
openFile(initialPath, initialPath.split('/').pop(), 0)
|
|
542
|
+
}, [initialPath])
|
|
543
|
+
const doSearch = () => {
|
|
544
|
+
const q = searchQ.trim()
|
|
545
|
+
if (!q) return
|
|
546
|
+
setHits(null); setFile(null)
|
|
547
|
+
setMsg('')
|
|
548
|
+
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/search?q=${encodeURIComponent(q)}&ref=${encodeURIComponent(rev)}`)
|
|
549
|
+
.then((d) => { setHits(Array.isArray(d.matches) ? d.matches : []); if (d.error) setMsg(d.error) })
|
|
550
|
+
.catch(() => setMsg(t('loadFailed')))
|
|
551
|
+
}
|
|
383
552
|
const startCreate = (mode) => {
|
|
384
553
|
setNewFile(mode); setMsg('')
|
|
385
554
|
setNf({ path: '', content: '', message: '' })
|
|
@@ -410,6 +579,11 @@ function FileTree({ repo, rev, t, overview, onOverview }) {
|
|
|
410
579
|
h('span', { className: 'dgs-crumb' + (i === arr.length - 1 ? ' cur' : ''), onClick: () => setPath(arr.slice(0, i + 1).join('/')) }, seg),
|
|
411
580
|
i < arr.length - 1 ? h('span', { className: 'dgs-sub' }, '/') : null))),
|
|
412
581
|
h('span', { style: { flex: 1 } }),
|
|
582
|
+
h('input', {
|
|
583
|
+
className: 'dgs-input', style: { maxWidth: 180, flex: 'none' }, placeholder: '搜索代码…',
|
|
584
|
+
value: searchQ, onChange: (e) => setSearchQ(e.target.value),
|
|
585
|
+
onKeyDown: (e) => { if (e.key === 'Enter') doSearch() },
|
|
586
|
+
}),
|
|
413
587
|
h('button', { className: 'dgs-btn', onClick: () => startCreate('create') }, '新的文件'),
|
|
414
588
|
h('button', { className: 'dgs-btn ghost', onClick: () => startCreate('upload') }, '上传文件')),
|
|
415
589
|
msg ? h('div', { className: 'dgs-err' }, msg) : null,
|
|
@@ -448,15 +622,17 @@ function FileTree({ repo, rev, t, overview, onOverview }) {
|
|
|
448
622
|
h('span', { style: { fontWeight: 600, fontSize: 12.5 } }, '📄 ' + file.name + ' ' + (file.size ? fmtSize(file.size) : '')),
|
|
449
623
|
h('span', { style: { flex: 1 } }),
|
|
450
624
|
file.text !== null ? h('a', { className: 'dgs-sub', style: { cursor: 'pointer', marginRight: 12 }, onClick: () => {
|
|
451
|
-
|
|
625
|
+
// 永久链接走面板的 hash 路由(旧网页 UI 已裁撤,裸路径链接是死的)
|
|
626
|
+
const link = location.origin + location.pathname + '#/r/' + repo.owner + '/' + repo.name + '/src/' + encodeURIComponent(rev) + '/' + (file.path || '').split('/').map(encodeURIComponent).join('/')
|
|
627
|
+
try { navigator.clipboard.writeText(link) } catch {}
|
|
452
628
|
setMsg('✓ 已复制永久链接'); setTimeout(() => setMsg(''), 1500)
|
|
453
629
|
} }, '永久链接') : null,
|
|
454
630
|
file.text !== null ? h('a', { className: 'dgs-sub', style: { cursor: 'pointer', marginRight: 12 }, onClick: () => setHistMode(histMode === 'history' ? null : 'history') }, '文件历史') : null,
|
|
455
|
-
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,
|
|
631
|
+
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,
|
|
456
632
|
file.text !== null ? h('button', { className: 'dgs-btn ghost', style: { padding: '2px 8px' }, onClick: () => { setEditing(true); setEditText(file.text || '') } }, '✏️') : null,
|
|
457
633
|
file.text !== null ? h('button', { className: 'dgs-btn danger', style: { padding: '2px 8px' }, onClick: async () => {
|
|
458
634
|
if (!confirm('删除文件 ' + file.name + ' ?')) return
|
|
459
|
-
const d = await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/files`, { path, branch: rev })
|
|
635
|
+
const d = await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/files`, { path: file.path || path, branch: rev })
|
|
460
636
|
if (d && d.ok) { setFile(null); load(path) } else setMsg((d && d.error) || '删除失败')
|
|
461
637
|
} }, '🗑️') : null,
|
|
462
638
|
file.text !== null ? h('button', { className: 'dgs-btn ghost', style: { padding: '2px 8px' }, onClick: () => setBlameOn(!blameOn) }, 'Blame') : null),
|
|
@@ -468,20 +644,37 @@ function FileTree({ repo, rev, t, overview, onOverview }) {
|
|
|
468
644
|
h('button', { className: 'dgs-btn ghost', onClick: () => setEditing(false) }, '取消'),
|
|
469
645
|
h('button', { className: 'dgs-btn', disabled: busy, onClick: async () => {
|
|
470
646
|
setBusy(true); setMsg('')
|
|
471
|
-
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/files`, { path, branch: rev, content: editText, message: editMsg, overwrite: true })
|
|
647
|
+
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/files`, { path: file.path || path, branch: rev, content: editText, message: editMsg, overwrite: true })
|
|
472
648
|
setBusy(false)
|
|
473
|
-
if (d && d.ok) { setEditing(false); load(path) } else setMsg((d && d.error) || '保存失败')
|
|
649
|
+
if (d && d.ok) { setEditing(false); setFile(null); load(path) } else setMsg((d && d.error) || '保存失败')
|
|
474
650
|
} }, '提交修改')))
|
|
475
|
-
: histMode === 'history' ? h(FileHistory, { repo, rev, path })
|
|
651
|
+
: histMode === 'history' ? h(FileHistory, { repo, rev, path: file.path || path })
|
|
476
652
|
: blameOn && file.text !== null
|
|
477
|
-
? h(BlameView, { repo, rev, path, text: file.text })
|
|
653
|
+
? h(BlameView, { repo, rev, path: file.path || path, text: file.text })
|
|
478
654
|
: file.isMd
|
|
479
655
|
? h('div', { className: 'dgs-md', style: { padding: '14px 16px' }, dangerouslySetInnerHTML: { __html: file.html || '' } })
|
|
480
|
-
:
|
|
656
|
+
: file.text === null
|
|
657
|
+
? h('pre', { className: 'dgs-file', style: { border: 'none', borderRadius: 0 } }, t('emptyFile'))
|
|
658
|
+
: h('pre', { className: 'dgs-file', style: { border: 'none', borderRadius: 0 }, dangerouslySetInnerHTML: { __html: highlightCode(file.text, file.name) } })))
|
|
659
|
+
: hits !== null ? renderHits()
|
|
481
660
|
: entries === null ? h('div', { className: 'dgs-empty' }, t('loading'))
|
|
482
661
|
: renderDir(),
|
|
483
662
|
)
|
|
484
663
|
|
|
664
|
+
function renderHits() {
|
|
665
|
+
return h('div', null,
|
|
666
|
+
h('div', { className: 'dgs-row', style: { marginBottom: 8 } },
|
|
667
|
+
h('button', { className: 'dgs-btn ghost', onClick: () => setHits(null) }, t('back')),
|
|
668
|
+
h('span', { className: 'dgs-sub' }, '在 ' + rev + ' 中搜索「' + searchQ.trim() + '」')),
|
|
669
|
+
hits.length === 0 ? h('div', { className: 'dgs-empty' }, '—')
|
|
670
|
+
: hits.map((hit, i) =>
|
|
671
|
+
h('div', { key: i, className: 'dgs-hit', onClick: () => { setHits(null); setPath(dirnameOf(hit.path)); openFile(hit.path, hit.path.split('/').pop(), 0) } },
|
|
672
|
+
h('div', null,
|
|
673
|
+
h('span', { style: { color: 'var(--dsw-alias-state-business-primary)', fontWeight: 500, fontSize: 12.5 } }, hit.path),
|
|
674
|
+
h('span', { className: 'dgs-sub' }, ':' + hit.line)),
|
|
675
|
+
h('div', { className: 'dgs-hit-code' }, hit.text))))
|
|
676
|
+
}
|
|
677
|
+
|
|
485
678
|
function renderDir() {
|
|
486
679
|
const head = entries.find((e) => e.last)
|
|
487
680
|
const rows = entries.map((e) =>
|
|
@@ -498,7 +691,9 @@ function FileTree({ repo, rev, t, overview, onOverview }) {
|
|
|
498
691
|
h('span', { style: { flex: 1 } }),
|
|
499
692
|
h('span', { className: 'dgs-sub' }, timeAgo(head.last.date))) : null
|
|
500
693
|
const table = entries.length === 0
|
|
501
|
-
?
|
|
694
|
+
? (path === '' && overview && overview.numCommits === 0
|
|
695
|
+
? renderEmptyRepoGuide()
|
|
696
|
+
: h('div', { className: 'dgs-empty' }, t('emptyDir')))
|
|
502
697
|
: h('div', null, headRow, h('table', { className: 'dgs-table' }, h('tbody', null, rows)))
|
|
503
698
|
const readmeEl = (path === '' && overview && overview.readmeHtml)
|
|
504
699
|
? h('div', { className: 'dgs-card', style: { marginTop: 12 } },
|
|
@@ -507,9 +702,26 @@ function FileTree({ repo, rev, t, overview, onOverview }) {
|
|
|
507
702
|
: null
|
|
508
703
|
return h('div', null, table, readmeEl)
|
|
509
704
|
}
|
|
705
|
+
|
|
706
|
+
function renderEmptyRepoGuide() {
|
|
707
|
+
const remote = (cloneUrl || '').replace(/^git clone\s+/, '') || ('http://<服务器>:3400/' + repo.owner + '/' + repo.name + '.git')
|
|
708
|
+
const branch = (overview && overview.defaultBranch) || 'master'
|
|
709
|
+
const copyBtn = (text) => h('button', { className: 'dgs-btn ghost', style: { padding: '2px 10px', fontSize: 12 }, onClick: () => { try { navigator.clipboard.writeText(text) } catch {} } }, t('copy'))
|
|
710
|
+
return h('div', { className: 'dgs-card', style: { maxWidth: 660, margin: '24px auto' } },
|
|
711
|
+
h('div', { style: { fontWeight: 700, fontSize: 15, marginBottom: 6 } }, '这个仓库还是空的'),
|
|
712
|
+
h('div', { className: 'dgs-sub', style: { marginBottom: 12 } }, '克隆到本地开始开发,或把已有的本地仓库推送上来。git 凭据使用 dsh(user-management)的用户名和密码。'),
|
|
713
|
+
h('div', { className: 'dgs-row', style: { marginBottom: 4 } },
|
|
714
|
+
h('span', { style: { fontWeight: 600, fontSize: 12.5, flex: 1 } }, '克隆仓库'),
|
|
715
|
+
copyBtn('git clone ' + remote)),
|
|
716
|
+
h('div', { className: 'dgs-guide' }, 'git clone ' + remote),
|
|
717
|
+
h('div', { className: 'dgs-row', style: { margin: '12px 0 4px' } },
|
|
718
|
+
h('span', { style: { fontWeight: 600, fontSize: 12.5, flex: 1 } }, '推送已有仓库'),
|
|
719
|
+
copyBtn('git remote add origin ' + remote + '\ngit push -u origin ' + branch)),
|
|
720
|
+
h('div', { className: 'dgs-guide' }, 'git remote add origin ' + remote + '\ngit push -u origin ' + branch))
|
|
721
|
+
}
|
|
510
722
|
}
|
|
511
723
|
|
|
512
|
-
function IssuesArea({ repo, t, initialSub }) {
|
|
724
|
+
function IssuesArea({ repo, t, initialSub, initialIdx }) {
|
|
513
725
|
const [sub, setSub] = useState(initialSub || 'list')
|
|
514
726
|
const [labelJump, setLabelJump] = useState(null)
|
|
515
727
|
const [msJump, setMsJump] = useState(null)
|
|
@@ -525,12 +737,12 @@ function IssuesArea({ repo, t, initialSub }) {
|
|
|
525
737
|
h('span', { style: { flex: 1 } }),
|
|
526
738
|
sub === 'list' ? h('button', { className: 'dgs-btn', onClick: () => setCreateSignal((n) => n + 1) }, t('issueNew')) : null),
|
|
527
739
|
sub === 'list' && h(Issues, { repo, t, presetLabel: labelJump, presetMilestone: msJump,
|
|
528
|
-
onPresetDone: () => { setLabelJump(null); setMsJump(null) }, createSignal }),
|
|
740
|
+
onPresetDone: () => { setLabelJump(null); setMsJump(null) }, createSignal, initialIdx }),
|
|
529
741
|
sub === 'labels' && h(LabelsManage, { repo, t, onFilterLabel: (lid) => { setLabelJump(lid); setSub('list') } }),
|
|
530
742
|
sub === 'milestones' && h(MilestonesManage, { repo, t, onOpenMilestone: (m) => { setMsJump(m.id); setSub('list') } }))
|
|
531
743
|
}
|
|
532
744
|
|
|
533
|
-
function Issues({ repo, t, presetLabel, presetMilestone, onPresetDone, createSignal }) {
|
|
745
|
+
function Issues({ repo, t, presetLabel, presetMilestone, onPresetDone, createSignal, initialIdx }) {
|
|
534
746
|
const [state, setState] = useState('open')
|
|
535
747
|
const [flt, setFlt] = useState({ label: '', milestone: '', assignee: '' })
|
|
536
748
|
useEffect(() => {
|
|
@@ -545,7 +757,7 @@ function Issues({ repo, t, presetLabel, presetMilestone, onPresetDone, createSig
|
|
|
545
757
|
const [meta, setMeta] = useState({ labels: [], milestones: [], collabs: [] })
|
|
546
758
|
const [creating, setCreating] = useState(false)
|
|
547
759
|
useEffect(() => { if (createSignal) setCreating(true) }, [createSignal])
|
|
548
|
-
const [openIdx, setOpenIdx] = useState(null)
|
|
760
|
+
const [openIdx, setOpenIdx] = useState(initialIdx ?? null)
|
|
549
761
|
const reload = useCallback(() => {
|
|
550
762
|
setList(null)
|
|
551
763
|
const q = new URLSearchParams({ state, sort })
|
|
@@ -690,6 +902,8 @@ function IssueDetail({ repo, idx, t, onBack }) {
|
|
|
690
902
|
setComments(null)
|
|
691
903
|
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/issues/${idx}`).then((d) => setIssue(d && d.number ? d : null))
|
|
692
904
|
api('GET', `/repos/${repo.owner}/${repo.name}/issues/${idx}/comments`).then((d) => setComments(d.comments || []))
|
|
905
|
+
// 打开详情即视为已读(通知中心据此消未读)
|
|
906
|
+
api('POST', `/dsh/repos/${repo.owner}/${repo.name}/issues/${idx}/read`).catch(() => {})
|
|
693
907
|
}, [repo.owner, repo.name, idx])
|
|
694
908
|
useEffect(reload, [reload])
|
|
695
909
|
useEffect(() => {
|
|
@@ -719,7 +933,13 @@ function IssueDetail({ repo, idx, t, onBack }) {
|
|
|
719
933
|
comments === null ? h('div', { className: 'dgs-empty' }, t('loading'))
|
|
720
934
|
: comments.length === 0 ? h('div', { className: 'dgs-empty' }, t('noIssues'))
|
|
721
935
|
: comments.map((c) =>
|
|
722
|
-
|
|
936
|
+
c.type === 2 // 提交关单事件(push 时 fixes #N 触发)
|
|
937
|
+
? h('div', { key: c.id || ('ev' + c.created), className: 'dgs-row', style: { padding: '5px 4px', gap: 6 } },
|
|
938
|
+
h('span', null, '🔒'),
|
|
939
|
+
h('span', { className: 'dgs-sub' }, (c.user || '') + ' 通过提交 '),
|
|
940
|
+
h('code', { className: 'dgs-badge' }, (c.commit_sha || '').slice(0, 10) || '?'),
|
|
941
|
+
h('span', { className: 'dgs-sub' }, ' 关闭了此工单 · ' + fmtDate(c.created)))
|
|
942
|
+
: h('div', { key: c.id, className: 'dgs-comment' },
|
|
723
943
|
h('div', { className: 'dgs-row', style: { marginBottom: 4 } },
|
|
724
944
|
h('span', { className: 'dgs-sub' }, `${c.user || ''} · ${fmtDate(c.created)}`),
|
|
725
945
|
h('span', { style: { flex: 1 } }),
|
|
@@ -736,7 +956,7 @@ function IssueDetail({ repo, idx, t, onBack }) {
|
|
|
736
956
|
await api('PATCH', `/repos/${repo.owner}/${repo.name}/issues/comments/${c.id}`, { body: editC.text })
|
|
737
957
|
setEditC(null); reload()
|
|
738
958
|
} }, t('save'))))
|
|
739
|
-
: h('div', { style: { whiteSpace: 'pre-wrap' } }, c.body))),
|
|
959
|
+
: h('div', { style: { whiteSpace: 'pre-wrap' } }, linkifyIssues(c.body, repo)))),
|
|
740
960
|
h('div', { className: 'dgs-card', style: { margin: '12px 0' } },
|
|
741
961
|
h('textarea', { className: 'dgs-input', placeholder: t('commentPlaceholder'), value: text, onChange: (e) => setText(e.target.value) }),
|
|
742
962
|
h('div', { className: 'dgs-row', style: { marginTop: 8 } },
|
|
@@ -819,7 +1039,7 @@ function Commits({ repo, rev, t }) {
|
|
|
819
1039
|
h('table', { className: 'dgs-table' },
|
|
820
1040
|
h('tbody', null, shown.map((c, i) =>
|
|
821
1041
|
h('tr', { key: i, style: { cursor: 'pointer' }, onClick: () => setSel(c.sha) },
|
|
822
|
-
h('td', null, h('div', { style: { fontWeight: 500 } }, (c.message || '').split('\n')[0]),
|
|
1042
|
+
h('td', null, h('div', { style: { fontWeight: 500 } }, linkifyIssues((c.message || '').split('\n')[0], repo)),
|
|
823
1043
|
h('div', { className: 'dgs-sub' }, c.author)),
|
|
824
1044
|
h('td', { className: 'dgs-sub', style: { textAlign: 'right', whiteSpace: 'nowrap' } }, (c.sha || '').slice(0, 10)),
|
|
825
1045
|
h('td', { className: 'dgs-sub', style: { textAlign: 'right', whiteSpace: 'nowrap', width: 100 } }, timeAgo(c.date)))))),
|
|
@@ -839,7 +1059,7 @@ function CommitDetail({ repo, sha, t, onBack }) {
|
|
|
839
1059
|
return h('div', null,
|
|
840
1060
|
h('div', { className: 'dgs-row', style: { marginBottom: 10 } },
|
|
841
1061
|
h('button', { className: 'dgs-btn ghost', onClick: onBack }, t('back')),
|
|
842
|
-
h('span', { style: { fontWeight: 600 } }, (d.message || '').split('\n')[0]),
|
|
1062
|
+
h('span', { style: { fontWeight: 600 } }, linkifyIssues((d.message || '').split('\n')[0], repo)),
|
|
843
1063
|
h('span', { className: 'dgs-sub' }, (d.sha || '').slice(0, 10))),
|
|
844
1064
|
h('div', { className: 'dgs-sub', style: { marginBottom: 8 } },
|
|
845
1065
|
`${(d.author && d.author.name) || ''} · ${fmtDate(when)}`),
|
|
@@ -933,8 +1153,9 @@ function Branches({ repo, t, onNewPR }) {
|
|
|
933
1153
|
b.name !== def ? h('button', { className: 'dgs-btn ghost', style: { marginLeft: 8, padding: '2px 8px' }, onClick: () => onNewPR && onNewPR(b.name, def) }, '+ PR') : null,
|
|
934
1154
|
b.name !== def ? h('button', { className: 'dgs-btn danger', style: { marginLeft: 4, padding: '2px 8px' }, onClick: async () => {
|
|
935
1155
|
if (!confirm('删除分支 ' + b.name + ' ?')) return
|
|
936
|
-
await api('DELETE', `/repos/${repo.owner}/${repo.name}/branches/${encodeURIComponent(b.name)}`)
|
|
937
|
-
reload()
|
|
1156
|
+
const d = await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/branches/${encodeURIComponent(b.name)}`)
|
|
1157
|
+
if (d && d.ok) reload()
|
|
1158
|
+
else alert((d && d.error) || '删除失败')
|
|
938
1159
|
} }, '删') : null))))),
|
|
939
1160
|
h('div', { style: { fontWeight: 700, margin: '18px 0 8px' } }, '标签'),
|
|
940
1161
|
tags === null ? h('div', { className: 'dgs-empty' }, t('loading'))
|
|
@@ -1189,19 +1410,33 @@ function RepoSettings({ repo, t }) {
|
|
|
1189
1410
|
const [form, setForm] = useState({ description: '', private: false, website: '' })
|
|
1190
1411
|
const [collabs, setCollabs] = useState([])
|
|
1191
1412
|
const [addName, setAddName] = useState('')
|
|
1413
|
+
const [addMode, setAddMode] = useState('write')
|
|
1192
1414
|
const [hooks, setHooks] = useState(null)
|
|
1193
1415
|
const [hookUrl, setHookUrl] = useState('')
|
|
1416
|
+
const [hookDeliveries, setHookDeliveries] = useState({}) // id → rows | 'loading'
|
|
1417
|
+
const [prots, setProts] = useState(null)
|
|
1418
|
+
const [protBranch, setProtBranch] = useState('')
|
|
1419
|
+
const [branchList, setBranchList] = useState([])
|
|
1420
|
+
const [mirrorInfo, setMirrorInfo] = useState(null)
|
|
1421
|
+
const [syncBusy, setSyncBusy] = useState(false)
|
|
1194
1422
|
const [msg, setMsg] = useState('')
|
|
1195
1423
|
const [busy, setBusy] = useState(false)
|
|
1196
1424
|
const loadCollabs = () => api('GET', `/dsh/repos/${repo.owner}/${repo.name}/collaborators`).then((d) => setCollabs(Array.isArray(d) ? d : []))
|
|
1197
1425
|
const loadHooks = () => api('GET', `/dsh/repos/${repo.owner}/${repo.name}/hooks`).then((d) => setHooks(Array.isArray(d) ? d : []))
|
|
1426
|
+
const loadProts = () => api('GET', `/dsh/repos/${repo.owner}/${repo.name}/protections`).then((d) => setProts(Array.isArray(d) ? d : []))
|
|
1198
1427
|
useEffect(() => {
|
|
1199
1428
|
api('GET', `/dsh/repos/${repo.owner}/${repo.name}`).then((d) => {
|
|
1200
|
-
if (d && d.name) {
|
|
1429
|
+
if (d && d.name) {
|
|
1430
|
+
setInfo(d)
|
|
1431
|
+
setForm({ description: d.description || '', private: !!d.private, website: d.website || '' })
|
|
1432
|
+
if (d.isMirror) setMirrorInfo(d.mirror || { address: '', updatedAt: 0, nextAt: 0 })
|
|
1433
|
+
}
|
|
1201
1434
|
else setMsg((d && d.error) || t('loadFailed'))
|
|
1202
1435
|
})
|
|
1203
1436
|
loadCollabs()
|
|
1204
1437
|
loadHooks()
|
|
1438
|
+
loadProts()
|
|
1439
|
+
api('GET', `/repos/${repo.owner}/${repo.name}/branches`).then((d) => setBranchList((d && d.branches) || []))
|
|
1205
1440
|
}, [repo.owner, repo.name])
|
|
1206
1441
|
if (info === null && !msg) return h('div', { className: 'dgs-empty' }, t('loading'))
|
|
1207
1442
|
const navItem = (k, label) => h('span', { key: k, className: 'item' + (sub === k ? ' active' : ''), onClick: () => setSub(k) }, label)
|
|
@@ -1226,19 +1461,33 @@ function RepoSettings({ repo, t }) {
|
|
|
1226
1461
|
setBusy(false)
|
|
1227
1462
|
d && d.ok ? setMsg('✓ 已保存') : setMsg((d && d.error) || 'failed')
|
|
1228
1463
|
} }, '更新设置')))
|
|
1464
|
+
const collabModeName = { 1: '只读', 2: '可写', 3: '管理' }
|
|
1229
1465
|
const collab = h('div', { className: 'dgs-card' },
|
|
1230
1466
|
h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '协作者'),
|
|
1231
1467
|
h('div', { className: 'dgs-row' },
|
|
1232
1468
|
h('input', { className: 'dgs-input', style: { maxWidth: 200 }, placeholder: '用户名', value: addName, onChange: (e) => setAddName(e.target.value) }),
|
|
1469
|
+
h('select', { className: 'dgs-input', style: { maxWidth: 110, flex: 'none', width: 'auto' }, value: addMode, onChange: (e) => setAddMode(e.target.value) },
|
|
1470
|
+
h('option', { value: 'read' }, '只读'),
|
|
1471
|
+
h('option', { value: 'write' }, '可写'),
|
|
1472
|
+
h('option', { value: 'admin' }, '管理')),
|
|
1233
1473
|
h('button', { className: 'dgs-btn', disabled: !addName.trim(),
|
|
1234
1474
|
onClick: async () => {
|
|
1235
|
-
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/collaborators/${encodeURIComponent(addName.trim())}
|
|
1475
|
+
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/collaborators/${encodeURIComponent(addName.trim())}`, { mode: addMode })
|
|
1236
1476
|
if (d && d.ok !== false && !d.error) { setAddName(''); setMsg(''); loadCollabs() } else setMsg((d && d.error) || 'failed')
|
|
1237
1477
|
} }, '+ 添加')),
|
|
1238
1478
|
collabs.length === 0 ? h('div', { className: 'dgs-sub', style: { marginTop: 8 } }, '—') : null,
|
|
1239
1479
|
collabs.map((u) => h('div', { key: u.name, className: 'dgs-row', style: { marginTop: 6 } },
|
|
1240
1480
|
h('a', { className: 'dgs-name', href: '#/u/' + u.name }, u.name),
|
|
1241
1481
|
h('span', { style: { flex: 1 } }),
|
|
1482
|
+
h('select', { className: 'dgs-input', style: { maxWidth: 96, flex: 'none', width: 'auto', padding: '3px 6px', fontSize: 12 }, value: String(u.mode || 2),
|
|
1483
|
+
onChange: async (e) => {
|
|
1484
|
+
const mode = { 1: 'read', 2: 'write', 3: 'admin' }[e.target.value] || 'write'
|
|
1485
|
+
await api('PATCH', `/dsh/repos/${repo.owner}/${repo.name}/collaborators/${encodeURIComponent(u.name)}`, { mode })
|
|
1486
|
+
loadCollabs()
|
|
1487
|
+
} },
|
|
1488
|
+
h('option', { value: '1' }, '只读'),
|
|
1489
|
+
h('option', { value: '2' }, '可写'),
|
|
1490
|
+
h('option', { value: '3' }, '管理')),
|
|
1242
1491
|
h('button', { className: 'dgs-btn danger', onClick: async () => {
|
|
1243
1492
|
await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/collaborators/${encodeURIComponent(u.name)}`)
|
|
1244
1493
|
loadCollabs()
|
|
@@ -1251,16 +1500,77 @@ function RepoSettings({ repo, t }) {
|
|
|
1251
1500
|
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/hooks`, { url: hookUrl.trim() })
|
|
1252
1501
|
if (d && !d.error) { setHookUrl(''); loadHooks() } else setMsg((d && d.error) || 'failed')
|
|
1253
1502
|
} }, '+ 添加')),
|
|
1254
|
-
(hooks || []).map((hk) => h('div', { key: hk.id,
|
|
1255
|
-
h('
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1503
|
+
(hooks || []).map((hk) => h('div', { key: hk.id, style: { marginTop: 6 } },
|
|
1504
|
+
h('div', { className: 'dgs-row' },
|
|
1505
|
+
h('span', { className: 'dgs-badge' + (hk.is_active ? ' ok' : ' closed') }, hk.is_active ? '启用' : '停用'),
|
|
1506
|
+
hk.last_status === 1 ? h('span', { className: 'dgs-badge ok', title: '最近一次投递成功' }, '✓ 投递成功')
|
|
1507
|
+
: hk.last_status === 2 ? h('span', { className: 'dgs-badge closed', title: '最近一次投递失败' }, '✗ 投递失败') : null,
|
|
1508
|
+
h('span', { className: 'dgs-sub', style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' } }, hk.url),
|
|
1509
|
+
h('button', { className: 'dgs-btn ghost', onClick: async () => {
|
|
1510
|
+
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/hooks/${hk.id}/test`)
|
|
1511
|
+
if (d && d.ok) { setMsg('✓ 已发送测试事件(push)'); setTimeout(() => setMsg(''), 1600); setTimeout(loadHooks, 1200) }
|
|
1512
|
+
else setMsg((d && d.error) || 'failed')
|
|
1513
|
+
} }, '测试'),
|
|
1514
|
+
h('button', { className: 'dgs-btn ghost', onClick: async () => {
|
|
1515
|
+
if (hookDeliveries[hk.id]) { setHookDeliveries({ ...hookDeliveries, [hk.id]: undefined }); return }
|
|
1516
|
+
setHookDeliveries({ ...hookDeliveries, [hk.id]: 'loading' })
|
|
1517
|
+
const d = await api('GET', `/dsh/repos/${repo.owner}/${repo.name}/hooks/${hk.id}/deliveries`)
|
|
1518
|
+
setHookDeliveries((m) => ({ ...m, [hk.id]: Array.isArray(d) ? d : [] }))
|
|
1519
|
+
} }, '记录'),
|
|
1520
|
+
h('button', { className: 'dgs-btn ghost', onClick: async () => {
|
|
1521
|
+
await api('PATCH', `/dsh/repos/${repo.owner}/${repo.name}/hooks/${hk.id}`, { active: !hk.is_active }); loadHooks()
|
|
1522
|
+
} }, hk.is_active ? '停用' : '启用'),
|
|
1523
|
+
h('button', { className: 'dgs-btn danger', onClick: async () => {
|
|
1524
|
+
await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/hooks/${hk.id}`); loadHooks()
|
|
1525
|
+
} }, t('delete'))),
|
|
1526
|
+
Array.isArray(hookDeliveries[hk.id]) ? h('div', { style: { margin: '4px 0 4px 24px' } },
|
|
1527
|
+
hookDeliveries[hk.id].length === 0 ? h('div', { className: 'dgs-sub' }, '暂无投递记录')
|
|
1528
|
+
: hookDeliveries[hk.id].map((dv) =>
|
|
1529
|
+
h('div', { key: dv.id, className: 'dgs-row', style: { padding: '2px 0', gap: 8 } },
|
|
1530
|
+
h('span', { className: 'dgs-badge' + (dv.ok ? ' ok' : ' closed') }, dv.ok ? '成功' : '失败'),
|
|
1531
|
+
h('span', { className: 'dgs-sub' }, '#' + dv.id + ' · ' + dv.event + (dv.status ? ' · HTTP ' + dv.status : '')),
|
|
1532
|
+
dv.err ? h('span', { className: 'dgs-sub', style: { color: 'var(--dsw-alias-state-error-primary)' } }, dv.err) : null)))
|
|
1533
|
+
: hookDeliveries[hk.id] === 'loading' ? h('div', { className: 'dgs-sub', style: { margin: '4px 0 4px 24px' } }, t('loading')) : null)),
|
|
1263
1534
|
hooks !== null && hooks.length === 0 ? h('div', { className: 'dgs-sub', style: { marginTop: 6 } }, '—') : null)
|
|
1535
|
+
const protsCard = h('div', { className: 'dgs-card' },
|
|
1536
|
+
h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '分支保护'),
|
|
1537
|
+
h('div', { className: 'dgs-sub', style: { marginBottom: 10 } }, '受保护的分支不可删除,也不接受强推(非快进推送会被拒绝)。合并 PR、网页编辑等正常快进操作不受影响。'),
|
|
1538
|
+
h('div', { className: 'dgs-row' },
|
|
1539
|
+
h('select', { className: 'dgs-input', style: { maxWidth: 220, flex: 'none', width: 'auto' }, value: protBranch, onChange: (e) => setProtBranch(e.target.value) },
|
|
1540
|
+
h('option', { value: '' }, '选择分支…'),
|
|
1541
|
+
branchList.map((b) => h('option', { key: b.name, value: b.name }, b.name + (info && b.name === info.defaultBranch ? '(默认)' : '')))),
|
|
1542
|
+
h('button', { className: 'dgs-btn', disabled: !protBranch, onClick: async () => {
|
|
1543
|
+
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/protections`, { branch: protBranch, protected: true })
|
|
1544
|
+
if (d && d.ok) { setProtBranch(''); loadProts() } else setMsg((d && d.error) || 'failed')
|
|
1545
|
+
} }, '+ 保护')),
|
|
1546
|
+
prots === null ? h('div', { className: 'dgs-sub', style: { marginTop: 8 } }, t('loading'))
|
|
1547
|
+
: prots.length === 0 ? h('div', { className: 'dgs-sub', style: { marginTop: 8 } }, '—')
|
|
1548
|
+
: prots.map((p) => h('div', { key: p.branch, className: 'dgs-row', style: { marginTop: 6 } },
|
|
1549
|
+
h('span', { className: 'dgs-badge ok' }, '🔒 ' + p.branch),
|
|
1550
|
+
h('span', { style: { flex: 1 } }),
|
|
1551
|
+
h('button', { className: 'dgs-btn danger', onClick: async () => {
|
|
1552
|
+
await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/protections`, { branch: p.branch, protected: false })
|
|
1553
|
+
loadProts()
|
|
1554
|
+
} }, '解除保护'))))
|
|
1555
|
+
const mirrorCard = mirrorInfo ? h('div', { className: 'dgs-card' },
|
|
1556
|
+
h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '镜像同步'),
|
|
1557
|
+
h('div', { className: 'dgs-row' },
|
|
1558
|
+
h('span', { className: 'dgs-sub', style: { minWidth: 70 } }, '上游地址'),
|
|
1559
|
+
h('span', { style: { fontFamily: 'ui-monospace,monospace', fontSize: 12.5, wordBreak: 'break-all' } }, mirrorInfo.address || '—')),
|
|
1560
|
+
h('div', { className: 'dgs-row' },
|
|
1561
|
+
h('span', { className: 'dgs-sub', style: { minWidth: 70 } }, '上次同步'),
|
|
1562
|
+
h('span', { className: 'dgs-sub' }, mirrorInfo.updatedAt ? timeAgo(mirrorInfo.updatedAt) : '从未'),
|
|
1563
|
+
h('span', { className: 'dgs-sub' }, mirrorInfo.nextAt ? '· 下次 ' + timeAgo(mirrorInfo.nextAt).replace('之前', '后') : '')),
|
|
1564
|
+
h('div', { className: 'dgs-row', style: { marginTop: 8 } },
|
|
1565
|
+
h('button', { className: 'dgs-btn', disabled: syncBusy, onClick: async () => {
|
|
1566
|
+
setSyncBusy(true); setMsg('')
|
|
1567
|
+
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/mirror-sync`)
|
|
1568
|
+
setSyncBusy(false)
|
|
1569
|
+
if (d && d.ok) {
|
|
1570
|
+
setMirrorInfo({ ...mirrorInfo, updatedAt: d.updatedAt, nextAt: d.nextAt })
|
|
1571
|
+
setMsg('✓ 同步完成')
|
|
1572
|
+
} else setMsg((d && d.error) || '同步失败')
|
|
1573
|
+
} }, syncBusy ? '同步中…(拉取上游可能需要数十秒)' : '立即同步'))) : null
|
|
1264
1574
|
const danger = h('div', { className: 'dgs-card', style: { marginTop: 12, borderColor: 'var(--dsw-alias-state-error-primary)55' } },
|
|
1265
1575
|
h('div', { style: { fontWeight: 700, marginBottom: 8, color: 'var(--dsw-alias-state-error-primary)' } }, '危险区域'),
|
|
1266
1576
|
h('div', { className: 'dgs-row' },
|
|
@@ -1292,17 +1602,19 @@ function RepoSettings({ repo, t }) {
|
|
|
1292
1602
|
h('div', { className: 'dgs-settings-nav' },
|
|
1293
1603
|
navItem('basic', '基本设置'),
|
|
1294
1604
|
navItem('collab', '管理协作者'),
|
|
1605
|
+
navItem('prot', '分支保护'),
|
|
1295
1606
|
navItem('hooks', '管理 Web 钩子'),
|
|
1607
|
+
mirrorInfo ? navItem('mirror', '镜像同步') : null,
|
|
1296
1608
|
navItem('danger', '危险区域')),
|
|
1297
1609
|
h('div', { style: { flex: 1, minWidth: 0 } },
|
|
1298
|
-
sub === 'basic' ? basic : sub === 'collab' ? collab : sub === 'hooks' ? hooksCard : danger)))
|
|
1610
|
+
sub === 'basic' ? basic : sub === 'collab' ? collab : sub === 'prot' ? protsCard : sub === 'hooks' ? hooksCard : sub === 'mirror' ? mirrorCard : danger)))
|
|
1299
1611
|
}
|
|
1300
1612
|
|
|
1301
1613
|
// ── PR(列表 + 详情 + 合并 + 评论复用 issue 通道) ────────────────────────
|
|
1302
1614
|
|
|
1303
|
-
function Pulls({ repo, t, preset, onPresetDone, onGoIssuesSub }) {
|
|
1615
|
+
function Pulls({ repo, t, preset, onPresetDone, onGoIssuesSub, initialIdx }) {
|
|
1304
1616
|
const [list, setList] = useState(null)
|
|
1305
|
-
const [openIdx, setOpenIdx] = useState(null)
|
|
1617
|
+
const [openIdx, setOpenIdx] = useState(initialIdx ?? null)
|
|
1306
1618
|
const [creating, setCreating] = useState(false)
|
|
1307
1619
|
const [state, setState] = useState('open')
|
|
1308
1620
|
const [flt, setFlt] = useState({ label: '', milestone: '', assignee: '' })
|
|
@@ -1473,6 +1785,11 @@ function PullDetail({ repo, idx, t, onBack }) {
|
|
|
1473
1785
|
const [allMs, setAllMs] = useState([])
|
|
1474
1786
|
const [collabs, setCollabs] = useState([])
|
|
1475
1787
|
const [mergeMsg, setMergeMsg] = useState('')
|
|
1788
|
+
const [reviews, setReviews] = useState([])
|
|
1789
|
+
const [reviewText, setReviewText] = useState('')
|
|
1790
|
+
const loadReviews = useCallback(() => {
|
|
1791
|
+
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/pulls/${idx}/reviews`).then((d) => setReviews(Array.isArray(d) ? d : []))
|
|
1792
|
+
}, [repo.owner, repo.name, idx])
|
|
1476
1793
|
useEffect(() => {
|
|
1477
1794
|
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/pulls`).then((d) => {
|
|
1478
1795
|
const all = Array.isArray(d) ? d : (d.data || d.pulls || [])
|
|
@@ -1482,6 +1799,8 @@ function PullDetail({ repo, idx, t, onBack }) {
|
|
|
1482
1799
|
})
|
|
1483
1800
|
api('GET', `/repos/${repo.owner}/${repo.name}/issues/${idx}/comments`).then((d) => setComments(d.comments || []))
|
|
1484
1801
|
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/issues/${idx}`).then((d) => setIssue(d && d.number ? d : null))
|
|
1802
|
+
api('POST', `/dsh/repos/${repo.owner}/${repo.name}/issues/${idx}/read`).catch(() => {})
|
|
1803
|
+
loadReviews()
|
|
1485
1804
|
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/labels`).then((d) => setAllLabels(Array.isArray(d) ? d : []))
|
|
1486
1805
|
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/milestones`).then((d) => setAllMs(Array.isArray(d) ? d : []))
|
|
1487
1806
|
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/collaborators`).then((d) => setCollabs(Array.isArray(d) ? d : []))
|
|
@@ -1522,13 +1841,37 @@ function PullDetail({ repo, idx, t, onBack }) {
|
|
|
1522
1841
|
onChange: (e) => patchIssue({ assignee: e.target.value }) },
|
|
1523
1842
|
h('option', { value: '' }, '未指派成员'),
|
|
1524
1843
|
collabs.map((u) => h('option', { key: u.name, value: u.name }, u.name)))),
|
|
1844
|
+
h('div', { className: 'dgs-side-block' },
|
|
1845
|
+
h('div', { className: 'dgs-side-title' }, '评审'),
|
|
1846
|
+
reviews.length === 0 ? h('div', { className: 'dgs-sub' }, '暂无评审') : null,
|
|
1847
|
+
reviews.map((rv) => h('div', { key: rv.user, className: 'dgs-row', style: { padding: '3px 0', gap: 6 } },
|
|
1848
|
+
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 ? '✓' : '✗'),
|
|
1849
|
+
h('span', { style: { fontSize: 12.5 } }, rv.user),
|
|
1850
|
+
h('span', { className: 'dgs-sub' }, rv.approved ? '通过' : '请求修改')))),
|
|
1525
1851
|
h('div', { className: 'dgs-side-block' },
|
|
1526
1852
|
h('div', { className: 'dgs-side-title' }, (issue && issue.participants || 1) + ' 名参与者'),
|
|
1527
1853
|
h('div', { className: 'dgs-sub' }, pr.author || '')))
|
|
1854
|
+
const approvals = reviews.filter((rv) => rv.approved).length
|
|
1855
|
+
const submitReview = async (approved) => {
|
|
1856
|
+
setBusy(true)
|
|
1857
|
+
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/pulls/${idx}/reviews`, { approved, content: reviewText.trim() })
|
|
1858
|
+
setBusy(false)
|
|
1859
|
+
if (d && d.ok) {
|
|
1860
|
+
setReviewText('')
|
|
1861
|
+
loadReviews()
|
|
1862
|
+
if (reviewText.trim()) api('GET', `/repos/${repo.owner}/${repo.name}/issues/${idx}/comments`).then((d2) => setComments(d2.comments || []))
|
|
1863
|
+
} else setMsg((d && d.error) || 'failed')
|
|
1864
|
+
}
|
|
1528
1865
|
const mergeBox = pr.state === 'open' ? h('div', { className: 'dgs-card', style: { margin: '10px 0', borderColor: 'var(--dsw-alias-state-success-primary)55' } },
|
|
1529
1866
|
h('div', { className: 'dgs-row', style: { gap: 8 } },
|
|
1530
1867
|
h('span', { style: { color: 'var(--dsw-alias-state-success-primary)', fontSize: 16 } }, '⑂'),
|
|
1531
|
-
h('span', { style: { color: 'var(--dsw-alias-state-success-primary)', fontSize: 13 } }, '该合并请求可以进行自动合并操作。')
|
|
1868
|
+
h('span', { style: { color: 'var(--dsw-alias-state-success-primary)', fontSize: 13 } }, '该合并请求可以进行自动合并操作。'),
|
|
1869
|
+
approvals > 0 ? h('span', { className: 'dgs-badge ok' }, approvals + ' 个评审通过') : null),
|
|
1870
|
+
h('div', { style: { margin: '10px 0', borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: 10 } },
|
|
1871
|
+
h('textarea', { className: 'dgs-input', style: { minHeight: 48 }, placeholder: '评审意见(可选,会同时出现在对话中)', value: reviewText, onChange: (e) => setReviewText(e.target.value) }),
|
|
1872
|
+
h('div', { className: 'dgs-row', style: { marginTop: 6 } },
|
|
1873
|
+
h('button', { className: 'dgs-btn ghost', disabled: busy, onClick: () => submitReview(true) }, '✓ 通过评审'),
|
|
1874
|
+
h('button', { className: 'dgs-btn ghost', disabled: busy, onClick: () => submitReview(false) }, '✗ 请求修改'))),
|
|
1532
1875
|
h('div', { style: { margin: '10px 0' } },
|
|
1533
1876
|
h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 6, alignItems: 'center', margin: '4px 0' } },
|
|
1534
1877
|
h('input', { type: 'radio', name: 'mergeStyle', checked: mergeStyle === 'merge', onChange: () => setMergeStyle('merge') }), '创建一个新的合并提交'),
|
|
@@ -1560,7 +1903,7 @@ function PullDetail({ repo, idx, t, onBack }) {
|
|
|
1560
1903
|
(comments || []).map((c) =>
|
|
1561
1904
|
h('div', { key: c.id, className: 'dgs-comment' },
|
|
1562
1905
|
h('div', { className: 'dgs-sub', style: { marginBottom: 4 } }, `${c.user || ''} 评论于 ${fmtDate(c.created)}`),
|
|
1563
|
-
h('div', { style: { whiteSpace: 'pre-wrap' } }, c.body
|
|
1906
|
+
h('div', { style: { whiteSpace: 'pre-wrap' } }, c.body ? linkifyIssues(c.body, repo) : '这个人很懒,什么都没留下。'))),
|
|
1564
1907
|
h('div', { className: 'dgs-card', style: { margin: '10px 0' } },
|
|
1565
1908
|
h('textarea', { className: 'dgs-input', placeholder: t('commentPlaceholder'), value: text, onChange: (e) => setText(e.target.value) }),
|
|
1566
1909
|
h('div', { className: 'dgs-row', style: { marginTop: 8 } },
|
|
@@ -1578,7 +1921,7 @@ function PullDetail({ repo, idx, t, onBack }) {
|
|
|
1578
1921
|
: h('table', { className: 'dgs-table' },
|
|
1579
1922
|
h('tbody', null, cmp.commits.map((c) =>
|
|
1580
1923
|
h('tr', { key: c.sha, style: { cursor: 'pointer' }, onClick: () => setSelCommit(c.sha) },
|
|
1581
|
-
h('td', null, h('div', { style: { fontWeight: 500 } }, (c.message || '').split('\n')[0]),
|
|
1924
|
+
h('td', null, h('div', { style: { fontWeight: 500 } }, linkifyIssues((c.message || '').split('\n')[0], repo)),
|
|
1582
1925
|
h('div', { className: 'dgs-sub' }, c.author)),
|
|
1583
1926
|
h('td', { className: 'dgs-sub', style: { textAlign: 'right' } },
|
|
1584
1927
|
h('a', { style: { color: 'var(--dsw-alias-state-business-primary)', cursor: 'pointer' } }, (c.sha || '').slice(0, 10)))))))
|
|
@@ -1799,16 +2142,46 @@ function Releases({ repo, t }) {
|
|
|
1799
2142
|
r.prerelease ? h('span', { className: 'dgs-badge pri' }, '预发布') : null,
|
|
1800
2143
|
h('span', { style: { fontWeight: 600 } }, r.title),
|
|
1801
2144
|
h('span', { style: { flex: 1 } }),
|
|
1802
|
-
h('a', { className: 'dgs-sub', style: { cursor: 'pointer' }, onClick: () => { setEditId(r.id); setEditForm({ title: r.title || '', note: r.noteRaw || '' }) } }, '(编辑)')
|
|
2145
|
+
h('a', { className: 'dgs-sub', style: { cursor: 'pointer' }, onClick: () => { setEditId(r.id); setEditForm({ title: r.title || '', note: r.noteRaw || '' }) } }, '(编辑)'),
|
|
2146
|
+
h('a', { className: 'dgs-sub', style: { cursor: 'pointer', color: 'var(--dsw-alias-state-error-primary)' }, onClick: async () => {
|
|
2147
|
+
if (!confirm('删除发版 ' + r.tag + '(git 标签保留)?')) return
|
|
2148
|
+
const d = await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/releases/${r.id}`)
|
|
2149
|
+
if (d && d.ok) reload(); else setMsg((d && d.error) || '删除失败')
|
|
2150
|
+
} }, '(删除)')),
|
|
1803
2151
|
h('div', { className: 'dgs-sub', style: { marginTop: 4 } },
|
|
1804
2152
|
(r.author || '') + ' · ' + timeAgo((r.createdAt || 0) * 1000) + ' 发布 · ' +
|
|
1805
2153
|
(r.behind ? '在该版本发布之后已有 ' + r.behind + ' 次代码提交到 ' + (r.target || '默认') + ' 分支' : '暂无后续提交')),
|
|
1806
2154
|
r.noteHtml ? h('div', { className: 'dgs-md', style: { marginTop: 8 }, dangerouslySetInnerHTML: { __html: r.noteHtml } }) : null,
|
|
1807
2155
|
h('div', { style: { marginTop: 10 } },
|
|
1808
|
-
h('div', {
|
|
2156
|
+
h('div', { className: 'dgs-row', style: { marginBottom: 4 } },
|
|
2157
|
+
h('div', { style: { fontWeight: 600, fontSize: 13 } }, '下载附件'),
|
|
2158
|
+
h('span', { style: { flex: 1 } }),
|
|
2159
|
+
h('label', { className: 'dgs-sub', style: { cursor: 'pointer' } }, '⭱ 上传附件',
|
|
2160
|
+
h('input', { type: 'file', style: { display: 'none' }, onChange: async (e) => {
|
|
2161
|
+
const f = e.target.files && e.target.files[0]
|
|
2162
|
+
e.target.value = ''
|
|
2163
|
+
if (!f) return
|
|
2164
|
+
if (f.size > 25 * 1024 * 1024) { setMsg('附件超过 25MB 上限'); return }
|
|
2165
|
+
const bytes = new Uint8Array(await f.arrayBuffer())
|
|
2166
|
+
let bin = ''
|
|
2167
|
+
for (let i = 0; i < bytes.length; i += 32768) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 32768))
|
|
2168
|
+
setMsg('')
|
|
2169
|
+
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/releases/${r.id}/assets`, { name: f.name, contentBase64: btoa(bin) })
|
|
2170
|
+
if (d && d.ok) reload(); else setMsg((d && d.error) || '上传失败')
|
|
2171
|
+
} }))),
|
|
1809
2172
|
h('div', { className: 'dgs-row', style: { gap: 8 } },
|
|
1810
2173
|
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)'),
|
|
1811
|
-
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)'))
|
|
2174
|
+
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)')),
|
|
2175
|
+
(r.assets || []).length === 0 ? null
|
|
2176
|
+
: h('div', { style: { marginTop: 6 } }, r.assets.map((a) =>
|
|
2177
|
+
h('div', { key: a.id, className: 'dgs-row', style: { gap: 8, padding: '2px 0' } },
|
|
2178
|
+
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),
|
|
2179
|
+
h('span', { className: 'dgs-sub' }, fmtSize(a.size)),
|
|
2180
|
+
h('a', { className: 'dgs-sub', style: { cursor: 'pointer', color: 'var(--dsw-alias-state-error-primary)' }, onClick: async () => {
|
|
2181
|
+
if (!confirm('删除附件 ' + a.name + ' ?')) return
|
|
2182
|
+
await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/attachments/${a.id}`)
|
|
2183
|
+
reload()
|
|
2184
|
+
} }, '删除')))))))),
|
|
1812
2185
|
(plainTags && plainTags.length > 0) ? h('div', { style: { marginTop: 16 } },
|
|
1813
2186
|
h('div', { style: { fontWeight: 700, margin: '4px 0 8px' } }, '未发版的标签'),
|
|
1814
2187
|
plainTags.map((tg) => h('div', { key: tg, className: 'dgs-issue' },
|
|
@@ -1940,20 +2313,44 @@ function OrgView({ name, t }) {
|
|
|
1940
2313
|
|
|
1941
2314
|
function UserProfile({ name, t }) {
|
|
1942
2315
|
const [profile, setProfile] = useState(null)
|
|
1943
|
-
|
|
2316
|
+
const [showList, setShowList] = useState(null) // 'followers' | 'following' | null
|
|
2317
|
+
const [followBusy, setFollowBusy] = useState(false)
|
|
2318
|
+
const reload = useCallback(() => {
|
|
1944
2319
|
api('GET', '/dsh/users/' + encodeURIComponent(name)).then((d) => setProfile(d.data || d))
|
|
1945
2320
|
}, [name])
|
|
2321
|
+
useEffect(() => { reload() }, [reload])
|
|
1946
2322
|
if (profile === null) return h('div', { className: 'dgs-empty' }, t('loading'))
|
|
2323
|
+
const followBtn = profile.isSelf === false ? h('button', {
|
|
2324
|
+
className: 'dgs-btn' + (profile.isFollowing ? ' ghost' : ''), style: { marginTop: 10, width: '100%' }, disabled: followBusy,
|
|
2325
|
+
onClick: async () => {
|
|
2326
|
+
setFollowBusy(true)
|
|
2327
|
+
const d = await api('POST', '/dsh/users/' + encodeURIComponent(name) + '/follow')
|
|
2328
|
+
setFollowBusy(false)
|
|
2329
|
+
if (d && typeof d.on === 'boolean') reload()
|
|
2330
|
+
},
|
|
2331
|
+
}, profile.isFollowing ? '取消关注' : '+ 关注') : null
|
|
2332
|
+
const countBtn = (k, n, label) => h('a', {
|
|
2333
|
+
className: 'dgs-sub', style: { cursor: 'pointer', color: showList === k ? 'var(--dsw-alias-state-business-primary)' : undefined },
|
|
2334
|
+
onClick: () => setShowList(showList === k ? null : k),
|
|
2335
|
+
}, `${n} ${label}`)
|
|
2336
|
+
const listNames = showList === 'followers' ? profile.followers : showList === 'following' ? profile.following : []
|
|
1947
2337
|
return h('div', { className: 'dgs-settings-layout' },
|
|
1948
2338
|
h('div', { className: 'dgs-settings-nav', style: { width: 230 } },
|
|
1949
2339
|
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 } },
|
|
1950
2340
|
(name || '?').slice(0, 1).toUpperCase()),
|
|
1951
2341
|
h('div', { className: 'dgs-h1', style: { fontSize: 17 } }, name),
|
|
1952
2342
|
profile.isAdmin ? h('span', { className: 'dgs-badge ok', style: { marginTop: 4 } }, 'admin') : null,
|
|
2343
|
+
followBtn,
|
|
1953
2344
|
h('div', { className: 'dgs-sub', style: { marginTop: 10 } }, profile.email ? '✉ ' + profile.email : ''),
|
|
1954
2345
|
h('div', { className: 'dgs-sub', style: { marginTop: 4 } }, '🕐 加入于 ' + (profile.created ? new Date(profile.created * 1000).toLocaleDateString() : '')),
|
|
1955
2346
|
h('div', { className: 'dgs-sub', style: { marginTop: 10 } },
|
|
1956
|
-
|
|
2347
|
+
countBtn('followers', profile.followers.length, '关注者'),
|
|
2348
|
+
' - ',
|
|
2349
|
+
countBtn('following', profile.following.length, '关注中')),
|
|
2350
|
+
showList ? h('div', { style: { marginTop: 8, borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: 8 } },
|
|
2351
|
+
listNames.length === 0 ? h('div', { className: 'dgs-sub' }, '—')
|
|
2352
|
+
: listNames.map((u) => h('div', { key: u, style: { padding: '3px 0' } },
|
|
2353
|
+
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),
|
|
1957
2354
|
h('div', { style: { flex: 1, minWidth: 0 } },
|
|
1958
2355
|
h('div', { style: { fontWeight: 700, margin: '4px 0 10px' } }, '仓库'),
|
|
1959
2356
|
profile.repos.length === 0 ? h('div', { className: 'dgs-empty' }, '—') : null,
|
|
@@ -2053,6 +2450,69 @@ function Admin({ t }) {
|
|
|
2053
2450
|
sub === 'panel' ? panel : sub === 'users' ? usersTab : sub === 'repos' ? reposTab : orgsTab))
|
|
2054
2451
|
}
|
|
2055
2452
|
|
|
2453
|
+
// ── 通知中心 ───────────────────────────────────────────────────────────────
|
|
2454
|
+
|
|
2455
|
+
function Notifications({ t }) {
|
|
2456
|
+
const [data, setData] = useState(null)
|
|
2457
|
+
const reload = useCallback(() => {
|
|
2458
|
+
api('GET', '/dsh/notifications').then((d) => setData(d && Array.isArray(d.items) ? d : { items: [], unread: 0 }))
|
|
2459
|
+
}, [])
|
|
2460
|
+
useEffect(() => { reload() }, [reload])
|
|
2461
|
+
const reasonLabel = { mentioned: '@ 提及了你', assigned: '指派给你', poster: '你发起的', comment: '有新评论' }
|
|
2462
|
+
const open = (n) => {
|
|
2463
|
+
api('POST', `/dsh/repos/${n.repoOwner}/${n.repoName}/issues/${n.index}/read`).catch(() => {})
|
|
2464
|
+
nav('/r/' + n.repoOwner + '/' + n.repoName + '/' + (n.isPull ? 'pulls' : 'issues') + '/' + n.index)
|
|
2465
|
+
}
|
|
2466
|
+
return h('div', null,
|
|
2467
|
+
h('div', { className: 'dgs-row', style: { margin: '8px 0 12px' } },
|
|
2468
|
+
h('span', { style: { fontWeight: 700 } }, '未读通知' + (data && data.unread ? '(' + data.unread + ')' : '')),
|
|
2469
|
+
h('span', { style: { flex: 1 } }),
|
|
2470
|
+
h('button', { className: 'dgs-btn ghost', disabled: !data || !data.unread, onClick: async () => { await api('POST', '/dsh/notifications/read-all'); reload() } }, '全部标记已读')),
|
|
2471
|
+
data === null ? h('div', { className: 'dgs-empty' }, t('loading'))
|
|
2472
|
+
: data.items.length === 0 ? h('div', { className: 'dgs-empty' }, '没有未读通知 🎉')
|
|
2473
|
+
: data.items.map((n) =>
|
|
2474
|
+
h('div', { key: n.repoOwner + '/' + n.repoName + '#' + n.index, className: 'dgs-issue', style: { cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }, onClick: () => open(n) },
|
|
2475
|
+
h('span', { className: 'dgs-issue-num' + (n.isClosed ? ' closed' : '') }, '#' + n.index),
|
|
2476
|
+
h('div', { style: { flex: 1, minWidth: 0 } },
|
|
2477
|
+
h('div', null, h('span', { className: 'dgs-issue-title' }, n.title), n.isPull ? h('span', { className: 'dgs-badge', style: { marginLeft: 6 } }, 'PR') : null),
|
|
2478
|
+
h('div', { className: 'dgs-sub', style: { marginTop: 2 } }, n.repoOwner + '/' + n.repoName + ' · ' + (reasonLabel[n.reason] || '有更新') + ' · ' + timeAgo(n.updatedAt))),
|
|
2479
|
+
h('span', { className: 'dgs-sub', style: { flex: 'none' } }, '›'))))
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
// ── 我的工单 / 我的 PR(跨仓库聚合) ────────────────────────────────────────
|
|
2483
|
+
|
|
2484
|
+
function MyIssues({ t, isPull }) {
|
|
2485
|
+
const [filter, setFilter] = useState('all')
|
|
2486
|
+
const [state, setState] = useState('open')
|
|
2487
|
+
const [list, setList] = useState(null)
|
|
2488
|
+
useEffect(() => {
|
|
2489
|
+
setList(null)
|
|
2490
|
+
api('GET', `/dsh/my/issues?type=${isPull ? 'pulls' : 'issues'}&state=${state}&filter=${filter}`)
|
|
2491
|
+
.then((d) => setList(Array.isArray(d) ? d : []))
|
|
2492
|
+
}, [isPull, state, filter])
|
|
2493
|
+
const fBtn = (k, label) => h('button', { key: k, className: 'dgs-subtab' + (filter === k ? ' active' : ''), onClick: () => setFilter(k) }, label)
|
|
2494
|
+
return h('div', null,
|
|
2495
|
+
h('div', { className: 'dgs-row', style: { margin: '8px 0 12px' } },
|
|
2496
|
+
h('button', { className: 'dgs-pill' + (state === 'open' ? ' active' : ''), onClick: () => setState('open') }, '⊘ ' + t('openState')),
|
|
2497
|
+
h('button', { className: 'dgs-pill closed' + (state === 'closed' ? ' active' : ''), onClick: () => setState('closed') }, '✓ ' + t('closedState')),
|
|
2498
|
+
h('span', { style: { flex: 1 } }),
|
|
2499
|
+
h('div', { className: 'dgs-subtabs', style: { margin: 0 } },
|
|
2500
|
+
fBtn('all', '与我相关'), fBtn('created', '我创建的'), fBtn('assigned', '指派给我的'))),
|
|
2501
|
+
list === null ? h('div', { className: 'dgs-empty' }, t('loading'))
|
|
2502
|
+
: list.length === 0 ? h('div', { className: 'dgs-empty' }, '—')
|
|
2503
|
+
: list.map((i) =>
|
|
2504
|
+
h('div', {
|
|
2505
|
+
key: i.repoOwner + '/' + i.repoName + '#' + i.index, className: 'dgs-issue', style: { cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 },
|
|
2506
|
+
onClick: () => nav('/r/' + i.repoOwner + '/' + i.repoName + '/' + (isPull ? 'pulls' : 'issues') + '/' + i.index),
|
|
2507
|
+
},
|
|
2508
|
+
h('span', { className: 'dgs-issue-num' + (i.state === 'open' ? '' : ' closed') }, '#' + i.index),
|
|
2509
|
+
h('div', { style: { flex: 1, minWidth: 0 } },
|
|
2510
|
+
h('div', { className: 'dgs-issue-title' }, i.title),
|
|
2511
|
+
h('div', { className: 'dgs-sub', style: { marginTop: 2 } },
|
|
2512
|
+
i.repoOwner + '/' + i.repoName + ' · ' + (i.author || '') + ' · ' + timeAgo(i.updatedAt) + (i.assignee ? ' · @' + i.assignee : ''))),
|
|
2513
|
+
h('span', { className: 'dgs-sub', style: { flex: 'none' } }, '💬 ' + (i.comments || 0)))))
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2056
2516
|
// ── 主页(仓库列表 + 建仓) ────────────────────────────────────────────────
|
|
2057
2517
|
|
|
2058
2518
|
function GitPage({ onClose, t, standalone }) {
|
|
@@ -2062,6 +2522,8 @@ function GitPage({ onClose, t, standalone }) {
|
|
|
2062
2522
|
const [err, setErr] = useState('')
|
|
2063
2523
|
const [name, setName] = useState('')
|
|
2064
2524
|
const [isPrivate, setIsPrivate] = useState(false)
|
|
2525
|
+
const [createMode, setCreateMode] = useState('new') // new | import
|
|
2526
|
+
const [importForm, setImportForm] = useState({ url: '', mirror: false })
|
|
2065
2527
|
const [busy, setBusy] = useState(false)
|
|
2066
2528
|
const [toast, setToast] = useState('')
|
|
2067
2529
|
const reload = useCallback(() => {
|
|
@@ -2102,28 +2564,70 @@ function GitPage({ onClose, t, standalone }) {
|
|
|
2102
2564
|
return () => document.removeEventListener('click', onClickRow, true)
|
|
2103
2565
|
}, [standalone, onClose])
|
|
2104
2566
|
const notify = (m) => { setToast(m); setTimeout(() => setToast(''), 1600) }
|
|
2567
|
+
const [unread, setUnread] = useState(0)
|
|
2568
|
+
useEffect(() => {
|
|
2569
|
+
api('GET', '/dsh/notifications/count').then((d) => { if (d && typeof d.unread === 'number') setUnread(d.unread) }).catch(() => {})
|
|
2570
|
+
}, [route])
|
|
2105
2571
|
const seg = route.split('/').filter(Boolean)
|
|
2106
2572
|
const view = seg[0] || 'repos'
|
|
2107
|
-
const
|
|
2573
|
+
const deepTab = seg[3] ? decodeURIComponent(seg[3]) : ''
|
|
2574
|
+
// 深链:#/r/:o/:r 之外支持 /issues/:idx /pulls/:idx /src/:ref/:path...
|
|
2575
|
+
const repoRoute = view === 'r' && seg[1] && seg[2] ? {
|
|
2576
|
+
owner: seg[1], name: seg[2],
|
|
2577
|
+
deep: {
|
|
2578
|
+
tab: deepTab,
|
|
2579
|
+
ref: deepTab === 'src' ? decodeURIComponent(seg[4] || '') : '',
|
|
2580
|
+
arg: deepTab && deepTab !== 'src' ? decodeURIComponent(seg[4] || '') : '',
|
|
2581
|
+
filePath: deepTab === 'src' && seg.length > 5 ? seg.slice(5).map((s) => decodeURIComponent(s)).join('/') : '',
|
|
2582
|
+
},
|
|
2583
|
+
} : null
|
|
2108
2584
|
const body = err ? h('div', { className: 'dgs-card dgs-err' }, err,
|
|
2109
2585
|
h('button', { className: 'dgs-btn ghost', style: { marginLeft: 12 }, onClick: reload }, t('retry')))
|
|
2110
|
-
: repoRoute ? h(RepoBrowser, { repo: repoRoute, t, onBack: () => nav('/repos') })
|
|
2586
|
+
: repoRoute ? h(RepoBrowser, { key: route, repo: repoRoute, t, deep: repoRoute.deep, onBack: () => nav('/repos') })
|
|
2111
2587
|
: view === 'explore' ? h(Explore, { t })
|
|
2112
2588
|
: view === 'orgs' ? h(Orgs, { t })
|
|
2113
2589
|
: view === 'org' && seg[1] ? h(OrgView, { name: seg[1], t })
|
|
2114
2590
|
: view === 'u' && seg[1] ? h(UserProfile, { name: decodeURIComponent(seg[1]), t })
|
|
2115
2591
|
: view === 'admin' ? h(Admin, { t })
|
|
2592
|
+
: view === 'notifications' ? h(Notifications, { t })
|
|
2593
|
+
: view === 'myissues' ? h(MyIssues, { t, isPull: false })
|
|
2594
|
+
: view === 'mypulls' ? h(MyIssues, { t, isPull: true })
|
|
2116
2595
|
: me === null ? h('div', { className: 'dgs-empty' }, t('loading'))
|
|
2117
2596
|
: h('div', null,
|
|
2118
2597
|
h('div', { className: 'dgs-card' },
|
|
2119
2598
|
h('div', { className: 'dgs-row' },
|
|
2120
2599
|
h('span', { style: { fontWeight: 700 } }, t('newRepo')),
|
|
2600
|
+
h('div', { className: 'dgs-subtabs', style: { margin: 0 } },
|
|
2601
|
+
h('button', { className: 'dgs-subtab' + (createMode === 'new' ? ' active' : ''), onClick: () => setCreateMode('new') }, '新建'),
|
|
2602
|
+
h('button', { className: 'dgs-subtab' + (createMode === 'import' ? ' active' : ''), onClick: () => setCreateMode('import') }, '导入'))),
|
|
2603
|
+
createMode === 'new' ? h('div', { className: 'dgs-row', style: { marginTop: 8 } },
|
|
2121
2604
|
h('input', { className: 'dgs-input', placeholder: t('repoName'), value: name,
|
|
2122
2605
|
onChange: (e) => setName(e.target.value),
|
|
2123
2606
|
onKeyDown: (e) => { if (e.key === 'Enter' && name.trim()) create() } }),
|
|
2124
2607
|
h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 4, alignItems: 'center' } },
|
|
2125
2608
|
h('input', { type: 'checkbox', checked: isPrivate, onChange: (e) => setIsPrivate(e.target.checked) }), t('private')),
|
|
2126
|
-
h('button', { className: 'dgs-btn', disabled: busy || !name.trim(), onClick: create }, t('create')))
|
|
2609
|
+
h('button', { className: 'dgs-btn', disabled: busy || !name.trim(), onClick: create }, t('create')))
|
|
2610
|
+
: h('div', null,
|
|
2611
|
+
h('div', { className: 'dgs-row', style: { marginTop: 8 } },
|
|
2612
|
+
h('input', {
|
|
2613
|
+
className: 'dgs-input', placeholder: 'https://github.com/owner/repo.git', value: importForm.url,
|
|
2614
|
+
onChange: (e) => {
|
|
2615
|
+
const url = e.target.value
|
|
2616
|
+
setImportForm({ ...importForm, url })
|
|
2617
|
+
if (!name) {
|
|
2618
|
+
const m = /\/([^/]+?)(?:\.git)?\/?$/.exec(url.trim())
|
|
2619
|
+
if (m) setName(m[1])
|
|
2620
|
+
}
|
|
2621
|
+
},
|
|
2622
|
+
})),
|
|
2623
|
+
h('div', { className: 'dgs-row', style: { marginTop: 8 } },
|
|
2624
|
+
h('input', { className: 'dgs-input', placeholder: t('repoName'), value: name, onChange: (e) => setName(e.target.value) }),
|
|
2625
|
+
h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 4, alignItems: 'center' } },
|
|
2626
|
+
h('input', { type: 'checkbox', checked: importForm.mirror, onChange: (e) => setImportForm({ ...importForm, mirror: e.target.checked }) }), '镜像同步'),
|
|
2627
|
+
h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 4, alignItems: 'center' } },
|
|
2628
|
+
h('input', { type: 'checkbox', checked: isPrivate, onChange: (e) => setIsPrivate(e.target.checked) }), t('private')),
|
|
2629
|
+
h('button', { className: 'dgs-btn', disabled: busy || !name.trim() || !importForm.url.trim(), onClick: importRepo }, busy ? '导入中…' : '导入')),
|
|
2630
|
+
h('div', { className: 'dgs-sub', style: { marginTop: 6 } }, '私有仓库可在地址里带凭据:https://user:token@host/owner/repo.git;勾选「镜像同步」后每小时自动拉取上游更新。'))),
|
|
2127
2631
|
h('div', { style: { height: 10 } }),
|
|
2128
2632
|
h('div', { style: { fontWeight: 700, margin: '4px 0 10px' } }, `${t('myRepos')}(${me.repos.length})`),
|
|
2129
2633
|
me.repos.length === 0 ? h('div', { className: 'dgs-empty' }, t('emptyRepo'))
|
|
@@ -2144,7 +2648,7 @@ function GitPage({ onClose, t, standalone }) {
|
|
|
2144
2648
|
h('div', { className: 'dgs-row' }, title, badge, h('span', { style: { flex: 1 } }), stats, del), desc)
|
|
2145
2649
|
}
|
|
2146
2650
|
|
|
2147
|
-
const navItems = [['repos', t('myRepos')], ['explore', '探索'], ['orgs', '组织'], ['admin', '管理']]
|
|
2651
|
+
const navItems = [['repos', t('myRepos')], ['myissues', '工单'], ['mypulls', 'PR'], ['notifications', '通知'], ['explore', '探索'], ['orgs', '组织'], ['admin', '管理']]
|
|
2148
2652
|
return h('div', { className: 'dgs-page' },
|
|
2149
2653
|
h('div', { className: 'dgs-head' },
|
|
2150
2654
|
h('span', { className: 'dgs-h1' }, t('title')),
|
|
@@ -2152,7 +2656,9 @@ function GitPage({ onClose, t, standalone }) {
|
|
|
2152
2656
|
navItems.map(([k, label]) =>
|
|
2153
2657
|
h('button', { key: k, className: 'dgs-btn ghost',
|
|
2154
2658
|
style: view === k ? { color: 'var(--dsw-alias-state-business-primary)', borderColor: 'var(--dsw-alias-state-business-primary)55' } : undefined,
|
|
2155
|
-
onClick: () => nav('/' + k) },
|
|
2659
|
+
onClick: () => nav('/' + k) },
|
|
2660
|
+
label,
|
|
2661
|
+
k === 'notifications' && unread > 0 ? h('span', { className: 'dgs-notif-dot' }, unread > 99 ? '99+' : unread) : null))),
|
|
2156
2662
|
h('button', { className: 'dgs-close', onClick: onClose, title: 'Esc' }, '✕')),
|
|
2157
2663
|
h('div', { className: 'dgs-body' }, body),
|
|
2158
2664
|
toast ? h('div', { className: 'dgs-toast' }, toast) : null)
|
|
@@ -2163,6 +2669,20 @@ function GitPage({ onClose, t, standalone }) {
|
|
|
2163
2669
|
setBusy(false)
|
|
2164
2670
|
if (d.ok) { setName(''); notify('✓ ' + name); reload() } else notify(d.error || 'failed')
|
|
2165
2671
|
}
|
|
2672
|
+
|
|
2673
|
+
async function importRepo() {
|
|
2674
|
+
setBusy(true)
|
|
2675
|
+
const d = await api('POST', '/dsh/migrate', {
|
|
2676
|
+
cloneAddr: importForm.url.trim(), name: name.trim(), private: isPrivate, mirror: importForm.mirror,
|
|
2677
|
+
})
|
|
2678
|
+
setBusy(false)
|
|
2679
|
+
if (d && d.ok) {
|
|
2680
|
+
const repoName = name
|
|
2681
|
+
setName(''); setImportForm({ url: '', mirror: false })
|
|
2682
|
+
notify('✓ ' + repoName)
|
|
2683
|
+
nav('/r/' + d.owner + '/' + d.name)
|
|
2684
|
+
} else notify((d && d.error) || 'failed')
|
|
2685
|
+
}
|
|
2166
2686
|
}
|
|
2167
2687
|
|
|
2168
2688
|
// ── 设置节(服务配置,无 iframe) ──────────────────────────────────────────
|
|
@@ -2326,9 +2846,17 @@ html[data-dsh-git-active] .dsh-git-entry{background:var(--dsw-active,rgba(128,12
|
|
|
2326
2846
|
entry.addEventListener('click', () => openGitPage(t))
|
|
2327
2847
|
const stats = entry.querySelector('.dsh-git-entry-stats')
|
|
2328
2848
|
const refreshStats = () => {
|
|
2849
|
+
let repos = ''
|
|
2329
2850
|
fetch(API + '/me').then((r) => r.json()).then((d) => {
|
|
2330
|
-
if (
|
|
2331
|
-
}).catch(() => {})
|
|
2851
|
+
if (d && Array.isArray(d.repos)) repos = String(d.repos.length)
|
|
2852
|
+
}).catch(() => {}).then(() =>
|
|
2853
|
+
fetch(API + '/dsh/notifications/count').then((r) => r.json()).then((d) => {
|
|
2854
|
+
if (!stats) return
|
|
2855
|
+
const unread = d && typeof d.unread === 'number' ? d.unread : 0
|
|
2856
|
+
stats.textContent = repos + (unread > 0 ? ' · ' + unread + ' 未读' : '')
|
|
2857
|
+
if (unread > 0) stats.style.color = 'var(--dsw-alias-state-error-primary)'
|
|
2858
|
+
else stats.style.color = ''
|
|
2859
|
+
}).catch(() => { if (stats) stats.textContent = repos }))
|
|
2332
2860
|
}
|
|
2333
2861
|
refreshStats()
|
|
2334
2862
|
const poll = setInterval(refreshStats, 30000)
|