y595705120 2 týždňov pred
rodič
commit
948d874e28

+ 0 - 54
build.go

@@ -1,54 +0,0 @@
-package main
-
-import (
-	"flag"
-	"fmt"
-	"os"
-	"os/exec"
-	"runtime"
-	"strings"
-)
-
-var target = "../www/"
-
-func main() {
-	fmt.Println("=============start==============")
-	dir := flag.String("dir", "yyq_admin", "dir")
-	flag.Parse()
-	target += *dir + "/"
-	fmt.Println("target_dir", target)
-	os.RemoveAll(target + "static")
-	os.Remove(target + "index.html")
-	// os.Remove(target + "favicon.ico")
-
-	copyDir("./dist", target)
-}
-func FormatPath(s string) string {
-	switch runtime.GOOS {
-	case "windows":
-		return strings.Replace(s, "/", "\\", -1)
-	case "darwin", "linux":
-		return strings.Replace(s, "\\", "/", -1)
-	default:
-		return s
-	}
-}
-
-func copyDir(src string, dest string) {
-	src = FormatPath(src)
-	dest = FormatPath(dest)
-	var cmd *exec.Cmd
-	switch runtime.GOOS {
-	case "windows":
-		cmd = exec.Command("xcopy", src, dest, "/I", "/E")
-	case "darwin", "linux":
-		cmd = exec.Command("cp", "-R", src, dest)
-	}
-
-	outPut, e := cmd.Output()
-	if e != nil {
-		fmt.Println("copy error", e.Error())
-		return
-	}
-	fmt.Println(string(outPut))
-}

+ 16 - 0
deploy.bat

@@ -0,0 +1,16 @@
+@echo off
+set TARGET=..\www\study_nd
+
+echo Deleting %TARGET%\static...
+if exist "%TARGET%\static" rmdir /s /q "%TARGET%\static"
+
+echo Deleting %TARGET%\index.html...
+if exist "%TARGET%\index.html" del /q "%TARGET%\index.html"
+
+echo Copying dist\index.html to %TARGET%...
+copy /y dist\index.html "%TARGET%\index.html"
+
+echo Copying dist\static to %TARGET%...
+xcopy /e /i /y dist\static "%TARGET%\static"
+
+echo Done.

+ 0 - 10
go.mod

@@ -1,10 +0,0 @@
-module study
-
-go 1.15
-
-require (
-	github.com/GeertJohan/go.rice v1.0.0 // indirect
-	github.com/lxn/win v0.0.0-20201111105847-2a20daff6a55 // indirect
-	github.com/sciter-sdk/go-sciter v0.5.0
-	github.com/webview/webview v0.0.0-20200724072439-e0c01595b361 // indirect
-)

+ 0 - 19
go.sum

@@ -1,19 +0,0 @@
-github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
-github.com/GeertJohan/go.rice v1.0.0 h1:KkI6O9uMaQU3VEKaj01ulavtF7o1fWT7+pk/4voiMLQ=
-github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0=
-github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
-github.com/daaku/go.zipexe v1.0.0 h1:VSOgZtH418pH9L16hC/JrgSNJbbAL26pj7lmD1+CGdY=
-github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
-github.com/lxn/win v0.0.0-20201111105847-2a20daff6a55 h1:4BxFx5XCtXc+nFtXDGDW+Uu5sPtsAbvPh6RObj3fG9o=
-github.com/lxn/win v0.0.0-20201111105847-2a20daff6a55/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
-github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E=
-github.com/sciter-sdk/go-sciter v0.5.0 h1:A0gESvTFZR/yMFkWnQ4tFVUdsSchuShNGr25QodE8gQ=
-github.com/sciter-sdk/go-sciter v0.5.0/go.mod h1:myhbEunWE4R+6J7GxJsr4SU4q0OIMOEbWFJ2qYzIdPc=
-github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
-github.com/webview/webview v0.0.0-20200724072439-e0c01595b361 h1:e0+/fQY5l9NdCwPsEg9S8AgE5lFhZ/6UX+b2KkpIBFg=
-github.com/webview/webview v0.0.0-20200724072439-e0c01595b361/go.mod h1:rpXAuuHgyEJb6kXcXldlkOjU6y4x+YcASKKXJNUhh0Y=
-golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13 h1:5jaG59Zhd+8ZXe8C+lgiAGqkOaZBruqrWclLkgAww34=
-golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

+ 0 - 36
main.go

@@ -1,36 +0,0 @@
-package main
-
-import (
-	"log"
-
-	"github.com/sciter-sdk/go-sciter"
-	"github.com/sciter-sdk/go-sciter/window"
-)
-
-func main() {
-	//创建window窗口
-	//参数一表示创建窗口的样式
-	//SW_TITLEBAR 顶层窗口,有标题栏
-	//SW_RESIZEABLE 可调整大小
-	//SW_CONTROLS 有最小/最大按钮
-	//SW_MAIN 应用程序主窗口,关闭后其他所有窗口也会关闭
-	//SW_ENABLE_DEBUG 可以调试
-	//参数二表示创建窗口的矩形
-	w, err := window.New(sciter.SW_TITLEBAR|
-		sciter.SW_RESIZEABLE|
-		sciter.SW_CONTROLS|
-		sciter.SW_MAIN|
-		sciter.SW_ENABLE_DEBUG,
-		nil)
-	if err != nil {
-		log.Fatal(err)
-	}
-	//加载文件
-	w.LoadFile("dist/index.html")
-	//设置标题
-	w.SetTitle("你好,世界")
-	//显示窗口
-	w.Show()
-	//运行窗口,进入消息循环
-	w.Run()
-}

+ 823 - 0
play2.html

