效果嘎嘎好

#6
by aifeifei798 - opened

效果嘎嘎好

server.py

import os
import json
import uuid
import random
import datetime
import threading
from pathlib import Path
from typing import Optional

from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from yue2 import YuE2Pipeline

# ----------------- 目录与持久化 -----------------
MODEL_REPO = "m-a-p/YuE2-3B"
OUTPUT_DIR = Path("outputs")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
HISTORY_FILE = OUTPUT_DIR / "history.json"

app = FastAPI(title="YuE2 Music Studio")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.mount("/audio", StaticFiles(directory=str(OUTPUT_DIR)), name="audio")
gpu_lock = threading.Lock()

print("⏳ 正在加载 YuE2 模型到显存,请稍候...")
pipe = YuE2Pipeline.from_pretrained(MODEL_REPO, device="cuda")
print("✅ 模型加载成功,服务就绪!")


def get_all_history():
    if not HISTORY_FILE.exists():
        return []
    try:
        return json.loads(HISTORY_FILE.read_text(encoding="utf-8"))
    except Exception:
        return []


def add_history_record(record: dict):
    history = get_all_history()
    history.insert(0, record)
    HISTORY_FILE.write_text(
        json.dumps(history, ensure_ascii=False, indent=2), encoding="utf-8"
    )


class GenerateRequest(BaseModel):
    title: Optional[str] = "未命名歌曲"
    style: str
    lyrics: str
    cot: Optional[str] = "full"
    seed: Optional[int] = None  # 默认 None,表示随机


@app.get("/")
def read_root():
    return FileResponse("index.html")


@app.get("/api/history")
def fetch_history():
    return get_all_history()


@app.post("/api/generate")
def generate_music(req: GenerateRequest):
    if not gpu_lock.acquire(blocking=False):
        raise HTTPException(
            status_code=429, detail="当前显卡正在生成其他音乐,请稍等前一个任务完成!"
        )

    try:
        task_id = uuid.uuid4().hex[:8]
        filename = f"{task_id}.flac"
        file_path = OUTPUT_DIR / filename
        artifacts_dir = OUTPUT_DIR / f"{task_id}_artifacts"

        title = req.title.strip() if req.title and req.title.strip() else "未命名歌曲"

        # 处理种子:若未填写或小于0,则生成一个确定的随机种子并落库
        if req.seed is None or req.seed < 0:
            actual_seed = random.randint(1, 2**31 - 1)
        else:
            actual_seed = req.seed

        print(f"🎵 开始生成 [{task_id}] - 歌名: {title} | Seed: {actual_seed}")

        # 调用 Pipeline
        song = pipe(style=req.style, lyrics=req.lyrics, cot=req.cot, seed=actual_seed)

        song.save(str(file_path))
        song.save_artifacts(str(artifacts_dir))

        # 完整记录制作信息
        record = {
            "task_id": task_id,
            "title": title,
            "style": req.style,
            "lyrics": req.lyrics,
            "seed": actual_seed,
            "cot": req.cot,
            "audio_url": f"/audio/{filename}",
            "created_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        }
        add_history_record(record)

        return {"status": "success", **record}

    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))
    finally:
        gpu_lock.release()


if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app, host="0.0.0.0", port=8000)

index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>YuE2 AI 音乐工坊</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
  <style>
    body { background-color: #0d0f17; }
    .glass { background: rgba(22, 27, 46, 0.7); backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.08); }
    .glow { box-shadow: 0 0 25px -5px rgba(168, 85, 247, 0.4); }
    ::-webkit-scrollbar { width: 5px; height: 5px; }
    ::-webkit-scrollbar-track { background: transparent; }
    ::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 4px; }
    ::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); }
  </style>
