feat(server): 新增子赛题四网页终端 gitlink-cli server

新增 server 子命令启动 HTTP 服务(:8080),提供 /api/scenarios、/api/run、/api/result/{key}、/api/health;前端含仪表盘(index)、独立结果页(result)、热点追踪页(hotspot),6 科研场景一键运行与可视化。在 cmd/root.go 注册子命令。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
whale 2026-07-05 22:03:39 +08:00
parent 39f634535c
commit 5dcdd436eb
5 changed files with 1878 additions and 0 deletions

View File

@ -14,6 +14,7 @@ import (
doctorCmd "github.com/gitlink-org/gitlink-cli/cmd/doctor"
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
serverCmd "github.com/gitlink-org/gitlink-cli/cmd/server"
"github.com/gitlink-org/gitlink-cli/shortcuts"
)
@ -58,6 +59,7 @@ func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) {
rootCmd.AddCommand(apiCmd.NewAPICmd(tr))
rootCmd.AddCommand(configCmd.NewConfigCmd(tr))
rootCmd.AddCommand(doctorCmd.NewDoctorCmd(tr))
rootCmd.AddCommand(serverCmd.NewServerCmd())
rootCmd.AddCommand(newVersionCmd(version, tr))
shortcuts.RegisterAll(rootCmd, tr)

293
cmd/server/server.go Normal file
View File

@ -0,0 +1,293 @@
// Package server 提供子赛题四的「网页终端」演示服务子赛题四·Phase 7
//
// 在云服务器上 `gitlink-cli server` 启动一个轻量 HTTP 服务:前端按 7 个科研场景按钮
// 触发后端运行对应的 Python 算法脚本scripts/research/*.py并把 JSON / Mermaid /
// 报告等产物回显/渲染。所有数据仍由 gitlink-cli 获取,本服务只做命令编排与产物转发。
//
// 安全:仅接受 7 个固定场景 + owner/repo/keyword 结构化参数(非任意 shell
// 若设置 --token / DEMO_TOKEN 环境变量,则请求需带 X-Demo-Token 头匹配。
package server
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/spf13/cobra"
)
//go:embed static/*
var staticFS embed.FS
const defaultPort = 8080
// 场景定义:前端按钮 ↔ 后端 Python 脚本。
type scenarioDef struct {
Key string `json:"key"`
Label string `json:"label"`
Desc string `json:"desc"`
Script string `json:"script"`
Needs []string `json:"needs"` // 需要的输入: "owner","repo","keyword"
Timeout int `json:"timeout_s"`
}
var scenarios = []scenarioDef{
{Key: "s1", Label: "S1 仓库洞悉", Desc: "科研项目演进谱系 + 创新点", Script: "lineage.py", Needs: []string{"owner", "repo"}, Timeout: 180},
{Key: "s2", Label: "S2 知识图谱", Desc: "科研领域知识图谱(networkx)", Script: "graph_build.py", Needs: []string{"keyword"}, Timeout: 240},
{Key: "s3", Label: "S3 合规复现", Desc: "许可证/密钥/复现性检查", Script: "repro.py", Needs: []string{"owner", "repo"}, Timeout: 120},
{Key: "s4", Label: "S4 协作匹配", Desc: "学者×缺口 智能匹配", Script: "match.py", Needs: []string{"owner", "repo"}, Timeout: 180},
{Key: "s5", Label: "S5 进度预警", Desc: "周报 + 风险预警", Script: "report.py", Needs: []string{"owner", "repo"}, Timeout: 180},
{Key: "s6", Label: "S6 成果可视化", Desc: "交互图表(plotly)", Script: "visual.py", Needs: []string{"owner", "repo"}, Timeout: 240},
{Key: "hotspot", Label: "🔥 热点追踪", Desc: "科研热点全景观测:飙升项目+活跃讨论+主题热度+学者团队", Script: "hotspot.py", Needs: []string{"keyword"}, Timeout: 300},
}
type Options struct {
Port int
ResearchDir string // scripts/research 目录
WorkDir string // 产物输出根目录
Token string // 可选鉴权 token
}
func NewServerCmd() *cobra.Command {
opts := Options{Port: defaultPort, ResearchDir: "scripts/research", WorkDir: "research-output"}
cmd := &cobra.Command{
Use: "server",
Short: "启动子赛题四网页终端HTTP 演示服务)",
RunE: func(cmd *cobra.Command, args []string) error {
return Run(opts)
},
}
cmd.Flags().IntVarP(&opts.Port, "port", "p", defaultPort, "监听端口")
cmd.Flags().StringVar(&opts.ResearchDir, "research-dir", "scripts/research", "scripts/research 目录")
cmd.Flags().StringVar(&opts.WorkDir, "work-dir", "research-output", "产物输出根目录")
cmd.Flags().StringVar(&opts.Token, "token", "", "可选鉴权 token亦可用 DEMO_TOKEN 环境变量)")
return cmd
}
// Run 启动 HTTP 服务(阻塞)。
func Run(opts Options) error {
if t := os.Getenv("DEMO_TOKEN"); t != "" && opts.Token == "" {
opts.Token = t
}
_ = os.MkdirAll(opts.WorkDir, 0o755)
mux := http.NewServeMux()
h := &handler{opts: opts}
mux.HandleFunc("GET /api/scenarios", h.handleScenarios)
mux.HandleFunc("POST /api/run", h.handleRun)
mux.HandleFunc("GET /api/result/{key}", h.handleResult)
mux.HandleFunc("GET /api/health", h.handleHealth)
sub, err := fs.Sub(staticFS, "static")
if err != nil {
return fmt.Errorf("static fs: %w", err)
}
mux.Handle("GET /", http.FileServer(http.FS(sub)))
addr := fmt.Sprintf(":%d", opts.Port)
fmt.Fprintf(os.Stderr, "子赛题四 网页终端已启动: http://localhost%s\n", addr)
fmt.Fprintf(os.Stderr, " research-dir=%s work-dir=%s auth=%v\n", opts.ResearchDir, opts.WorkDir, opts.Token != "")
srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
return srv.ListenAndServe()
}
type handler struct {
opts Options
mu sync.Mutex // 串行化场景执行,避免并发打爆 GitLink API
}
func (h *handler) authed(r *http.Request) bool {
if h.opts.Token == "" {
return true
}
return r.Header.Get("X-Demo-Token") == h.opts.Token
}
func (h *handler) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"ok": true, "scenarios": len(scenarios)})
}
func (h *handler) handleScenarios(w http.ResponseWriter, r *http.Request) {
if !h.authed(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
writeJSON(w, map[string]any{"ok": true, "scenarios": scenarios})
}
// handleResult 返回某场景最近一次运行的产物(供 result.html 独立结果页按 key 读取,
// URL 可刷新/分享,便于演示讲解)。无需鉴权串行锁——只读已落盘产物。
func (h *handler) handleResult(w http.ResponseWriter, r *http.Request) {
if !h.authed(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
key := r.PathValue("key")
sc, ok := findScenario(key)
if !ok {
writeJSON(w, map[string]any{"ok": false, "error": "unknown scenario: " + key})
return
}
outDir := filepath.Join(h.opts.WorkDir, sc.Key)
writeJSON(w, map[string]any{
"ok": true,
"scenario": sc.Key,
"label": sc.Label,
"desc": sc.Desc,
"artifacts": readArtifacts(outDir),
})
}
type runRequest struct {
Scenario string `json:"scenario"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Keyword string `json:"keyword"`
}
func (h *handler) handleRun(w http.ResponseWriter, r *http.Request) {
if !h.authed(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req runRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": "bad request: " + err.Error()})
return
}
sc, ok := findScenario(req.Scenario)
if !ok {
writeJSON(w, map[string]any{"ok": false, "error": "unknown scenario: " + req.Scenario})
return
}
for _, need := range sc.Needs {
if (need == "keyword" && req.Keyword == "") ||
(need == "owner" && req.Owner == "") ||
(need == "repo" && req.Repo == "") {
writeJSON(w, map[string]any{"ok": false, "error": "missing parameter: " + need})
return
}
}
// 串行执行:一次只跑一个场景,保护 GitLink API。
h.mu.Lock()
defer h.mu.Unlock()
scriptPath := filepath.Join(h.opts.ResearchDir, sc.Script)
outDir := filepath.Join(h.opts.WorkDir, sc.Key)
_ = os.MkdirAll(outDir, 0o755)
argv := []string{scriptPath, "--out", outDir}
if contains(sc.Needs, "owner") {
argv = append(argv, "--owner", req.Owner, "--repo", req.Repo)
}
if contains(sc.Needs, "keyword") {
argv = append(argv, "--keywords", req.Keyword)
}
// python3 优先,回退 python
py, err := pythonBin()
if err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
cmd := exec.Command(py, argv...)
cmd.Env = os.Environ()
start := time.Now()
out, err := cmd.CombinedOutput()
dur := time.Since(start)
resp := map[string]any{
"ok": err == nil,
"scenario": sc.Key,
"command": py + " " + strings.Join(argv, " "),
"duration": dur.Truncate(time.Millisecond).String(),
"stdout": string(out),
"out_dir": outDir,
}
if err != nil {
resp["error"] = err.Error()
}
// 附带读取关键产物json + 第一个 mmd + report.md便于前端直接渲染
resp["artifacts"] = readArtifacts(outDir)
writeJSON(w, resp)
}
func findScenario(key string) (scenarioDef, bool) {
for _, s := range scenarios {
if s.Key == key || strings.EqualFold(s.Key, key) {
return s, true
}
}
return scenarioDef{}, false
}
func readArtifacts(dir string) map[string]string {
out := map[string]string{}
// json 产物(取第一个 *.json
if entries, err := os.ReadDir(dir); err == nil {
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
switch {
case strings.HasSuffix(name, ".json"):
b, _ := os.ReadFile(filepath.Join(dir, name))
out["json"] = string(b)
case strings.HasSuffix(name, ".mmd"):
b, _ := os.ReadFile(filepath.Join(dir, name))
out["mermaid"] = string(b)
case name == "visual.html":
b, _ := os.ReadFile(filepath.Join(dir, name))
out["html"] = string(b)
case strings.HasSuffix(name, ".md"):
// 第一份 .md 报告report.md / weekly_report.md / compliance_report.md
if _, ok := out["report"]; !ok {
b, _ := os.ReadFile(filepath.Join(dir, name))
out["report"] = string(b)
}
}
}
}
return out
}
func pythonBin() (string, error) {
// 候选按 Linux 习惯 python3 优先,再 python / py(Windows)。
// 必须实测能产出Windows 的 WindowsApps\python3.exe 是 Store 桩,对 -c 也可能 exit 0 但不真正执行,
// 故用「stdout 必须含 PYOK」来拦截桩。
for _, name := range []string{"python3", "python", "py"} {
path, err := exec.LookPath(name)
if err != nil {
continue
}
if out, err := exec.Command(path, "-c", "print('PYOK')").Output(); err == nil &&
strings.Contains(string(out), "PYOK") {
return path, nil
}
}
return "", fmt.Errorf("python 未安装;容器需内置 python3 并 pip install -r scripts/research/requirements.txt")
}
func contains(xs []string, s string) bool {
for _, x := range xs {
if x == s {
return true
}
}
return false
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(v)
}

View File

@ -0,0 +1,485 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>科研热点追踪 · GitLink Research Atlas</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&family=Noto+Serif+SC:wght@600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root{
--bg:#F7F9FC; --card:#FFFFFF; --ink:#1E2A44; --ink2:#475569; --mute:#94A3B8;
--line:#E6EBF2; --indigo:#4F6BED; --coral:#F26B5E; --mint:#2EC4B6;
--t-blue:#E8EEFF; --t-coral:#FFEDEA; --t-mint:#E6F7F4; --t-amber:#FEF3C7;
--serif:"Cormorant Garamond","Noto Serif SC",Georgia,serif;
--sans:"Inter","Noto Sans SC",-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
--shadow:0 1px 2px rgba(30,42,68,.04),0 6px 18px rgba(30,42,68,.06);
--shadow-sm:0 1px 2px rgba(30,42,68,.06);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{min-height:100%}
body{font-family:var(--sans);color:var(--ink);background:var(--bg);
background-image:radial-gradient(1200px 480px at 80% -8%,#EAF0FF 0%,rgba(234,240,255,0) 60%),
radial-gradient(900px 420px at 0% 0%,#FFF1EE 0%,rgba(255,241,238,0) 55%);
-webkit-font-smoothing:antialiased}
.wrap{max-width:1180px;margin:0 auto;padding:28px 36px 72px}
/* header */
.hdr{display:flex;align-items:center;gap:14px;margin-bottom:22px}
.back{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600;color:var(--indigo);
text-decoration:none;padding:7px 14px;border-radius:10px;background:var(--t-blue);white-space:nowrap}
.back:hover{background:#D6DEFF}
.hdr .titles h1{font-family:var(--serif);font-weight:700;font-size:30px;line-height:1.15}
.hdr .titles .sub{font-size:13px;color:var(--mute);margin-top:3px}
/* control panel */
.ctl{background:var(--card);border:1px solid var(--line);border-radius:16px;box-shadow:var(--shadow);
padding:22px 24px;margin-bottom:22px}
.row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.row + .row{margin-top:14px}
.row .lbl{font-size:12px;font-weight:600;color:var(--ink2);min-width:84px;letter-spacing:.02em}
.search-input{flex:1;min-width:260px;display:flex;align-items:center;gap:8px;background:#F8FAFD;
border:1px solid var(--line);border-radius:11px;padding:11px 14px}
.search-input:focus-within{border-color:var(--indigo);background:#fff;box-shadow:0 0 0 3px rgba(79,107,237,.12)}
.search-input input{border:0;outline:0;font-family:var(--mono);font-size:14px;width:100%;background:transparent;color:var(--ink)}
.ex-chips{display:flex;gap:7px;flex-wrap:wrap}
.ex-chips .ex{font-size:12px;color:var(--ink2);background:#F1F5F9;border:1px solid var(--line);
padding:5px 11px;border-radius:999px;cursor:pointer;transition:.15s}
.ex-chips .ex:hover{background:var(--t-blue);border-color:#C7D2FE;color:var(--indigo)}
.angles{display:flex;gap:9px;flex-wrap:wrap}
.ang{display:inline-flex;align-items:center;gap:7px;font-size:13px;font-weight:500;padding:8px 14px;
border-radius:11px;border:1px solid var(--line);background:#fff;cursor:pointer;transition:.15s;user-select:none}
.ang .dot{width:9px;height:9px;border-radius:50%;background:var(--mute);transition:.15s}
.ang.on{border-color:var(--indigo);background:#F5F8FF;color:var(--ink)}
.ang.on .dot{background:var(--indigo);box-shadow:0 0 0 3px rgba(79,107,237,.18)}
.ang[data-a="trending"] .dot{background:var(--coral)} .ang.on[data-a="trending"] .dot{background:var(--coral);box-shadow:0 0 0 3px rgba(242,107,94,.2)}
.ang[data-a="active"] .dot{background:#F59E0B} .ang.on[data-a="active"] .dot{background:#F59E0B;box-shadow:0 0 0 3px rgba(245,158,11,.2)}
.ang[data-a="topics"] .dot{background:#06B6D4} .ang.on[data-a="topics"] .dot{background:#06B6D4;box-shadow:0 0 0 3px rgba(6,182,212,.2)}
.ang[data-a="scholars"] .dot{background:var(--mint)} .ang.on[data-a="scholars"] .dot{background:var(--mint);box-shadow:0 0 0 3px rgba(46,196,182,.2)}
select.lim{font-family:var(--mono);font-size:13px;padding:8px 10px;border:1px solid var(--line);border-radius:10px;background:#fff;color:var(--ink)}
.btn-run{margin-left:auto;font-size:14px;font-weight:600;color:#fff;background:linear-gradient(135deg,var(--indigo),#7C8CF5);
border:0;border-radius:11px;padding:11px 22px;cursor:pointer;box-shadow:var(--shadow-sm);transition:.15s}
.btn-run:hover{filter:brightness(1.05);transform:translateY(-1px)}
.btn-run:disabled{opacity:.6;cursor:not-allowed;transform:none}
.status{font-family:var(--mono);font-size:12px;color:var(--mute);margin-top:14px;display:flex;align-items:center;gap:8px}
.status .ok{color:var(--mint)} .status .err{color:var(--coral)}
.spin{width:14px;height:14px;border-radius:50%;border:2px solid var(--t-blue);border-top-color:var(--indigo);animation:rot .8s linear infinite}
@keyframes rot{to{transform:rotate(360deg)}}
/* summary banner */
.summary{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:18px}
.sum-tile{border:1px solid var(--line);border-radius:13px;padding:14px 16px;background:var(--card);box-shadow:var(--shadow-sm)}
.sum-tile .sl{font-size:11px;color:var(--mute);font-weight:600;letter-spacing:.02em}
.sum-tile .sv{font-family:var(--mono);font-weight:700;font-size:26px;line-height:1.1;margin-top:4px;color:var(--ink)}
.sum-tile .ss{font-size:11px;color:var(--mute);margin-top:2px}
/* sections */
.sec{background:var(--card);border:1px solid var(--line);border-radius:16px;box-shadow:var(--shadow);
padding:20px 24px;margin-top:18px}
.sec-head{display:flex;align-items:center;gap:10px;margin-bottom:16px}
.sec-head .ico{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;font-size:15px}
.sec-head h2{font-family:var(--serif);font-weight:700;font-size:21px}
.sec-head .eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);display:block;margin-top:1px}
.sec-head .cnt{font-family:var(--mono);font-size:12px;color:var(--mute);margin-left:auto}
.ic-coral{background:var(--t-coral);color:var(--coral)} .ic-amber{background:var(--t-amber);color:#B45309}
.ic-cyan{background:#E0F7FA;color:#0891B2} .ic-mint{background:var(--t-mint);color:#0E7E72}
/* trending table */
.tbl{width:100%;border-collapse:collapse;font-size:13px}
.tbl th{text-align:left;font-size:11px;color:var(--mute);font-weight:600;padding:8px 10px;border-bottom:1px solid var(--line);text-transform:uppercase;letter-spacing:.04em}
.tbl td{padding:10px;border-bottom:1px solid var(--line);vertical-align:top}
.tbl tr:hover td{background:#F8FAFD}
.tbl .repo{font-family:var(--mono);font-weight:600;color:var(--indigo)}
.tbl .num{font-family:var(--mono);font-weight:700;text-align:right}
.lang-pill{font-size:11px;padding:2px 8px;border-radius:6px;background:var(--t-blue);color:var(--indigo)}
.scorebar{height:6px;border-radius:4px;background:var(--line);overflow:hidden;margin-top:4px;min-width:70px}
.scorebar i{display:block;height:100%;background:linear-gradient(90deg,var(--coral),#FF9B8F)}
.kw-chip{display:inline-block;font-size:10px;font-weight:600;padding:2px 7px;border-radius:5px;background:var(--t-amber);color:#92400E;margin-right:3px}
/* active discussions */
.disc{display:flex;flex-direction:column;gap:10px}
.disc-item{display:flex;gap:12px;align-items:flex-start;padding:12px 14px;border:1px solid var(--line);border-radius:11px;background:#FCFDFE}
.disc-item .badge{font-size:10px;font-weight:700;padding:3px 8px;border-radius:6px;text-transform:uppercase;letter-spacing:.05em;flex-shrink:0;margin-top:1px}
.badge.issue{background:var(--t-blue);color:var(--indigo)} .badge.pr{background:var(--t-mint);color:#0E7E72}
.disc-item .main{flex:1;min-width:0}
.disc-item .ttl{font-size:13px;font-weight:500;line-height:1.4}
.disc-item .meta{font-size:11px;color:var(--mute);margin-top:3px;font-family:var(--mono)}
.disc-item .cmt{font-family:var(--mono);font-weight:700;color:#B45309;font-size:13px;flex-shrink:0}
/* topic bars */
.tlist{display:flex;flex-direction:column;gap:11px}
.trow{display:flex;align-items:center;gap:12px}
.trow .tname{width:180px;font-size:13px;font-weight:500;font-family:var(--mono);color:var(--ink)}
.trow .tbar{flex:1;height:24px;background:#F1F5F9;border-radius:7px;overflow:hidden}
.trow .tbar i{display:block;height:100%;background:linear-gradient(90deg,#06B6D4,#22D3EE);border-radius:7px}
.trow .tval{font-family:var(--mono);font-weight:700;font-size:13px;width:48px;text-align:right}
/* scholars grid */
.sgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}
.scard{border:1px solid var(--line);border-radius:11px;padding:13px 15px;background:#FCFDFE}
.scard .nm{font-family:var(--mono);font-weight:600;color:var(--ink);font-size:13px}
.scard .ct{font-size:12px;color:var(--mute);margin-top:3px}
.scard.org{border-left:3px solid var(--indigo)}
/* repos network mini-list */
.rlist{display:flex;flex-direction:column;gap:6px}
.rlist .rli{font-family:var(--mono);font-size:11px;color:var(--mute);padding:2px 0}
/* empty / loading / placeholder */
.empty{padding:50px 20px;text-align:center;color:var(--mute);font-size:14px}
.placeholder{margin-top:18px;padding:60px 20px;text-align:center;color:var(--mute);background:var(--card);
border:1px dashed var(--line);border-radius:16px;font-size:14px}
.placeholder b{color:var(--ink2)}
/* skeleton loading */
@keyframes shimmer{0%{background-position:-400px 0}100%{background-position:400px 0}}
.skel{background:linear-gradient(90deg,#F1F5F9 25%,#E6EBF2 50%,#F1F5F9 75%);background-size:800px 100%;animation:shimmer 1.5s infinite;border-radius:8px}
/* responsive */
@media(max-width:760px){
.wrap{padding:16px}
.summary{grid-template-columns:repeat(2,1fr)}
.sgrid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}
.row .lbl{min-width:60px;font-size:11px}
}
</style>
</head>
<body>
<div class="wrap">
<div class="hdr">
<a class="back" href="index.html">← 仪表盘</a>
<div class="titles">
<h1>科研热点追踪</h1>
<div class="sub">输入领域/关键词,勾选追踪角度,扫描 GitLink 科研仓库 → 飙升项目 · 活跃讨论 · 主题热度 · 学者团队</div>
</div>
</div>
<!-- control panel -->
<div class="ctl">
<div class="row">
<span class="lbl">领域 / 关键词</span>
<label class="search-input"><span>🔍</span>
<input id="keyword" value="deep learning,机器学习" placeholder='输入研究领域或技术关键词,逗号分隔多个(如 "计算机视觉,NLP,transformer"'>
</label>
<button class="btn-run" id="runBtn">开始追踪 ▶</button>
</div>
<div class="row">
<span class="lbl">示例</span>
<div class="ex-chips" id="exChips">
<span class="ex">深度学习</span><span class="ex">computer vision</span>
<span class="ex">自然语言处理</span><span class="ex">强化学习</span>
<span class="ex">knowledge graph</span><span class="ex">联邦学习</span>
<span class="ex">LLM agent</span><span class="ex">自动驾驶</span>
</div>
</div>
<div class="row">
<span class="lbl">追踪角度</span>
<div class="angles" id="angles">
<span class="ang on" data-a="trending"><span class="dot"></span>🔥 飙升项目</span>
<span class="ang on" data-a="active"><span class="dot"></span>💬 活跃讨论</span>
<span class="ang on" data-a="topics"><span class="dot"></span>📊 热门主题</span>
<span class="ang on" data-a="scholars"><span class="dot"></span>👥 核心学者·团队</span>
</div>
<span class="lbl" style="margin-left:auto">仓库上限</span>
<select class="lim" id="lim">
<option>5</option><option>8</option><option selected>12</option><option>20</option><option>30</option>
</select>
</div>
<div class="status" id="status"><span>就绪 · 点"开始追踪"扫描 GitLink</span></div>
</div>
<!-- summary tiles (hidden until first run) -->
<div class="summary" id="summary" style="display:none"></div>
<!-- results -->
<div id="results">
<div class="placeholder">输入关键词并选择角度,点 <b>开始追踪</b> —— 服务器将通过 gitlink-cli 实时搜索并构建该领域的科研热点全景。</div>
</div>
</div>
<script>
/* ========== 工具函数 ========== */
function esc(s){return String(s||"").replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));}
function token(){return localStorage.getItem("demo_token")||"";}
/* ========== angle 切换 + 示例芯片 ========== */
document.querySelectorAll(".ang").forEach(a=>a.onclick=()=>a.classList.toggle("on"));
document.querySelectorAll(".ex").forEach(e=>e.onclick=()=>{
document.getElementById("keyword").value=e.textContent.trim();
});
/* ========== 状态栏 ========== */
function setStatus(html,cls){
const s=document.getElementById("status");
s.innerHTML=html;
s.className="status"+(cls?" "+cls:"");
}
/* ========== 主运行 ========== */
async function run(){
const kw=document.getElementById("keyword").value.trim();
if(!kw){setStatus("请输入关键词","err");return;}
const angles=[...document.querySelectorAll(".ang.on")].map(a=>a.dataset.a);
if(!angles.length){setStatus("请至少选择一个追踪角度","err");return;}
const btn=document.getElementById("runBtn");
btn.disabled=true;
setStatus('<span class="spin"></span> 扫描中:搜索 "'+esc(kw)+'" → 拉取仓库详情 → 批量获取 Issue/PR 讨论…(约 3090s取决于仓库数和 API 响应速度)');
document.getElementById("results").innerHTML="";
document.getElementById("summary").style.display="none";
try{
const r=await fetch("/api/run",{
method:"POST",
headers:{"Content-Type":"application/json","X-Demo-Token":token()},
body:JSON.stringify({scenario:"hotspot",keyword:kw})
});
const j=await r.json();
if(!j.ok){
const errMsg=j.error||j.stdout||"未知错误";
setStatus("✗ 运行失败: "+esc(errMsg)+" · "+(j.duration||""),"err");
btn.disabled=false;
return;
}
// 解析 JSON
let obj=null;
try{
const raw=(j.artifacts&&j.artifacts.json)||(j.stdout||"");
obj=typeof raw==="string"?JSON.parse(raw):raw;
}catch(e){
setStatus("✗ 解析 JSON 产物失败 · 可能是 Python 脚本异常: "+(e.message||""),"err");
btn.disabled=false;
return;
}
if(!obj||typeof obj!=="object"){
setStatus("✗ 返回数据为空 · 请确认目标关键词在 GitLink 上有结果","err");
btn.disabled=false;
return;
}
const meta=obj.meta||{};
setStatus(
"✓ 完成 · "+(j.duration||"")+
" · 仓库 "+meta.repo_count+" 个"+
" · 讨论 "+meta.discussion_count+" 条"+
" · 主题 "+meta.topic_count+" 个"+
" · 学者 "+meta.scholar_count+" 位",
"ok"
);
// 汇总卡片
renderSummary(meta);
// 结果
render(angles,obj);
}catch(e){
setStatus("✗ 请求失败: "+esc(e.message||e),"err");
}finally{
btn.disabled=false;
}
}
/* ========== 汇总卡片 ========== */
function renderSummary(meta){
const el=document.getElementById("summary");
el.style.display="grid";
el.innerHTML=
tile("indigo","扫描仓库",meta.repo_count||0,"个")+
tile("coral","活跃讨论",meta.discussion_count||0,"条")+
tile("cyan","热门主题",meta.topic_count||0,"个")+
tile("mint","核心学者",meta.scholar_count||0,"位");
}
function tile(c,label,val,unit){
const colors={indigo:"var(--indigo)",coral:"var(--coral)",cyan:"#06B6D4",mint:"var(--mint)"};
const bg={indigo:"var(--t-blue)",coral:"var(--t-coral)",cyan:"#E0F7FA",mint:"var(--t-mint)"};
return `<div class="sum-tile" style="border-top:3px solid ${colors[c]||colors.indigo}">
<div class="sl">${label}</div><div class="sv">${val}</div><div class="ss">${unit}</div></div>`;
}
/* ========== 渲染各个角度 ========== */
function render(angles,o){
const box=document.getElementById("results");
let h="";
const maxScore=Math.max(1,...(o.trending_repos||[]).map(r=>r.score||0));
// --- 🔥 飙升项目 ---
if(angles.includes("trending")){
const tr=o.trending_repos||[];
h+=`<div class="sec">
<div class="sec-head">
<div class="ico ic-coral">🔥</div>
<div>
<h2>飙升 / 热门项目</h2>
<span class="eyebrow">Trending repos · 热度 = Star + Fork×2 + 近期更新加成</span>
</div>
<span class="cnt">${tr.length} 个</span>
</div>`;
if(tr.length){
h+=`<table class="tbl"><thead><tr>
<th>仓库</th><th>语言</th><th class="num">★ Star</th><th class="num">⑂ Fork</th><th class="num">日均★</th><th>热度</th><th>更新</th></tr></thead><tbody>`;
tr.forEach(r=>{
const kws=(r.matched_keywords||[]).map(k=>`<span class="kw-chip">${esc(k)}</span>`).join("");
h+=`<tr>
<td><div class="repo">${esc(r.repo)}</div>
${kws?`<div style="margin-top:3px">${kws}</div>`:""}
<div style="font-size:11px;color:var(--mute);margin-top:2px">${esc(r.description||"").slice(0,100)}</div></td>
<td>${r.language?`<span class="lang-pill">${esc(r.language)}</span>`:"—"}</td>
<td class="num">${r.stars}</td>
<td class="num">${r.forks}</td>
<td class="num">${r.velocity}</td>
<td>
<div class="scorebar"><i style="width:${Math.round(r.score/maxScore*100)}%"></i></div>
<div style="font-size:11px;color:var(--mute);margin-top:2px;font-family:var(--mono)">${r.score}</div>
</td>
<td style="font-family:var(--mono);font-size:12px;color:var(--ink2)">${esc(r.updated||"—")}</td>
</tr>`;
});
h+=`</tbody></table>`;
}else{
h+=`<div class="empty">未找到匹配仓库</div>`;
}
h+=`</div>`;
}
// --- 💬 活跃讨论 ---
if(angles.includes("active")){
const ac=o.active_discussions||[];
h+=`<div class="sec">
<div class="sec-head">
<div class="ico ic-amber">💬</div>
<div>
<h2>活跃讨论</h2>
<span class="eyebrow">Active issues / PRs · 按评论数排序 · 来自所有扫描仓库</span>
</div>
<span class="cnt">${ac.length} 条</span>
</div>`;
if(ac.length){
h+=`<div class="disc">`;
ac.slice(0,30).forEach(a=>{
h+=`<div class="disc-item">
<span class="badge ${a.type}">${a.type==="pr"?"PR":"Issue"}</span>
<div class="main">
<div class="ttl">${esc(a.title||"(无标题)")}</div>
<div class="meta">${esc(a.repo)} · #${esc(a.number||"")} · ${esc(a.state||"")}</div>
</div>
<span class="cmt">${a.comments} 💬</span>
</div>`;
});
h+=`</div>`;
}else{
h+=`<div class="empty">该领域暂无活跃讨论(所有仓库的开放 Issue/PR 评论数均为 0</div>`;
}
h+=`</div>`;
}
// --- 📊 热门主题 ---
if(angles.includes("topics")){
const tp=o.topic_heat||[];
const maxT=Math.max(1,...tp.map(t=>t.count));
h+=`<div class="sec">
<div class="sec-head">
<div class="ico ic-cyan">📊</div>
<div>
<h2>热门主题</h2>
<span class="eyebrow">Topic heat · 在仓库描述/README 中命中的主题词频</span>
</div>
<span class="cnt">${tp.length} 个</span>
</div>`;
if(tp.length){
h+=`<div class="tlist">`;
tp.forEach(t=>{
h+=`<div class="trow">
<span class="tname">${esc(t.topic)}</span>
<div class="tbar"><i style="width:${Math.round(t.count/maxT*100)}%"></i></div>
<span class="tval">${t.count}</span>
</div>`;
});
h+=`</div>`;
}else{
h+=`<div class="empty">未识别到明确主题(仓库描述/README 不包含已知科研关键词)</div>`;
}
h+=`</div>`;
}
// --- 👥 核心学者·团队 ---
if(angles.includes("scholars")){
const sc=o.core_scholars||[];
const tm=o.core_teams||[];
h+=`<div class="sec">
<div class="sec-head">
<div class="ico ic-mint">👥</div>
<div>
<h2>核心学者 · 团队</h2>
<span class="eyebrow">Key scholars & teams · 按关联仓库数排列</span>
</div>
<span class="cnt">学者 ${(sc||[]).length} · 团队 ${(tm||[]).length}</span>
</div>`;
// 学者
if(sc.length){
h+=`<div style="margin-bottom:14px"><div style="font-size:12px;font-weight:600;color:var(--ink2);margin-bottom:8px">🧑 学者</div>`;
h+=`<div class="sgrid">`;
sc.slice(0,12).forEach(s=>{
const repoNames=(s.repos||[]).slice(0,5);
h+=`<div class="scard">
<div class="nm">${esc(s.login)}</div>
<div class="ct">关联 ${s.repo_count} 个仓库</div>
${repoNames.length?`<div class="rlist">${repoNames.map(rn=>`<span class="rli">${esc(rn)}</span>`).join("")}</div>`:""}
</div>`;
});
h+=`</div></div>`;
}
// 团队
if(tm.length){
h+=`<div><div style="font-size:12px;font-weight:600;color:var(--ink2);margin-bottom:8px">🏛 活跃组织/团队</div>`;
h+=`<div class="sgrid">`;
tm.slice(0,8).forEach(t=>{
h+=`<div class="scard org">
<div class="nm">${esc(t.login)}</div>
<div class="ct">${t.repo_count} 个仓库</div>
</div>`;
});
h+=`</div></div>`;
}
if(!sc.length&&!tm.length){
h+=`<div class="empty">未识别到学者/团队(仓库无贡献者数据)</div>`;
}
h+=`</div>`;
}
box.innerHTML=h||'<div class="empty" style="padding:70px 20px">无匹配角度的数据 —— 请选择至少一个追踪角度</div>';
}
/* ========== 事件绑定 ========== */
document.getElementById("runBtn").onclick=run;
document.getElementById("keyword").addEventListener("keydown",e=>{if(e.key==="Enter")run();});
/* ========== 页面加载时尝试载入上次的 hotspot 结果 ========== */
(async function init(){
try{
const r=await fetch("/api/result/hotspot",{headers:{"X-Demo-Token":token()}});
const j=await r.json();
if(j.ok&&j.artifacts&&j.artifacts.json){
const obj=JSON.parse(j.artifacts.json);
if(obj&&obj.trending_repos){
const angles=[...document.querySelectorAll(".ang.on")].map(a=>a.dataset.a);
const meta=obj.meta||{};
renderSummary(meta);
render(angles,obj);
setStatus("✓ 已载入上次扫描 · 点"开始追踪"用新关键词重新扫描","ok");
}
}
}catch(e){}
})();
</script>
</body>
</html>

View File

@ -0,0 +1,594 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GitLink Research Atlas · 科研代码图谱智能体</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&family=Noto+Serif+SC:wght@600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
:root{
--bg:#F7F9FC; --card:#FFFFFF; --ink:#1E2A44; --ink2:#475569; --mute:#94A3B8;
--line:#E6EBF2; --indigo:#4F6BED; --coral:#F26B5E; --mint:#2EC4B6;
--t-blue:#E8EEFF; --t-coral:#FFEDEA; --t-mint:#E6F7F4; --t-amber:#FEF3C7;
--drawer:#111A2E;
--serif:"Cormorant Garamond","Noto Serif SC",Georgia,serif;
--sans:"Inter","Noto Sans SC",-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
--shadow:0 1px 2px rgba(30,42,68,.04),0 6px 18px rgba(30,42,68,.06);
--shadow-sm:0 1px 2px rgba(30,42,68,.06);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;margin:0;overflow:hidden}
body{font-family:var(--sans);color:var(--ink);background:var(--bg);
background-image:radial-gradient(1200px 480px at 80% -8%,#EAF0FF 0%,rgba(234,240,255,0) 60%),
radial-gradient(900px 420px at 0% 0%,#FFF1EE 0%,rgba(255,241,238,0) 55%);
-webkit-font-smoothing:antialiased}
.app{display:flex;flex-direction:column;height:100vh;overflow:hidden}
/* ========== HEADER ========== */
header.bar{
display:flex;align-items:center;gap:18px;padding:0 22px;
min-height:62px;height:auto;
background:rgba(255,255,255,.86);backdrop-filter:blur(8px);
border-bottom:1px solid var(--line);position:relative;z-index:5;
overflow:visible;flex-shrink:0;
}
.brand{display:flex;align-items:center;gap:10px;min-width:0;flex-shrink:0}
.brand .mark{width:30px;height:30px;border-radius:9px;background:linear-gradient(135deg,var(--indigo),#7C8CF5 60%,var(--coral));
display:grid;place-items:center;color:#fff;font-family:var(--serif);font-weight:700;font-size:18px;box-shadow:var(--shadow-sm)}
.brand .t1{font-family:var(--serif);font-weight:700;font-size:20px;line-height:1.1;letter-spacing:.2px}
.brand .t2{font-size:10px;color:var(--mute);letter-spacing:.12em;text-transform:uppercase;line-height:1.2}
.search{display:flex;align-items:center;gap:8px;margin-left:8px;flex:1;max-width:640px}
.field{display:flex;align-items:center;gap:7px;background:var(--card);border:1px solid var(--line);
border-radius:10px;padding:7px 11px;box-shadow:var(--shadow-sm);flex:1}
.field .k{font-family:var(--mono);font-size:11px;color:var(--mute)}
.field input{border:0;outline:0;font-family:var(--mono);font-size:13px;color:var(--ink);width:100%;background:transparent}
.chip{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;padding:6px 11px;border-radius:999px;white-space:nowrap}
.chip.agent{background:var(--t-mint);color:#0E7E72;border:1px solid #B6EBE3}
.chip.agent::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--mint);box-shadow:0 0 0 3px rgba(46,196,182,.18);animation:pulse 1.8s infinite}
.btn-login{font-size:12px;font-weight:600;color:#fff;background:var(--indigo);border:0;border-radius:10px;padding:8px 15px;cursor:pointer;box-shadow:var(--shadow-sm)}
.btn-hot{font-size:12px;font-weight:600;color:#fff;background:linear-gradient(135deg,var(--coral),#FF8A7A);text-decoration:none;border:0;border-radius:10px;padding:8px 14px;cursor:pointer;box-shadow:var(--shadow-sm);display:inline-flex;align-items:center;gap:5px;white-space:nowrap}
.btn-hot:hover{filter:brightness(1.05)}
@keyframes pulse{50%{opacity:.4}}
/* ========== MAIN GRID ========== */
main.grid{
display:grid;grid-template-columns:262px 1fr 332px;
gap:16px;padding:16px;flex:1;min-height:0;overflow:hidden;
}
aside.left,aside.right{min-height:0;overflow:auto;display:flex;flex-direction:column;gap:12px;padding-right:2px}
section.center{min-height:0;display:flex;flex-direction:column;gap:14px;overflow:hidden}
.panel{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}
.panel .ph{padding:14px 16px 6px}
.panel .ph h3{margin:0;font-size:13px;font-weight:700;letter-spacing:.02em}
.panel .ph .sub{font-size:11px;color:var(--mute);margin-top:2px}
.eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}
/* ========== SCENARIO CARDS ========== */
.scn{display:flex;gap:11px;align-items:flex-start;padding:11px 12px;border:1px solid var(--line);border-radius:12px;
background:var(--card);cursor:pointer;transition:.15s;position:relative;box-shadow:var(--shadow-sm)}
.scn:hover{border-color:#C7D2FE;transform:translateY(-1px)}
.scn.active{border-color:var(--indigo);background:linear-gradient(180deg,#F5F8FF,#fff)}
.scn.active::after{content:"";position:absolute;top:10px;right:10px;width:7px;height:7px;border-radius:50%;background:var(--coral)}
.scn.running{pointer-events:none;opacity:.7}
.scn .ico{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;flex:0 0 auto;font-size:15px}
.scn .t{min-width:0}
.scn .t b{font-size:13px;font-weight:600;display:block}
.scn .t small{font-size:11px;color:var(--mute);line-height:1.35;display:block;margin-top:1px}
.tint-blue .ico{background:var(--t-blue);color:var(--indigo)}
.tint-coral .ico{background:var(--t-coral);color:var(--coral)}
.tint-mint .ico{background:var(--t-mint);color:#0E7E72}
.tint-amber .ico{background:var(--t-amber);color:#B45309}
/* ========== CENTER HERO / GRAPH ========== */
.hero{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}
.hero .ph{display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
.hero .ph .live{font-family:var(--mono);font-size:11px;color:var(--indigo);background:var(--t-blue);padding:4px 9px;border-radius:999px}
.graph-wrap{flex:1;min-height:0;position:relative;margin:0 4px 4px;border-radius:10px;
background:radial-gradient(420px 320px at 50% 42%,#F2F6FF 0%,rgba(247,249,252,0) 70%);overflow:hidden}
svg.graph{width:100%;height:100%;display:block}
.g-edge{stroke:#9DB4F0;stroke-opacity:.55;stroke-width:1}
.g-node circle{stroke:#fff;stroke-width:2;transition:.2s}
.g-node:hover circle{filter:brightness(1.08)}
.g-node text{font-family:var(--sans);font-size:10px;font-weight:600;fill:var(--ink);pointer-events:none;text-anchor:middle}
.glow{filter:drop-shadow(0 0 6px rgba(242,107,94,.55))}
/* hero-overlay: ONLY visible during loading/empty, hidden after graph renders */
.hero-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;pointer-events:none;
transition:opacity .3s}
.hero-overlay.hidden{opacity:0;pointer-events:none}
.hero-overlay .hint{font-family:var(--mono);font-size:12px;color:var(--mute);background:rgba(255,255,255,.8);padding:6px 12px;border-radius:999px}
.spin{width:26px;height:26px;border-radius:50%;border:3px solid var(--t-blue);border-top-color:var(--indigo);animation:rot .8s linear infinite}
@keyframes rot{to{transform:rotate(360deg)}}
.center-extra{flex:0 0 auto;max-height:46%;display:flex;flex-direction:column;gap:10px;overflow:auto}
.center-extra:empty{display:none}
iframe.plot{width:100%;height:420px;border:0;border-radius:10px;background:#fff}
.mmd-box{background:#fff;padding:8px;border-radius:10px;overflow:auto;max-height:420px}
/* Graph legend bar */
.graph-legend{display:flex;align-items:center;gap:14px;padding:6px 16px 10px;flex-wrap:wrap;flex-shrink:0}
.graph-legend .leg-item{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--ink2)}
.graph-legend .leg-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.graph-legend .leg-note{font-size:11px;color:var(--mute);margin-left:auto}
/* ========== RIGHT COLUMN ========== */
.metrics{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:6px 14px 14px}
.tile{border:1px solid var(--line);border-radius:12px;padding:12px;background:#FCFDFE;box-shadow:var(--shadow-sm)}
.tile .lab{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--ink2);font-weight:500}
.tile .lab .dot{width:8px;height:8px;border-radius:50%}
.tile .val{font-family:var(--mono);font-weight:700;font-size:24px;line-height:1.1;margin-top:6px;color:var(--ink)}
.tile .of{font-size:11px;color:var(--mute);margin-top:3px}
.d-indigo{background:var(--indigo)} .d-mint{background:var(--mint)} .d-coral{background:var(--coral)} .d-amber{background:#F59E0B}
.findings{padding:4px 14px 14px;display:flex;flex-direction:column;gap:9px;max-height:330px;overflow:auto}
.find{border-left:3px solid var(--indigo);padding:8px 11px;background:#F8FAFF;border-radius:0 9px 9px 0}
.find.critical{border-color:var(--coral);background:#FFF5F3}
.find.warning{border-color:#F59E0B;background:#FFFBEB}
.find.success{border-color:var(--mint);background:#F0FBF9}
.find .ft{font-size:11px;font-weight:700;color:var(--ink2);display:flex;justify-content:space-between;gap:8px}
.find .fb{font-size:12px;color:var(--ink);margin-top:3px;line-height:1.45}
.find .fs{font-size:11px;color:var(--mute);margin-top:3px}
.bar{height:6px;border-radius:4px;background:var(--line);overflow:hidden;margin-top:6px}
.bar > i{display:block;height:100%;background:linear-gradient(90deg,var(--indigo),#7C8CF5)}
.empty{font-size:12px;color:var(--mute);padding:18px;text-align:center}
.loading-brief{font-size:12px;color:var(--mute);padding:18px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:8px}
.loading-brief .spin{width:20px;height:20px;border-width:2px}
/* CTA button to view full result */
.view-result-btn{display:block;margin:8px 14px 14px;padding:10px 18px;border:0;border-radius:12px;
background:linear-gradient(135deg,var(--indigo),#7C8CF5);color:#fff;font-family:var(--sans);
font-size:13px;font-weight:600;cursor:pointer;text-align:center;box-shadow:var(--shadow);
transition:.15s}
.view-result-btn:hover{transform:translateY(-1px);box-shadow:0 4px 14px rgba(79,107,237,.25)}
/* ========== COMMAND DRAWER ========== */
footer.drawer{display:flex;align-items:center;gap:14px;padding:0 18px;background:var(--drawer);color:#CBD5E1;font-family:var(--mono);font-size:12px;
flex-shrink:0;min-height:52px;height:52px}
.dots{display:flex;gap:6px}
.dots i{width:11px;height:11px;border-radius:50%;display:block}
.drawer .prompt{color:var(--mint)}
.drawer .cmd{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#E2E8F0}
.drawer .st{color:var(--mute);font-size:11px;white-space:nowrap}
.drawer .st.running{color:var(--indigo);animation:pulse 1s infinite}
/* ========== SCROLLBARS ========== */
aside::-webkit-scrollbar,.findings::-webkit-scrollbar,.center-extra::-webkit-scrollbar{width:8px;height:8px}
::-webkit-scrollbar-thumb{background:#D7DEEA;border-radius:8px}
::-webkit-scrollbar-track{background:transparent}
/* ========== RESPONSIVE ========== */
@media (max-width:1180px){
main.grid{grid-template-columns:230px 1fr;grid-template-rows:auto auto}
aside.right{grid-column:1 / span 2;flex-direction:row;flex-wrap:wrap}
aside.right .panel{flex:1;min-width:280px}
}
@media (max-width:760px){
main.grid{grid-template-columns:1fr;overflow:auto}
aside.left,aside.right{grid-column:auto}
.search{display:none}
}
</style>
</head>
<body>
<div class="app">
<!-- ============ TOP BAR ============ -->
<header class="bar">
<div class="brand">
<div class="mark">G</div>
<div>
<div class="t1">GitLink Research Atlas</div>
<div class="t2">科研代码图谱智能体</div>
</div>
</div>
<div class="search">
<label class="field"><span class="k">repo</span>
<input id="owner" value="mindspore-Ecosystem" placeholder="owner">
<span class="k" style="color:#CBD5E1">/</span>
<input id="repo" value="mindspore" placeholder="repo" style="max-width:150px">
</label>
<label class="field"><span class="k">keyword</span>
<input id="keyword" value="deep learning,机器学习" placeholder="S2 用,逗号分隔">
</label>
</div>
<a class="btn-hot" href="hotspot.html">🔥 热点追踪</a>
<span class="chip agent">AI Agent online</span>
<button class="btn-login" id="loginBtn">Login</button>
</header>
<!-- ============ MAIN ============ -->
<main class="grid">
<!-- LEFT: scenarios -->
<aside class="left">
<div class="panel">
<div class="ph"><div class="eyebrow">Research Scenarios</div>
<h3>科研场景</h3><div class="sub">点选任一场景,服务器终端即运行 gitlink-cli + Python 算法</div>
</div>
<div id="scenarios" style="padding:8px 12px 14px;display:flex;flex-direction:column;gap:9px"></div>
</div>
</aside>
<!-- CENTER: graph / viz -->
<section class="center">
<div class="panel hero">
<div class="ph">
<div><div class="eyebrow">Knowledge Graph</div><h3 id="heroTitle">科研知识图谱</h3></div>
<span class="live" id="heroLive" style="display:none">&#9679; live build</span>
</div>
<div class="graph-wrap">
<svg class="graph" id="graph" preserveAspectRatio="xMidYMid meet"></svg>
<div class="hero-overlay hidden" id="heroOverlay">
<div class="hint">选择左侧科研场景以启动分析</div>
</div>
</div>
</div>
<div class="graph-legend" id="graphLegend"></div>
<div class="center-extra" id="centerExtra"></div>
</section>
<!-- RIGHT: metrics + report -->
<aside class="right">
<div class="panel">
<div class="ph"><div class="eyebrow">Live Metrics</div><h3>科研指标</h3></div>
<div class="metrics" id="metrics"></div>
</div>
<div class="panel" style="flex:1;min-height:0;display:flex;flex-direction:column">
<div class="ph"><div class="eyebrow">AI Research Brief</div><h3 id="briefTitle">研究简报</h3></div>
<div class="findings" id="findings"><div class="empty">运行场景后,这里展示创新点 / 风险预警 / 协作推荐等结构化结论</div></div>
<button class="view-result-btn" id="viewResultBtn" style="display:none" target="_blank">查看完整结果 &#8594;</button>
</div>
</aside>
</main>
<!-- ============ DRAWER ============ -->
<footer class="drawer">
<div class="dots"><i style="background:#FF5F57"></i><i style="background:#FEBC2E"></i><i style="background:#28C840"></i></div>
<span class="prompt">atlas &#10095;</span>
<span class="cmd" id="cmd">gitlink-cli research --repo mindspore-Ecosystem/mindspore --agent atlas</span>
<span class="st" id="cmdStatus">ready</span>
</footer>
</div>
<script>
mermaid.initialize({startOnLoad:false, theme:"base", securityLevel:"loose",
themeVariables:{primaryColor:"#E8EEFF",primaryTextColor:"#1E2A44",primaryBorderColor:"#4F6BED",lineColor:"#9DB4F0",fontSize:"12px"}});
let SCENARIOS=[], ACTIVE=null, LAST_RESULT_KEY=null;
/* ---------- node color palette ---------- */
const NODE_COLOR = {
repo:"#4F6BED", scholar:"#3B82F6", team:"#6366F1", topic:"#06B6D4", method:"#6366F1",
paper:"#8B5CF6", dataset:"#2EC4B6", model:"#4F6BED", experiment:"#F26B5E",
reproduce:"#2EC4B6", license:"#64748B", trend:"#F26B5E", issue:"#F59E0B", pr:"#10B981",
default:"#94A3B8"
};
const NODE_LABEL = {
repo:"仓库", scholar:"学者", topic:"主题", paper:"论文", dataset:"数据集",
model:"模型", experiment:"实验", reproduce:"复现", license:"许可证",
trend:"趋势", issue:"Issue", pr:"PR"
};
/* Default decorative graph — clean, ~12 nodes showing the research world */
const DEMO = {
nodes:[
{id:"repo",type:"repo",label:"GitLink Repo",imp:true},
{id:"scholar",type:"scholar",label:"Scholar"},
{id:"topic",type:"topic",label:"Topic"},
{id:"paper",type:"paper",label:"Paper"},
{id:"dataset",type:"dataset",label:"Dataset"},
{id:"model",type:"model",label:"Model"},
{id:"exp",type:"experiment",label:"Experiment",imp:true},
{id:"repr",type:"reproduce",label:"Reproduce"},
{id:"lic",type:"license",label:"License"},
{id:"trend",type:"trend",label:"Trend"},
{id:"issue",type:"issue",label:"Issue Signals"},
{id:"pr",type:"pr",label:"Pull Request"}
],
edges:[["repo","scholar"],["repo","topic"],["repo","paper"],["repo","dataset"],
["repo","model"],["repo","exp"],["repo","repr"],["repo","lic"],
["scholar","paper"],["topic","trend"],["exp","repr"],["issue","pr"],["pr","repo"],["model","dataset"]]
};
/* ---------- build legend HTML ---------- */
function buildLegend(){
const el=document.getElementById("graphLegend");
const types=new Set();
// gather types from current graph data
const src=ACTIVE&&ACTIVE._graphData?ACTIVE._graphData:DEMO;
(src.nodes||[]).forEach(n=>types.add(n.type||"default"));
let h="";
for(const t of [...types].sort()){
h+=`<span class="leg-item"><span class="leg-dot" style="background:${NODE_COLOR[t]||NODE_COLOR.default}"></span>${NODE_LABEL[t]||t}</span>`;
}
h+=`<span class="leg-note">示例:科研仓库与学者/主题/数据集的关联 — 选择 S2 生成真实领域图谱</span>`;
el.innerHTML=h;
}
/* ---------- force-directed layout ---------- */
function forceLayout(nodes, edges, W, H, iters){
const cx=W/2, cy=H/2;
nodes.forEach(n=>{ if(n.x==null){n.x=cx+(Math.random()-.5)*W*.5; n.y=cy+(Math.random()-.5)*H*.5;} });
for(let it=0; it<iters; it++){
for(const n of nodes){ n._fx=0; n._fy=0; }
for(let i=0;i<nodes.length;i++){
for(let j=i+1;j<nodes.length;j++){
let dx=nodes[i].x-nodes[j].x, dy=nodes[i].y-nodes[j].y;
let d2=dx*dx+dy*dy+0.02, d=Math.sqrt(d2), f=2600/d2;
let fx=dx/d*f, fy=dy/d*f;
nodes[i]._fx+=fx; nodes[i]._fy+=fy; nodes[j]._fx-=fx; nodes[j]._fy-=fy;
}
}
for(const e of edges){
const a=nodes.find(n=>n.id===e[0]), b=nodes.find(n=>n.id===e[1]); if(!a||!b) continue;
let dx=b.x-a.x, dy=b.y-a.y, d=Math.sqrt(dx*dx+dy*dy)+.02, L=a.imp||b.imp?120:78, k=.04;
let f=(d-L)*k, fx=dx/d*f, fy=dy/d*f;
a._fx+=fx; a._fy+=fy; b._fx-=fx; b._fy-=fy;
}
for(const n of nodes){
n._fx+=(cx-n.x)*.012; n._fy+=(cy-n.y)*.012;
n.x+=Math.max(-14,Math.min(14,n._fx)); n.y+=Math.max(-14,Math.min(14,n._fy));
n.x=Math.max(40,Math.min(W-40,n.x)); n.y=Math.max(34,Math.min(H-30,n.y));
}
}
}
function drawGraph(data){
const svg=document.getElementById("graph");
const W=svg.clientWidth||760, H=svg.clientHeight||460;
svg.setAttribute("viewBox",`0 0 ${W} ${H}`);
let nodes=(data.nodes||[]).map(n=>Object.assign({},n));
let rawEdges=(data.edges||[]);
// normalize edges to [source,target] pairs
let edges=rawEdges.map(e=>Array.isArray(e)?e:[e.source||e.from,e.target||e.to]);
// cap to ~40 for performance
if(nodes.length>40){
const deg=new Map(nodes.map(n=>[n.id,0]));
edges.forEach(e=>{deg.set(e[0],(deg.get(e[0])||0)+1);deg.set(e[1],(deg.get(e[1])||0)+1);});
nodes.sort((a,b)=>(deg.get(b.id)||0)-(deg.get(a.id)||0));
const keep=new Set(nodes.slice(0,40).map(n=>n.id));
nodes=nodes.filter(n=>keep.has(n.id));
edges=edges.filter(e=>keep.has(e[0])&&keep.has(e[1]));
}
forceLayout(nodes, edges, W, H, nodes.length>20?220:320);
let s=`<defs>
<filter id="glow"><feGaussianBlur stdDeviation="4" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
</defs>`;
for(const e of edges){
const a=nodes.find(n=>n.id===e[0]), b=nodes.find(n=>n.id===e[1]); if(!a||!b) continue;
s+=`<line class="g-edge" x1="${a.x.toFixed(1)}" y1="${a.y.toFixed(1)}" x2="${b.x.toFixed(1)}" y2="${b.y.toFixed(1)}"/>`;
}
for(const n of nodes){
const col=NODE_COLOR[n.type]||NODE_COLOR.default;
const r=n.imp?11:7;
const glowAttr=n.imp?` filter="url(#glow)"`:"";
s+=`<g class="g-node" transform="translate(${n.x.toFixed(1)},${n.y.toFixed(1)})">
<circle r="${r}" fill="${col}"${glowAttr}/><text y="${r+12}">${esc(n.label||n.id).slice(0,16)}</text></g>`;
}
svg.innerHTML=s;
// BUG FIX #1: after graph renders, immediately hide the overlay
hideOverlay();
buildLegend();
}
/* ---------- overlay helpers ---------- */
const overlay=document.getElementById("heroOverlay");
function showOverlay(html){
overlay.innerHTML=html||'<div class="hint">选择左侧科研场景以启动分析</div>';
overlay.classList.remove("hidden");
}
function showLoading(msg){
overlay.innerHTML=`<div class="spin"></div><div class="hint">${esc(msg||"加载中...")}</div>`;
overlay.classList.remove("hidden");
}
function hideOverlay(){
overlay.classList.add("hidden");
}
/* ---------- init scenarios ---------- */
async function init(){
try{
const r=await fetch("/api/scenarios",{headers:{"X-Demo-Token":token()}});
const j=await r.json(); SCENARIOS=j.scenarios||[];
}catch(e){ SCENARIOS=[]; }
const tints=["tint-blue","tint-coral","tint-mint","tint-amber","tint-blue","tint-mint"];
const icons=["","Ⅱ","Ⅲ","Ⅳ","","Ⅵ"];
const box=document.getElementById("scenarios");
box.innerHTML=SCENARIOS.map((s,i)=>`
<div class="scn ${tints[i%tints.length]}" data-key="${s.key}">
<div class="ico">${icons[i]||"•"}</div>
<div class="t"><b>${esc(s.label)}</b><small>${esc(s.desc)}<br><span style="color:#A5B4CF">需: ${(s.needs||[]).join(" · ")}</span></small></div>
</div>`).join("");
box.querySelectorAll(".scn").forEach(el=>el.onclick=()=>run(el.dataset.key, el));
// Draw default graph
drawGraph(DEMO);
setMetrics(defaultMetrics());
buildLegend();
// Login button
document.getElementById("loginBtn").onclick=()=>{
const t=prompt("粘贴演示 token留空表示无鉴权", localStorage.getItem("demo_token")||"");
if(t!=null){ localStorage.setItem("demo_token",t.trim()); alert(t.trim()?"已保存":"已清除"); }
};
// View result button
document.getElementById("viewResultBtn").onclick=()=>{
if(LAST_RESULT_KEY) window.open("result.html?scenario="+LAST_RESULT_KEY,"_blank");
};
}
/* ---------- run scenario ---------- */
async function run(key, el){
const s=SCENARIOS.find(x=>x.key===key); if(!s) return;
ACTIVE=s;
// Mark card active + running
document.querySelectorAll(".scn").forEach(e=>{e.classList.remove("active");e.classList.remove("running");});
if(el) el.classList.add("active","running");
document.getElementById("heroTitle").textContent=s.label;
// BUG FIX #3: immediate loading feedback everywhere
showLoading("服务器终端执行中:"+s.script+" …");
document.getElementById("heroLive").style.display="none";
document.getElementById("cmd").textContent=cmdText(s);
document.getElementById("cmdStatus").textContent="running…";
document.getElementById("cmdStatus").className="st running";
document.getElementById("centerExtra").innerHTML="";
document.getElementById("viewResultBtn").style.display="none";
LAST_RESULT_KEY=null;
// BUG FIX #3: set brief + metrics to loading state
setFindings('<div class="loading-brief"><div class="spin"></div><div>正在分析…</div></div>');
setMetrics(tile("indigo","status","…","计算中")+tile("mint","status","…","")+tile("coral","status","…","")+tile("amber","status","…",""));
const body={scenario:key, owner:val("owner"), repo:val("repo"), keyword:val("keyword")};
try{
const r=await fetch("/api/run",{method:"POST",headers:{"Content-Type":"application/json","X-Demo-Token":token()},body:JSON.stringify(body)});
const j=await r.json();
// Clear running state
document.querySelectorAll(".scn").forEach(e=>e.classList.remove("running"));
document.getElementById("cmdStatus").textContent=(j.ok?"✓ done":"✗ failed")+" · "+(j.duration||"");
document.getElementById("cmdStatus").className="st";
if(!j.ok){
document.getElementById("heroOverlay").innerHTML='<div class="hint" style="color:#F26B5E">运行失败:'+esc(j.error||"未知错误")+'</div>';
document.getElementById("heroOverlay").classList.remove("hidden");
return;
}
renderResult(s, j);
}catch(e){
document.querySelectorAll(".scn").forEach(e=>e.classList.remove("running"));
document.getElementById("cmdStatus").textContent="✗ 请求失败";
document.getElementById("cmdStatus").className="st";
document.getElementById("heroOverlay").innerHTML='<div class="hint" style="color:#F26B5E">请求失败:'+esc(e)+'</div>';
document.getElementById("heroOverlay").classList.remove("hidden");
}
}
function cmdText(s){
const o=val("owner"), r=val("repo"), k=val("keyword");
if(s.key==="s2") return `gitlink-cli research --graph -k "${k}"`;
return `gitlink-cli research ${s.script.replace(".py","")} --repo ${o}/${r}`;
}
function val(id){return document.getElementById(id).value.trim();}
function token(){return localStorage.getItem("demo_token")||"";}
/* ---------- render result ---------- */
function parseJson(j){
if(!j.artifacts||!j.artifacts.json) return null;
try{return JSON.parse(j.artifacts.json);}catch(e){return null;}
}
function renderResult(s, j){
const obj=parseJson(j);
const extra=document.getElementById("centerExtra");
// S2: real knowledge graph
if(obj && obj.nodes && obj.edges){
const graphData={nodes:obj.nodes.map(n=>({id:n.id,type:n.type||"default",label:n.label||n.id,imp:false})), edges:obj.edges.map(e=>[e.source||e.from,e.target||e.to])};
drawGraph(graphData);
ACTIVE._graphData=graphData;
document.getElementById("heroLive").style.display="";
document.getElementById("heroLive").textContent="● live — "+(obj.nodes.length)+" nodes, "+(obj.edges.length)+" edges";
}
// S6: plotly iframe
else if(j.artifacts && j.artifacts.html){
const blob=URL.createObjectURL(new Blob([j.artifacts.html],{type:"text/html"}));
extra.innerHTML='<div class="panel" style="padding:10px"><div class="eyebrow" style="padding:4px 6px">S6 Visualization · plotly</div>'+
'<iframe class="plot" src="'+blob+'"></iframe></div>';
// restore demo graph if no real graph
drawGraph(DEMO);
ACTIVE._graphData=null;
}
// S1/S4: mermaid
else if(j.artifacts && j.artifacts.mermaid){
const id="mmd_"+Date.now();
extra.innerHTML='<div class="panel" style="padding:10px"><div class="eyebrow" style="padding:4px 6px">关系图 · Mermaid</div><div class="mmd-box"><div class="mermaid" id="'+id+'">'+j.artifacts.mermaid.replace(/```mermaid|```/g,"")+'</div></div></div>';
setTimeout(()=>mermaid.run({nodes:["#"+id]}).catch(()=>{}),30);
drawGraph(DEMO);
ACTIVE._graphData=null;
}
// S3/S5 or others: keep demo graph, hide overlay
else{
drawGraph(DEMO);
ACTIVE._graphData=null;
}
// Metrics + findings
if(obj){
setMetrics(metricsFor(s.key, obj));
setFindings(findingsFor(s.key, obj));
} else {
setMetrics(defaultMetrics());
setFindings('<div class="empty">'+(j.stdout?esc(j.stdout.slice(-400)):"(无可解析 JSON 产物)")+'</div>');
}
// Show "view full result" CTA
LAST_RESULT_KEY=s.key;
const btn=document.getElementById("viewResultBtn");
btn.style.display="block";
btn.textContent="查看完整结果 → "+s.label;
}
/* ---------- metrics per scenario ---------- */
function tile(c,label,val,of){return `<div class="tile"><div class="lab"><span class="dot d-${c}"></span>${label}</div><div class="val">${val}</div><div class="of">${of||""}</div></div>`;}
function defaultMetrics(){
return tile("indigo","commits","1,248","+18% this month")+
tile("mint","PR merge","72.6%","healthy collab")+
tile("indigo","contributors","36","8 core scholars")+
tile("coral","compliance","88","示例数据 · 待运行");
}
function metricsFor(key,o){
const M=o.meta||{};
switch(key){
case "s1": return tile("indigo","commits",M.commit_count||0,"采样提交")+tile("mint","merged PR",M.merged_pr_count||0,"已合并")+tile("amber","docs",M.doc_count||0,"文档文件")+tile("coral","创新点",(o.innovation_points||[]).length,"高影响合并");
case "s2": return tile("indigo","repos",M.repo_count||(o.nodes||[]).length,"仓库节点")+tile("coral","scholars",M.scholar_count||0,"学者")+tile("mint","topics",M.topic_count||0,"主题方向")+tile("amber","edges",M.edge_count||(o.edges||[]).length,"关系边");
case "s3": return tile("mint","repro",(o.repro_score||0)+"/10","复现性")+tile("indigo","compliance",(o.compliance_score||0)+"/10","合规")+tile("coral","risks",(o.risks||o.risk_items||[]).length,"风险项")+tile("amber","license",esc(o.license?(typeof o.license==="string"?o.license:(o.license.id||o.license.name||o.license.spdx_id||"—")):(o.license_id||"—")),"识别许可证");
case "s4": return tile("indigo","gap topics",(o.gap_topics||[]).length,"缺口主题")+tile("mint","candidates",(o.candidates||[]).length,"推荐候选")+tile("coral","top score",(o.candidates&&o.candidates[0]?o.candidates[0].score:0),"最高匹配")+tile("amber","pool",o.meta&&o.meta.pool_size||0,"候选池");
case "s5": {const tw=(o.week_stats&&o.week_stats.this_week)||{}; return tile("indigo","commits/周",tw.commits||0,"本周")+tile("coral","risks",(o.risk_warnings||[]).length,"预警")+tile("mint","PR 合并",tw.prs_merged||0,"本周")+tile("amber","trend",(o.trend&&o.trend.activity_level)||"—","活跃度");}
case "s6": {const tl=o.timeline||{}; const sm=a=>(a||[]).reduce((x,y)=>x+(y||0),0); return tile("indigo","weeks",(tl.labels||[]).length,"时间线周数")+tile("coral","commits",sm(tl.commits),"累计")+tile("mint","PRs",sm(tl.prs),"累计")+tile("amber","issues",sm(tl.issues),"累计");}
}
return defaultMetrics();
}
function setMetrics(html){document.getElementById("metrics").innerHTML=html;}
/* ---------- findings per scenario ---------- */
function find(cls,title,body,sub){return `<div class="find ${cls}"><div class="ft"><span>${title}</span></div><div class="fb">${body}</div>${sub?`<div class="fs">${sub}</div>`:""}</div>`;}
function rank(c){return `<div class="bar"><i style="width:${Math.min(100,Math.max(4,c))}%"></i></div>`;}
function findingsFor(key,o){
let out="";
switch(key){
case "s1": (o.innovation_points||[]).slice(0,6).forEach(i=>{out+=find("warning","["+i.category+"] 创新点",esc(i.description),esc(i.evidence));}); break;
case "s2": (o.topic_heat||[]).slice(0,4).forEach(h=>{out+=find("success","主题热度 · "+h.topic,"覆盖 "+h.count+" 个仓库",rank(h.count*15));});
(o.core_scholars||[]).slice(0,3).forEach(s=>{out+=find("",esc(s.login),"关联 "+s.repo_count+" 个仓库");}); break;
case "s3": (o.risk_items||o.risks||[]).slice(0,6).forEach(r=>{const lv=(r.level||"").includes("high")||r.severity==="P0"?"critical":"warning"; out+=find(lv,esc((r.level||r.severity||"")+" · "+(r.category||r.name||"")),esc(r.detail||r.evidence||r.name||""),esc(r.file?r.file:""));});
out+=find("success","复现性 "+(o.repro_score||0)+"/10 · 合规 "+(o.compliance_score||0)+"/10","许可证: "+esc(o.license?(typeof o.license==="string"?o.license:(o.license.id||"—")):"—")); break;
case "s4": (o.candidates||[]).slice(0,5).forEach((c,i)=>{out+=find(i===0?"success":"", "#"+(i+1)+" "+esc(c.login), (c.reasons||[]).join("")||"—", rank(c.score)+` <span style="float:right">${c.score}分</span>`);}); break;
case "s5": (o.risk_warnings||[]).slice(0,6).forEach(w=>{out+=find(w.level==="critical"?"critical":"warning",esc(w.level+" · "+w.type),esc(w.message),esc(w.suggestion||""));}); break;
case "s6": out+=find("success","可视化产物","时间线 "+((o.timeline&&o.timeline.labels||[]).length)+" 周 · 语言 "+((o.language_pie&&o.language_pie.length)||0)+" 类","已生成可交互 plotly 图表"); break;
}
return out||'<div class="empty">(该场景无结构化简报,详见完整结果页)</div>';
}
function setFindings(html){document.getElementById("findings").innerHTML=html;}
/* ---------- utilities ---------- */
function esc(s){return String(s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));}
/* ---------- resize handler ---------- */
let resizeTimer;
window.addEventListener("resize",()=>{
clearTimeout(resizeTimer);
resizeTimer=setTimeout(()=>{
const src=ACTIVE&&ACTIVE._graphData?ACTIVE._graphData:DEMO;
drawGraph(src);
},200);
});
/* go */
init();
</script>
</body>
</html>

View File

@ -0,0 +1,504 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GitLink Research Atlas · 结果详情</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&family=Noto+Serif+SC:wght@600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
:root{
--bg:#F7F9FC; --card:#FFFFFF; --ink:#1E2A44; --ink2:#475569; --mute:#94A3B8;
--line:#E6EBF2; --indigo:#4F6BED; --coral:#F26B5E; --mint:#2EC4B6;
--t-blue:#E8EEFF; --t-coral:#FFEDEA; --t-mint:#E6F7F4; --t-amber:#FEF3C7;
--serif:"Cormorant Garamond","Noto Serif SC",Georgia,serif;
--sans:"Inter","Noto Sans SC",-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
--shadow:0 1px 2px rgba(30,42,68,.04),0 6px 18px rgba(30,42,68,.06);
--shadow-sm:0 1px 2px rgba(30,42,68,.06);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:auto;min-height:100%;margin:0}
body{font-family:var(--sans);color:var(--ink);background:var(--bg);
background-image:radial-gradient(1200px 480px at 80% -8%,#EAF0FF 0%,rgba(234,240,255,0) 60%),
radial-gradient(900px 420px at 0% 0%,#FFF1EE 0%,rgba(255,241,238,0) 55%);
-webkit-font-smoothing:antialiased}
/* ========== PRINT ========== */
@media print{
body{background:#fff!important}
.no-print{display:none!important}
.page-wrap{max-width:100%!important;box-shadow:none!important}
}
/* ========== PAGE WRAPPER ========== */
.page-wrap{max-width:1120px;margin:0 auto;padding:32px 40px 60px}
@media(max-width:760px){.page-wrap{padding:20px 16px 40px}}
/* ========== HEADER ========== */
.res-header{margin-bottom:32px}
.res-header .back{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600;
color:var(--indigo);text-decoration:none;margin-bottom:16px;padding:6px 14px;border-radius:10px;
background:var(--t-blue);transition:.15s}
.res-header .back:hover{background:#D6DEFF}
.res-header h1{font-family:var(--serif);font-weight:700;font-size:34px;line-height:1.2;letter-spacing:.3px;color:var(--ink)}
.res-header .sub{font-size:15px;color:var(--ink2);margin-top:6px;line-height:1.5}
.res-header .meta{display:flex;flex-wrap:wrap;gap:16px;margin-top:14px;align-items:center}
.res-header .meta .chip{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:500;
padding:5px 12px;border-radius:999px;background:var(--card);border:1px solid var(--line);color:var(--ink2);box-shadow:var(--shadow-sm)}
.res-header .meta .chip b{font-family:var(--mono);font-weight:700;color:var(--ink)}
.res-header .cmd-drawer{margin-top:14px;background:var(--ink);color:#CBD5E1;font-family:var(--mono);
font-size:12px;padding:10px 16px;border-radius:10px;display:flex;align-items:center;gap:10px;overflow:hidden}
.res-header .cmd-drawer .dots{display:flex;gap:6px;flex-shrink:0}
.res-header .cmd-drawer .dots i{width:11px;height:11px;border-radius:50%;display:block}
.res-header .cmd-drawer .cmd{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#E2E8F0}
/* ========== SECTION TITLES ========== */
.section{margin-top:36px}
.section-title{font-family:var(--serif);font-weight:700;font-size:22px;color:var(--ink);margin-bottom:16px;
padding-bottom:8px;border-bottom:2px solid var(--line)}
.section-title .eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;
color:var(--mute);display:block;margin-bottom:4px}
/* ========== METRICS ROW ========== */
.metrics-row{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:8px}
@media(max-width:760px){.metrics-row{grid-template-columns:repeat(2,1fr)}}
.metric-card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:18px;
box-shadow:var(--shadow);text-align:center}
.metric-card .label{font-size:12px;color:var(--ink2);font-weight:500;margin-bottom:8px}
.metric-card .value{font-family:var(--mono);font-weight:700;font-size:32px;color:var(--ink);line-height:1}
.metric-card .note{font-size:11px;color:var(--mute);margin-top:6px}
/* ========== KNOWLEDGE GRAPH ========== */
.graph-container{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);
padding:20px;overflow:hidden}
.graph-svg-wrap{width:100%;height:500px;position:relative;background:radial-gradient(500px 380px at 50% 45%,#F2F6FF 0%,rgba(255,255,255,0) 70%);
border-radius:10px;overflow:hidden}
.graph-svg-wrap svg{width:100%;height:100%;display:block}
.g-edge{stroke:#9DB4F0;stroke-opacity:.55;stroke-width:1}
.g-node circle{stroke:#fff;stroke-width:2;transition:.2s}
.g-node:hover circle{filter:brightness(1.08)}
.g-node text{font-family:var(--sans);font-size:11px;font-weight:600;fill:var(--ink);pointer-events:none;text-anchor:middle}
.graph-legend{display:flex;flex-wrap:wrap;gap:14px;margin-top:14px;padding-top:12px;border-top:1px solid var(--line)}
.graph-legend .leg-item{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--ink2)}
.graph-legend .leg-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.graph-legend .leg-note{font-size:12px;color:var(--mute);margin-left:auto}
/* ========== VIZ IFRAME (S6) ========== */
.viz-container{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);
padding:12px;overflow:hidden}
.viz-container iframe{width:100%;height:520px;border:0;border-radius:10px;background:#fff}
/* ========== MERMAID ========== */
.mmd-container{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);
padding:16px;overflow:auto;max-height:600px}
/* ========== RADAR / SCORE (S3) ========== */
.score-display{display:flex;align-items:center;justify-content:center;gap:40px;padding:30px;
background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);flex-wrap:wrap}
.score-circle{width:140px;height:140px;border-radius:50%;display:flex;flex-direction:column;align-items:center;justify-content:center;
position:relative;border:6px solid}
.score-circle .val{font-family:var(--mono);font-weight:700;font-size:40px;line-height:1}
.score-circle .label{font-size:13px;color:var(--ink2);margin-top:2px}
.score-circle.repro{border-color:var(--mint);color:var(--mint);background:var(--t-mint)}
.score-circle.compliance{border-color:var(--indigo);color:var(--indigo);background:var(--t-blue)}
/* ========== WEEKLY COMPARISON (S5) ========== */
.weekly-bars{display:flex;flex-direction:column;gap:16px;padding:20px;
background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}
.week-row{display:flex;align-items:center;gap:14px}
.week-row .label{width:70px;font-size:13px;font-weight:600;color:var(--ink2);flex-shrink:0}
.week-row .bar-bg{flex:1;height:28px;background:#F1F5F9;border-radius:8px;overflow:hidden;position:relative}
.week-row .bar-fill{height:100%;border-radius:8px;transition:width .6s ease}
.week-row .bar-fill.this-week{background:linear-gradient(90deg,var(--indigo),#7C8CF5)}
.week-row .bar-fill.last-week{background:var(--line)}
.week-row .bar-val{position:absolute;right:8px;top:50%;transform:translateY(-50%);font-family:var(--mono);font-size:12px;font-weight:600;color:var(--ink)}
.risk-list{margin-top:20px;display:flex;flex-direction:column;gap:10px}
.risk-item{padding:10px 14px;border-radius:10px;border-left:4px solid}
.risk-item.critical{border-color:var(--coral);background:var(--t-coral)}
.risk-item.warning{border-color:#F59E0B;background:var(--t-amber)}
.risk-item .type{font-size:11px;font-family:var(--mono);color:var(--mute);text-transform:uppercase}
.risk-item .msg{font-size:13px;color:var(--ink);margin-top:4px}
.risk-item .suggestion{font-size:12px;color:var(--ink2);margin-top:4px;font-style:italic}
/* ========== REPORT (markdown) ========== */
.report-content{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);
padding:28px 32px;line-height:1.75;font-size:14px;color:var(--ink)}
.report-content h1{font-family:var(--serif);font-weight:700;font-size:26px;margin:24px 0 12px;padding-bottom:8px;border-bottom:2px solid var(--line)}
.report-content h1:first-child{margin-top:0}
.report-content h2{font-family:var(--serif);font-weight:700;font-size:20px;margin:20px 0 10px;color:var(--ink)}
.report-content h3{font-weight:700;font-size:16px;margin:16px 0 8px;color:var(--ink2)}
.report-content p{margin:10px 0}
.report-content ul,.report-content ol{margin:8px 0;padding-left:24px}
.report-content li{margin:4px 0}
.report-content strong{font-weight:700;color:var(--ink)}
.report-content code{font-family:var(--mono);font-size:12.5px;background:var(--t-blue);padding:2px 6px;border-radius:4px;color:var(--indigo)}
.report-content table{width:100%;border-collapse:collapse;margin:12px 0;font-size:13px}
.report-content th{background:var(--t-blue);font-weight:600;text-align:left;padding:8px 12px;border:1px solid var(--line)}
.report-content td{padding:8px 12px;border:1px solid var(--line)}
/* ========== STRUCTURED CARDS ========== */
.cards-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px;margin-top:20px}
.conclusion-card{background:var(--card);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow);
padding:16px 18px;border-top:4px solid}
.conclusion-card.innovation{border-top-color:var(--indigo)}
.conclusion-card.risk{border-top-color:var(--coral)}
.conclusion-card.collab{border-top-color:var(--mint)}
.conclusion-card .card-title{font-weight:700;font-size:14px;color:var(--ink);margin-bottom:8px}
.conclusion-card .card-body{font-size:13px;color:var(--ink2);line-height:1.55}
.conclusion-card .card-body p{margin:4px 0}
/* ========== LOADING STATE ========== */
.loading-state{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:80px 20px;color:var(--mute);gap:16px}
.spin-lg{width:40px;height:40px;border-radius:50%;border:4px solid var(--t-blue);border-top-color:var(--indigo);animation:rot .8s linear infinite}
@keyframes rot{to{transform:rotate(360deg)}}
/* ========== ERROR ========== */
.error-state{padding:60px 20px;text-align:center;color:var(--coral)}
.error-state h2{font-family:var(--serif);font-size:24px;margin-bottom:12px}
.error-state p{font-size:14px;color:var(--ink2);max-width:500px;margin:0 auto}
/* empty */
.empty-state{padding:60px 20px;text-align:center;color:var(--mute)}
.empty-state p{font-size:14px}
</style>
</head>
<body>
<div class="page-wrap" id="app">
<div class="loading-state"><div class="spin-lg"></div><div>正在加载结果…</div></div>
</div>
<script>
mermaid.initialize({startOnLoad:false, theme:"base", securityLevel:"loose",
themeVariables:{primaryColor:"#E8EEFF",primaryTextColor:"#1E2A44",primaryBorderColor:"#4F6BED",lineColor:"#9DB4F0",fontSize:"13px"}});
const NODE_COLOR={repo:"#4F6BED",scholar:"#3B82F6",team:"#6366F1",topic:"#06B6D4",method:"#6366F1",
paper:"#8B5CF6",dataset:"#2EC4B6",model:"#4F6BED",experiment:"#F26B5E",
reproduce:"#2EC4B6",license:"#64748B",trend:"#F26B5E",issue:"#F59E0B",pr:"#10B981",default:"#94A3B8"};
const NODE_LABEL={repo:"仓库",scholar:"学者",topic:"主题",paper:"论文",dataset:"数据集",
model:"模型",experiment:"实验",reproduce:"复现",license:"许可证",trend:"趋势",issue:"Issue",pr:"PR"};
function esc(s){return String(s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));}
function token(){return localStorage.getItem("demo_token")||"";}
function licOf(o){const l=o.license;if(!l)return o.license_id||"—";return typeof l==="string"?l:(l.id||l.name||l.spdx_id||l.identifier||"—");}
/* ========== Force layout (same as index) ========== */
function forceLayout(nodes,edges,W,H,iters){
const cx=W/2,cy=H/2;
nodes.forEach(n=>{if(n.x==null){n.x=cx+(Math.random()-.5)*W*.5;n.y=cy+(Math.random()-.5)*H*.5;}});
for(let it=0;it<iters;it++){
for(const n of nodes){n._fx=0;n._fy=0;}
for(let i=0;i<nodes.length;i++){for(let j=i+1;j<nodes.length;j++){
let dx=nodes[i].x-nodes[j].x,dy=nodes[i].y-nodes[j].y,d2=dx*dx+dy*dy+.02,d=Math.sqrt(d2),f=2600/d2,fx=dx/d*f,fy=dy/d*f;
nodes[i]._fx+=fx;nodes[i]._fy+=fy;nodes[j]._fx-=fx;nodes[j]._fy-=fy;}}
for(const e of edges){const a=nodes.find(n=>n.id===e[0]),b=nodes.find(n=>n.id===e[1]);if(!a||!b)continue;
let dx=b.x-a.x,dy=b.y-a.y,d=Math.sqrt(dx*dx+dy*dy)+.02,L=a.imp||b.imp?130:85,k=.04;
let f=(d-L)*k,fx=dx/d*f,fy=dy/d*f;a._fx+=fx;a._fy+=fy;b._fx-=fx;b._fy-=fy;}
for(const n of nodes){n._fx+=(cx-n.x)*.012;n._fy+=(cy-n.y)*.012;
n.x+=Math.max(-14,Math.min(14,n._fx));n.y+=Math.max(-14,Math.min(14,n._fy));
n.x=Math.max(45,Math.min(W-45,n.x));n.y=Math.max(38,Math.min(H-34,n.y));}}
}
function drawGraphIn(containerId,data){
const svg=document.getElementById(containerId); if(!svg) return;
const W=svg.clientWidth||800,H=svg.clientHeight||500;
svg.setAttribute("viewBox",`0 0 ${W} ${H}`);
let nodes=(data.nodes||[]).map(n=>Object.assign({},n));
let edges=(data.edges||[]).map(e=>Array.isArray(e)?e:[e.source||e.from,e.target||e.to]);
if(nodes.length>40){
const deg=new Map(nodes.map(n=>[n.id,0]));
edges.forEach(e=>{deg.set(e[0],(deg.get(e[0])||0)+1);deg.set(e[1],(deg.get(e[1])||0)+1);});
nodes.sort((a,b)=>(deg.get(b.id)||0)-(deg.get(a.id)||0));
const keep=new Set(nodes.slice(0,40).map(n=>n.id));nodes=nodes.filter(n=>keep.has(n.id));edges=edges.filter(e=>keep.has(e[0])&&keep.has(e[1]));
}
forceLayout(nodes,edges,W,H,300);
let s=`<defs><filter id="glow2"><feGaussianBlur stdDeviation="5" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>`;
for(const e of edges){const a=nodes.find(n=>n.id===e[0]),b=nodes.find(n=>n.id===e[1]);if(!a||!b)continue;
s+=`<line class="g-edge" x1="${a.x.toFixed(1)}" y1="${a.y.toFixed(1)}" x2="${b.x.toFixed(1)}" y2="${b.y.toFixed(1)}"/>`;}
for(const n of nodes){const col=NODE_COLOR[n.type]||NODE_COLOR.default,r=n.imp?13:8;
s+=`<g class="g-node" transform="translate(${n.x.toFixed(1)},${n.y.toFixed(1)})"><circle r="${r}" fill="${col}"${n.imp?' filter="url(#glow2)"':""}/><text y="${r+13}">${esc(n.label||n.id).slice(0,18)}</text></g>`;}
svg.innerHTML=s;
}
function buildGraphLegend(types){
let h="";
for(const t of [...types].sort()){
h+=`<span class="leg-item"><span class="leg-dot" style="background:${NODE_COLOR[t]||NODE_COLOR.default}"></span>${NODE_LABEL[t]||t}</span>`;
}
return h;
}
/* ========== Simple markdown to HTML ========== */
function mdToHtml(md){
if(!md) return "";
let html=esc(md);
// code blocks
html=html.replace(/```(\w*)\n([\s\S]*?)```/g,'<pre><code>$2</code></pre>');
// inline code
html=html.replace(/`([^`]+)`/g,'<code>$1</code>');
// tables
html=html.replace(/^(\|.+\|)\n(\|[-:\s|]+\|)\n((?:\|.+\|\n?)*)/gm,function(_,hdr,sep,body){
const ths=hdr.split("|").filter(c=>c.trim()).map(c=>`<th>${c.trim()}</th>`).join("");
let rows="";
body.trim().split("\n").forEach(row=>{
const tds=row.split("|").filter(c=>c.trim()).map(c=>`<td>${c.trim()}</td>`).join("");
rows+=`<tr>${tds}</tr>`;
});
return `<table><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`;
});
// headings
html=html.replace(/^### (.+)$/gm,'<h3>$1</h3>');
html=html.replace(/^## (.+)$/gm,'<h2>$1</h2>');
html=html.replace(/^# (.+)$/gm,'<h1>$1</h1>');
// bold
html=html.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>');
// unordered lists
html=html.replace(/^- (.+)$/gm,'<li>$1</li>');
html=html.replace(/(<li>.*<\/li>\n?)+/g,'<ul>$&</ul>');
// paragraphs (lines not inside tags)
html=html.replace(/^(?!<[huplo])((?!<).+)$/gm,'<p>$1</p>');
return html;
}
/* ========== MAIN RENDER ========== */
async function loadResult(){
const params=new URLSearchParams(window.location.search);
const scenario=params.get("scenario");
if(!scenario){
document.getElementById("app").innerHTML='<div class="error-state"><h2>缺少场景参数</h2><p>请从仪表盘点击场景,或手动打开 <code>result.html?scenario=s1</code></p></div>';
return;
}
let data;
try{
const r=await fetch("/api/result/"+encodeURIComponent(scenario),{headers:{"X-Demo-Token":token()}});
data=await r.json();
}catch(e){
document.getElementById("app").innerHTML='<div class="error-state"><h2>加载失败</h2><p>'+esc(e.message)+'</p><a class="back" href="index.html">返回仪表盘</a></div>';
return;
}
if(!data.ok){
document.getElementById("app").innerHTML='<div class="error-state"><h2>'+esc(data.error||"数据未就绪")+'</h2><p>请先在仪表盘运行该场景以生成结果。</p><a class="back" href="index.html">返回仪表盘</a></div>';
return;
}
const art=data.artifacts||{};
const obj=art.json?JSON.parse(art.json):null;
const owner=params.get("owner")||val_from("owner")||"";
const repo=params.get("repo")||val_from("repo")||"";
const keyword=params.get("keyword")||val_from("keyword")||"";
render(data,obj,art,scenario,owner,repo,keyword);
}
function val_from(name){try{return localStorage.getItem("atlas_"+name)||"";}catch(e){return"";}}
function render(data,obj,art,scenario,owner,repo,keyword){
const label=data.label||scenario;
const desc=data.desc||"";
const now=new Date().toLocaleString("zh-CN");
let h="";
// ---- HEADER ----
h+=`<div class="res-header">
<a class="back no-print" href="index.html">&#8592; 返回仪表盘</a>
<h1>${esc(label)}</h1>
<div class="sub">${esc(desc)}</div>
<div class="meta">`;
if(owner||repo) h+=`<span class="chip">仓库 <b>${esc(owner)}${repo?"/"+esc(repo):""}</b></span>`;
if(keyword) h+=`<span class="chip">关键词 <b>${esc(keyword)}</b></span>`;
h+=`<span class="chip">场景 <b>${esc(scenario)}</b></span>
<span class="chip">生成于 ${esc(now)}</span>
</div>
<div class="cmd-drawer no-print">
<div class="dots"><i style="background:#FF5F57"></i><i style="background:#FEBC2E"></i><i style="background:#28C840"></i></div>
<span style="color:var(--mint)">atlas &#10095;</span>
<span class="cmd">gitlink-cli research --scenario ${esc(scenario)} ${owner?"--repo "+esc(owner)+"/"+esc(repo):""} ${keyword?'-k "'+esc(keyword)+'"':""}</span>
</div></div>`;
// ---- METRICS ROW ----
const metrics=metricsFor(scenario,obj);
if(metrics){
h+=`<div class="section"><div class="section-title"><span class="eyebrow">Key Metrics</span>关键指标</div>
<div class="metrics-row">${metrics}</div></div>`;
}
// ---- MAIN VIZ ----
h+=`<div class="section"><div class="section-title"><span class="eyebrow">Visualization</span>可视化</div>`;
// S2: Knowledge Graph
if(scenario==="s2"&&obj&&obj.nodes&&obj.edges){
const graphNodes=obj.nodes.map(n=>({id:n.id,type:n.type||"default",label:n.label||n.id,imp:false}));
// highlight repo and high-degree nodes
const deg=new Map();
obj.edges.forEach(e=>{deg.set((e.source||e.from),(deg.get(e.source||e.from)||0)+1);deg.set((e.target||e.to),(deg.get(e.target||e.to)||0)+1);});
graphNodes.forEach(n=>{if(n.type==="repo"||deg.get(n.id)>3)n.imp=true;});
const graphEdges=obj.edges.map(e=>[e.source||e.from,e.target||e.to]);
const types=new Set(graphNodes.map(n=>n.type));
const gId="resGraph_"+Date.now();
h+=`<div class="graph-container">
<div class="graph-svg-wrap"><svg id="${gId}" preserveAspectRatio="xMidYMid meet"></svg></div>
<div class="graph-legend">${buildGraphLegend(types)}
<span class="leg-note">${esc(label)} — ${(obj.meta||{}).repo_count||graphNodes.length} 个仓库节点, ${(obj.meta||{}).edge_count||graphEdges.length} 条关联边</span>
</div></div>`;
// defer drawing
setTimeout(()=>drawGraphIn(gId,{nodes:graphNodes,edges:graphEdges}),100);
}
// S6: plotly
else if(scenario==="s6"&&art.html){
const blob=URL.createObjectURL(new Blob([art.html],{type:"text/html"}));
h+=`<div class="viz-container"><iframe src="${blob}"></iframe></div>`;
}
// S1/S4: mermaid
else if((scenario==="s1"||scenario==="s4")&&art.mermaid){
const mId="resMmd_"+Date.now();
const mmd=art.mermaid.replace(/```mermaid|```/g,"");
h+=`<div class="mmd-container"><div class="mermaid" id="${mId}">${esc(mmd)}</div></div>`;
setTimeout(()=>{const el=document.getElementById(mId);if(el)el.removeAttribute("data-processed");
mermaid.run({nodes:["#"+mId]}).catch(()=>{});},100);
}
// S3: radar / scores
else if(scenario==="s3"&&obj){
const rs=obj.repro_score||0;const cs=obj.compliance_score||0;
h+=`<div class="score-display">
<div class="score-circle repro"><span class="val">${rs}</span><span class="label">复现性 / 10</span></div>
<div class="score-circle compliance"><span class="val">${cs}</span><span class="label">合规性 / 10</span></div>
</div>`;
}
// S5: weekly comparison + risks
else if(scenario==="s5"&&obj){
const ws=obj.week_stats||{};
const tw=ws.this_week||{};const lw=ws.last_week||{};
const maxC=Math.max(tw.commits||0,lw.commits||0,1);
const maxP=Math.max(tw.prs_merged||0,lw.prs_merged||0,1);
const maxI=Math.max(tw.issues_closed||0,lw.issues_closed||0,1);
h+=`<div class="weekly-bars">
<div style="font-weight:700;font-size:14px;margin-bottom:8px;color:var(--ink)">本周 vs 上周 对比</div>
<div class="week-row"><span class="label">Commits</span><div class="bar-bg"><div class="bar-fill last-week" style="width:${(lw.commits/maxC*100||0).toFixed(1)}%"><span class="bar-val">${lw.commits||0}</span></div><div class="bar-fill this-week" style="width:${(tw.commits/maxC*100||0).toFixed(1)}%;position:relative;margin-top:-28px"><span class="bar-val">${tw.commits||0}</span></div></div></div>
<div class="week-row"><span class="label">PR merged</span><div class="bar-bg"><div class="bar-fill last-week" style="width:${(lw.prs_merged/maxP*100||0).toFixed(1)}%"><span class="bar-val">${lw.prs_merged||0}</span></div><div class="bar-fill this-week" style="width:${(tw.prs_merged/maxP*100||0).toFixed(1)}%;position:relative;margin-top:-28px"><span class="bar-val">${tw.prs_merged||0}</span></div></div></div>
<div class="week-row"><span class="label">Issues</span><div class="bar-bg"><div class="bar-fill last-week" style="width:${(lw.issues_closed/maxI*100||0).toFixed(1)}%"><span class="bar-val">${lw.issues_closed||0}</span></div><div class="bar-fill this-week" style="width:${(tw.issues_closed/maxI*100||0).toFixed(1)}%;position:relative;margin-top:-28px"><span class="bar-val">${tw.issues_closed||0}</span></div></div></div>
</div>`;
// risk warnings
const risks=obj.risk_warnings||[];
if(risks.length){
h+=`<div style="margin-top:20px;font-weight:700;font-size:14px;color:var(--ink)">风险预警</div><div class="risk-list">`;
risks.forEach(r=>{const cls=r.level==="critical"?"critical":"warning";
h+=`<div class="risk-item ${cls}"><div class="type">${esc(r.level||"")} · ${esc(r.type||"")}</div><div class="msg">${esc(r.message||"")}</div>${r.suggestion?`<div class="suggestion">${esc(r.suggestion)}</div>`:""}</div>`;});
h+=`</div>`;
}
}
else{
h+=`<div class="empty-state"><p>该场景无可视化产物,详见下方报告。</p></div>`;
}
h+=`</div>`;
// ---- REPORT ----
if(art.report){
h+=`<div class="section"><div class="section-title"><span class="eyebrow">Full Report</span>完整报告</div>
<div class="report-content">${mdToHtml(art.report)}</div></div>`;
}
// ---- STRUCTURED CONCLUSION CARDS ----
const conclusions=buildConclusionCards(scenario,obj);
if(conclusions){
h+=`<div class="section"><div class="section-title"><span class="eyebrow">Conclusions</span>结构化结论</div>
<div class="cards-grid">${conclusions}</div></div>`;
}
document.getElementById("app").innerHTML=h;
}
/* ========== METRICS ========== */
function metricCard(label,val,note){
return `<div class="metric-card"><div class="label">${esc(label)}</div><div class="value">${esc(String(val))}</div><div class="note">${esc(note||"")}</div></div>`;
}
function metricsFor(key,o){
if(!o) return "";
const M=o.meta||{};
switch(key){
case "s1": return metricCard("commits",M.commit_count||0,"采样提交")+metricCard("merged PR",M.merged_pr_count||0,"已合并")+metricCard("docs",M.doc_count||0,"文档文件")+metricCard("创新点",(o.innovation_points||[]).length,"高影响合并");
case "s2": return metricCard("repos",M.repo_count||(o.nodes||[]).length,"仓库节点")+metricCard("scholars",M.scholar_count||0,"学者")+metricCard("topics",M.topic_count||0,"主题方向")+metricCard("edges",M.edge_count||(o.edges||[]).length,"关系边");
case "s3": return metricCard("复现性",(o.repro_score||0)+"/10","reproducibility")+metricCard("合规性",(o.compliance_score||0)+"/10","compliance")+metricCard("风险项",(o.risks||o.risk_items||[]).length,"项")+metricCard("许可证",esc(licOf(o)),"识别");
case "s4": return metricCard("缺口主题",(o.gap_topics||[]).length,"gap topics")+metricCard("推荐候选",(o.candidates||[]).length,"candidates")+metricCard("最高匹配",o.candidates&&o.candidates[0]?o.candidates[0].score:0,"top score")+metricCard("候选池",o.meta&&o.meta.pool_size||0,"pool size");
case "s5":{const tw=(o.week_stats&&o.week_stats.this_week)||{};return metricCard("本周 commits",tw.commits||0,"")+metricCard("预警",(o.risk_warnings||[]).length,"risks")+metricCard("PR 合并",tw.prs_merged||0,"")+metricCard("活跃度",(o.trend&&o.trend.activity_level)||"—","");}
case "s6":{const tl=o.timeline||{};const sm=a=>(a||[]).reduce((x,y)=>x+(y||0),0);return metricCard("周数",(tl.labels||[]).length,"")+metricCard("commits",sm(tl.commits),"")+metricCard("PRs",sm(tl.prs),"")+metricCard("issues",sm(tl.issues),"");}
}
return "";
}
/* ========== CONCLUSION CARDS ========== */
function buildConclusionCards(key,o){
if(!o) return "";
let cards="";
switch(key){
case "s1":{
const ips=o.innovation_points||[];
if(ips.length){
cards+=`<div class="conclusion-card innovation"><div class="card-title">&#128161; 创新点 (${ips.length})</div><div class="card-body">`;
ips.slice(0,8).forEach(i=>{cards+=`<p><strong>[${esc(i.category)}]</strong> ${esc(i.description)}<br><small style="color:var(--mute)">${esc(i.evidence||"")}</small></p>`;});
cards+=`</div></div>`;
}
break;}
case "s2":{
const scholars=o.core_scholars||[];
if(scholars.length){
cards+=`<div class="conclusion-card collab"><div class="card-title">&#128101; 核心学者</div><div class="card-body">`;
scholars.slice(0,6).forEach(s=>{cards+=`<p><strong>${esc(s.login)}</strong> — ${s.repo_count} 个仓库</p>`;});
cards+=`</div></div>`;
}
const topics=o.topic_heat||[];
if(topics.length){
cards+=`<div class="conclusion-card innovation"><div class="card-title">&#128293; 热门主题</div><div class="card-body">`;
topics.slice(0,6).forEach(t=>{cards+=`<p><strong>${esc(t.topic)}</strong> — ${t.count} 个仓库</p>`;});
cards+=`</div></div>`;
}
break;}
case "s3":{
const risks=o.risk_items||o.risks||[];
if(risks.length){
cards+=`<div class="conclusion-card risk"><div class="card-title">&#9888;&#65039; 风险项 (${risks.length})</div><div class="card-body">`;
risks.slice(0,8).forEach(r=>{
const lvl=(r.level||"").includes("high")||r.severity==="P0"?"&#x1F534;":"&#x1F7E1;";
cards+=`<p>${lvl} <strong>${esc(r.category||r.name||"")}</strong> — ${esc(r.detail||r.evidence||"")}</p>`;});
cards+=`</div></div>`;
}
break;}
case "s4":{
const cands=o.candidates||[];
if(cands.length){
cards+=`<div class="conclusion-card collab"><div class="card-title">&#129309; 协作推荐 (${cands.length})</div><div class="card-body">`;
cands.slice(0,6).forEach((c,i)=>{cards+=`<p><strong>#${i+1} ${esc(c.login)}</strong> (${c.score}分)<br><small>${(c.reasons||[]).join("")}</small></p>`;});
cards+=`</div></div>`;
}
const gaps=o.gap_topics||[];
if(gaps.length){
cards+=`<div class="conclusion-card innovation"><div class="card-title">&#128270; 缺口主题 (${gaps.length})</div><div class="card-body">`;
gaps.slice(0,6).forEach(g=>{cards+=`<p>${esc(typeof g==="string"?g:(g.topic||g.name||JSON.stringify(g)))}</p>`;});
cards+=`</div></div>`;
}
break;}
case "s5":{
const risks=o.risk_warnings||[];
if(risks.length){
cards+=`<div class="conclusion-card risk"><div class="card-title">&#9888;&#65039; 风险预警 (${risks.length})</div><div class="card-body">`;
risks.slice(0,6).forEach(w=>{cards+=`<p><strong>[${esc(w.type)}]</strong> ${esc(w.message)}${w.suggestion?"<br><small>"+esc(w.suggestion)+"</small>":""}</p>`;});
cards+=`</div></div>`;
}
const trend=o.trend||{};
cards+=`<div class="conclusion-card innovation"><div class="card-title">&#128200; 活跃趋势</div><div class="card-body"><p>commit 变化: <strong>${trend.commit_delta_pct||"—"}</strong></p><p>活跃度: <strong>${esc(trend.activity_level||"—")}</strong></p></div></div>`;
break;}
case "s6":{
const tl=o.timeline||{};
cards+=`<div class="conclusion-card innovation"><div class="card-title">&#128202; 数据概览</div><div class="card-body"><p>时间线: ${(tl.labels||[]).length} 周</p><p>语言分布: ${(o.language_pie||[]).length} 类</p><p>累计 commits: ${(tl.commits||[]).reduce((a,b)=>a+(b||0),0)}</p></div></div>`;
break;}
}
return cards;
}
/* go */
loadResult();
</script>
</body>
</html>