forked from Gitlink/gitlink-cli
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:
parent
39f634535c
commit
5dcdd436eb
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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=>({'&':'&','<':'<','>':'>'}[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 讨论…(约 30–90s,取决于仓库数和 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>
|
||||
|
|
@ -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">● 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">查看完整结果 →</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 ❯</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=>({'&':'&','<':'<','>':'>'}[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>
|
||||
|
|
@ -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=>({'&':'&','<':'<','>':'>'}[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">← 返回仪表盘</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 ❯</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">💡 创新点 (${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">👥 核心学者</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">🔥 热门主题</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">⚠️ 风险项 (${risks.length})</div><div class="card-body">`;
|
||||
risks.slice(0,8).forEach(r=>{
|
||||
const lvl=(r.level||"").includes("high")||r.severity==="P0"?"🔴":"🟡";
|
||||
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">🤝 协作推荐 (${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">🔎 缺口主题 (${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">⚠️ 风险预警 (${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">📈 活跃趋势</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">📊 数据概览</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>
|
||||
Loading…
Reference in New Issue