@@ -0,0 +1,823 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>视频播放 - 宁德市建筑业协会</title>
+  <script src="./js/hls.min.js"></script>
+  <script src="./js/md5.min.js"></script>
+  <style>
+    * { margin: 0; padding: 0; box-sizing: border-box; }
+    body {
+      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+      background: #f0f2f5;
+      min-height: 100vh;
+    }
+    .pc-player-container {
+      min-height: 100vh;
+      background: #f0f2f5;
+      padding: 20px;
+    }
+    .pc-player-wrapper {
+      max-width: 1440px;
+      margin: 0 auto;
+      display: flex;
+      gap: 20px;
+      height: calc(100vh - 40px);
+    }
+    .pc-player-left { flex: 1; min-width: 0; display: flex; flex-direction: column; }
+    .panel-toggle {
+      flex-shrink: 0; width: 20px; display: flex; align-items: center; justify-content: center;
+      cursor: pointer; color: #999; font-size: 14px; user-select: none;
+    }
+    .panel-toggle:hover { color: #409EFF; }
+    .video-section {
+      background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow: hidden;
+      display: flex; flex-direction: column; height: 100%;
+    }
+    .video-title {
+      padding: 16px 20px; font-size: 16px; font-weight: 600; color: #1a1a2e;
+      border-bottom: 1px solid #f0f0f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+      flex-shrink: 0;
+    }
+    .video-player-wrap {
+      width: 100%; background: #000; position: relative; flex: 1; min-height: 0;
+      display: flex; align-items: center; justify-content: center;
+    }
+    .video-player-wrap video {
+      width: 100%; height: 100%; display: block; outline: none;
+      max-height: calc(100vh - 200px);
+    }
+    .loading-overlay {
+      position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
+      background: rgba(0,0,0,0.5); color: #fff; font-size: 16px; z-index: 10;
+      transition: opacity .3s;
+    }
+    .loading-overlay.hidden { opacity: 0; pointer-events: none; }
+    .loading-spinner { width: 40px; height: 40px; border: 3px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: spin .8s linear infinite; margin-right: 12px; }
+    @keyframes spin { to { transform: rotate(360deg); } }
+    .dialog-footer {
+      padding: 8px 16px; border-top: 1px solid #f0f0f0; background: #fafafa; flex-shrink: 0;
+    }
+    .media-footer { display: flex; align-items: center; line-height: 1; }
+    .media-time {
+      font-size: 14px; color: #666; white-space: nowrap; font-variant-numeric: tabular-nums;
+      display: flex; align-items: center; gap: 4px;
+    }
+    .media-center { display: flex; align-items: center; justify-content: center; gap: 12px; flex:1; }
+    .btn-icon {
+      display: inline-flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 600;
+      cursor: pointer; user-select: none; padding: 4px 8px; border: 1px solid #dcdfe6;
+      border-radius: 4px; color: #606266; background: #fff; transition: all .2s;
+    }
+    .btn-icon:hover { color: #409EFF; border-color: #c6e2ff; background: #ecf5ff; }
+    .btn-icon.is-active { color: #409EFF; border-color: #409EFF; background: #ecf5ff; }
+    .volume-group { display: inline-flex; align-items: center; gap: 6px; }
+    .volume-slider { width: 100px; height: 4px; -webkit-appearance: none; appearance: none; background: #dcdfe6; border-radius: 2px; outline: none; }
+    .volume-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: #409EFF; cursor: pointer; }
+    .quality-select {
+      height: 28px; font-size: 12px; padding: 0 8px; border: 1px solid #dcdfe6;
+      border-radius: 4px; outline: none; color: #606266; background: #fff; cursor: pointer;
+    }
+    .quality-select:focus { border-color: #409EFF; }
+
+    .pc-player-right {
+      width: 380px; flex-shrink: 0; background: #fff; border-radius: 12px;
+      box-shadow: 0 2px 12px rgba(0,0,0,0.08); display: flex; flex-direction: column; overflow: hidden;
+    }
+    .playlist-header {
+      padding: 16px 20px 12px; border-bottom: 1px solid #f0f0f0; flex-shrink: 0;
+    }
+    .playlist-title { font-size: 16px; font-weight: 600; color: #1a1a2e; }
+    .playlist-header-row {
+      display: flex; align-items: center; justify-content: space-between; margin-top: 6px;
+    }
+    .playlist-count { font-size: 12px; color: #999; }
+    .finished-count { color: #67C23A; }
+    .hide-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; cursor: pointer; color: #666; user-select: none; }
+    .hide-toggle input { cursor: pointer; }
+    .playlist-body { flex: 1; overflow-y: auto; padding: 8px 0; }
+    .playlist-body::-webkit-scrollbar { width: 6px; }
+    .playlist-body::-webkit-scrollbar-thumb { background: #ddd; border-radius: 3px; }
+    .playlist-item {
+      display: flex; align-items: center; padding: 12px 20px; cursor: pointer;
+      transition: all .2s; border-left: 3px solid transparent; gap: 12px;
+    }
+    .playlist-item:hover { background: #f5f7fa; }
+    .playlist-item.is-active { background: #ecf5ff; border-left-color: #409EFF; }
+    .playlist-item.is-finished { opacity: .65; }
+    .item-index {
+      width: 28px; height: 28px; border-radius: 50%; background: #f0f2f5;
+      display: flex; align-items: center; justify-content: center; font-size: 12px;
+      font-weight: 600; color: #666; flex-shrink: 0;
+    }
+    .item-index.active-index { background: #409EFF; color: #fff; }
+    .finish-badge { color: #67C23A; font-size: 14px; }
+    .item-info { flex: 1; min-width: 0; }
+    .item-name { font-size: 14px; color: #333; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 2px; }
+    .is-finished .item-name { color: #999; text-decoration: line-through; }
+    .is-active .item-name { color: #409EFF; }
+    .item-meta { display: flex; gap: 12px; font-size: 12px; color: #999; }
+    .item-progress { color: #409EFF; }
+    .is-finished .item-progress { color: #67C23A; }
+    .item-action { flex-shrink: 0; }
+    .play-btn {
+      padding: 4px 10px; font-size: 12px; border: 1px solid #409EFF; color: #409EFF;
+      background: transparent; border-radius: 4px; cursor: pointer; transition: all .2s;
+    }
+    .play-btn:hover { background: #409EFF; color: #fff; }
+    .play-btn.pause-btn { background: #e6a23c; border-color: #e6a23c; color: #fff; }
+    .playlist-empty { text-align: center; padding: 60px 20px; color: #ccc; font-size: 14px; }
+
+    .toast {
+      position: fixed; top: 20px; left: 50%; transform: translateX(-50%); z-index: 9999;
+      padding: 10px 24px; border-radius: 6px; font-size: 14px; color: #fff;
+      transition: all .3s; opacity: 0; pointer-events: none;
+    }
+    .toast.show { opacity: 1; }
+    .toast.success { background: #67C23A; }
+    .toast.error { background: #F56C6C; }
+    .toast.info { background: #909399; }
+
+    .qr-modal-overlay {
+      position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 9998;
+      display: none; align-items: center; justify-content: center;
+    }
+    .qr-modal-overlay.show { display: flex; }
+    .qr-modal {
+      background: #fff; border-radius: 12px; padding: 24px; text-align: center;
+      box-shadow: 0 8px 32px rgba(0,0,0,0.2); max-width: 320px; width: 90%;
+      position: relative;
+    }
+    .qr-modal-title { font-size: 16px; font-weight: 600; color: #1a1a2e; margin-bottom: 16px; }
+    .qr-modal img { width: 200px; height: 200px; border: 1px solid #eee; border-radius: 8px; }
+    .qr-modal-hint { font-size: 12px; color: #999; margin-top: 12px; }
+    .qr-modal-close {
+      position: absolute; top: 12px; right: 12px; width: 28px; height: 28px;
+      border: none; background: #f0f0f0; border-radius: 50%; cursor: pointer;
+      font-size: 16px; color: #666; display: flex; align-items: center; justify-content: center;
+    }
+    .qr-modal-close:hover { background: #e0e0e0; }
+
+    @media (max-width: 900px) {
+      .pc-player-wrapper { flex-direction: column; height: auto; }
+      .pc-player-right { width: 100%; max-height: 400px; }
+      .pc-player-container { padding: 12px; }
+      .volume-slider { width: 60px; }
+    }
+  </style>
+</head>
+<body>
+  <div class="pc-player-container">
+    <div class="pc-player-wrapper">
+      <div class="pc-player-left">
+        <div class="video-section">
+          <div class="video-title" id="videoTitle">加载中...</div>
+          <div class="video-player-wrap">
+            <video id="videoElement" playsinline preload="auto"></video>
+            <div class="loading-overlay" id="loadingOverlay">
+              <div class="loading-spinner"></div>
+              <span>加载中...</span>
+            </div>
+          </div>
+          <div class="dialog-footer">
+            <div class="media-footer">
+              <div class="media-time">
+                <span id="currentTime">00:00:00</span>
+                <strong>/</strong>
+                <span id="totalTime">00:00:00</span>
+              </div>
+              <div class="media-center">
+                <span class="volume-group">
+                  <span class="btn-icon" id="muteBtn">音量</span>
+                  <input type="range" class="volume-slider" id="volumeSlider" min="0" max="1" step="0.05" value="0.5">
+                </span>
+                <span class="btn-icon" id="pipBtn">画中画</span>
+                <span class="btn-icon" id="qrBtn">二维码</span>
+              </div>
+              <div style="display:flex;align-items:center;justify-content:flex-end;flex:1;">
+                <select class="quality-select" id="qualitySelect">
+                  <option value="ld">流畅</option>
+                  <option value="hls" selected>标清</option>
+                  <option value="mp4">适配</option>
+                </select>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="panel-toggle" id="panelToggle">&lt;</div>
+
+      <div class="pc-player-right" id="playlistPanel">
+        <div class="playlist-header">
+          <div class="playlist-title">课程列表</div>
+          <div class="playlist-header-row">
+            <div class="playlist-count">
+              共 <span id="totalCount">0</span> 个视频
+              <span class="finished-count" id="finishedCountWrap" style="display:none;">,已完成 <span id="finishedCount">0</span> 个</span>
+            </div>
+            <label class="hide-toggle">
+              <input type="checkbox" id="hideFinishedCheck"> 隐藏已完成
+            </label>
+          </div>
+        </div>
+        <div class="playlist-body" id="playlistBody"></div>
+      </div>
+    </div>
+  </div>
+
+  <div class="toast" id="toast"></div>
+
+  <div class="qr-modal-overlay" id="qrModalOverlay">
+    <div class="qr-modal">
+      <button class="qr-modal-close" id="qrModalClose">&times;</button>
+      <div class="qr-modal-title">扫码分享当前页面</div>
+      <img id="qrCodeImg" src="" alt="二维码">
+      <div class="qr-modal-hint">使用手机扫描二维码打开当前页面</div>
+    </div>
+  </div>
+
+  <script>
+    const VERSION = '1.0';
+    const SECRET = '017c0743819088468e590246464cc75f';
+    const API_BASE = 'https://ndapi.hqedust.com:18443/rental';
+
+    // ---- DOM refs ----
+    const video = document.getElementById('videoElement');
+    const loadingOverlay = document.getElementById('loadingOverlay');
+    const videoTitle = document.getElementById('videoTitle');
+    const currentTimeEl = document.getElementById('currentTime');
+    const totalTimeEl = document.getElementById('totalTime');
+    const volumeSlider = document.getElementById('volumeSlider');
+    const muteBtn = document.getElementById('muteBtn');
+    const pipBtn = document.getElementById('pipBtn');
+    const qualitySelect = document.getElementById('qualitySelect');
+    const panelToggle = document.getElementById('panelToggle');
+    const playlistPanel = document.getElementById('playlistPanel');
+    const playlistBody = document.getElementById('playlistBody');
+    const totalCount = document.getElementById('totalCount');
+    const finishedCount = document.getElementById('finishedCount');
+    const finishedCountWrap = document.getElementById('finishedCountWrap');
+    const hideFinishedCheck = document.getElementById('hideFinishedCheck');
+    const toast = document.getElementById('toast');
+
+    // ---- state ----
+    let uid = 0, token = '', courseId = 0, mediaId = 0;
+    let mediaList = [];
+    let currentMedia = null;
+    let currentMediaUrl = '';
+    let isPlaying = false;
+    let isMuted = false;
+    let volume = 0.5;
+    let curTimes = 0;
+    let tickTimer = null;
+    let tickNum = 0;
+    let hasSeeked = false;
+    let hiddenStartTime = 0;
+    let hiddenBasePosition = 0;
+    let isPiP = false;
+    let hls = null;
+    let showPanel = true;
+
+    // ---- utils ----
+    function parseTime(seconds) {
+      seconds = parseInt(seconds) || 0;
+      const h = String(Math.floor(seconds / 3600)).padStart(2, '0');
+      const m = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
+      const s = String(seconds % 60).padStart(2, '0');
+      return h + ':' + m + ':' + s;
+    }
+
+    function getPercent(item) {
+      if (item.isFinish == 1) return 100;
+      if (!item.duration) return 0;
+      let p = parseInt((item.position || 0) * 10000 / item.duration) / 100;
+      return p >= 100 ? 99.99 : p;
+    }
+
+    function showToast(msg, type, duration) {
+      toast.textContent = msg;
+      toast.className = 'toast ' + type + ' show';
+      clearTimeout(toast._timer);
+      toast._timer = setTimeout(() => toast.classList.remove('show'), duration || 3000);
+    }
+
+    // ---- API ----
+    function signRequest(method, data) {
+      const body = JSON.stringify(data);
+      const mtime = Math.floor(Date.now() / 1000);
+      const raw = 'hall_' + VERSION + token + body + method + mtime + SECRET;
+      const sign = md5(raw);
+      const u = method.startsWith('Auth.') ? '0' : (uid || 0);
+      return API_BASE + '/' + method + '?t=' + mtime + '&u=' + u + '&v=' + VERSION + '&s=' + sign;
+    }
+
+    async function api(method, data) {
+      const url = signRequest(method, data);
+      const res = await fetch(url, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(data)
+      });
+      const json = await res.json();
+      if (json.code === 401) {
+        showToast('用户被踢下线,请重新登入', 'error', 5000);
+        return json;
+      }
+      if (json.code !== 200) {
+        showToast(json.msg || '请求失败', 'error');
+      }
+      return json;
+    }
+
+    function report(action, msg) {
+      api('course.report', { action, msg });
+    }
+
+    // ---- player ----
+    function loadVideo(src, position) {
+      if (hls) {
+        hls.destroy();
+        hls = null;
+      }
+      video.removeAttribute('src');
+
+      if (src.includes('/mp4/')) {
+        video.src = src;
+        video.addEventListener('loadedmetadata', function() {
+          loadingOverlay.classList.add('hidden');
+          if (position > 0) video.currentTime = position;
+          safePlay();
+        }, { once: true });
+        video.addEventListener('error', function() {
+          showToast('视频加载失败,尝试切换清晰度', 'error');
+        }, { once: true });
+      } else if (Hls.isSupported()) {
+        hls = new Hls({
+          enableWorker: true,
+          xhrSetup: function(xhr) {
+            xhr.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
+            xhr.setRequestHeader('Pragma', 'no-cache');
+          }
+        });
+        hls.loadSource(src);
+        hls.attachMedia(video);
+        hls.on(Hls.Events.MANIFEST_PARSED, function() {
+          loadingOverlay.classList.add('hidden');
+          if (position > 0) {
+            video.currentTime = position;
+          }
+          safePlay();
+        });
+        hls.on(Hls.Events.ERROR, function(e, data) {
+          if (data.fatal) {
+            showToast('视频加载失败,尝试切换清晰度', 'error');
+          }
+        });
+      } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
+        video.src = src;
+        video.addEventListener('loadedmetadata', function() {
+          loadingOverlay.classList.add('hidden');
+          if (position > 0) video.currentTime = position;
+          safePlay();
+        }, { once: true });
+      } else {
+        showToast('您的浏览器不支持 HLS 播放', 'error');
+      }
+    }
+
+    function safePlay() {
+      const p = video.play();
+      if (p && p.catch) p.catch(function() {});
+    }
+
+    function setVolume(val) {
+      volume = Math.max(0, Math.min(1, val));
+      video.volume = volume;
+      video.muted = isMuted;
+      volumeSlider.value = volume;
+      muteBtn.textContent = volume === 0 || isMuted ? '静音' : '音量';
+    }
+
+    function setPosition(pos) {
+      if (pos > (currentMedia ? currentMedia.duration : Infinity)) pos = currentMedia.duration;
+      video.currentTime = pos;
+      safePlay();
+      if (currentMedia && currentMedia.isFinish) return;
+      if (currentMedia && currentMedia.position >= currentMedia.duration - 10 && !currentMedia.isFinish) {
+        tick(true);
+      }
+    }
+
+    // ---- course loading ----
+    async function loadCourse() {
+      const res = await api('course.getCourse', { courseId });
+      if (res.code !== 200) return;
+      const { info, extra, list, tpl } = res.data;
+      const courseInfo = Object.assign(info || {}, extra || {});
+      mediaList = (list || []).map(function(item) {
+        item.percent = getPercent(item);
+        return item;
+      });
+      updatePlaylist();
+
+      let target = null;
+      if (mediaId) {
+        target = mediaList.find(function(i) { return i.id == mediaId || i.mediaId == mediaId; });
+      }
+      if (!target) target = mediaList.find(function(i) { return i.isFinish != 1; });
+      if (!target && mediaList.length) target = mediaList[0];
+      if (target) {
+        loadMedia(target);
+      } else {
+        videoTitle.textContent = (tpl || {}).name || '暂无视频';
+      }
+    }
+
+    async function loadMedia(item, replay) {
+      currentMedia = item;
+      videoTitle.textContent = item.name;
+      loadingOverlay.classList.remove('hidden');
+      hasSeeked = false;
+      isPlaying = false;
+      stopTick();
+
+      const res = await api('course.GetMedia', { id: item.id });
+      if (res.code !== 200) {
+        loadingOverlay.classList.add('hidden');
+        return;
+      }
+      const data = res.data || {};
+      let mediaUrl = data.mediaUrl || '';
+      const position = (data.position || 0) < 10 ? 0 : (data.position || 0);
+      const duration = data.duration || 0;
+      currentMedia.position = position;
+      currentMedia.duration = duration;
+      currentMedia.id = data.id || item.id;
+      if(!replay){
+        currentMediaUrl = mediaUrl;
+        qualitySelect.value = mediaUrl.includes('/mp4/') ? 'mp4' : (mediaUrl.includes('/ld/') ? 'ld' : 'hls');
+      }
+      curTimes = 0;
+      loadVideo(mediaUrl, position);
+      startTick();
+      updatePlaylist();
+      report('play', 'start');
+    }
+
+    function changeMedia(quality) {
+      if (!currentMediaUrl) return;
+      const u = new URL(currentMediaUrl);
+      const m = u.pathname.match(/(\d+)(?:\.mp4|\.m3u8)/);
+      if (!m) return;
+      const id = m[1];
+      if (quality === 'mp4') {
+        u.pathname = '/mp4/' + id + '.mp4';
+      } else if (quality === 'hls') {
+        u.pathname = '/hls/' + id + '/' + id + '.m3u8';
+      } else {
+        u.pathname = '/ld/' + id + '/' + id + '.m3u8';
+      }
+      currentMediaUrl = u.toString();
+      const pos = video.currentTime || 0;
+      loadVideo(currentMediaUrl, pos > 0 ? pos : 0);
+    }
+
+    // ---- tick system ----
+    function startTick() {
+      stopTick();
+      tickTimer = setInterval(tryTick, 5000);
+    }
+
+    function stopTick() {
+      if (tickTimer) {
+        clearInterval(tickTimer);
+        tickTimer = null;
+      }
+    }
+
+    function getPlayerTime() {
+      if (hiddenStartTime) {
+        const elapsed = (Date.now() - hiddenStartTime) / 1000;
+        const estimated = hiddenBasePosition + elapsed;
+        const actual = video.currentTime || 0;
+        if (actual >= estimated - 0.5) {
+          hiddenStartTime = 0;
+          return actual;
+        }
+        return Math.min(estimated, currentMedia ? currentMedia.duration : Infinity);
+      }
+      return video.currentTime || 0;
+    }
+
+    function tryTick() {
+      try { doTick(); } catch (e) { report('play', '' + e.message); }
+    }
+
+    async function doTick(force) {
+      if (!currentMedia || currentMedia.isFinish) return;
+      tickNum++;
+      const ct = Math.floor(getPlayerTime());
+      curTimes = ct;
+
+      if (navigator.webdriver) {
+        report('play', 'webdriver');
+        doPause();
+        return;
+      }
+
+      let isFinish = force ? 1 : 0;
+      if (ct >= currentMedia.duration) isFinish = 1;
+
+      const res = await api('course.tick', {
+        id: currentMedia.id,
+        position: ct,
+        isFinish: isFinish
+      });
+      if (res.code !== 200) return;
+      const d = res.data || {};
+      if (d.closed) {
+        doPause();
+        showToast(d.msg || '课程已关闭', 'error', 10000);
+        return;
+      }
+      if (d.pause) {
+        doPause();
+        showToast(d.msg || '已暂停', 'info', 10000);
+        setTimeout(async function() {
+          console.log("load", currentMedia)
+          loadMedia(currentMedia);
+        }, 10000);
+        return;
+      }
+      if (!d.skip) {
+        const diff = Math.abs((d.position || 0) - ct);
+        if (diff > 3) setPosition(d.position);
+      }
+      Object.assign(currentMedia, d);
+      if (d.isFinish == 1) {
+        updatePlaylist();
+        await new Promise(r => setTimeout(r, 1000));
+        playNext();
+      }
+      updatePlaylist();
+    }
+
+    function tick(force) {
+      doTick(force);
+    }
+
+    // ---- playlist ----
+    function updatePlaylist() {
+      const finished = mediaList.filter(function(i) { return i.isFinish == 1; }).length;
+      totalCount.textContent = mediaList.length;
+      if (finished > 0) {
+        finishedCountWrap.style.display = 'inline';
+        finishedCount.textContent = finished;
+      } else {
+        finishedCountWrap.style.display = 'none';
+      }
+
+      const hide = hideFinishedCheck.checked;
+      const filtered = hide ? mediaList.filter(function(i) { return i.isFinish != 1; }) : mediaList;
+
+      playlistBody.innerHTML = '';
+      filtered.forEach(function(item, idx) {
+        const div = document.createElement('div');
+        div.className = 'playlist-item';
+        if (currentMedia && currentMedia.id === item.id) div.classList.add('is-active');
+        if (item.isFinish == 1) div.classList.add('is-finished');
+
+        const isCurrent = currentMedia && currentMedia.id === item.id;
+        const origIdx = mediaList.indexOf(item);
+
+        div.innerHTML =
+          '<div class="item-index' + (isCurrent ? ' active-index' : '') + '">' +
+            (item.isFinish == 1 ? '<span class="finish-badge">&#10003;</span>' : '<span>' + (origIdx + 1) + '</span>') +
+          '</div>' +
+          '<div class="item-info">' +
+            '<div class="item-name">' + item.name + '</div>' +
+            '<div class="item-meta">' +
+              '<span>学时:' + (item.xs / 10) + '</span>' +
+              '<span class="item-progress">' + getPercent(item) + '%</span>' +
+            '</div>' +
+          '</div>' +
+          '<div class="item-action">' +
+            (isCurrent && isPlaying
+              ? '<button class="play-btn pause-btn" data-action="pause">暂停</button>'
+              : isCurrent
+                ? '<button class="play-btn" data-action="play">播放</button>'
+                : '<button class="play-btn" data-action="load">播放</button>') +
+          '</div>';
+
+        div.addEventListener('click', function(e) {
+          if (e.target.tagName === 'BUTTON') return;
+          loadMedia(item);
+        });
+        const btn = div.querySelector('button');
+        if (btn) {
+          btn.addEventListener('click', function(e) {
+            e.stopPropagation();
+            const action = btn.dataset.action;
+            if (action === 'play') doPlay();
+            else if (action === 'pause') doPause();
+            else loadMedia(item);
+          });
+        }
+        playlistBody.appendChild(div);
+      });
+
+      if (filtered.length === 0) {
+        playlistBody.innerHTML = '<div class="playlist-empty">暂无视频</div>';
+      }
+    }
+
+    function playNext() {
+      if (!currentMedia) return;
+      const idx = mediaList.findIndex(function(i) { return i.id === currentMedia.id; });
+      let next = null;
+      for (let i = idx + 1; i < mediaList.length; i++) {
+        if (mediaList[i].isFinish != 1) { next = mediaList[i]; break; }
+      }
+      if (!next) {
+        for (let i = 0; i < idx; i++) {
+          if (mediaList[i].isFinish != 1) { next = mediaList[i]; break; }
+        }
+      }
+      if (next) {
+        showToast('即将播放: ' + next.name, 'success');
+        setTimeout(function() { loadMedia(next); }, 1000);
+      }
+    }
+
+    function doPlay() {
+      isPlaying = true;
+      startTick();
+      safePlay();
+      tickNum = 0;
+      updatePlaylist();
+    }
+
+    function doPause() {
+      isPlaying = false;
+      stopTick();
+      video.pause();
+      updatePlaylist();
+    }
+
+    // ---- PiP ----
+    async function enterPiP() {
+      if (!document.pictureInPictureEnabled || isPiP) return;
+      try {
+        await video.requestPictureInPicture();
+      } catch (e) {}
+    }
+
+    async function exitPiP() {
+      if (document.pictureInPictureElement) {
+        try { await document.exitPictureInPicture(); } catch (e) {}
+      }
+    }
+
+    function togglePiP() {
+      if (document.pictureInPictureElement) {
+        exitPiP();
+      } else {
+        enterPiP();
+      }
+    }
+
+    // ---- visibility change ----
+    function onVisibilityChange() {
+      if (document.hidden) {
+        hiddenStartTime = Date.now();
+        hiddenBasePosition = video.currentTime || 0;
+      } else if (hiddenStartTime) {
+        const elapsed = (Date.now() - hiddenStartTime) / 1000;
+        const estimated = hiddenBasePosition + elapsed;
+        hiddenStartTime = 0;
+        if (estimated < (currentMedia ? currentMedia.duration : Infinity)) {
+          setPosition(estimated);
+        }
+      }
+    }
+
+    // ---- events ----
+    video.addEventListener('timeupdate', function() {
+      curTimes = video.currentTime || 0;
+      currentTimeEl.textContent = parseTime(curTimes);
+    });
+
+    video.addEventListener('loadedmetadata', function() {
+      totalTimeEl.textContent = parseTime(video.duration || currentMedia?.duration || 0);
+    });
+
+    video.addEventListener('play', function() {
+      isPlaying = true;
+      report('play', 'start');
+      updatePlaylist();
+    });
+
+    video.addEventListener('pause', function() {
+      isPlaying = false;
+      report('play', 'pause');
+      updatePlaylist();
+    });
+
+    video.addEventListener('ended', function() {
+      report('play', 'end');
+      tick(true);
+      setTimeout(playNext, 500);
+    });
+
+    video.addEventListener('error', function(e) {
+      showToast('视频播放出错', 'error');
+      report('play', 'error');
+    });
+
+    video.addEventListener('enterpictureinpicture', function() {
+      isPiP = true;
+      pipBtn.textContent = '退出画中画';
+      pipBtn.classList.add('is-active');
+    });
+
+    video.addEventListener('leavepictureinpicture', function() {
+      isPiP = false;
+      pipBtn.textContent = '画中画';
+      pipBtn.classList.remove('is-active');
+    });
+
+    volumeSlider.addEventListener('input', function() {
+      setVolume(this.valueAsNumber);
+    });
+
+    muteBtn.addEventListener('click', function() {
+      isMuted = !isMuted;
+      setVolume(volume);
+    });
+
+    pipBtn.addEventListener('click', togglePiP);
+
+    qualitySelect.addEventListener('change', function() {
+      changeMedia(this.value);
+    });
+
+    panelToggle.addEventListener('click', function() {
+      showPanel = !showPanel;
+      playlistPanel.style.display = showPanel ? '' : 'none';
+      panelToggle.textContent = showPanel ? '>' : '<';
+    });
+
+    hideFinishedCheck.addEventListener('change', updatePlaylist);
+
+    // ---- QR code ----
+    const qrBtn = document.getElementById('qrBtn');
+    const qrModalOverlay = document.getElementById('qrModalOverlay');
+    const qrModalClose = document.getElementById('qrModalClose');
+    const qrCodeImg = document.getElementById('qrCodeImg');
+
+    function showQRCode() {
+      const url = window.location.href;
+      qrCodeImg.src = 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' + encodeURIComponent(url);
+      qrModalOverlay.classList.add('show');
+    }
+
+    function hideQRCode() {
+      qrModalOverlay.classList.remove('show');
+    }
+
+    qrBtn.addEventListener('click', showQRCode);
+    qrModalClose.addEventListener('click', hideQRCode);
+    qrModalOverlay.addEventListener('click', function(e) {
+      if (e.target === qrModalOverlay) hideQRCode();
+    });
+
+    // ---- init ----
+    function init() {
+      const params = new URLSearchParams(window.location.search);
+      uid = params.get('uid') || localStorage.getItem('uid') || 0;
+      token = params.get('token') || localStorage.getItem('token') || '';
+      courseId = parseInt(params.get('courseId') || params.get('id') || '0');
+      mediaId = parseInt(params.get('mediaId') || '0');
+
+      if (params.get('uid')) localStorage.setItem('uid', uid);
+      if (params.get('token')) localStorage.setItem('token', token);
+
+      document.addEventListener('visibilitychange', onVisibilityChange);
+      window.addEventListener('beforeunload', function() {
+        stopTick();
+        exitPiP();
+      });
+
+      if (courseId) {
+        loadCourse();
+      } else {
+        videoTitle.textContent = '缺少课程ID';
+      }
+    }
+
+    init();
+  </script>
+</body>
+</html>

+ 4 - 3
src/containers/apply/index.vue

@@ -95,10 +95,11 @@
 </template>
 <script>
   import {
-    httpServer
-  } from "@/components/httpServer/httpServer.js";
-  import { MessageBox } from "element-ui";
+  httpServer
+} from "@/components/httpServer/httpServer.js";
+import { MessageBox } from "element-ui";
   const defaultForm = {
+      id:0,
       cardId:'',
       nickname:'',
       email:'',

+ 1 - 25
src/containers/center/components/menu/menu.js

@@ -36,31 +36,7 @@ const menu = [
      path: '/center/myOrder',
      name: '我的订单',
      icon: 'ico-order'
-   },
-   {
-     path: '/center/exam',
-     name: '考试培训',
-     icon: 'ico-files',
-     show: true,
-     children: [
-       {
-         path: '/center/examSeat',
-         name: '打印准考证'
-       },
-       {
-         path: '/center/examDownload',
-         name: '承诺书下载'
-       },
-       {
-         path: '/center/examScore',
-         name: '成绩查询'
-       },
-       {
-         path: '/center/userCert',
-         name: '我的证书'
-       }
-     ]
-    }
+   }
  ]
 
  export default menu

+ 16 - 4
src/containers/center/market/index.vue

@@ -148,6 +148,7 @@
               <el-radio :label="0">否</el-radio>
             </el-radio-group>
           </el-form-item>
+
           <template v-if="buyForm.needInvoice">
             <el-form-item label="开票类型">
               <el-radio-group v-model="buyForm.invoiceType">
@@ -161,11 +162,10 @@
             <el-form-item label="手机号码" prop="phone">
               <el-input v-model="buyForm.phone" placeholder="请输入手机号码" style="width: 360px;"></el-input>
             </el-form-item>
-            <el-form-item label="邮箱地址" prop="email">
-              <el-input v-model="buyForm.email" placeholder="请输入邮箱地址" style="width: 360px;"></el-input>
-            </el-form-item>
           </template>
-
+          <el-form-item label="邮箱地址" prop="email">
+            <el-input v-model="buyForm.email" placeholder="请输入邮箱地址" style="width: 360px;"></el-input>
+          </el-form-item>
           <el-divider></el-divider>
           <h2 class="tc"> 本课程费用: <strong style="color: red;"> {{buyForm.fee/100}} 元 </strong> </h2>
 
@@ -271,6 +271,7 @@
           needInvoice: 0,
           invoiceType: 'personal'
         },
+        userInvoiceList:[],
         imgUrl: '',
         qrcodeUrl: "",
         wxPayUrl:"",
@@ -361,6 +362,7 @@
     beforeMount() {
       this.type = this.$route.query.type || ''
       this.getData();
+      this.getUserInvoice()
     },
 
     beforeDestroy() {
@@ -375,6 +377,12 @@
       closeDialog() {
         if (this.timer) window.clearInterval(this.timer);
       },
+      getUserInvoice(){
+        httpServer("weixin.getUserInvoice", {}).then((res) => {
+          if (res.code != 200) return
+          this.userInvoiceList = res.data||[]
+        })
+      },
       onLoad(e) {
         const img = e.target;
         let width = 0;
@@ -513,6 +521,10 @@
         }).then((res) => {
           if (res.code != 200) return;
           Object.assign(this.buyForm, res.data);
+          if(this.userInvoiceList.length >0){
+            this.buyForm.email  = this.userInvoiceList[0].email
+            this.buyForm.title = this.userInvoiceList[0].title
+          }
           this.buyCourseDialog = true;
         });
       },

+ 1 - 4
src/containers/center/play/components/iCourseInfo.vue

@@ -30,15 +30,12 @@
         <p style="font-size: 24px;"> 说明 </p>
         <p class="mt10">岗位名称:{{tpl.name}}<strong style="color: red;">{{tpl.nd}}</strong></p>
         <p class="mt10">
-          通过条件: <strong style="color: red;" >学满{{tpl.mediaXs/10}}学时 <span v-show="tpl.tplId>0">并考试合格</span></strong>
+          通过条件: <strong style="color: red;" >学满{{tpl.mediaXs/10}}学时 <span v-show="tpl.examGroupId>0">并考试合格</span></strong>
         </p>
         <p class="mt10" v-if="tpl.testGroupId>0">
           参与练习: <strong style="color: red;"> {{finishCount}}</strong>题,满100题可得
           <strong style="color: red;">{{tpl.testXs/10}}</strong>学时
         </p>
-        <p class="mt10" v-if="tpl.examGroupId>0">
-          报名成功: <strong style="color: red;" >80天内完成学习与考试</strong>
-        </p>
         <div>
           <el-button type="primary" class="mt10" style="font-size: 14px;" @click="startExam" v-if="tpl.examGroupId>0"
             :disabled="info.score>=60">

+ 82 - 14
static/play.html

@@ -4,8 +4,8 @@
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>视频播放 - 宁德市建筑业协会</title>
-  <script src="/static/js/hls.min.js"></script>
-  <script src="/static/js/md5.min.js"></script>
+  <script src="./js/hls.min.js"></script>
+  <script src="./js/md5.min.js"></script>
   <style>
     * { margin: 0; padding: 0; box-sizing: border-box; }
     body {
@@ -139,6 +139,26 @@
     .toast.error { background: #F56C6C; }
     .toast.info { background: #909399; }
 
+    .qr-modal-overlay {
+      position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 9998;
+      display: none; align-items: center; justify-content: center;
+    }
+    .qr-modal-overlay.show { display: flex; }
+    .qr-modal {
+      background: #fff; border-radius: 12px; padding: 24px; text-align: center;
+      box-shadow: 0 8px 32px rgba(0,0,0,0.2); max-width: 320px; width: 90%;
+      position: relative;
+    }
+    .qr-modal-title { font-size: 16px; font-weight: 600; color: #1a1a2e; margin-bottom: 16px; }
+    .qr-modal img { width: 200px; height: 200px; border: 1px solid #eee; border-radius: 8px; }
+    .qr-modal-hint { font-size: 12px; color: #999; margin-top: 12px; }
+    .qr-modal-close {
+      position: absolute; top: 12px; right: 12px; width: 28px; height: 28px;
+      border: none; background: #f0f0f0; border-radius: 50%; cursor: pointer;
+      font-size: 16px; color: #666; display: flex; align-items: center; justify-content: center;
+    }
+    .qr-modal-close:hover { background: #e0e0e0; }
+
     @media (max-width: 900px) {
       .pc-player-wrapper { flex-direction: column; height: auto; }
       .pc-player-right { width: 100%; max-height: 400px; }
@@ -173,11 +193,13 @@
                   <input type="range" class="volume-slider" id="volumeSlider" min="0" max="1" step="0.05" value="0.5">
                 </span>
                 <span class="btn-icon" id="pipBtn">画中画</span>
+                <span class="btn-icon" id="qrBtn">二维码</span>
               </div>
               <div style="display:flex;align-items:center;justify-content:flex-end;flex:1;">
                 <select class="quality-select" id="qualitySelect">
                   <option value="ld">流畅</option>
                   <option value="hls" selected>标清</option>
+                  <option value="mp4">高清</option>
                 </select>
               </div>
             </div>
@@ -207,6 +229,15 @@
 
   <div class="toast" id="toast"></div>
 
+  <div class="qr-modal-overlay" id="qrModalOverlay">
+    <div class="qr-modal">
+      <button class="qr-modal-close" id="qrModalClose">&times;</button>
+      <div class="qr-modal-title">扫码分享当前页面</div>
+      <img id="qrCodeImg" src="" alt="二维码">
+      <div class="qr-modal-hint">使用手机扫描二维码打开当前页面</div>
+    </div>
+  </div>
+
   <script>
     const VERSION = '1.0';
     const SECRET = '017c0743819088468e590246464cc75f';
@@ -312,7 +343,17 @@
       }
       video.removeAttribute('src');
 
-      if (Hls.isSupported()) {
+      if (src.includes('/mp4/')) {
+        video.src = src;
+        video.addEventListener('loadedmetadata', function() {
+          loadingOverlay.classList.add('hidden');
+          if (position > 0) video.currentTime = position;
+          safePlay();
+        }, { once: true });
+        video.addEventListener('error', function() {
+          showToast('视频加载失败,尝试切换清晰度', 'error');
+        }, { once: true });
+      } else if (Hls.isSupported()) {
         hls = new Hls({
           enableWorker: true,
           xhrSetup: function(xhr) {
@@ -360,7 +401,6 @@
     }
 
     function setPosition(pos) {
-      if (pos < 10) return;
       if (pos > (currentMedia ? currentMedia.duration : Infinity)) pos = currentMedia.duration;
       video.currentTime = pos;
       safePlay();
@@ -418,7 +458,7 @@
       currentMedia.duration = duration;
       currentMedia.id = data.id || item.id;
 
-      qualitySelect.value = mediaUrl.includes('/ld/') ? 'ld' : 'hls';
+      qualitySelect.value = mediaUrl.includes('/mp4/') ? 'mp4' : (mediaUrl.includes('/ld/') ? 'ld' : 'hls');
 
       curTimes = 0;
       loadVideo(mediaUrl, position);
@@ -429,15 +469,20 @@
 
     function changeMedia(quality) {
       if (!currentMediaUrl) return;
-      let url = currentMediaUrl;
-      if (quality === 'ld') {
-        url = url.replace('/hls/', '/ld/');
+      const u = new URL(currentMediaUrl);
+      const m = u.pathname.match(/(\d+)(?:\.mp4|\.m3u8)/);
+      if (!m) return;
+      const id = m[1];
+      if (quality === 'mp4') {
+        u.pathname = '/mp4/' + id + '.mp4';
+      } else if (quality === 'hls') {
+        u.pathname = '/hls/' + id + '/' + id + '.m3u8';
       } else {
-        url = url.replace('/ld/', '/hls/');
+        u.pathname = '/ld/' + id + '/' + id + '.m3u8';
       }
-      currentMediaUrl = url;
+      currentMediaUrl = u.toString();
       const pos = video.currentTime || 0;
-      loadVideo(url, pos > 0 ? pos : 0);
+      loadVideo(currentMediaUrl, pos > 0 ? pos : 0);
     }
 
     // ---- tick system ----
@@ -502,12 +547,13 @@
         doPause();
         showToast(d.msg || '已暂停', 'info', 10000);
         setTimeout(async function() {
-          await loadCourse();
-        }, 30000);
+          loadCourse();
+        }, 10000);
         return;
       }
       if (!d.skip) {
-        setPosition(d.position || 0);
+        const diff = Math.abs((d.position || 0) - ct);
+        if (diff > 3) setPosition(d.position);
       }
       Object.assign(currentMedia, d);
       if (d.isFinish == 1) {
@@ -725,6 +771,28 @@
 
     hideFinishedCheck.addEventListener('change', updatePlaylist);
 
+    // ---- QR code ----
+    const qrBtn = document.getElementById('qrBtn');
+    const qrModalOverlay = document.getElementById('qrModalOverlay');
+    const qrModalClose = document.getElementById('qrModalClose');
+    const qrCodeImg = document.getElementById('qrCodeImg');
+
+    function showQRCode() {
+      const url = window.location.href;
+      qrCodeImg.src = 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' + encodeURIComponent(url);
+      qrModalOverlay.classList.add('show');
+    }
+
+    function hideQRCode() {
+      qrModalOverlay.classList.remove('show');
+    }
+
+    qrBtn.addEventListener('click', showQRCode);
+    qrModalClose.addEventListener('click', hideQRCode);
+    qrModalOverlay.addEventListener('click', function(e) {
+      if (e.target === qrModalOverlay) hideQRCode();
+    });
+
     // ---- init ----
     function init() {
       const params = new URLSearchParams(window.location.search);

+ 0 - 2
sync.bat

@@ -1,2 +0,0 @@
-go run build.go -dir=study_nd
-pause

+ 0 - 4
sync.sh

@@ -1,4 +0,0 @@
-cd ../www/study_sm
-git pull
-rm -rf static index.html
-cp -rf ../../study_sm/dist/* .