</head>
<body class="text-slate-200 min-h-screen flex flex-col items-center p-3 md:p-6">

  <!-- 头部 -->
  <header class="text-center mb-5">
    <div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 text-purple-400 text-xs tracking-wider uppercase mb-1.5 border border-purple-500/20">
      <i class="fa-solid fa-wand-magic-sparkles"></i> YuE2-3B Studio
    </div>
    <h1 class="text-2xl md:text-4xl font-extrabold text-white tracking-tight">
      AI 全曲音乐创作工坊
    </h1>
  </header>

  <!-- 主体工作区 -->
  <main class="w-full max-w-6xl grid grid-cols-1 lg:grid-cols-12 gap-5">

    <!-- 左侧:参数输入区 (7列) -->
    <section class="lg:col-span-7 glass p-5 rounded-2xl flex flex-col gap-3.5">
      <div class="flex items-center justify-between border-b border-slate-700/50 pb-2.5">
        <h2 class="text-base font-bold text-white flex items-center gap-2">
          <i class="fa-solid fa-sliders text-purple-400"></i> 参数设定
        </h2>
        <button id="loadPresetBtn" class="text-xs bg-slate-800 hover:bg-slate-700 text-purple-300 px-3 py-1 rounded-lg border border-purple-500/30 transition">
          <i class="fa-solid fa-bookmark mr-1"></i> 填入《今晚不眠》预设
        </button>
      </div>

      <!-- 歌曲标题 -->
      <div>
        <label class="block text-xs font-semibold uppercase text-slate-400 mb-1">歌曲标题</label>
        <input id="titleInput" type="text" placeholder="例如:今晚不眠" class="w-full bg-slate-900/80 border border-slate-700 rounded-xl px-3.5 py-2 text-white text-sm focus:outline-none focus:border-purple-500">
      </div>

      <!-- 曲风 Style (多行) -->
      <div>
        <label class="block text-xs font-semibold uppercase text-slate-400 mb-1">曲风描述 (Style Prompt - 支持多行详细描述)</label>
        <textarea id="styleInput" rows="3" placeholder="City Pop, upbeat, danceable, groovy bass&#10;electric guitar, synth, energetic, joyful&#10;neon city night, emotional male vocal" class="w-full bg-slate-900/80 border border-slate-700 rounded-xl p-2.5 text-white focus:outline-none focus:border-purple-500 text-xs leading-relaxed"></textarea>
      </div>

      <!-- 歌词 Lyrics (多行) -->
      <div>
        <label class="block text-xs font-semibold uppercase text-slate-400 mb-1">歌词排版 (含结构标签 如 [Verse], [Chorus])</label>
        <textarea id="lyricsInput" rows="10" placeholder="[Verse]&#10;路灯眨着眼睛 偷看谁的身影...&#10;&#10;[Chorus]&#10;今晚不眠 快乐无限..." class="w-full bg-slate-900/80 border border-slate-700 rounded-xl p-2.5 text-white focus:outline-none focus:border-purple-500 font-mono text-xs leading-relaxed"></textarea>
      </div>

      <!-- 种子与 CoT -->
      <div class="grid grid-cols-2 gap-3">
        <div>
          <div class="flex justify-between items-center mb-1">
            <label class="text-xs font-semibold uppercase text-slate-400">种子 (Seed)</label>
            <button id="randSeedBtn" type="button" class="text-[11px] text-purple-400 hover:text-purple-300">
              <i class="fa-solid fa-dice"></i> 摇个随机数
            </button>
          </div>
          <input id="seedInput" type="number" placeholder="留空则自动随机" class="w-full bg-slate-900/80 border border-slate-700 rounded-xl px-3 py-1.5 text-white text-xs focus:outline-none focus:border-purple-500">
        </div>
        <div>
          <label class="block text-xs font-semibold uppercase text-slate-400 mb-1">思维链 (CoT)</label>
          <select id="cotInput" class="w-full bg-slate-900/80 border border-slate-700 rounded-xl px-3 py-1.5 text-white text-xs focus:outline-none focus:border-purple-500">
            <option value="full" selected>Full (推荐,结构完整)</option>
            <option value="none">None</option>
          </select>
        </div>
      </div>

      <!-- 生成按钮 -->
      <button id="submitBtn" class="mt-1 w-full py-3 px-6 rounded-xl font-bold text-white bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 shadow-lg glow transition duration-200 flex items-center justify-center gap-2 text-sm">
        <i class="fa-solid fa-compact-disc"></i> 开始生成全曲
      </button>
    </section>

    <!-- 右侧:播放卡片、当前歌曲档案、生成历史 (5列) -->
    <section class="lg:col-span-5 flex flex-col gap-4">

      <!-- 1. 播放器卡片 -->
      <div class="glass p-4 rounded-2xl flex flex-col items-center text-center relative overflow-hidden">
        <div id="cdDisc" class="w-24 h-24 rounded-full border-4 border-slate-700 bg-slate-950 flex items-center justify-center my-1.5 shadow-xl relative">
          <div class="w-8 h-8 rounded-full border-2 border-purple-500/50 bg-slate-900 flex items-center justify-center">
            <i class="fa-solid fa-music text-purple-400 text-sm"></i>
          </div>
        </div>

        <h3 id="displayTitle" class="text-base font-bold text-white mb-0.5">未播放</h3>
        <p id="displaySeedBadge" class="text-[11px] text-purple-300 mb-2">等待生成或在下方选择历史曲目</p>

        <!-- 原生播放器 -->
        <audio id="audioPlayer" controls class="w-full mb-2 h-9"></audio>

        <a id="downloadLink" href="#" download class="hidden w-full py-1.5 rounded-lg border border-slate-600 bg-slate-800/80 hover:bg-slate-700 text-xs font-medium transition items-center justify-center gap-2">
          <i class="fa-solid fa-download"></i> 下载无损 FLAC
        </a>
      </div>

      <!-- 2. 当前播放歌曲的制作信息面板 -->
      <div id="metaPanel" class="glass p-4 rounded-2xl flex flex-col gap-2.5">
        <div class="flex items-center justify-between border-b border-slate-700/50 pb-1.5">
          <h4 class="text-xs font-bold uppercase text-slate-300 tracking-wider flex items-center gap-1.5">
            <i class="fa-solid fa-circle-info text-cyan-400"></i> 本曲制作信息
          </h4>
          <button id="reuseConfigBtn" class="hidden text-[11px] text-purple-400 hover:text-purple-300 bg-purple-950/40 hover:bg-purple-900/50 px-2 py-0.5 rounded border border-purple-500/30 transition">
            <i class="fa-solid fa-arrow-left mr-1"></i> 回填到编辑器
          </button>
        </div>

        <!-- 种子与基本信息 -->
        <div class="grid grid-cols-2 gap-2 text-[11px] bg-slate-950/60 p-2 rounded-lg border border-slate-800/80">
          <div><span class="text-slate-500">制作种子:</span><span id="metaSeed" class="text-amber-400 font-mono font-bold">-</span></div>
          <div><span class="text-slate-500">思维模式:</span><span id="metaCot" class="text-slate-300 font-mono">-</span></div>
          <div class="col-span-2"><span class="text-slate-500">生成时间:</span><span id="metaTime" class="text-slate-400 font-mono">-</span></div>
        </div>

        <!-- 当时的风格描述 -->
        <div>
          <span class="text-[10px] font-semibold uppercase text-slate-400 block mb-0.5">当时的风格 (Style)</span>
          <div id="metaStyle" class="text-xs text-slate-300 bg-slate-950/50 border border-slate-800 p-2 rounded-lg max-h-16 overflow-y-auto whitespace-pre-wrap leading-relaxed">-</div>
        </div>

        <!-- 当时的歌词 -->
        <div>
          <span class="text-[10px] font-semibold uppercase text-slate-400 block mb-0.5">当时的完整歌词 (Lyrics)</span>
          <div id="metaLyrics" class="text-xs text-slate-300 font-mono bg-slate-950/50 border border-slate-800 p-2 rounded-lg max-h-36 overflow-y-auto whitespace-pre-wrap leading-relaxed">-</div>
        </div>
      </div>

      <!-- 3. 生成历史列表 (点击标题直接播放与查看信息) -->
      <div class="glass p-4 rounded-2xl flex flex-col">
        <div class="flex items-center justify-between border-b border-slate-700/50 pb-2 mb-2">
          <h4 class="text-xs font-bold uppercase text-slate-300 tracking-wider flex items-center gap-1.5">
            <i class="fa-solid fa-clock-rotate-left text-purple-400"></i> 创作历史 (<span id="historyCount">0</span>)
          </h4>
          <span class="text-[10px] text-slate-500">点击任意歌曲即播</span>
        </div>

        <div id="historyList" class="max-h-52 overflow-y-auto space-y-1.5 pr-1">
          <div class="text-xs text-slate-500 text-center py-4">暂无历史记录</div>
        </div>
      </div>

      <!-- 控制台日志 -->
      <div class="glass p-3 rounded-2xl flex flex-col">
        <div class="flex items-center justify-between mb-1">
          <span class="text-[11px] font-bold uppercase text-slate-400"><i class="fa-solid fa-terminal text-emerald-400 mr-1"></i> 系统状态</span>
          <button id="clearLogBtn" class="text-[10px] text-slate-500 hover:text-slate-300">清屏</button>
        </div>
        <div id="statusBox" class="max-h-20 bg-slate-950/90 rounded-lg p-2 font-mono text-[11px] text-slate-400 overflow-y-auto space-y-0.5 border border-slate-800">
          <div>[Ready] 页面已就绪</div>
        </div>
      </div>

    </section>
  </main>

  <script>
    const API_BASE = window.location.origin.startsWith("http")
      ? window.location.origin
      : "http://127.0.0.1:8000";

    const samplePreset = {
      title: "今晚不眠",
      style: "City Pop, upbeat, danceable, groovy bass\nelectric guitar, synth, energetic, joyful\nneon city night, emotional male vocal",
      lyrics: "[Intro]\n\n[Verse]\n路灯眨着眼睛 偷看谁的身影\n街道哼着小调 节奏多轻盈\n晚风染成霓虹 吹乱发际线\n脚步踩着鼓点 不需要终点\n\n[Pre-Chorus]\n旋转的唱片 划破了寂静\n气泡在上升 快乐在飞行\n把烦恼抛去 别再去在意\n这里的空气 充满了魔力\n\n[Chorus]\n今晚不眠 快乐无限\n城市在狂欢 我们在中间\n自由摇摆 光芒盛开\n跟着这节拍 把心打开\n\n[Outro]\n霓虹色的风 吹向那梦\n摇摆\n闪耀\nYeah",
      cot: "full",
      seed: 12300
    };

    let historyRecords = [];
    let currentItem = null;

    // DOM 元素
    const titleInput = document.getElementById("titleInput");
    const styleInput = document.getElementById("styleInput");
    const lyricsInput = document.getElementById("lyricsInput");
    const seedInput = document.getElementById("seedInput");
    const cotInput = document.getElementById("cotInput");
    const randSeedBtn = document.getElementById("randSeedBtn");
    const submitBtn = document.getElementById("submitBtn");
    const statusBox = document.getElementById("statusBox");
    const audioPlayer = document.getElementById("audioPlayer");
    const downloadLink = document.getElementById("downloadLink");
    const cdDisc = document.getElementById("cdDisc");
    const historyList = document.getElementById("historyList");
    const historyCount = document.getElementById("historyCount");

    // 详情面板 DOM
    const metaPanel = document.getElementById("metaPanel");
    const metaSeed = document.getElementById("metaSeed");
    const metaCot = document.getElementById("metaCot");
    const metaTime = document.getElementById("metaTime");
    const metaStyle = document.getElementById("metaStyle");
    const metaLyrics = document.getElementById("metaLyrics");
    const reuseConfigBtn = document.getElementById("reuseConfigBtn");

    function log(msg, color = "text-slate-400") {
      const el = document.createElement("div");
      el.className = color;
      el.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
      statusBox.appendChild(el);
      statusBox.scrollTop = statusBox.scrollHeight;
    }

    document.getElementById("clearLogBtn").addEventListener("click", () => statusBox.innerHTML = "");

    // 随机种子按钮
    randSeedBtn.addEventListener("click", () => {
      seedInput.value = Math.floor(Math.random() * 2147483647);
      log(`已生成随机种子: ${seedInput.value}`, "text-purple-400");
    });

    // 载入预设
    document.getElementById("loadPresetBtn").addEventListener("click", () => {
      titleInput.value = samplePreset.title;
      styleInput.value = samplePreset.style;
      lyricsInput.value = samplePreset.lyrics;
      cotInput.value = samplePreset.cot;
      seedInput.value = samplePreset.seed;
      log("已填入《今晚不眠》预设参数", "text-purple-400");
    });

    // 回填配置到左侧输入框
    reuseConfigBtn.addEventListener("click", () => {
      if (!currentItem) return;
      titleInput.value = currentItem.title || "";
      styleInput.value = currentItem.style || "";
      lyricsInput.value = currentItem.lyrics || "";
      cotInput.value = currentItem.cot || "full";
      seedInput.value = currentItem.seed !== undefined ? currentItem.seed : "";
      log(`已将《${currentItem.title}》的制作参数回填到左侧!`, "text-cyan-400");
    });

    // 播放指定歌曲并展示当时的制作信息
    function playAndShowDetails(item) {
      currentItem = item;
      const finalAudioUrl = item.audio_url.startsWith("http") ? item.audio_url : `${API_BASE}${item.audio_url}`;

      // 1. 播放卡片更新
      document.getElementById("displayTitle").textContent = item.title;
      document.getElementById("displaySeedBadge").textContent = `Seed: ${item.seed !== undefined ? item.seed : '未记录'}`;
      audioPlayer.src = finalAudioUrl;
      audioPlayer.play().catch(() => {});

      downloadLink.href = finalAudioUrl;
      downloadLink.download = `${item.title}.flac`;
      downloadLink.classList.remove("hidden");
      downloadLink.classList.add("flex");

      // 2. 制作信息面板更新
      metaSeed.textContent = item.seed !== undefined ? item.seed : "未记录";
      metaCot.textContent = item.cot || "full";
      metaTime.textContent = item.created_at || "未知";
      metaStyle.textContent = item.style || "无";
      metaLyrics.textContent = item.lyrics || "无歌词记录";
      reuseConfigBtn.classList.remove("hidden");

      renderHistory(); // 刷新高亮
      log(`正在播放并展示: 《${item.title}》`, "text-cyan-400");
    }

    // 渲染历史列表
    function renderHistory() {
      historyCount.textContent = historyRecords.length;
      if (historyRecords.length === 0) {
        historyList.innerHTML = `<div class="text-xs text-slate-500 text-center py-4">暂无历史记录</div>`;
        return;
      }

      historyList.innerHTML = historyRecords.map(item => {
        const isPlaying = currentItem && item.task_id === currentItem.task_id;
        const activeClass = isPlaying
          ? "bg-purple-900/40 border-purple-500/70 shadow-[0_0_12px_rgba(168,85,247,0.3)]"
          : "bg-slate-900/60 border-slate-800 hover:border-slate-700 hover:bg-slate-800/60";

        return `
          <div onclick='handleHistoryClick("${item.task_id}")'
               class="cursor-pointer group flex items-center justify-between p-2 rounded-xl border ${activeClass} transition-all duration-150">
            <div class="flex items-center gap-2 overflow-hidden">
              <div class="w-6 h-6 rounded-lg ${isPlaying ? 'bg-purple-600 text-white animate-pulse' : 'bg-slate-800 text-purple-400 group-hover:bg-purple-600 group-hover:text-white'} flex items-center justify-center text-[10px] shrink-0 transition">
                <i class="fa-solid ${isPlaying ? 'fa-volume-high' : 'fa-play'}"></i>
              </div>
              <div class="overflow-hidden">
                <div class="text-xs font-semibold text-white truncate group-hover:text-purple-300 transition">${item.title}</div>
                <div class="text-[10px] text-slate-500 truncate">Seed: ${item.seed !== undefined ? item.seed : '-'} | ${item.created_at ? item.created_at.split(' ')[1] : ''}</div>
              </div>
            </div>
            <span class="text-[10px] px-1.5 py-0.5 rounded bg-slate-800 text-slate-400 border border-slate-700 shrink-0">听歌/详情</span>
          </div>
        `;
      }).join('');
    }

    window.handleHistoryClick = (taskId) => {
      const found = historyRecords.find(x => x.task_id === taskId);
      if (found) playAndShowDetails(found);
    };

    async function loadHistory() {
      try {
        const res = await fetch(`${API_BASE}/api/history`);
        if (res.ok) {
          historyRecords = await res.json();
          renderHistory();
          if (historyRecords.length > 0 && !currentItem) {
            // 默认把最新一首歌的详情展现出来(不自动发声打扰)
            const latest = historyRecords[0];
            metaSeed.textContent = latest.seed !== undefined ? latest.seed : "未记录";
            metaCot.textContent = latest.cot || "full";
            metaTime.textContent = latest.created_at || "未知";
            metaStyle.textContent = latest.style || "无";
            metaLyrics.textContent = latest.lyrics || "无歌词记录";
            reuseConfigBtn.classList.remove("hidden");
            document.getElementById("displayTitle").textContent = latest.title;
            document.getElementById("displaySeedBadge").textContent = `Seed: ${latest.seed !== undefined ? latest.seed : '未记录'}`;
          }
        }
      } catch (e) {
        log(`拉取历史失败: ${e.message}`, "text-rose-400");
      }
    }

    audioPlayer.addEventListener("play", () => cdDisc.classList.add("animate-spin"));
    audioPlayer.addEventListener("pause", () => cdDisc.classList.remove("animate-spin"));
    audioPlayer.addEventListener("ended", () => cdDisc.classList.remove("animate-spin"));

    // 提交生成任务
    submitBtn.addEventListener("click", async () => {
      const seedVal = seedInput.value.trim();
      const payload = {
        title: titleInput.value.trim() || "未命名歌曲",
        style: styleInput.value.trim(),
        lyrics: lyricsInput.value.trim(),
        cot: cotInput.value,
        seed: seedVal === "" ? null : parseInt(seedVal)
      };

      if (!payload.style || !payload.lyrics) {
        alert("曲风描述 (Style) 和 歌词 (Lyrics) 为必填项!");
        return;
      }

      submitBtn.disabled = true;
      submitBtn.classList.add("opacity-50", "cursor-not-allowed");
      submitBtn.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> 正在生成,耗时 1~3 分钟,请稍候...`;
      cdDisc.classList.add("animate-spin");
      log(`🚀 任务提交: 《${payload.title}${payload.seed === null ? ' (模式: 随机种子)' : ` (Seed: ${payload.seed})`}`, "text-yellow-400");

      try {
        const resp = await fetch(`${API_BASE}/api/generate`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(payload)
        });

        if (!resp.ok) {
          const errData = await resp.json().catch(() => ({}));
          throw new Error(errData.detail || `服务端错误 (HTTP ${resp.status})`);
        }

        const data = await resp.json();
        log(`🎉 生成成功!使用的随机种子为: ${data.seed}`, "text-emerald-400");

        // 插入历史
        historyRecords.unshift(data);
        renderHistory();

        // 播放并展示完整档案
        playAndShowDetails(data);

      } catch (err) {
        log(`❌ 失败: ${err.message}`, "text-rose-400");
      } finally {
        submitBtn.disabled = false;
        submitBtn.classList.remove("opacity-50", "cursor-not-allowed");
        submitBtn.innerHTML = `<i class="fa-solid fa-compact-disc"></i> 开始生成全曲`;
        if (audioPlayer.paused) cdDisc.classList.remove("animate-spin");
      }
    });

    window.addEventListener("DOMContentLoaded", loadHistory);
  </script>
</body>
</html>

一个小demo,懒得提交github了

Sign up or log in to comment