فهرست منبع

Update codebase

bob 2 هفته پیش
والد
کامیت
0fd737b845

+ 11 - 0
.dockerignore

@@ -0,0 +1,11 @@
+.git
+.gitignore
+node_modules
+dist
+deploy.sh
+deployment
+*.pem
+*.pem.pub
+.env
+.DS_Store
+

+ 5 - 0
.gitignore

@@ -5,3 +5,8 @@ uploads/*
 !uploads/.gitkeep
 *.log
 .DS_Store
+*.pem
+*.pem.pub
+.env
+deploy-package.tar.gz
+.deploy-known-hosts

+ 1 - 1
Dockerfile

@@ -12,8 +12,8 @@ COPY package*.json ./
 RUN npm install --omit=dev
 COPY --from=build /app/dist ./dist
 COPY --from=build /app/server ./server
+COPY --from=build /app/sample ./sample
 COPY --from=build /app/tsconfig*.json ./
-RUN npm install tsx
 RUN mkdir -p uploads
 EXPOSE 3001
 CMD ["npm", "start"]

+ 38 - 0
deploy.sh

@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+SERVER=root@47.93.193.127
+KEY_FILE="$ROOT_DIR/ccdw-meishi-1.pem"
+REMOTE_DIR=/opt/xinghen
+KNOWN_HOSTS="$ROOT_DIR/.deploy-known-hosts"
+IMAGE_ARCHIVE=$(mktemp /tmp/xinghen-app.XXXXXX.tar.gz)
+trap 'rm -f "$IMAGE_ARCHIVE"' EXIT
+
+if [[ ! -f "$KEY_FILE" ]]; then
+  echo "缺少 SSH 私钥:$KEY_FILE(.pem.pub 公钥不能用于登录)" >&2
+  exit 1
+fi
+
+SSH=(ssh -i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$KNOWN_HOSTS")
+RSYNC_SSH="ssh -i $KEY_FILE -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$KNOWN_HOSTS"
+
+docker buildx build --platform linux/amd64 --pull --load -t xinghen-app:deploy "$ROOT_DIR"
+docker save xinghen-app:deploy | gzip >"$IMAGE_ARCHIVE"
+
+"${SSH[@]}" "$SERVER" "install -d -m 755 '$REMOTE_DIR' '$REMOTE_DIR/.deploy'"
+rsync -az --delete \
+  --exclude '.git/' \
+  --exclude 'node_modules/' \
+  --exclude 'dist/' \
+  --exclude '.env' \
+  --exclude '.deploy/' \
+  --exclude '.deploy-known-hosts' \
+  --exclude '*.pem' \
+  --exclude '*.pem.pub' \
+  -e "$RSYNC_SSH" \
+  "$ROOT_DIR/" "$SERVER:$REMOTE_DIR/"
+rsync -az -e "$RSYNC_SSH" "$IMAGE_ARCHIVE" "$SERVER:$REMOTE_DIR/.deploy/app-image.tar.gz"
+
+"${SSH[@]}" "$SERVER" "chmod 755 '$REMOTE_DIR/deployment/remote-deploy.sh' && ENABLE_FUTURE_DOMAIN='${ENABLE_FUTURE_DOMAIN:-0}' '$REMOTE_DIR/deployment/remote-deploy.sh'"
+echo "部署完成:https://xinghen.lessncosmos.com"

+ 164 - 0
deployment/remote-deploy.sh

@@ -0,0 +1,164 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+APP_DIR=/opt/xinghen
+PROXY_CONTAINER=meishi_ccdw_life-proxy-1
+NGINX_CONFIG=/opt/meishi_ccdw_life/nginx.conf
+PRIMARY_DOMAIN=xinghen.lessncosmos.com
+FUTURE_DOMAIN=xinghen.work
+ACME_ROOT=/etc/letsencrypt/acme-challenge
+MARKER_BEGIN='# BEGIN XINGHEN MANAGED BLOCK'
+MARKER_END='# END XINGHEN MANAGED BLOCK'
+
+cd "$APP_DIR"
+
+if [[ -f .deploy/app-image.tar.gz ]]; then
+  gzip -dc .deploy/app-image.tar.gz | docker load
+  rm -f .deploy/app-image.tar.gz
+fi
+
+if [[ ! -f .env ]]; then
+  umask 077
+  cat >.env <<EOF
+POSTGRES_PASSWORD=$(openssl rand -hex 24)
+ADMIN_PHONE=18888888888
+ADMIN_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' | head -c 24)
+EOF
+fi
+
+docker compose -p xinghen -f docker-compose.prod.yml up -d --remove-orphans
+
+install_nginx_block() {
+  local block_file=$1
+  local staged
+  local backup
+  staged=$(mktemp)
+  backup="${NGINX_CONFIG}.backup-xinghen-$(date +%Y%m%d%H%M%S)"
+  cp -a "$NGINX_CONFIG" "$backup"
+
+  awk -v begin="$MARKER_BEGIN" -v end="$MARKER_END" '
+    $0 == begin {skip=1; next}
+    $0 == end {skip=0; next}
+    !skip {lines[++n]=$0}
+    END {
+      last=n
+      while (last > 0 && lines[last] ~ /^[[:space:]]*$/) last--
+      if (lines[last] !~ /^}$/) exit 2
+      for (i=1; i<last; i++) print lines[i]
+    }
+  ' "$NGINX_CONFIG" >"$staged"
+  printf '\n%s\n' "$MARKER_BEGIN" >>"$staged"
+  cat "$block_file" >>"$staged"
+  printf '%s\n\n}\n' "$MARKER_END" >>"$staged"
+
+  docker run --rm --network meishi_ccdw_life_default \
+    -v "$staged:/etc/nginx/nginx.conf:ro" \
+    -v /etc/letsencrypt:/etc/letsencrypt:ro \
+    docker.m.daocloud.io/library/nginx:alpine nginx -t
+  cp "$staged" "$NGINX_CONFIG"
+  docker exec "$PROXY_CONTAINER" nginx -t
+  docker exec "$PROXY_CONTAINER" nginx -s reload
+  rm -f "$staged"
+}
+
+http_block=$(mktemp)
+cat >"$http_block" <<EOF
+    server {
+        listen 80;
+        server_name $PRIMARY_DOMAIN;
+
+        location ^~ /.well-known/acme-challenge/ {
+            root $ACME_ROOT;
+            default_type text/plain;
+            try_files \$uri =404;
+        }
+
+        location / {
+            proxy_pass http://xinghen-app:3001;
+            proxy_set_header Host \$host;
+            proxy_set_header X-Real-IP \$remote_addr;
+            proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
+            proxy_set_header X-Forwarded-Proto \$scheme;
+            client_max_body_size 100m;
+        }
+    }
+EOF
+install_nginx_block "$http_block"
+rm -f "$http_block"
+
+certbot certonly --webroot -w "$ACME_ROOT" \
+  --cert-name "$PRIMARY_DOMAIN" -d "$PRIMARY_DOMAIN" \
+  --non-interactive --agree-tos --register-unsafely-without-email \
+  --keep-until-expiring
+
+server_names="$PRIMARY_DOMAIN"
+cert_name="$PRIMARY_DOMAIN"
+if [[ "${ENABLE_FUTURE_DOMAIN:-0}" == 1 ]]; then
+  resolved=$(getent ahostsv4 "$FUTURE_DOMAIN" | awk 'NR==1 {print $1}')
+  if [[ "$resolved" != 47.93.193.127 ]]; then
+    echo "$FUTURE_DOMAIN 尚未解析到 47.93.193.127,拒绝启用。" >&2
+    exit 1
+  fi
+  certbot certonly --webroot -w "$ACME_ROOT" \
+    --cert-name "$PRIMARY_DOMAIN" -d "$PRIMARY_DOMAIN" -d "$FUTURE_DOMAIN" \
+    --non-interactive --agree-tos --register-unsafely-without-email --expand
+  server_names="$PRIMARY_DOMAIN $FUTURE_DOMAIN"
+fi
+
+tls_block=$(mktemp)
+cat >"$tls_block" <<EOF
+    server {
+        listen 80;
+        server_name $server_names;
+
+        location ^~ /.well-known/acme-challenge/ {
+            root $ACME_ROOT;
+            default_type text/plain;
+            try_files \$uri =404;
+        }
+
+        location / { return 301 https://\$host\$request_uri; }
+    }
+
+    server {
+        listen 443 ssl;
+        server_name $server_names;
+        ssl_certificate /etc/letsencrypt/live/$cert_name/fullchain.pem;
+        ssl_certificate_key /etc/letsencrypt/live/$cert_name/privkey.pem;
+        ssl_protocols TLSv1.2 TLSv1.3;
+
+        location / {
+            proxy_pass http://xinghen-app:3001;
+            proxy_set_header Host \$host;
+            proxy_set_header X-Real-IP \$remote_addr;
+            proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
+            proxy_set_header X-Forwarded-Proto \$scheme;
+            proxy_read_timeout 120s;
+            client_max_body_size 100m;
+        }
+    }
+EOF
+install_nginx_block "$tls_block"
+rm -f "$tls_block"
+
+install -d -m 755 /etc/letsencrypt/renewal-hooks/deploy
+cat >/etc/letsencrypt/renewal-hooks/deploy/reload-xinghen-nginx.sh <<EOF
+#!/usr/bin/env bash
+docker exec $PROXY_CONTAINER nginx -t && docker exec $PROXY_CONTAINER nginx -s reload
+EOF
+chmod 755 /etc/letsencrypt/renewal-hooks/deploy/reload-xinghen-nginx.sh
+
+healthy=0
+for _ in {1..15}; do
+  if curl --noproxy '*' --fail --silent --max-time 5 \
+    --resolve "$PRIMARY_DOMAIN:443:127.0.0.1" "https://$PRIMARY_DOMAIN/" >/dev/null; then
+    healthy=1
+    break
+  fi
+  sleep 1
+done
+if [[ "$healthy" != 1 ]]; then
+  echo "HTTPS 健康检查失败:$PRIMARY_DOMAIN" >&2
+  exit 1
+fi
+docker compose -p xinghen -f docker-compose.prod.yml ps

+ 54 - 0
docker-compose.prod.yml

@@ -0,0 +1,54 @@
+services:
+  postgres:
+    image: postgres:16-alpine
+    restart: unless-stopped
+    environment:
+      POSTGRES_USER: xinghen
+      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+      POSTGRES_DB: xinghen
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U xinghen -d xinghen"]
+      interval: 5s
+      timeout: 5s
+      retries: 20
+    volumes:
+      - postgres_data:/var/lib/postgresql/data
+    networks:
+      - internal
+
+  app:
+    image: xinghen-app:deploy
+    pull_policy: never
+    restart: unless-stopped
+    depends_on:
+      postgres:
+        condition: service_healthy
+    environment:
+      PORT: 3001
+      HOST: 0.0.0.0
+      NODE_ENV: production
+      DATABASE_URL: postgresql://xinghen:${POSTGRES_PASSWORD}@postgres:5432/xinghen
+      UPLOAD_DIR: /app/uploads
+      MAX_UPLOAD_MB: 100
+      ADMIN_PHONE: ${ADMIN_PHONE}
+      ADMIN_PASSWORD: ${ADMIN_PASSWORD}
+    expose:
+      - "3001"
+    volumes:
+      - uploads_data:/app/uploads
+    networks:
+      internal: {}
+      proxy:
+        aliases:
+          - xinghen-app
+
+volumes:
+  postgres_data:
+  uploads_data:
+
+networks:
+  internal:
+    internal: true
+  proxy:
+    external: true
+    name: meishi_ccdw_life_default

+ 126 - 0
docs/UI_STYLE_GUIDE.md

@@ -0,0 +1,126 @@
+# UI 风格约束
+
+本文档用于约束星痕课程工坊的整体界面风格。所有新增页面、组件、弹窗和后台管理界面,都应与首页保持同一套视觉语言:清爽、留白充足、低噪声、以蓝色作为主行动色。
+
+## 设计基调
+
+- 整体气质:轻盈、克制、专业,避免强装饰感。
+- 页面背景:使用浅灰蓝底色,优先采用 `#f5f7fb` 或接近色。
+- 内容承载:主要内容放在白色卡片、白色面板或无框布局中。
+- 视觉重心:蓝色只用于主操作、当前状态、重要标签和焦点反馈。
+- 禁止新增大面积紫色、红色渐变、深色后台主题,除非是明确的品牌改版。
+
+## 颜色
+
+核心颜色以 `src/styles.css` 中的 CSS 变量为准:
+
+```css
+--blue: #245bd6;
+--blue-dark: #1646b5;
+--ink: #17213a;
+--muted: #78839a;
+--line: #e4e8f0;
+--paper: #fff;
+```
+
+使用规则:
+
+- 主按钮、当前导航、关键徽标使用 `--blue`。
+- 主按钮 hover 使用 `--blue-dark`。
+- 正文主色使用 `--ink` 或接近的深蓝灰。
+- 次要说明文字使用 `--muted` 或灰蓝色。
+- 边框使用 `--line` 或低饱和浅灰蓝。
+- 浅蓝背景优先使用 `#edf3ff`、`#eff6ff`、`#f8fbff` 这一类低饱和色。
+
+语义色可以保留:
+
+- 删除、错误、拒绝:红色。
+- 成功、完成:绿色。
+- 警告、待处理:黄色或金色。
+
+语义色只用于状态表达,不应用作普通装饰色。
+
+## 按钮
+
+按钮样式必须与首页主按钮保持一致,优先复用已有类名:
+
+- 主操作:`.primary-button`
+- 次操作:`.secondary-button`
+- 小按钮:`.small-button`
+- 强调按钮:`.highlight-button`
+
+按钮约束:
+
+- 圆角使用 `--button-radius`,当前为 `8px`。
+- 普通按钮最小高度使用 `--button-height`,当前为 `44px`。
+- 小按钮最小高度使用 `--button-small-height`,当前为 `34px`。
+- 主按钮背景使用 `--blue`,不要新增紫蓝渐变。
+- hover 可以轻微上浮,但不要使用夸张动画。
+- disabled 状态必须降低透明度并移除阴影。
+- 可点击元素必须保留清晰的 `focus-visible` 焦点样式。
+
+## 卡片与面板
+
+- 卡片背景优先使用白色或接近白色。
+- 卡片边框使用浅灰蓝,避免高对比描边。
+- 常规卡片圆角控制在 `8px` 到 `14px`。
+- 阴影应轻、薄、低透明度,只用于区分层级。
+- 后台页面也使用浅色背景和白色面板,不使用深色登录页或深色管理壳。
+- 不要在卡片里再堆多层装饰卡片;复杂信息优先用分组、分隔线、网格和标签组织。
+
+## 表单
+
+- 输入框背景使用白色或 `#fbfcfe`。
+- 输入框边框使用浅灰蓝。
+- 输入框 focus 使用蓝色边框和低透明度蓝色外发光。
+- label 使用中性深灰蓝,避免纯黑。
+- 必填星号允许使用红色,这是语义状态。
+- 表单间距保持稳定,不要因为错误提示或动态内容导致布局跳动。
+
+## 标签与徽标
+
+- 普通信息标签使用浅蓝、浅灰蓝或白色描边。
+- 当前状态、主要编号、关键 badge 使用蓝色体系。
+- 管理员身份、导航入口、分集编号等非错误状态,不使用紫色或红色。
+- 圆角标签可以使用 `4px` 到 `8px`,胶囊标签仅用于筛选、状态或非常短的元信息。
+
+## 导航
+
+- 当前导航项使用 `--blue`。
+- 普通导航项使用灰蓝色。
+- 顶部导航和后台导航都应保持浅色背景、细边框、轻阴影或无阴影。
+- 不同端的入口可以通过文字和小 badge 区分,但不应换成另一套颜色主题。
+
+## 弹窗
+
+- 弹窗使用白色背景、浅边框、轻阴影。
+- 弹窗标题、表单、按钮应与页面内组件同源。
+- tab、分段控件、上传区域等交互元素使用蓝色作为 active 或 focus 状态。
+- 遮罩和阴影只用于层级,不要让弹窗呈现深色主题。
+
+## 排版
+
+- 字体沿用全局字体栈:`Inter`、`SF Pro Display`、`PingFang SC`、`Microsoft YaHei`。
+- 首页可以使用更大的展示字号;后台、表格、卡片内标题应更紧凑。
+- 正文说明文字保持灰蓝色,行高舒适。
+- 不新增负 letter-spacing;界面文字应优先清晰易读。
+
+## 新增 UI 检查清单
+
+提交新增页面或组件前,至少检查:
+
+- 是否复用了现有按钮类,而不是重新写一套按钮。
+- 页面背景、卡片、输入框是否仍是浅灰蓝和白色体系。
+- 主色是否统一为 `--blue`,没有新增紫色、紫蓝渐变或深色主题。
+- 红色是否只用于错误、删除、拒绝等语义状态。
+- hover、active、disabled、focus-visible 状态是否完整。
+- 文案是否在移动端和桌面端都不会溢出按钮、卡片或标签。
+- 后台页面是否与首页保持同一品牌感,而不是变成独立视觉系统。
+
+## 修改入口
+
+全局视觉变量和通用组件样式集中在:
+
+- `src/styles.css`
+
+新增样式时优先扩展已有 token 和通用类,只有在现有样式无法表达新组件语义时,再添加新的局部类。

+ 10 - 30
package-lock.json

@@ -17,6 +17,7 @@
         "react": "^19.0.0",
         "react-dom": "^19.0.0",
         "react-router-dom": "^7.1.1",
+        "tsx": "^4.19.2",
         "zod": "^3.24.1"
       },
       "devDependencies": {
@@ -26,7 +27,6 @@
         "@types/react-dom": "^19.0.2",
         "@vitejs/plugin-react": "^4.3.4",
         "concurrently": "^9.1.2",
-        "tsx": "^4.19.2",
         "typescript": "~5.7.2",
         "vite": "^6.0.5"
       }
@@ -62,6 +62,7 @@
       "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@babel/code-frame": "^7.29.7",
         "@babel/generator": "^7.29.7",
@@ -320,7 +321,6 @@
       "cpu": [
         "ppc64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -337,7 +337,6 @@
       "cpu": [
         "arm"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -354,7 +353,6 @@
       "cpu": [
         "arm64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -371,7 +369,6 @@
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -388,7 +385,6 @@
       "cpu": [
         "arm64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -405,7 +401,6 @@
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -422,7 +417,6 @@
       "cpu": [
         "arm64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -439,7 +433,6 @@
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -456,7 +449,6 @@
       "cpu": [
         "arm"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -473,7 +465,6 @@
       "cpu": [
         "arm64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -490,7 +481,6 @@
       "cpu": [
         "ia32"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -507,7 +497,6 @@
       "cpu": [
         "loong64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -524,7 +513,6 @@
       "cpu": [
         "mips64el"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -541,7 +529,6 @@
       "cpu": [
         "ppc64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -558,7 +545,6 @@
       "cpu": [
         "riscv64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -575,7 +561,6 @@
       "cpu": [
         "s390x"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -592,7 +577,6 @@
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -609,7 +593,6 @@
       "cpu": [
         "arm64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -626,7 +609,6 @@
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -643,7 +625,6 @@
       "cpu": [
         "arm64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -660,7 +641,6 @@
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -677,7 +657,6 @@
       "cpu": [
         "arm64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -694,7 +673,6 @@
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -711,7 +689,6 @@
       "cpu": [
         "arm64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -728,7 +705,6 @@
       "cpu": [
         "ia32"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -745,7 +721,6 @@
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1522,6 +1497,7 @@
       "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "csstype": "^3.2.2"
       }
@@ -1721,6 +1697,7 @@
         }
       ],
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "baseline-browser-mapping": "^2.11.12",
         "caniuse-lite": "^1.0.30001809",
@@ -1953,7 +1930,6 @@
       "version": "0.28.2",
       "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz",
       "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
-      "dev": true,
       "hasInstallScript": true,
       "license": "MIT",
       "bin": {
@@ -2190,7 +2166,6 @@
       "version": "2.3.3",
       "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
       "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
-      "dev": true,
       "hasInstallScript": true,
       "license": "MIT",
       "optional": true,
@@ -2575,6 +2550,7 @@
       "resolved": "https://registry.npmmirror.com/pg/-/pg-8.23.0.tgz",
       "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "pg-connection-string": "^2.14.0",
         "pg-pool": "^3.14.0",
@@ -2672,6 +2648,7 @@
       "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=12"
       },
@@ -2811,6 +2788,7 @@
       "resolved": "https://registry.npmmirror.com/react/-/react-19.2.8.tgz",
       "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=0.10.0"
       }
@@ -2820,6 +2798,7 @@
       "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.8.tgz",
       "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "scheduler": "^0.27.0"
       },
@@ -3280,8 +3259,8 @@
       "version": "4.23.12",
       "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.23.12.tgz",
       "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
-      "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "esbuild": "~0.28.0"
       },
@@ -3353,6 +3332,7 @@
       "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "esbuild": "^0.25.0",
         "fdir": "^6.4.4",

+ 1 - 1
package.json

@@ -20,6 +20,7 @@
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
     "react-router-dom": "^7.1.1",
+    "tsx": "^4.19.2",
     "zod": "^3.24.1"
   },
   "devDependencies": {
@@ -29,7 +30,6 @@
     "@types/react-dom": "^19.0.2",
     "@vitejs/plugin-react": "^4.3.4",
     "concurrently": "^9.1.2",
-    "tsx": "^4.19.2",
     "typescript": "~5.7.2",
     "vite": "^6.0.5"
   }

BIN
public/sample/人物_坐.jpg


BIN
public/sample/人物_站.jpg


+ 58 - 27
server/db.ts

@@ -19,6 +19,8 @@ export const mapUser = (r: any) => ({
 
 export const mapAsset = (r: any) => ({
   id: r.id,
+  courseId: r.course_id,
+  episodeId: r.episode_id || null,
   originalName: r.original_name,
   size: Number(r.size),
   mimeType: r.mime_type,
@@ -36,6 +38,8 @@ export const mapInstructor = (r: any) => ({
 
 export const mapDeliverable = (r: any) => ({
   id: r.id,
+  courseId: r.course_id,
+  episodeId: r.episode_id || null,
   originalName: r.original_name,
   size: Number(r.size),
   mimeType: r.mime_type,
@@ -43,35 +47,62 @@ export const mapDeliverable = (r: any) => ({
   createdAt: r.created_at
 })
 
+export const mapEpisode = (r: any, assets: any[] = [], deliverables: any[] = []) => ({
+  id: r.id,
+  courseId: r.course_id,
+  episodeNumber: Number(r.episode_number) || 1,
+  title: r.title,
+  summary: r.summary || '',
+  lectureNotes: r.lecture_notes || '',
+  createdAt: r.created_at,
+  updatedAt: r.updated_at,
+  assets: assets.map(mapAsset),
+  deliverables: deliverables.map(mapDeliverable)
+})
+
 export const mapCourse = (
   r: any,
   assets: any[] = [],
   instructors: any[] = [],
   deliverables: any[] = [],
-  creatorUser: any = null
-) => ({
-  id: r.id,
-  userId: r.user_id,
-  name: r.name,
-  category: r.category || '',
-  audience: r.audience || '',
-  description: r.description || '',
-  status: r.status,
-  productionNotes: r.production_notes || '',
-  createdAt: r.created_at,
-  submittedAt: r.submitted_at || null,
-  completedAt: r.completed_at || null,
-  assets: assets.map(mapAsset),
-  instructors: instructors.map(mapInstructor),
-  deliverables: deliverables.map(mapDeliverable),
-  creator: creatorUser ? mapUser(creatorUser) : (r.user_phone ? {
-    id: r.user_id,
-    phone: r.user_phone,
-    role: r.user_role || 'USER',
-    organization: r.user_organization || '',
-    wechat: r.user_wechat || '',
-    contactName: r.user_contact_name || '',
-    bio: r.user_bio || '',
-    createdAt: r.user_created_at
-  } : undefined)
-})
+  creatorUser: any = null,
+  episodes: any[] = []
+) => {
+  const mappedAssets = assets.map(mapAsset)
+  const mappedDeliverables = deliverables.map(mapDeliverable)
+
+  // 为每个分集关联专属的 assets 与 deliverables
+  const mappedEpisodes = episodes.map((epRow) => {
+    const epAssets = assets.filter((a) => a.episode_id === epRow.id)
+    const epDeliverables = deliverables.filter((d) => d.episode_id === epRow.id)
+    return mapEpisode(epRow, epAssets, epDeliverables)
+  })
+
+  return {
+    id: r.id,
+    userId: r.user_id,
+    name: r.name,
+    category: r.category || '',
+    audience: r.audience || '',
+    description: r.description || '',
+    status: r.status,
+    productionNotes: r.production_notes || '',
+    createdAt: r.created_at,
+    submittedAt: r.submitted_at || null,
+    completedAt: r.completed_at || null,
+    assets: mappedAssets,
+    episodes: mappedEpisodes,
+    instructors: instructors.map(mapInstructor),
+    deliverables: mappedDeliverables,
+    creator: creatorUser ? mapUser(creatorUser) : (r.user_phone ? {
+      id: r.user_id,
+      phone: r.user_phone,
+      role: r.user_role || 'USER',
+      organization: r.user_organization || '',
+      wechat: r.user_wechat || '',
+      contactName: r.user_contact_name || '',
+      bio: r.user_bio || '',
+      createdAt: r.user_created_at
+    } : undefined)
+  }
+}

+ 294 - 30
server/index.ts

@@ -12,11 +12,13 @@ import { randomBytes, randomUUID, scrypt as scryptCb, timingSafeEqual, createHas
 import { promisify } from 'node:util'
 import { z } from 'zod'
 import { mapCourse, mapDeliverable, mapInstructor, mapUser, pool } from './db.js'
+import { COURSE_TEMPLATES, populateCourseFromTemplate } from './templates.js'
 
 const scrypt = promisify(scryptCb)
 const app = Fastify({ logger: true })
 const port = Number(process.env.PORT || 3001)
 const uploadDir = resolve(process.env.UPLOAD_DIR || './uploads')
+const sampleDir = resolve(process.env.SAMPLE_DIR || './sample')
 const maxBytes = Number(process.env.MAX_UPLOAD_MB || 200) * 1024 * 1024
 
 await mkdir(uploadDir, { recursive: true })
@@ -44,7 +46,7 @@ async function initDefaultAdmin() {
           "INSERT INTO users(id, phone, password_hash, role, contact_name, organization, bio) VALUES($1, $2, $3, 'ADMIN', $4, $5, $6)",
           [id, defaultPhone, hash, '系统管理员', '星痕课程工坊管理中心', '默认初始化管理员账号']
         )
-        app.log.info(`Initialized default admin account: ${defaultPhone} / ${defaultPassword}`)
+        app.log.info(`Initialized default admin account for ${defaultPhone}`)
       }
     }
   } catch (err) {
@@ -56,6 +58,7 @@ await initDefaultAdmin()
 await app.register(cors, { origin: process.env.NODE_ENV === 'production' ? false : true })
 await app.register(multipart, { limits: { fileSize: maxBytes, files: 20, fields: 30 } })
 await app.register(staticPlugin, { root: uploadDir, prefix: '/uploads/', decorateReply: false })
+await app.register(staticPlugin, { root: sampleDir, prefix: '/sample/', decorateReply: false })
 
 const sha = (v: string) => createHash('sha256').update(v).digest('hex')
 
@@ -109,17 +112,31 @@ const phonePassword = z.object({
 })
 
 const fullCourse = async (row: any, includeCreator = false) => {
-  const [a, i, d] = await Promise.all([
+  const [a, i, d, e] = await Promise.all([
     pool.query('SELECT * FROM course_assets WHERE course_id = $1 ORDER BY created_at', [row.id]),
     pool.query('SELECT * FROM instructors WHERE course_id = $1 ORDER BY created_at', [row.id]),
-    pool.query('SELECT * FROM course_deliverables WHERE course_id = $1 ORDER BY created_at', [row.id])
+    pool.query('SELECT * FROM course_deliverables WHERE course_id = $1 ORDER BY created_at', [row.id]),
+    pool.query('SELECT * FROM episodes WHERE course_id = $1 ORDER BY episode_number ASC, created_at ASC', [row.id])
   ])
+
+  let episodesRows = e.rows
+  // 确保课程永远至少有 1 个分集(自愈保底)
+  if (episodesRows.length === 0) {
+    const defaultEpId = randomUUID()
+    const defaultTitle = '第 1 集:' + (row.name || '核心内容讲解')
+    const insertRes = await pool.query(
+      'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, 1, $3, $4, $5) RETURNING *',
+      [defaultEpId, row.id, defaultTitle, '本集核心内容与制作说明', '']
+    )
+    episodesRows = insertRes.rows
+  }
+
   let creatorUser = null
   if (includeCreator && row.user_id) {
     const u = await pool.query('SELECT * FROM users WHERE id = $1', [row.user_id])
     creatorUser = u.rows[0] || null
   }
-  return mapCourse(row, a.rows, i.rows, d.rows, creatorUser)
+  return mapCourse(row, a.rows, i.rows, d.rows, creatorUser, episodesRows)
 }
 
 const ownedCourse = async (id: string, uid: string) =>
@@ -222,6 +239,8 @@ const courseInput = z.object({
   description: z.string().max(500).default('')
 })
 
+app.get('/api/course-templates', async () => ({ templates: COURSE_TEMPLATES }))
+
 app.get('/api/courses', { preHandler: auth }, async (req) => {
   const { rows } = await pool.query('SELECT * FROM courses WHERE user_id = $1 ORDER BY created_at DESC', [
     userId(req)
@@ -238,6 +257,34 @@ app.post('/api/courses', { preHandler: auth }, async (req, reply) => {
     'INSERT INTO courses(id, user_id, name, category, audience, description) VALUES($1, $2, $3, $4, $5, $6) RETURNING *',
     [id, userId(req), d.name, d.category, d.audience, d.description]
   )
+
+  // 课程创建后默认创建第 1 集内容,确保分集不为空
+  const defaultEpId = randomUUID()
+  const defaultTitle = '第 1 集:' + d.name
+  await pool.query(
+    'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, 1, $3, $4, $5)',
+    [defaultEpId, id, defaultTitle, '', '']
+  )
+
+  return reply.code(201).send({ course: await fullCourse(rows[0]) })
+})
+
+app.post('/api/courses/template', { preHandler: auth }, async (req, reply) => {
+  const p = z.object({
+    templateId: z.string().default('party-building-standard')
+  }).safeParse(req.body || {})
+  if (!p.success) return reply.code(400).send({ message: '模板参数不正确' })
+
+  const template = COURSE_TEMPLATES.find((t) => t.id === p.data.templateId) || COURSE_TEMPLATES[0]
+  const courseId = randomUUID()
+
+  const { rows } = await pool.query(
+    'INSERT INTO courses(id, user_id, name, category, audience, description) VALUES($1, $2, $3, $4, $5, $6) RETURNING *',
+    [courseId, userId(req), template.name, template.category, template.audience || '', template.description]
+  )
+
+  await populateCourseFromTemplate(pool, courseId, template.id, uploadDir, sampleDir)
+
   return reply.code(201).send({ course: await fullCourse(rows[0]) })
 })
 
@@ -248,28 +295,89 @@ app.get('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
   return { course: await fullCourse(row) }
 })
 
+app.delete('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
+  const { id } = req.params as any
+  const row = await ownedCourse(id, userId(req))
+  if (!row) return reply.code(404).send({ message: '课程不存在' })
+
+  // 已经等待制作、制作中或者制作完成的课程不能删除
+  if (row.status === 'WAITING_PRODUCTION' || row.status === 'IN_PRODUCTION' || row.status === 'COMPLETED') {
+    return reply.code(409).send({ message: '课程已进入制作排期或已制作完成,不可删除' })
+  }
+
+  // 1. 清理该课程关联的所有素材文件
+  const assetsRes = await pool.query('SELECT stored_name FROM course_assets WHERE course_id = $1', [id])
+  for (const ast of assetsRes.rows) {
+    if (ast.stored_name) await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
+  }
+
+  // 2. 清理讲师头像图片
+  const instRes = await pool.query('SELECT image_stored_name FROM instructors WHERE course_id = $1', [id])
+  for (const ins of instRes.rows) {
+    if (ins.image_stored_name) await unlink(resolve(uploadDir, ins.image_stored_name)).catch(() => {})
+  }
+
+  // 3. 清理交付成品文件
+  const delivRes = await pool.query('SELECT stored_name FROM course_deliverables WHERE course_id = $1', [id])
+  for (const del of delivRes.rows) {
+    if (del.stored_name) await unlink(resolve(uploadDir, del.stored_name)).catch(() => {})
+  }
+
+  // 4. 清理 PPT 文件
+  if (row.ppt_stored_name) {
+    await unlink(resolve(uploadDir, row.ppt_stored_name)).catch(() => {})
+  }
+
+  // 5. 从数据库中删除课程(级联删除 episodes, course_assets, instructors 等)
+  await pool.query('DELETE FROM courses WHERE id = $1 AND user_id = $2', [id, userId(req)])
+
+  return { ok: true, message: '课程已成功删除' }
+})
+
 app.post('/api/courses/:id/assets', { preHandler: auth }, async (req, reply) => {
   const { id } = req.params as any
   const row = await ownedCourse(id, userId(req))
   if (!row) return reply.code(404).send({ message: '课程不存在' })
   if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可再上传资产' })
-  let count = 0
-  for await (const part of req.files()) {
-    const ext = extname(part.filename).slice(0, 16)
-    const stored = id + '-' + randomUUID() + ext
-    const target = resolve(uploadDir, stored)
-    await pipeline(part.file, createWriteStream(target))
-    if (part.file.truncated) {
-      await unlink(target).catch(() => {})
-      return reply.code(413).send({ message: '文件超过大小限制' })
+
+  let episodeId: string | null = (req.query as any)?.episodeId || null
+  const fileList: Array<{ filename: string; stored: string; bytes: number; mimetype: string }> = []
+
+  for await (const part of req.parts()) {
+    if (part.type === 'file') {
+      const ext = extname(part.filename).slice(0, 16)
+      const stored = id + '-' + randomUUID() + ext
+      const target = resolve(uploadDir, stored)
+      await pipeline(part.file, createWriteStream(target))
+      if (part.file.truncated) {
+        await unlink(target).catch(() => {})
+        return reply.code(413).send({ message: '文件超过大小限制' })
+      }
+      fileList.push({
+        filename: part.filename,
+        stored,
+        bytes: part.file.bytesRead,
+        mimetype: part.mimetype
+      })
+    } else if (part.fieldname === 'episodeId' && part.value) {
+      episodeId = String(part.value)
     }
+  }
+
+  if (episodeId) {
+    const epCheck = await pool.query('SELECT id FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
+    if (!epCheck.rows[0]) episodeId = null
+  }
+
+  if (!fileList.length) return reply.code(400).send({ message: '请选择课程资产文件' })
+
+  for (const f of fileList) {
     await pool.query(
-      'INSERT INTO course_assets(id, course_id, original_name, stored_name, size, mime_type) VALUES($1, $2, $3, $4, $5, $6)',
-      [randomUUID(), id, part.filename, stored, part.file.bytesRead, part.mimetype]
+      'INSERT INTO course_assets(id, course_id, episode_id, original_name, stored_name, size, mime_type) VALUES($1, $2, $3, $4, $5, $6, $7)',
+      [randomUUID(), id, episodeId, f.filename, f.stored, f.bytes, f.mimetype]
     )
-    count++
   }
-  if (!count) return reply.code(400).send({ message: '请选择课程资产文件' })
+
   return { course: await fullCourse(row) }
 })
 
@@ -286,6 +394,141 @@ app.delete('/api/courses/:id/assets/:assetId', { preHandler: auth }, async (req,
   return { course: await fullCourse(row) }
 })
 
+// ==================== 课程分集操作 (Episodes) ====================
+
+// 1. 创建单集
+app.post('/api/courses/:id/episodes', { preHandler: auth }, async (req, reply) => {
+  const { id } = req.params as any
+  const row = await ownedCourse(id, userId(req))
+  if (!row) return reply.code(404).send({ message: '课程不存在' })
+  if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可再添加分集' })
+
+  const p = z.object({
+    title: z.string().trim().min(1, '请输入分集标题').max(200),
+    episodeNumber: z.number().int().min(1).optional(),
+    summary: z.string().max(500).default(''),
+    lectureNotes: z.string().default('')
+  }).safeParse(req.body)
+
+  if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '分集信息填写不完整' })
+
+  let epNumber = p.data.episodeNumber
+  if (!epNumber) {
+    const maxRes = await pool.query('SELECT COALESCE(MAX(episode_number), 0) AS max_num FROM episodes WHERE course_id = $1', [id])
+    epNumber = (Number(maxRes.rows[0].max_num) || 0) + 1
+  }
+
+  const epId = randomUUID()
+  await pool.query(
+    'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, $3, $4, $5, $6)',
+    [epId, id, epNumber, p.data.title, p.data.summary, p.data.lectureNotes]
+  )
+
+  return reply.code(201).send({ course: await fullCourse(row) })
+})
+
+// 2. 批量创建分集(如快速生成 12 集)
+app.post('/api/courses/:id/episodes/batch', { preHandler: auth }, async (req, reply) => {
+  const { id } = req.params as any
+  const row = await ownedCourse(id, userId(req))
+  if (!row) return reply.code(404).send({ message: '课程不存在' })
+  if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可再添加分集' })
+
+  const p = z.object({
+    count: z.number().int().min(1).max(100).optional(),
+    episodes: z.array(z.object({
+      title: z.string().trim().min(1).max(200),
+      episodeNumber: z.number().int().min(1).optional(),
+      summary: z.string().max(500).default(''),
+      lectureNotes: z.string().default('')
+    })).optional()
+  }).safeParse(req.body)
+
+  if (!p.success) return reply.code(400).send({ message: '批量创建参数不正确' })
+
+  const maxRes = await pool.query('SELECT COALESCE(MAX(episode_number), 0) AS max_num FROM episodes WHERE course_id = $1', [id])
+  let currentMax = Number(maxRes.rows[0].max_num) || 0
+
+  if (p.data.episodes && p.data.episodes.length > 0) {
+    for (const ep of p.data.episodes) {
+      currentMax++
+      await pool.query(
+        'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, $3, $4, $5, $6)',
+        [randomUUID(), id, ep.episodeNumber || currentMax, ep.title, ep.summary, ep.lectureNotes]
+      )
+    }
+  } else if (p.data.count) {
+    for (let i = 1; i <= p.data.count; i++) {
+      currentMax++
+      const title = `第 ${currentMax} 集:课程知识点精讲`
+      await pool.query(
+        'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, $3, $4, $5, $6)',
+        [randomUUID(), id, currentMax, title, '', '']
+      )
+    }
+  } else {
+    return reply.code(400).send({ message: '请指定集数或分集列表' })
+  }
+
+  return reply.code(201).send({ course: await fullCourse(row) })
+})
+
+// 3. 更新分集信息(标题、序号、讲稿文本等)
+app.patch('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (req, reply) => {
+  const { id, episodeId } = req.params as any
+  const row = await ownedCourse(id, userId(req))
+  if (!row) return reply.code(404).send({ message: '课程不存在' })
+  if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改分集' })
+
+  const p = z.object({
+    title: z.string().trim().min(1, '标题不能为空').max(200).optional(),
+    episodeNumber: z.number().int().min(1).optional(),
+    summary: z.string().max(500).optional(),
+    lectureNotes: z.string().optional()
+  }).safeParse(req.body)
+
+  if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '参数错误' })
+
+  const existing = await pool.query('SELECT * FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
+  if (!existing.rows[0]) return reply.code(404).send({ message: '分集不存在' })
+
+  const cur = existing.rows[0]
+  const newTitle = p.data.title !== undefined ? p.data.title : cur.title
+  const newNumber = p.data.episodeNumber !== undefined ? p.data.episodeNumber : cur.episode_number
+  const newSummary = p.data.summary !== undefined ? p.data.summary : cur.summary
+  const newNotes = p.data.lectureNotes !== undefined ? p.data.lectureNotes : cur.lecture_notes
+
+  await pool.query(
+    'UPDATE episodes SET title = $1, episode_number = $2, summary = $3, lecture_notes = $4, updated_at = NOW() WHERE id = $5 AND course_id = $6',
+    [newTitle, newNumber, newSummary, newNotes, episodeId, id]
+  )
+
+  return { course: await fullCourse(row) }
+})
+
+// 4. 删除分集及其关联的物理素材文件
+app.delete('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (req, reply) => {
+  const { id, episodeId } = req.params as any
+  const row = await ownedCourse(id, userId(req))
+  if (!row) return reply.code(404).send({ message: '课程不存在' })
+  if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可删除分集' })
+
+  // 课程至少需要保留一个分集,不可删除唯一分集
+  const countRes = await pool.query('SELECT COUNT(*)::int AS count FROM episodes WHERE course_id = $1', [id])
+  if ((countRes.rows[0]?.count || 0) <= 1) {
+    return reply.code(400).send({ message: '课程至少需要保留一个分集,不可删除唯一分集' })
+  }
+
+  // 查出该集下所有素材文件并清理磁盘
+  const astRes = await pool.query('SELECT stored_name FROM course_assets WHERE episode_id = $1', [episodeId])
+  for (const ast of astRes.rows) {
+    await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
+  }
+
+  await pool.query('DELETE FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
+  return { course: await fullCourse(row) }
+})
+
 app.post('/api/courses/:id/instructors', { preHandler: auth }, async (req, reply) => {
   const { id } = req.params as any
   const row = await ownedCourse(id, userId(req))
@@ -475,23 +718,44 @@ app.post('/api/admin/courses/:id/deliverables', { preHandler: adminAuth }, async
   const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
   if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' })
 
-  let count = 0
-  for await (const part of req.files()) {
-    const ext = extname(part.filename).slice(0, 16)
-    const stored = 'deliverable-' + id + '-' + randomUUID() + ext
-    const target = resolve(uploadDir, stored)
-    await pipeline(part.file, createWriteStream(target))
-    if (part.file.truncated) {
-      await unlink(target).catch(() => {})
-      return reply.code(413).send({ message: '文件超过大小限制' })
+  let episodeId: string | null = (req.query as any)?.episodeId || null
+  const fileList: Array<{ filename: string; stored: string; bytes: number; mimetype: string }> = []
+
+  for await (const part of req.parts()) {
+    if (part.type === 'file') {
+      const ext = extname(part.filename).slice(0, 16)
+      const stored = 'deliverable-' + id + '-' + randomUUID() + ext
+      const target = resolve(uploadDir, stored)
+      await pipeline(part.file, createWriteStream(target))
+      if (part.file.truncated) {
+        await unlink(target).catch(() => {})
+        return reply.code(413).send({ message: '文件超过大小限制' })
+      }
+      fileList.push({
+        filename: part.filename,
+        stored,
+        bytes: part.file.bytesRead,
+        mimetype: part.mimetype
+      })
+    } else if (part.fieldname === 'episodeId' && part.value) {
+      episodeId = String(part.value)
     }
+  }
+
+  if (episodeId) {
+    const epCheck = await pool.query('SELECT id FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
+    if (!epCheck.rows[0]) episodeId = null
+  }
+
+  if (!fileList.length) return reply.code(400).send({ message: '请选择交付成果文件' })
+
+  for (const f of fileList) {
     await pool.query(
-      'INSERT INTO course_deliverables(id, course_id, original_name, stored_name, size, mime_type) VALUES($1, $2, $3, $4, $5, $6)',
-      [randomUUID(), id, part.filename, stored, part.file.bytesRead, part.mimetype]
+      'INSERT INTO course_deliverables(id, course_id, episode_id, original_name, stored_name, size, mime_type) VALUES($1, $2, $3, $4, $5, $6, $7)',
+      [randomUUID(), id, episodeId, f.filename, f.stored, f.bytes, f.mimetype]
     )
-    count++
   }
-  if (!count) return reply.code(400).send({ message: '请选择交付成果文件' })
+
   return { course: await fullCourse(check.rows[0], true) }
 })
 

+ 19 - 0
server/schema.sql

@@ -55,16 +55,32 @@ END $$;
 CREATE INDEX IF NOT EXISTS idx_courses_user_created ON courses(user_id, created_at DESC);
 CREATE INDEX IF NOT EXISTS idx_courses_status_created ON courses(status, created_at DESC);
 
+-- 课程分集表(Episodes)
+CREATE TABLE IF NOT EXISTS episodes (
+  id UUID PRIMARY KEY,
+  course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
+  episode_number INT NOT NULL DEFAULT 1,
+  title VARCHAR(200) NOT NULL,
+  summary VARCHAR(500) NOT NULL DEFAULT '',
+  lecture_notes TEXT NOT NULL DEFAULT '',
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_episodes_course ON episodes(course_id, episode_number ASC);
+
 CREATE TABLE IF NOT EXISTS course_assets (
   id UUID PRIMARY KEY,
   course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
+  episode_id UUID REFERENCES episodes(id) ON DELETE CASCADE,
   original_name VARCHAR(255) NOT NULL,
   stored_name VARCHAR(255) NOT NULL,
   size BIGINT NOT NULL,
   mime_type VARCHAR(150) NOT NULL DEFAULT 'application/octet-stream',
   created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
 );
+ALTER TABLE course_assets ADD COLUMN IF NOT EXISTS episode_id UUID REFERENCES episodes(id) ON DELETE CASCADE;
 CREATE INDEX IF NOT EXISTS idx_assets_course ON course_assets(course_id, created_at);
+CREATE INDEX IF NOT EXISTS idx_assets_episode ON course_assets(episode_id, created_at);
 
 CREATE TABLE IF NOT EXISTS instructors (
   id UUID PRIMARY KEY,
@@ -82,10 +98,13 @@ CREATE INDEX IF NOT EXISTS idx_instructors_course ON instructors(course_id, crea
 CREATE TABLE IF NOT EXISTS course_deliverables (
   id UUID PRIMARY KEY,
   course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
+  episode_id UUID REFERENCES episodes(id) ON DELETE SET NULL,
   original_name VARCHAR(255) NOT NULL,
   stored_name VARCHAR(255) NOT NULL,
   size BIGINT NOT NULL,
   mime_type VARCHAR(150) NOT NULL DEFAULT 'application/octet-stream',
   created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
 );
+ALTER TABLE course_deliverables ADD COLUMN IF NOT EXISTS episode_id UUID REFERENCES episodes(id) ON DELETE SET NULL;
 CREATE INDEX IF NOT EXISTS idx_deliverables_course ON course_deliverables(course_id, created_at);
+CREATE INDEX IF NOT EXISTS idx_deliverables_episode ON course_deliverables(episode_id, created_at);

+ 291 - 0
server/templates.ts

@@ -0,0 +1,291 @@
+import { copyFile, writeFile, mkdir } from 'node:fs/promises'
+import { resolve, join } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import type { CourseTemplate } from '../src/types.js'
+
+export const COURSE_TEMPLATES: CourseTemplate[] = [
+  {
+    id: 'party-building-standard',
+    name: '《坚持人民至上根本立场》党课视频',
+    category: '党政专题',
+    audience: '党员干部 / 青年理论学习小组',
+    tags: ['精品党课', 'AI数字人', '1080P高清', 'PPT同步论证'],
+    badge: '官方推荐示范',
+    summary: '严格依据党课制作任务书规范,包含审定讲稿、全屏课件切换逻辑、数字人讲师近景与全景运镜、精准语速停顿控制。',
+    description: `【制作目标】PPT配合讲稿,完成一支可用于网络党课学习的横版课程视频。
+【形式结构】(课程包装片头—片名)—讲师讲述—PPT论证—讲师回场—章节转场—继续讲述—总结—片尾。
+【运镜与节奏】全片约 30% 为讲师中近景/全景,约 70% 为全屏课件;单页 PPT 驻留 10—45 秒;讲师近景口型眼神对齐,现代演播室虚化背景。
+【语速与语气】语速 210—225 汉字/分钟;句间停顿 0.3—0.5 秒,段落转折 0.6—0.9 秒,章节切换 1.2—1.8 秒;语气正式、平稳、有教学交流感。
+【交付规格】1920×1080、25fps、H.264/AAC、48kHz 立体声(1080p 高清)。`,
+    keySpecs: {
+      resolution: '1080P (1920×1080) 25fps',
+      aspectRatio: '16:9 横版',
+      speechSpeed: '210—225 汉字/分钟',
+      presenterRatio: '30% 讲师出镜 / 70% 全屏课件',
+      voiceTone: '正式、清晰、平稳教学感'
+    },
+    taskBrief: {
+      focalPoints: [
+        '视频质量 1080p 高清,少 AI 味,人物形象清晰,手要露出来,表情自然',
+        'AI 生成音频要跟原稿完全一致,严禁读错字、漏字或擅自改写'
+      ],
+      goals: [
+        '口播稿:《坚持人民至上根本立场 课程讲稿》,字幕原则上与审定讲稿完全一致',
+        '课件:坚持人民至上根本立场.pptx,配合讲稿完成逻辑严密的同步展示',
+        '镜头逻辑:结构上“讲师讲述—PPT论证—讲师回场—章节转场”保持庄重感',
+        '技术参数:1920×1080、25fps、H.264/AAC、48kHz 立体声'
+      ],
+      cameraAndPacing: [
+        '讲师近景与中景切换通常约 20—90 秒,避免静态数字人长时间无动作',
+        '单页 PPT 始终全屏展示(无需画中画),常驻通常约 10—45 秒',
+        '画面切换应落在完整句、自然停顿或主题转折处,严禁半句话中间切镜头',
+        '句间停顿 0.3—0.5 秒,段落转折 0.6—0.9 秒,章节切换 1.2—1.8 秒'
+      ],
+      acceptanceCriteria: [
+        '讲稿无漏句、错句;全部数字、年份、人名、地名读音准确无误',
+        'PPT 使用顺序、讲述内容和动画出现点严格一致,每页均可追溯',
+        '章节层级清楚,各一级部分与小节均有明确进入与收束'
+      ]
+    },
+    instructors: [
+      {
+        name: '特邀党建专家 张教授',
+        organization: '中共党史与理论研究中心',
+        introduction: '资深党建研究专家、特聘理论导师,长年从事党史党建理论研究与党课教学,语调稳健、讲授权威亲和。',
+        sampleImageFile: '人物_站.jpg'
+      }
+    ],
+    episodes: [
+      {
+        episodeNumber: 1,
+        title: '第一讲:江山就是人民,人民就是江山',
+        summary: '阐释人民至上的理论渊源与历史必然性。',
+        lectureNotes: '同志们好!今天我们开始第一讲的专题学习。坚持人民至上,是我们党的根本立场,也是马克思主义唯物史观的集中体现……'
+      },
+      {
+        episodeNumber: 2,
+        title: '第二讲:把人民对美好生活的向往作为奋斗目标',
+        summary: '结合新时代生动实践,论述高质量发展中保障和改善民生。',
+        lectureNotes: '在第二讲中,我们将从实践维度来深入领会。治国有常,利民为本。如何把发展成果转化为人民群众实实在在的幸福感……'
+      },
+      {
+        episodeNumber: 3,
+        title: '第三讲:紧紧依靠人民创造历史伟业',
+        summary: '总结提炼群众路线与青年党员担当使命。',
+        lectureNotes: '第三讲,我们聚焦新时代青年理论骨干的使命担当。历史是人民书写的,一切成就归功于人民……'
+      }
+    ],
+    assets: [
+      {
+        originalName: '坚持人民至上根本立场 课程讲稿(审定版).docx',
+        mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+        sizeText: '156 KB',
+        description: '第一讲口播讲稿,字词及发音注音已审定',
+        episodeNumber: 1
+      },
+      {
+        originalName: '第一讲_坚持人民至上根本立场.pptx',
+        mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+        sizeText: '40.5 MB',
+        description: '第一讲16:9 高清论证课件,对应核心要点',
+        episodeNumber: 1
+      },
+      {
+        originalName: '第二讲_美好生活奋斗目标讲义.docx',
+        mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+        sizeText: '142 KB',
+        description: '第二讲讲稿与案例参考',
+        episodeNumber: 2
+      },
+      {
+        originalName: 'teacher_voice_reference_replace.wav',
+        mimeType: 'audio/wav',
+        sizeText: '8.2 MB',
+        description: '讲师原声录音切片,全套课程声纹克隆通用参考'
+      }
+    ]
+  },
+  {
+    id: 'higher-edu-calculus',
+    name: '《深入浅出微积分:极限与导数之美》公开课',
+    category: '高校公开课',
+    audience: '理工科大一新生 / 考研复习群体',
+    tags: ['高校公开课', '数理基础', '公式板书', '生动启发'],
+    badge: '高校示范',
+    summary: '高校精品开放课程标准,聚焦概念启发与几何直观推导,支持讲师半身出镜与动态板书公式画中画同步。',
+    description: `【制作目标】打造通俗易懂、几何直观性强的大学数学精品微课,适合线上慕课与翻转课堂。
+【形式结构】知识点引言—几何直观演示—严密数学推导—典型例题精讲—思考总结。
+【运镜与节奏】讲师半身讲述约 40%,公式板书与动态动画演示约 60%;支持黑板/手写板与讲师同框画中画。
+【语速与语气】语速 190—210 汉字/分钟,关键公式推导处适当放慢并留白停顿;语气启发温和、循序渐进。
+【交付规格】1080P 60fps 高帧率(保证公式动画平滑),立体声 48kHz。`,
+    keySpecs: {
+      resolution: '1080P (1920×1080) 60fps',
+      aspectRatio: '16:9 横版',
+      speechSpeed: '190—210 汉字/分钟',
+      presenterRatio: '40% 讲师半身 / 60% 动效课件板书',
+      voiceTone: '启发亲切、循序渐进'
+    },
+    taskBrief: {
+      focalPoints: [
+        '公式与图形动画平滑无卡顿,60fps 高帧率输出',
+        '讲解与手写板书笔迹同步出现,重点定理高亮标红'
+      ],
+      goals: [
+        '讲稿:《微积分第一讲:极限的直观与定义》讲义',
+        '课件:微积分动效演示.pptx(含动画与函数图像)',
+        '音画同步:讲师语调配合板书推进节奏'
+      ],
+      cameraAndPacing: [
+        '引言部分讲师中景亲切互动,概念阐述时平滑切至动态图像',
+        '推导定理时采用双画面布局(左侧公式、右侧讲师视线交互)'
+      ],
+      acceptanceCriteria: [
+        '数学符号、希腊字母及公式读法严谨规范',
+        '函数曲线动画与讲述点完全对齐'
+      ]
+    },
+    instructors: [
+      {
+        name: '林副教授',
+        organization: '数学与应用数学学院',
+        introduction: '国家级精品课程主讲教师,擅长以直观几何图像剖析抽象数学概念,教学风格深入浅出。',
+        sampleImageFile: '人物_坐.jpg'
+      }
+    ],
+    episodes: [
+      {
+        episodeNumber: 1,
+        title: '第 1 集:极限的直观与严密定义 (ε-δ 语言)',
+        summary: '从割线切线问题切入,通过动画直观展现数列与函数极限。',
+        lectureNotes: '同学们好!欢迎来到《深入浅出微积分》第1集。今天我们要探索微积分大厦的基石——极限。首先我们来看一个割线逼近切线的动态图像……'
+      },
+      {
+        episodeNumber: 2,
+        title: '第 2 集:导数的几何意义与瞬时变化率',
+        summary: '剖析速度与切线斜率的统一物理数学背景,推导核心求导法则。',
+        lectureNotes: '在第2集中,我们将从瞬时速度的物理问题出发,正式引出导数定义。请大家看屏幕左侧的割线斜率变化……'
+      },
+      {
+        episodeNumber: 3,
+        title: '第 3 集:微分中值定理与函数单调性',
+        summary: '罗尔定理与拉格朗日中值定理的几何证明及在极值分析中的应用。',
+        lectureNotes: '第3集我们将迎来微分学的核心支柱——拉格朗日中值定理。它是联系局部导数与整体增量的桥梁……'
+      }
+    ],
+    assets: [
+      {
+        originalName: '第1集_极限直观与定义讲稿.docx',
+        mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+        sizeText: '98 KB',
+        description: '第1集课堂讲稿与板书推导脚本',
+        episodeNumber: 1
+      },
+      {
+        originalName: '第1集_极限动效演示课件.pptx',
+        mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+        sizeText: '18.6 MB',
+        description: '第1集16:9 动态数学图像课件',
+        episodeNumber: 1
+      },
+      {
+        originalName: '第2集_导数几何意义讲稿.docx',
+        mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+        sizeText: '112 KB',
+        description: '第2集导数与切线推导手稿',
+        episodeNumber: 2
+      },
+      {
+        originalName: '微积分全课程教学大纲与符号规范.pdf',
+        mimeType: 'application/pdf',
+        sizeText: '1.2 MB',
+        description: '全课程通用大纲与板书排版技术标准'
+      }
+    ]
+  }
+]
+
+export async function populateCourseFromTemplate(
+  pool: any,
+  courseId: string,
+  templateId: string,
+  uploadDir: string,
+  sampleDir: string
+) {
+  const template = COURSE_TEMPLATES.find((t) => t.id === templateId) || COURSE_TEMPLATES[0]
+
+  // 1. 填充讲师
+  for (const inst of template.instructors) {
+    let storedImageName: string | null = null
+    let origImageName: string | null = null
+    let mimeType: string | null = null
+
+    if (inst.sampleImageFile) {
+      try {
+        const sourcePath = resolve(sampleDir, inst.sampleImageFile)
+        storedImageName = `instructor-${randomUUID()}.jpg`
+        const destPath = resolve(uploadDir, storedImageName)
+        await copyFile(sourcePath, destPath)
+        origImageName = inst.sampleImageFile
+        mimeType = 'image/jpeg'
+      } catch (err) {
+        console.error('Failed to copy sample instructor image:', err)
+      }
+    }
+
+    await pool.query(
+      `INSERT INTO instructors(id, course_id, name, organization, introduction, image_original_name, image_stored_name, image_mime_type)
+       VALUES($1, $2, $3, $4, $5, $6, $7, $8)`,
+      [
+        randomUUID(),
+        courseId,
+        inst.name,
+        inst.organization,
+        inst.introduction,
+        origImageName,
+        storedImageName,
+        mimeType
+      ]
+    )
+  }
+
+  // 2. 填充预设分集 (Episodes)
+  const episodeMap = new Map<number, string>() // episodeNumber -> episodeId
+  if (template.episodes && template.episodes.length > 0) {
+    for (const ep of template.episodes) {
+      const epId = randomUUID()
+      episodeMap.set(ep.episodeNumber, epId)
+      await pool.query(
+        `INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes)
+         VALUES($1, $2, $3, $4, $5, $6)`,
+        [epId, courseId, ep.episodeNumber, ep.title, ep.summary || '', ep.lectureNotes || '']
+      )
+    }
+  }
+
+  // 3. 填充示例课件资产(支持分配到指定分集或作为通用资产)
+  for (const asset of template.assets) {
+    const ext = asset.originalName.split('.').pop() || 'dat'
+    const storedName = `${courseId}-${randomUUID()}.${ext}`
+    const destPath = resolve(uploadDir, storedName)
+
+    const targetEpId = asset.episodeNumber ? episodeMap.get(asset.episodeNumber) || null : null
+
+    // 创建示例占位内容文件
+    const sampleContent = `=== 星痕智能课程工坊 示范资产 ===\n课程名称:${template.name}\n资产名称:${asset.originalName}\n所属分集:${asset.episodeNumber ? `第 ${asset.episodeNumber} 集` : '课程通用素材'}\n类型描述:${asset.description || '示例教学资料'}\n\n该文件为系统预设课程示例,用户可作为制作任务书与课件标准进行参考。`
+    await writeFile(destPath, Buffer.from(sampleContent, 'utf8'))
+
+    // 虚拟大小 (参考展示)
+    let approxSize = 1024 * 256
+    if (asset.sizeText.includes('MB')) {
+      approxSize = Math.round(parseFloat(asset.sizeText) * 1024 * 1024)
+    } else if (asset.sizeText.includes('KB')) {
+      approxSize = Math.round(parseFloat(asset.sizeText) * 1024)
+    }
+
+    await pool.query(
+      `INSERT INTO course_assets(id, course_id, episode_id, original_name, stored_name, size, mime_type)
+       VALUES($1, $2, $3, $4, $5, $6, $7)`,
+      [randomUUID(), courseId, targetEpId, asset.originalName, storedName, approxSize, asset.mimeType]
+    )
+  }
+}

+ 48 - 3
src/api.ts

@@ -1,4 +1,4 @@
-import type { AdminStats, Course, CourseStatus, User } from './types'
+import type { AdminStats, Course, CourseStatus, CourseTemplate, User } from './types'
 
 const token = () => localStorage.getItem('xinghen_token')
 
@@ -46,6 +46,15 @@ export const api = {
       body: JSON.stringify({ currentPassword, newPassword })
     }),
 
+  // 课程模板
+  getTemplates: () => request<{ templates: CourseTemplate[] }>('/course-templates'),
+  createFromTemplate: (templateId?: string) =>
+    request<{ course: Course }>('/courses/template', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ templateId })
+    }),
+
   // 普通用户课程操作
   listCourses: () => request<{ courses: Course[] }>('/courses'),
   createCourse: (body: Pick<Course, 'name' | 'category' | 'audience' | 'description'>) =>
@@ -55,13 +64,48 @@ export const api = {
       body: JSON.stringify(body)
     }),
   getCourse: (id: string) => request<{ course: Course }>(`/courses/${id}`),
-  uploadAssets: (id: string, files: File[]) => {
+  deleteCourse: (id: string) => request<{ ok: boolean; message?: string }>(`/courses/${id}`, { method: 'DELETE' }),
+  uploadAssets: (id: string, files: File[], episodeId?: string) => {
     const form = new FormData()
     files.forEach((f) => form.append('assets', f))
+    if (episodeId) form.append('episodeId', episodeId)
     return request<{ course: Course }>(`/courses/${id}/assets`, { method: 'POST', body: form })
   },
   deleteAsset: (id: string, assetId: string) =>
     request<{ course: Course }>(`/courses/${id}/assets/${assetId}`, { method: 'DELETE' }),
+
+  // 课程分集操作
+  createEpisode: (
+    courseId: string,
+    data: { title: string; episodeNumber?: number; summary?: string; lectureNotes?: string }
+  ) =>
+    request<{ course: Course }>(`/courses/${courseId}/episodes`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(data)
+    }),
+  batchCreateEpisodes: (
+    courseId: string,
+    data: { count?: number; episodes?: Array<{ title: string; episodeNumber?: number; summary?: string; lectureNotes?: string }> }
+  ) =>
+    request<{ course: Course }>(`/courses/${courseId}/episodes/batch`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(data)
+    }),
+  updateEpisode: (
+    courseId: string,
+    episodeId: string,
+    data: { title?: string; episodeNumber?: number; summary?: string; lectureNotes?: string }
+  ) =>
+    request<{ course: Course }>(`/courses/${courseId}/episodes/${episodeId}`, {
+      method: 'PATCH',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(data)
+    }),
+  deleteEpisode: (courseId: string, episodeId: string) =>
+    request<{ course: Course }>(`/courses/${courseId}/episodes/${episodeId}`, { method: 'DELETE' }),
+
   addInstructor: (
     id: string,
     data: { name: string; organization: string; introduction: string; image?: File }
@@ -100,9 +144,10 @@ export const api = {
         headers: { 'Content-Type': 'application/json' },
         body: JSON.stringify({ status })
       }),
-    uploadDeliverables: (id: string, files: File[]) => {
+    uploadDeliverables: (id: string, files: File[], episodeId?: string) => {
       const form = new FormData()
       files.forEach((f) => form.append('deliverables', f))
+      if (episodeId) form.append('episodeId', episodeId)
       return request<{ course: Course }>(`/admin/courses/${id}/deliverables`, { method: 'POST', body: form })
     },
     deleteDeliverable: (id: string, delivId: string) =>

BIN
src/assets/sample/人物_坐.jpg


BIN
src/assets/sample/人物_站.jpg


+ 146 - 0
src/components/AddInstructorModal.tsx

@@ -0,0 +1,146 @@
+import { FormEvent, useState, useEffect } from 'react'
+import { X, UserPlus, Sparkles } from 'lucide-react'
+import { InstructorImageField } from './InstructorImageField'
+import { api } from '../api'
+import type { Course } from '../types'
+
+interface AddInstructorModalProps {
+  isOpen: boolean
+  onClose: () => void
+  courseId: string
+  onSuccess: (course: Course) => void
+}
+
+export function AddInstructorModal({
+  isOpen,
+  onClose,
+  courseId,
+  onSuccess
+}: AddInstructorModalProps) {
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState('')
+
+  useEffect(() => {
+    function handleKeyDown(e: KeyboardEvent) {
+      if (e.key === 'Escape' && isOpen && !busy) {
+        onClose()
+      }
+    }
+    window.addEventListener('keydown', handleKeyDown)
+    return () => window.removeEventListener('keydown', handleKeyDown)
+  }, [isOpen, busy, onClose])
+
+  if (!isOpen) return null
+
+  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
+    e.preventDefault()
+    const form = e.currentTarget
+    const f = new FormData(form)
+    const image = form.elements.namedItem('image') as HTMLInputElement
+
+    const name = String(f.get('name') || '').trim()
+    if (!name) {
+      setError('请填写讲师姓名')
+      return
+    }
+
+    setBusy(true)
+    setError('')
+    try {
+      const res = await api.addInstructor(courseId, {
+        name,
+        organization: String(f.get('organization') || '').trim(),
+        introduction: String(f.get('introduction') || '').trim(),
+        image: image?.files?.[0]
+      })
+      onSuccess(res.course)
+      onClose()
+    } catch (err) {
+      setError((err as Error).message || '保存讲师信息失败,请重试')
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  return (
+    <div className="modal-backdrop" onClick={() => !busy && onClose()}>
+      <div
+        className="modal-dialog instructor-modal"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="modal-header">
+          <div>
+            <span className="modal-badge">INSTRUCTORS</span>
+            <h3>添加讲师信息</h3>
+          </div>
+          <button
+            type="button"
+            className="close-modal-btn"
+            onClick={onClose}
+            disabled={busy}
+            title="关闭"
+          >
+            <X size={18} />
+          </button>
+        </div>
+
+        <form onSubmit={handleSubmit} className="modal-form-content">
+          <div className="modal-body instructor-modal-body">
+            {error && <div className="notice notice-error">{error}</div>}
+
+            <div className="form-group-row">
+              <label className="form-label required">
+                <span>讲师姓名</span>
+                <input
+                  name="name"
+                  required
+                  placeholder="如:张教授 / 李主讲"
+                  disabled={busy}
+                  autoFocus
+                />
+              </label>
+              <label className="form-label">
+                <span>所属单位 / 机构</span>
+                <input
+                  name="organization"
+                  placeholder="如:中共党史与理论研究中心"
+                  disabled={busy}
+                />
+              </label>
+            </div>
+
+            <label className="form-label">
+              <span>讲师介绍</span>
+              <textarea
+                name="introduction"
+                rows={3}
+                placeholder="简要描述讲师专业背景、主讲方向与授课风格"
+                disabled={busy}
+              />
+            </label>
+
+            <InstructorImageField disabled={busy} />
+          </div>
+
+          <div className="modal-footer">
+            <button
+              type="button"
+              className="secondary-button"
+              onClick={onClose}
+              disabled={busy}
+            >
+              取消
+            </button>
+            <button
+              type="submit"
+              className="primary-button"
+              disabled={busy}
+            >
+              {busy ? '正在保存…' : '保存讲师'}
+            </button>
+          </div>
+        </form>
+      </div>
+    </div>
+  )
+}

+ 159 - 0
src/components/DeleteCourseModal.tsx

@@ -0,0 +1,159 @@
+import { AlertTriangle, Loader2, Trash2, X } from 'lucide-react'
+import { FormEvent, useEffect, useState } from 'react'
+import { api } from '../api'
+import type { Course } from '../types'
+
+interface DeleteCourseModalProps {
+  isOpen: boolean
+  onClose: () => void
+  course: Course
+  onSuccess: () => void
+}
+
+export function DeleteCourseModal({
+  isOpen,
+  onClose,
+  course,
+  onSuccess
+}: DeleteCourseModalProps) {
+  const [confirmName, setConfirmName] = useState('')
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState('')
+
+  useEffect(() => {
+    if (isOpen) {
+      setConfirmName('')
+      setError('')
+      setBusy(false)
+    }
+  }, [isOpen])
+
+  // 监听 ESC 键关闭
+  useEffect(() => {
+    function handleKeyDown(e: KeyboardEvent) {
+      if (e.key === 'Escape' && isOpen && !busy) {
+        onClose()
+      }
+    }
+    window.addEventListener('keydown', handleKeyDown)
+    return () => window.removeEventListener('keydown', handleKeyDown)
+  }, [isOpen, busy, onClose])
+
+  if (!isOpen) return null
+
+  const isMatch = confirmName.trim() === course.name.trim()
+
+  async function handleSubmit(e: FormEvent) {
+    e.preventDefault()
+    if (!isMatch) {
+      setError('输入的课程名称不一致,请核对后重试')
+      return
+    }
+
+    setBusy(true)
+    setError('')
+    try {
+      await api.deleteCourse(course.id)
+      onSuccess()
+    } catch (err) {
+      setError((err as Error).message || '删除课程失败,请稍后重试')
+      setBusy(false)
+    }
+  }
+
+  return (
+    <div
+      className="modal-backdrop"
+      onClick={(e) => {
+        if (e.target === e.currentTarget && !busy) {
+          onClose()
+        }
+      }}
+    >
+      <div className="modal-dialog delete-course-modal" role="dialog" aria-modal="true">
+        <div className="modal-header danger-header">
+          <div className="danger-header-title">
+            <div className="danger-icon-badge">
+              <AlertTriangle size={20} />
+            </div>
+            <div>
+              <span className="modal-badge danger-badge">DANGER ZONE</span>
+              <h3>删除课程</h3>
+            </div>
+          </div>
+          <button
+            type="button"
+            className="close-modal-btn"
+            onClick={onClose}
+            disabled={busy}
+            aria-label="关闭"
+          >
+            <X size={18} />
+          </button>
+        </div>
+
+        <form onSubmit={handleSubmit} className="modal-form-content">
+          <div className="delete-modal-body">
+            <div className="danger-alert-card">
+              <p>
+                此操作<strong>无法撤销</strong>。该课程及其关联的 <strong>{course.episodes?.length || 0} 个分集</strong>、<strong>{course.assets?.length || 0} 份素材文件</strong>和讲师信息都将被永久删除。
+              </p>
+            </div>
+
+            <div className="form-group">
+              <label className="form-label" htmlFor="delete-course-input">
+                请输入课程名称进行确认:
+              </label>
+              <div className="target-course-name-box">
+                <code>{course.name}</code>
+              </div>
+              <input
+                id="delete-course-input"
+                type="text"
+                autoFocus
+                value={confirmName}
+                onChange={(e) => {
+                  setConfirmName(e.target.value)
+                  if (error) setError('')
+                }}
+                placeholder="在此输入完整的课程名称"
+                disabled={busy}
+                className="delete-confirm-input"
+              />
+            </div>
+
+            {error && <div className="notice notice-error">{error}</div>}
+          </div>
+
+          <div className="modal-footer">
+            <button
+              type="button"
+              className="secondary-button"
+              onClick={onClose}
+              disabled={busy}
+            >
+              取消
+            </button>
+            <button
+              type="submit"
+              className="danger-button danger-submit-btn"
+              disabled={!isMatch || busy}
+            >
+              {busy ? (
+                <>
+                  <Loader2 size={16} className="spin" />
+                  正在删除…
+                </>
+              ) : (
+                <>
+                  <Trash2 size={16} />
+                  确认彻底删除
+                </>
+              )}
+            </button>
+          </div>
+        </form>
+      </div>
+    </div>
+  )
+}

+ 370 - 0
src/components/EpisodeModal.tsx

@@ -0,0 +1,370 @@
+import { X, Sparkles, Plus, ListPlus, FileText, ChevronDown, ChevronUp } from 'lucide-react'
+import { FormEvent, useEffect, useState } from 'react'
+import { api } from '../api'
+import type { Course, Episode } from '../types'
+
+interface EpisodeModalProps {
+  isOpen: boolean
+  onClose: () => void
+  courseId: string
+  episode?: Episode | null
+  nextNumber?: number
+  onSuccess: (course: Course) => void
+}
+
+export function EpisodeModal({
+  isOpen,
+  onClose,
+  courseId,
+  episode,
+  nextNumber = 1,
+  onSuccess
+}: EpisodeModalProps) {
+  const [activeTab, setActiveTab] = useState<'single' | 'batch'>('single')
+
+  // 单集表单状态
+  const [title, setTitle] = useState('')
+  const [episodeNumber, setEpisodeNumber] = useState(nextNumber)
+  const [summary, setSummary] = useState('')
+  const [lectureNotes, setLectureNotes] = useState('')
+  const [showOptionalFields, setShowOptionalFields] = useState(false)
+
+  // 批量创建状态
+  const [batchCount, setBatchCount] = useState<number>(3)
+  const [batchTitlesText, setBatchTitlesText] = useState('')
+
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState('')
+
+  useEffect(() => {
+    if (isOpen) {
+      if (episode) {
+        setActiveTab('single')
+        setTitle(episode.title)
+        setEpisodeNumber(episode.episodeNumber)
+        setSummary(episode.summary || '')
+        setLectureNotes(episode.lectureNotes || '')
+        setShowOptionalFields(!!(episode.summary?.trim() || episode.lectureNotes?.trim()))
+      } else {
+        setActiveTab('single')
+        setTitle(`第 ${nextNumber} 集:`)
+        setEpisodeNumber(nextNumber)
+        setSummary('')
+        setLectureNotes('')
+        setShowOptionalFields(false)
+        setBatchCount(3)
+        setBatchTitlesText('')
+      }
+      setError('')
+    }
+  }, [isOpen, episode, nextNumber])
+
+  // 监听 ESC 键关闭
+  useEffect(() => {
+    function handleKeyDown(e: KeyboardEvent) {
+      if (e.key === 'Escape' && isOpen && !busy) {
+        onClose()
+      }
+    }
+    window.addEventListener('keydown', handleKeyDown)
+    return () => window.removeEventListener('keydown', handleKeyDown)
+  }, [isOpen, busy, onClose])
+
+  if (!isOpen) return null
+
+  // 计算多行标题预览
+  const parsedBatchTitles = batchTitlesText
+    .split('\n')
+    .map((t) => t.trim())
+    .filter(Boolean)
+
+  const effectiveBatchCount = parsedBatchTitles.length > 0 ? parsedBatchTitles.length : batchCount
+
+  // 口播时长估算(按 260 字/分钟)
+  const estMinutes = lectureNotes.trim() ? (lectureNotes.trim().length / 260).toFixed(1) : '0'
+
+  async function handleSingleSubmit(e: FormEvent) {
+    e.preventDefault()
+    if (!title.trim()) {
+      setError('请输入分集标题')
+      return
+    }
+
+    setBusy(true)
+    setError('')
+    try {
+      let res: { course: Course }
+      if (episode) {
+        res = await api.updateEpisode(courseId, episode.id, {
+          title: title.trim(),
+          episodeNumber: Number(episodeNumber) || 1,
+          summary: summary.trim(),
+          lectureNotes: lectureNotes.trim()
+        })
+      } else {
+        res = await api.createEpisode(courseId, {
+          title: title.trim(),
+          episodeNumber: Number(episodeNumber) || nextNumber,
+          summary: summary.trim(),
+          lectureNotes: lectureNotes.trim()
+        })
+      }
+      onSuccess(res.course)
+      onClose()
+    } catch (err: any) {
+      setError(err.message || '保存失败,请重试')
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  async function handleBatchSubmit(e: FormEvent) {
+    e.preventDefault()
+    setBusy(true)
+    setError('')
+    try {
+      let res: { course: Course }
+      if (parsedBatchTitles.length > 0) {
+        // 使用自定义多行标题
+        res = await api.batchCreateEpisodes(courseId, {
+          episodes: parsedBatchTitles.map((t, idx) => ({
+            title: t,
+            episodeNumber: nextNumber + idx,
+            summary: '',
+            lectureNotes: ''
+          }))
+        })
+      } else {
+        // 使用指定数量生成
+        res = await api.batchCreateEpisodes(courseId, {
+          count: batchCount
+        })
+      }
+      onSuccess(res.course)
+      onClose()
+    } catch (err: any) {
+      setError(err.message || '批量添加失败,请重试')
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  return (
+    <div className="modal-backdrop" onClick={() => !busy && onClose()}>
+      <div className="modal-dialog episode-modal" onClick={(e) => e.stopPropagation()}>
+        <div className="modal-header">
+          <div>
+            <span className="modal-badge">EPISODES</span>
+            <h3>{episode ? '编辑课程分集' : '添加课程分集'}</h3>
+          </div>
+          <button
+            type="button"
+            className="close-modal-btn"
+            onClick={onClose}
+            disabled={busy}
+            title="关闭"
+          >
+            <X size={18} />
+          </button>
+        </div>
+
+        {/* 添加模式下的 Tab 切换 */}
+        {!episode && (
+          <div className="modal-tabs-header">
+            <button
+              type="button"
+              className={`modal-tab-btn ${activeTab === 'single' ? 'active' : ''}`}
+              onClick={() => setActiveTab('single')}
+            >
+              <Plus size={15} /> 单集添加
+            </button>
+            <button
+              type="button"
+              className={`modal-tab-btn ${activeTab === 'batch' ? 'active' : ''}`}
+              onClick={() => setActiveTab('batch')}
+            >
+              <ListPlus size={15} /> 批量快速生成 (如 5~12 集)
+            </button>
+          </div>
+        )}
+
+        {activeTab === 'single' ? (
+          <form onSubmit={handleSingleSubmit} className="modal-form-content">
+            <div className="modal-body episode-modal-body">
+              {error && <div className="notice notice-error">{error}</div>}
+
+              <div className="grid-two-inputs">
+                <label className="form-label required">
+                  <span>集数序号</span>
+                  <input
+                    type="number"
+                    min={1}
+                    max={999}
+                    required
+                    value={episodeNumber}
+                    onChange={(e) => setEpisodeNumber(Math.max(1, parseInt(e.target.value) || 1))}
+                    disabled={busy}
+                  />
+                </label>
+
+                <label className="form-label required">
+                  <span>分集标题</span>
+                  <input
+                    type="text"
+                    required
+                    maxLength={200}
+                    value={title}
+                    onChange={(e) => setTitle(e.target.value)}
+                    placeholder="例如:第 1 集:极限的直观与严密定义"
+                    disabled={busy}
+                    autoFocus
+                  />
+                </label>
+              </div>
+
+              {/* 选填信息折叠开关 */}
+              <div className="optional-fields-toggle">
+                <button
+                  type="button"
+                  className="toggle-optional-btn"
+                  onClick={() => setShowOptionalFields(!showOptionalFields)}
+                >
+                  {showOptionalFields ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
+                  <span>{showOptionalFields ? '收起选填信息' : '+ 填写分集简介或口播讲稿(选填)'}</span>
+                </button>
+              </div>
+
+              {showOptionalFields && (
+                <div className="optional-fields-container">
+                  <label className="form-label">
+                    <span>分集简介 / 制作要求(选填)</span>
+                    <input
+                      type="text"
+                      maxLength={500}
+                      value={summary}
+                      onChange={(e) => setSummary(e.target.value)}
+                      placeholder="本集核心要点、运镜重点或特殊说明"
+                      disabled={busy}
+                    />
+                  </label>
+
+                  <label className="form-label">
+                    <div className="label-with-badge">
+                      <span>分集口播讲稿正文(选填)</span>
+                      {lectureNotes.trim().length > 0 && (
+                        <span className="lecture-stats-badge">
+                          <FileText size={12} /> {lectureNotes.trim().length} 字 · 预计口播约 {estMinutes} 分钟
+                        </span>
+                      )}
+                    </div>
+                    <textarea
+                      rows={5}
+                      value={lectureNotes}
+                      onChange={(e) => setLectureNotes(e.target.value)}
+                      placeholder="选填。可在线粘贴讲稿,也可在创建分集后随时在工作台编辑或上传 Word/PDF 附件…"
+                      disabled={busy}
+                    />
+                    <small className="hint-text">
+                      {lectureNotes
+                        ? `已录入 ${lectureNotes.length} 字符`
+                        : '创建分集后可在工作台直接在线编辑讲稿或上传附件'}
+                    </small>
+                  </label>
+                </div>
+              )}
+            </div>
+
+            <div className="modal-footer">
+              <button type="button" className="secondary-button" onClick={onClose} disabled={busy}>
+                取消
+              </button>
+              <button type="submit" className="primary-button" disabled={busy || !title.trim()}>
+                {busy ? '正在保存…' : episode ? '保存分集' : '确认添加分集'}
+              </button>
+            </div>
+          </form>
+        ) : (
+          <form onSubmit={handleBatchSubmit} className="modal-form-content">
+            <div className="modal-body episode-modal-body">
+              {error && <div className="notice notice-error">{error}</div>}
+
+              <div className="batch-notice-banner">
+                <Sparkles size={16} />
+                <div>
+                  <strong>快速批量规划分集</strong>
+                  <p>支持一键生成多集占位,或直接粘贴分集大纲标题,系统将自动依次编排集数序号。</p>
+                </div>
+              </div>
+
+              <div className="batch-quick-section">
+                <label className="form-label">
+                  <span>快捷生成集数(从第 {nextNumber} 集开始)</span>
+                  <div className="quick-count-tags">
+                    {[1, 2, 3, 5, 8, 10, 12].map((num) => (
+                      <button
+                        key={num}
+                        type="button"
+                        className={`count-pill ${batchCount === num && parsedBatchTitles.length === 0 ? 'active' : ''}`}
+                        onClick={() => {
+                          setBatchCount(num)
+                          setBatchTitlesText('')
+                        }}
+                      >
+                        +{num} 集
+                      </button>
+                    ))}
+                  </div>
+                </label>
+              </div>
+
+              <label className="form-label">
+                <span>或直接粘贴多行分集标题(每行一集,优先按粘贴文本生成)</span>
+                <textarea
+                  rows={5}
+                  value={batchTitlesText}
+                  onChange={(e) => setBatchTitlesText(e.target.value)}
+                  placeholder={`例如直接粘贴教学大纲:\n第 ${nextNumber} 集:微积分的起源与导数几何意义\n第 ${nextNumber + 1} 集:常见函数求导法则实战精讲\n第 ${nextNumber + 2} 集:极限与连续性定理深度剖析`}
+                  disabled={busy}
+                />
+              </label>
+
+              {/* 预览区 */}
+              <div className="batch-preview-box">
+                <span className="preview-title">即将批量创建的分集清单 (共 {effectiveBatchCount} 集):</span>
+                <div className="batch-preview-list">
+                  {parsedBatchTitles.length > 0 ? (
+                    parsedBatchTitles.map((t, idx) => (
+                      <div key={idx} className="preview-row">
+                        <span className="preview-ep-tag">EP {String(nextNumber + idx).padStart(2, '0')}</span>
+                        <span className="preview-ep-title">{t}</span>
+                      </div>
+                    ))
+                  ) : (
+                    Array.from({ length: Math.min(batchCount, 8) }).map((_, idx) => (
+                      <div key={idx} className="preview-row">
+                        <span className="preview-ep-tag">EP {String(nextNumber + idx).padStart(2, '0')}</span>
+                        <span className="preview-ep-title">第 {nextNumber + idx} 集:课程知识点精讲</span>
+                      </div>
+                    ))
+                  )}
+                  {parsedBatchTitles.length === 0 && batchCount > 8 && (
+                    <div className="preview-row-more">… 以及后续 {batchCount - 8} 个分集</div>
+                  )}
+                </div>
+              </div>
+            </div>
+
+            <div className="modal-footer">
+              <button type="button" className="secondary-button" onClick={onClose} disabled={busy}>
+                取消
+              </button>
+              <button type="submit" className="primary-button" disabled={busy || effectiveBatchCount < 1}>
+                {busy ? '正在生成…' : `一键创建 ${effectiveBatchCount} 个分集`}
+              </button>
+            </div>
+          </form>
+        )}
+      </div>
+    </div>
+  )
+}

+ 348 - 0
src/components/InstructorImageField.tsx

@@ -0,0 +1,348 @@
+import { useState, useRef, ChangeEvent, DragEvent } from "react"
+import { Image as ImageIcon, UploadCloud, X, ZoomIn, CheckCircle2, AlertCircle, Info } from "lucide-react"
+import sampleStandImg from "../assets/sample/人物_站.jpg"
+import sampleSitImg from "../assets/sample/人物_坐.jpg"
+
+interface InstructorImageFieldProps {
+  onFileChange?: (file: File | null) => void
+  disabled?: boolean
+}
+
+export function InstructorImageField({ onFileChange, disabled }: InstructorImageFieldProps) {
+  const fileInputRef = useRef<HTMLInputElement>(null)
+  const [selectedFile, setSelectedFile] = useState<File | null>(null)
+  const [previewUrl, setPreviewUrl] = useState<string | null>(null)
+  const [isDragging, setIsDragging] = useState(false)
+  const [activeModalSample, setActiveModalSample] = useState<"stand" | "sit" | null>(null)
+  const [showFullGuide, setShowFullGuide] = useState(false)
+
+  function handleFile(file: File | undefined) {
+    if (!file) return
+    if (!file.type.startsWith("image/")) {
+      alert("请上传图片文件(JPG、PNG、WEBP 等)")
+      return
+    }
+    if (previewUrl) {
+      URL.revokeObjectURL(previewUrl)
+    }
+    setSelectedFile(file)
+    setPreviewUrl(URL.createObjectURL(file))
+    if (onFileChange) {
+      onFileChange(file)
+    }
+  }
+
+  function handleInputChange(e: ChangeEvent<HTMLInputElement>) {
+    const file = e.target.files?.[0]
+    handleFile(file)
+  }
+
+  function handleDragOver(e: DragEvent<HTMLDivElement>) {
+    e.preventDefault()
+    e.stopPropagation()
+    if (!disabled) setIsDragging(true)
+  }
+
+  function handleDragLeave(e: DragEvent<HTMLDivElement>) {
+    e.preventDefault()
+    e.stopPropagation()
+    setIsDragging(false)
+  }
+
+  function handleDrop(e: DragEvent<HTMLDivElement>) {
+    e.preventDefault()
+    e.stopPropagation()
+    setIsDragging(false)
+    if (disabled) return
+    const file = e.dataTransfer.files?.[0]
+    if (file) {
+      handleFile(file)
+      // 同步到 input
+      if (fileInputRef.current) {
+        const dataTransfer = new DataTransfer()
+        dataTransfer.items.add(file)
+        fileInputRef.current.files = dataTransfer.files
+      }
+    }
+  }
+
+  function clearFile() {
+    if (previewUrl) {
+      URL.revokeObjectURL(previewUrl)
+    }
+    setSelectedFile(null)
+    setPreviewUrl(null)
+    if (fileInputRef.current) {
+      fileInputRef.current.value = ""
+    }
+    if (onFileChange) {
+      onFileChange(null)
+    }
+  }
+
+  const formatSize = (bytes: number) => {
+    return bytes > 1024 * 1024
+      ? (bytes / (1024 * 1024)).toFixed(1) + " MB"
+      : Math.ceil(bytes / 1024) + " KB"
+  }
+
+  return (
+    <div className="instructor-image-field-wrapper">
+
+      {/* 隐藏的文件输入 */}
+      <input
+        ref={fileInputRef}
+        type="file"
+        name="image"
+        accept="image/*"
+        style={{ display: "none" }}
+        onChange={handleInputChange}
+        disabled={disabled}
+      />
+
+      {/* 上传区域 */}
+      {!selectedFile ? (
+        <div
+          className={`instructor-upload-zone ${isDragging ? "dragging" : ""}`}
+          onDragOver={handleDragOver}
+          onDragLeave={handleDragLeave}
+          onDrop={handleDrop}
+          onClick={() => !disabled && fileInputRef.current?.click()}
+        >
+          <div className="upload-zone-icon">
+            <UploadCloud size={24} />
+          </div>
+          <div className="upload-zone-text">
+            <strong>点击或拖拽上传讲师出镜照</strong>
+            <span>支持 JPG / PNG / WEBP,建议 1080P 以上高清绿幕/纯色原图</span>
+          </div>
+        </div>
+      ) : (
+        <div className="instructor-image-preview-card">
+          <img src={previewUrl || ""} alt="讲师照片预览" className="preview-thumbnail" />
+          <div className="preview-info">
+            <span className="file-name" title={selectedFile.name}>
+              {selectedFile.name}
+            </span>
+            <span className="file-size">{formatSize(selectedFile.size)} · 已就绪</span>
+          </div>
+          <div className="preview-actions">
+            <button
+              type="button"
+              className="action-btn change-btn"
+              onClick={() => fileInputRef.current?.click()}
+              disabled={disabled}
+            >
+              更换
+            </button>
+            <button
+              type="button"
+              className="action-btn remove-btn"
+              onClick={clearFile}
+              disabled={disabled}
+              title="移除照片"
+            >
+              <X size={15} />
+            </button>
+          </div>
+        </div>
+      )}
+
+      {/* 规范示例展示卡片 */}
+      <div className="sample-guide-section">
+        <div className="sample-guide-header">
+          <div className="guide-title">
+            <ImageIcon size={14} className="guide-icon" />
+            <span>出镜照片标准示例(点击放大查看)</span>
+          </div>
+          <span className="guide-tag">绿幕/纯色抠像标准</span>
+        </div>
+
+        <div className="sample-cards-grid">
+          {/* 站姿示例 */}
+          <div
+            className="sample-card"
+            onClick={() => setActiveModalSample("stand")}
+            title="点击查看站姿高清示例与规范"
+          >
+            <div className="sample-img-box">
+              <img src={sampleStandImg} alt="站姿标准示例" />
+              <div className="zoom-hint">
+                <ZoomIn size={14} /> 查看大图
+              </div>
+              <span className="sample-pose-badge">站姿示例</span>
+            </div>
+            <div className="sample-card-desc">
+              <strong>全景/大半身站姿</strong>
+              <p>适合全屏主讲、演讲开场与总结。正面挺拔,双手自然交叠。</p>
+            </div>
+          </div>
+
+          {/* 坐姿示例 */}
+          <div
+            className="sample-card"
+            onClick={() => setActiveModalSample("sit")}
+            title="点击查看坐姿高清示例与规范"
+          >
+            <div className="sample-img-box">
+              <img src={sampleSitImg} alt="坐姿标准示例" />
+              <div className="zoom-hint">
+                <ZoomIn size={14} /> 查看大图
+              </div>
+              <span className="sample-pose-badge">坐姿示例</span>
+            </div>
+            <div className="sample-card-desc">
+              <strong>半身平稳坐姿</strong>
+              <p>适合课件画中画、定理推导与访谈。坐姿端正,双手置于桌面。</p>
+            </div>
+          </div>
+        </div>
+
+        {/* 文字要求要点列表 */}
+        <div className={`guide-requirements-list ${showFullGuide ? "expanded" : ""}`}>
+          <div className="req-item">
+            <div className="req-icon green-dot">
+              <CheckCircle2 size={13} />
+            </div>
+            <div className="req-content">
+              <strong>背景环境</strong>
+              <p>强烈推荐<strong>纯绿幕</strong>或干净单色平整背景,表面无杂物、无反光、无大面积褶皱,以保证高质量抠像。</p>
+            </div>
+          </div>
+
+          <div className="req-item">
+            <div className="req-icon blue-dot">
+              <CheckCircle2 size={13} />
+            </div>
+            <div className="req-content">
+              <strong>构图与姿态</strong>
+              <p>人物居中,面部与肩膀完整;双眼平视镜头,表情自然亲切;双手自然收拢,避免遮挡面部或夸张晃动。</p>
+            </div>
+          </div>
+
+          <div className="req-item">
+            <div className="req-icon amber-dot">
+              <CheckCircle2 size={13} />
+            </div>
+            <div className="req-content">
+              <strong>光线与清晰度</strong>
+              <p>采用明亮均匀的面部柔光,避免顶光硬影或过曝;建议提供 <strong>1080P (1920×1080) 以上</strong> 清晰原图。</p>
+            </div>
+          </div>
+
+          <div className="req-item">
+            <div className="req-icon red-dot">
+              <AlertCircle size={13} />
+            </div>
+            <div className="req-content">
+              <strong>着装与避坑提示</strong>
+              <p>着装整洁得体;<strong>请勿穿着与背景同色(如绿幕配绿色衣服)</strong>;避免细密密集条纹以防摩尔纹。</p>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      {/* 示例大图预览弹窗 */}
+      {activeModalSample && (
+        <div className="modal-overlay sample-modal-overlay" onClick={() => setActiveModalSample(null)}>
+          <div className="sample-modal-box" onClick={(e) => e.stopPropagation()}>
+            <div className="sample-modal-header">
+              <div className="modal-title-group">
+                <h3>
+                  {activeModalSample === "stand" ? "讲师出镜规范示例:站姿全景" : "讲师出镜规范示例:坐姿半身"}
+                </h3>
+                <span className="modal-sub-tag">
+                  {activeModalSample === "stand" ? "推荐用于全屏讲解与主讲出镜" : "推荐用于课件画中画与推导讲解"}
+                </span>
+              </div>
+              <button
+                type="button"
+                className="modal-close-btn"
+                onClick={() => setActiveModalSample(null)}
+              >
+                <X size={18} />
+              </button>
+            </div>
+
+            <div className="sample-modal-body">
+              <div className="sample-modal-view">
+                <img
+                  src={activeModalSample === "stand" ? sampleStandImg : sampleSitImg}
+                  alt={activeModalSample === "stand" ? "站姿示例大图" : "坐姿示例大图"}
+                  className="modal-large-img"
+                />
+              </div>
+
+              <div className="sample-modal-spec">
+                <h4>拍摄规范与构图要点</h4>
+                <ul className="spec-checklist">
+                  {activeModalSample === "stand" ? (
+                    <>
+                      <li>
+                        <strong>背景标准:</strong>纯色绿幕无褶皱,边缘与人物发丝对比分明。
+                      </li>
+                      <li>
+                        <strong>体态站姿:</strong>身姿端正挺拔,双肩齐平,双手交叠于腹前,正对镜头平视。
+                      </li>
+                      <li>
+                        <strong>景别范围:</strong>全身或七分身(膝盖以上),头顶上方预留约 10% 空间。
+                      </li>
+                      <li>
+                        <strong>应用场景:</strong>数字人全景演讲、片头引入与课程结语。
+                      </li>
+                    </>
+                  ) : (
+                    <>
+                      <li>
+                        <strong>背景与台面:</strong>平整绿幕背景,讲台或桌面整洁无杂物。
+                      </li>
+                      <li>
+                        <strong>坐姿与手势:</strong>坐姿挺拔不驼背,双手自然轻放于桌面,视线专注平视。
+                      </li>
+                      <li>
+                        <strong>景别范围:</strong>腰部以上中近景半身,头部居中适中。
+                      </li>
+                      <li>
+                        <strong>应用场景:</strong>公式推导、PPT 双画面画中画互动、重点定理精讲。
+                      </li>
+                    </>
+                  )}
+                </ul>
+
+                <div className="sample-modal-switch">
+                  <span className="switch-label">切换参考示例:</span>
+                  <div className="switch-buttons">
+                    <button
+                      type="button"
+                      className={`switch-btn ${activeModalSample === "stand" ? "active" : ""}`}
+                      onClick={() => setActiveModalSample("stand")}
+                    >
+                      站姿全景示例
+                    </button>
+                    <button
+                      type="button"
+                      className={`switch-btn ${activeModalSample === "sit" ? "active" : ""}`}
+                      onClick={() => setActiveModalSample("sit")}
+                    >
+                      坐姿半身示例
+                    </button>
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            <div className="sample-modal-footer">
+              <button
+                type="button"
+                className="primary-button"
+                onClick={() => setActiveModalSample(null)}
+              >
+                我知道了,返回填写
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}

+ 204 - 0
src/data/courseTemplates.ts

@@ -0,0 +1,204 @@
+import type { CourseTemplate, PresetAsset, PresetEpisode, PresetInstructor } from '../types'
+
+export type { CourseTemplate, PresetAsset, PresetEpisode, PresetInstructor }
+
+export const COURSE_TEMPLATES: CourseTemplate[] = [
+  {
+    id: 'party-building-standard',
+    name: '《坚持人民至上根本立场》党课视频',
+    category: '党政专题',
+    audience: '党员干部 / 青年理论学习小组',
+    tags: ['精品党课', 'AI数字人', '1080P高清', 'PPT同步论证'],
+    badge: '官方推荐示范',
+    summary: '严格依据党课制作任务书规范,包含审定讲稿、全屏课件切换逻辑、数字人讲师近景与全景运镜、精准语速停顿控制。',
+    description: `【制作目标】PPT配合讲稿,完成一支可用于网络党课学习的横版课程视频。
+【形式结构】(课程包装片头—片名)—讲师讲述—PPT论证—讲师回场—章节转场—继续讲述—总结—片尾。
+【运镜与节奏】全片约 30% 为讲师中近景/全景,约 70% 为全屏课件;单页 PPT 驻留 10—45 秒;讲师近景口型眼神对齐,现代演播室虚化背景。
+【语速与语气】语速 210—225 汉字/分钟;句间停顿 0.3—0.5 秒,段落转折 0.6—0.9 秒,章节切换 1.2—1.8 秒;语气正式、平稳、有教学交流感。
+【交付规格】1920×1080、25fps、H.264/AAC、48kHz 立体声(1080p 高清)。`,
+    keySpecs: {
+      resolution: '1080P (1920×1080) 25fps',
+      aspectRatio: '16:9 横版',
+      speechSpeed: '210—225 汉字/分钟',
+      presenterRatio: '30% 讲师出镜 / 70% 全屏课件',
+      voiceTone: '正式、清晰、平稳教学感'
+    },
+    taskBrief: {
+      focalPoints: [
+        '视频质量 1080p 高清,少 AI 味,人物形象清晰,手要露出来,表情自然',
+        'AI 生成音频要跟原稿完全一致,严禁读错字、漏字或擅自改写'
+      ],
+      goals: [
+        '口播稿:《坚持人民至上根本立场 课程讲稿》,字幕原则上与审定讲稿完全一致',
+        '课件:坚持人民至上根本立场.pptx,配合讲稿完成逻辑严密的同步展示',
+        '镜头逻辑:结构上“讲师讲述—PPT论证—讲师回场—章节转场”保持庄重感',
+        '技术参数:1920×1080、25fps、H.264/AAC、48kHz 立体声'
+      ],
+      cameraAndPacing: [
+        '讲师近景与中景切换通常约 20—90 秒,避免静态数字人长时间无动作',
+        '单页 PPT 始终全屏展示(无需画中画),常驻通常约 10—45 秒',
+        '画面切换应落在完整句、自然停顿或主题转折处,严禁半句话中间切镜头',
+        '句间停顿 0.3—0.5 秒,段落转折 0.6—0.9 秒,章节切换 1.2—1.8 秒'
+      ],
+      acceptanceCriteria: [
+        '讲稿无漏句、错句;全部数字、年份、人名、地名读音准确无误',
+        'PPT 使用顺序、讲述内容和动画出现点严格一致,每页均可追溯',
+        '章节层级清楚,各一级部分与小节均有明确进入与收束'
+      ]
+    },
+    instructors: [
+      {
+        name: '特邀党建专家 张教授',
+        organization: '中共党史与理论研究中心',
+        introduction: '资深党建研究专家、特聘理论导师,长年从事党史党建理论研究与党课教学,语调稳健、讲授权威亲和。',
+        sampleImageFile: '人物_站.jpg'
+      }
+    ],
+    episodes: [
+      {
+        episodeNumber: 1,
+        title: '第一讲:江山就是人民,人民就是江山',
+        summary: '阐释人民至上的理论渊源与历史必然性。',
+        lectureNotes: '同志们好!今天我们开始第一讲的专题学习。坚持人民至上,是我们党的根本立场,也是马克思主义唯物史观的集中体现……'
+      },
+      {
+        episodeNumber: 2,
+        title: '第二讲:把人民对美好生活的向往作为奋斗目标',
+        summary: '结合新时代生动实践,论述高质量发展中保障和改善民生。',
+        lectureNotes: '在第二讲中,我们将从实践维度来深入领会。治国有常,利民为本。如何把发展成果转化为人民群众实实在在的幸福感……'
+      },
+      {
+        episodeNumber: 3,
+        title: '第三讲:紧紧依靠人民创造历史伟业',
+        summary: '总结提炼群众路线与青年党员担当使命。',
+        lectureNotes: '第三讲,我们聚焦新时代青年理论骨干的使命担当。历史是人民书写的,一切成就归功于人民……'
+      }
+    ],
+    assets: [
+      {
+        originalName: '坚持人民至上根本立场 课程讲稿(审定版).docx',
+        mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+        sizeText: '156 KB',
+        description: '第一讲口播讲稿,字词及发音注音已审定',
+        episodeNumber: 1
+      },
+      {
+        originalName: '第一讲_坚持人民至上根本立场.pptx',
+        mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+        sizeText: '40.5 MB',
+        description: '第一讲16:9 高清论证课件,对应核心要点',
+        episodeNumber: 1
+      },
+      {
+        originalName: '第二讲_美好生活奋斗目标讲义.docx',
+        mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+        sizeText: '142 KB',
+        description: '第二讲讲稿与案例参考',
+        episodeNumber: 2
+      },
+      {
+        originalName: 'teacher_voice_reference_replace.wav',
+        mimeType: 'audio/wav',
+        sizeText: '8.2 MB',
+        description: '讲师原声录音切片,全套课程声纹克隆通用参考'
+      }
+    ]
+  },
+  {
+    id: 'higher-edu-calculus',
+    name: '《深入浅出微积分:极限与导数之美》公开课',
+    category: '高校公开课',
+    audience: '理工科大一新生 / 考研复习群体',
+    tags: ['高校公开课', '数理基础', '公式板书', '生动启发'],
+    badge: '高校示范',
+    summary: '高校精品开放课程标准,聚焦概念启发与几何直观推导,支持讲师半身出镜与动态板书公式画中画同步。',
+    description: `【制作目标】打造通俗易懂、几何直观性强的大学数学精品微课,适合线上慕课与翻转课堂。
+【形式结构】知识点引言—几何直观演示—严密数学推导—典型例题精讲—思考总结。
+【运镜与节奏】讲师半身讲述约 40%,公式板书与动态动画演示约 60%;支持黑板/手写板与讲师同框画中画。
+【语速与语气】语速 190—210 汉字/分钟,关键公式推导处适当放慢并留白停顿;语气启发温和、循序渐进。
+【交付规格】1080P 60fps 高帧率(保证公式动画平滑),立体声 48kHz。`,
+    keySpecs: {
+      resolution: '1080P (1920×1080) 60fps',
+      aspectRatio: '16:9 横版',
+      speechSpeed: '190—210 汉字/分钟',
+      presenterRatio: '40% 讲师半身 / 60% 动效课件板书',
+      voiceTone: '启发亲切、循序渐进'
+    },
+    taskBrief: {
+      focalPoints: [
+        '公式与图形动画平滑无卡顿,60fps 高帧率输出',
+        '讲解与手写板书笔迹同步出现,重点定理高亮标红'
+      ],
+      goals: [
+        '讲稿:《微积分第一讲:极限的直观与定义》讲义',
+        '课件:微积分动效演示.pptx(含动画与函数图像)',
+        '音画同步:讲师语调配合板书推进节奏'
+      ],
+      cameraAndPacing: [
+        '引言部分讲师中景亲切互动,概念阐述时平滑切至动态图像',
+        '推导定理时采用双画面布局(左侧公式、右侧讲师视线交互)'
+      ],
+      acceptanceCriteria: [
+        '数学符号、希腊字母及公式读法严谨规范',
+        '函数曲线动画与讲述点完全对齐'
+      ]
+    },
+    instructors: [
+      {
+        name: '林副教授',
+        organization: '数学与应用数学学院',
+        introduction: '国家级精品课程主讲教师,擅长以直观几何图像剖析抽象数学概念,教学风格深入浅出。',
+        sampleImageFile: '人物_坐.jpg'
+      }
+    ],
+    episodes: [
+      {
+        episodeNumber: 1,
+        title: '第 1 集:极限的直观与严密定义 (ε-δ 语言)',
+        summary: '从割线切线问题切入,通过动画直观展现数列与函数极限。',
+        lectureNotes: '同学们好!欢迎来到《深入浅出微积分》第1集。今天我们要探索微积分大厦的基石——极限。首先我们来看一个割线逼近切线的动态图像……'
+      },
+      {
+        episodeNumber: 2,
+        title: '第 2 集:导数的几何意义与瞬时变化率',
+        summary: '剖析速度与切线斜率的统一物理数学背景,推导核心求导法则。',
+        lectureNotes: '在第2集中,我们将从瞬时速度的物理问题出发,正式引出导数定义。请大家看屏幕左侧的割线斜率变化……'
+      },
+      {
+        episodeNumber: 3,
+        title: '第 3 集:微分中值定理与函数单调性',
+        summary: '罗尔定理与拉格朗日中值定理的几何证明及在极值分析中的应用。',
+        lectureNotes: '第3集我们将迎来微分学的核心支柱——拉格朗日中值定理。它是联系局部导数与整体增量的桥梁……'
+      }
+    ],
+    assets: [
+      {
+        originalName: '第1集_极限直观与定义讲稿.docx',
+        mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+        sizeText: '98 KB',
+        description: '第1集课堂讲稿与板书推导脚本',
+        episodeNumber: 1
+      },
+      {
+        originalName: '第1集_极限动效演示课件.pptx',
+        mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+        sizeText: '18.6 MB',
+        description: '第1集16:9 动态数学图像课件',
+        episodeNumber: 1
+      },
+      {
+        originalName: '第2集_导数几何意义讲稿.docx',
+        mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+        sizeText: '112 KB',
+        description: '第2集导数与切线推导手稿',
+        episodeNumber: 2
+      },
+      {
+        originalName: '微积分全课程教学大纲与符号规范.pdf',
+        mimeType: 'application/pdf',
+        sizeText: '1.2 MB',
+        description: '全课程通用大纲与板书排版技术标准'
+      }
+    ]
+  }
+]

+ 665 - 132
src/pages/CourseWorkspace.tsx

@@ -1,49 +1,161 @@
 import {
   ArrowLeft,
   CheckCircle2,
+  ChevronDown,
+  ChevronRight,
   Download,
+  Edit3,
   FileCheck,
   FilePlus2,
+  FileSpreadsheet,
   FileText,
+  Layers,
   LoaderCircle,
   PackageCheck,
+  Plus,
+  Save,
   Send,
   Sparkles,
   Trash2,
+  Upload,
   UserPlus,
   X
 } from 'lucide-react'
 import { FormEvent, useEffect, useRef, useState } from 'react'
-import { Link, useParams } from 'react-router-dom'
+import { Link, useNavigate, useParams } from 'react-router-dom'
 import { api } from '../api'
 import { StatusBadge } from '../components/StatusBadge'
-import type { Course } from '../types'
+import { AddInstructorModal } from '../components/AddInstructorModal'
+import { EpisodeModal } from '../components/EpisodeModal'
+import { DeleteCourseModal } from '../components/DeleteCourseModal'
+import type { Course, Episode } from '../types'
 
 const size = (n: number) =>
   n > 1024 * 1024 ? (n / 1024 / 1024).toFixed(1) + ' MB' : Math.ceil(n / 1024) + ' KB'
 
 export function CourseWorkspace() {
   const { id = '' } = useParams()
-  const input = useRef<HTMLInputElement>(null)
+  const navigate = useNavigate()
+  const globalInputRef = useRef<HTMLInputElement>(null)
+
   const [course, setCourse] = useState<Course | null>(null)
-  const [files, setFiles] = useState<File[]>([])
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState('')
-  const [showInstructor, setShowInstructor] = useState(false)
+  const [successNotice, setSuccessNotice] = useState('')
 
-  useEffect(() => {
+  // 弹窗状态
+  const [showAddInstructorModal, setShowAddInstructorModal] = useState(false)
+  const [showEpisodeModal, setShowEpisodeModal] = useState(false)
+  const [showDeleteModal, setShowDeleteModal] = useState(false)
+  const [editingEpisode, setEditingEpisode] = useState<Episode | null>(null)
+
+  // 全局资产待上传
+  const [globalFiles, setGlobalFiles] = useState<File[]>([])
+
+  // 分集展开折叠状态 Map (episodeId -> boolean)
+  const [expandedMap, setExpandedMap] = useState<Record<string, boolean>>({})
+
+  // 分集讲稿内联草稿状态 (episodeId -> string)
+  const [notesDraftMap, setNotesDraftMap] = useState<Record<string, string>>({})
+  const [savingNotesId, setSavingNotesId] = useState<string | null>(null)
+
+  // 分集上传文件输入引用 Map (episodeId -> File[])
+  const [epFilesMap, setEpFilesMap] = useState<Record<string, File[]>>({})
+  const [uploadingEpId, setUploadingEpId] = useState<string | null>(null)
+
+  const loadCourse = () => {
     api
       .getCourse(id)
-      .then((r) => setCourse(r.course))
+      .then((r) => {
+        setCourse(r.course)
+        const initialExpand: Record<string, boolean> = {}
+        const initialDrafts: Record<string, string> = {}
+        r.course.episodes?.forEach((ep, idx) => {
+          if (idx < 2) initialExpand[ep.id] = true
+          initialDrafts[ep.id] = ep.lectureNotes || ''
+        })
+        setExpandedMap((prev) => ({ ...initialExpand, ...prev }))
+        setNotesDraftMap((prev) => ({ ...initialDrafts, ...prev }))
+      })
       .catch((e) => setError(e.message))
+  }
+
+  useEffect(() => {
+    loadCourse()
   }, [id])
 
-  async function upload() {
+  const notify = (msg: string) => {
+    setSuccessNotice(msg)
+    setTimeout(() => setSuccessNotice(''), 4000)
+  }
+
+  // 上传全局通用资产
+  async function handleUploadGlobalAssets() {
+    if (!globalFiles.length) return
+    setBusy(true)
+    setError('')
+    try {
+      const res = await api.uploadAssets(id, globalFiles)
+      setCourse(res.course)
+      setGlobalFiles([])
+      notify('全局资产上传成功')
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  // 上传特定分集的专属资产
+  async function handleUploadEpisodeAssets(episodeId: string) {
+    const files = epFilesMap[episodeId] || []
     if (!files.length) return
+    setUploadingEpId(episodeId)
+    setError('')
+    try {
+      const res = await api.uploadAssets(id, files, episodeId)
+      setCourse(res.course)
+      setEpFilesMap((prev) => ({ ...prev, [episodeId]: [] }))
+      notify('分集素材上传成功')
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setUploadingEpId(null)
+    }
+  }
+
+  // 保存分集讲稿正文
+  async function handleSaveLectureNotes(episode: Episode) {
+    const newNotes = notesDraftMap[episode.id] ?? episode.lectureNotes
+    setSavingNotesId(episode.id)
+    setError('')
+    try {
+      const res = await api.updateEpisode(id, episode.id, {
+        title: episode.title,
+        lectureNotes: newNotes
+      })
+      setCourse(res.course)
+      notify(`《${episode.title}》口播讲稿已保存`)
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setSavingNotesId(null)
+    }
+  }
+
+  // 删除分集
+  async function handleDeleteEpisode(episode: Episode) {
+    if (episodes.length <= 1) {
+      setError('课程至少需要保留一个分集,您可以直接点击编辑修改本集内容。')
+      return
+    }
+    if (!confirm(`确定要删除分集「${episode.title}」及其下的所有素材文件吗?`)) return
     setBusy(true)
+    setError('')
     try {
-      setCourse((await api.uploadAssets(id, files)).course)
-      setFiles([])
+      const res = await api.deleteEpisode(id, episode.id)
+      setCourse(res.course)
+      notify('分集已成功删除')
     } catch (e) {
       setError((e as Error).message)
     } finally {
@@ -51,25 +163,15 @@ export function CourseWorkspace() {
     }
   }
 
-  async function addInstructor(e: FormEvent<HTMLFormElement>) {
-    e.preventDefault()
-    const form = e.currentTarget
-    const f = new FormData(form)
-    const image = form.elements.namedItem('image') as HTMLInputElement
+  // 删除资产
+  async function handleDeleteAsset(assetId: string) {
+    if (!confirm('确定删除该素材文件吗?')) return
     setBusy(true)
+    setError('')
     try {
-      setCourse(
-        (
-          await api.addInstructor(id, {
-            name: String(f.get('name')),
-            organization: String(f.get('organization')),
-            introduction: String(f.get('introduction')),
-            image: image.files?.[0]
-          })
-        ).course
-      )
-      form.reset()
-      setShowInstructor(false)
+      const res = await api.deleteAsset(id, assetId)
+      setCourse(res.course)
+      notify('素材文件已删除')
     } catch (e) {
       setError((e as Error).message)
     } finally {
@@ -77,6 +179,7 @@ export function CourseWorkspace() {
     }
   }
 
+  // 提交制作
   async function submit() {
     setBusy(true)
     try {
@@ -88,11 +191,32 @@ export function CourseWorkspace() {
     }
   }
 
+  // 切换折叠
+  const toggleExpand = (epId: string) => {
+    setExpandedMap((prev) => ({ ...prev, [epId]: !prev[epId] }))
+  }
+
+  // 全部展开/折叠
+  const toggleExpandAll = (expand: boolean) => {
+    const next: Record<string, boolean> = {}
+    course?.episodes?.forEach((ep) => {
+      next[ep.id] = expand
+    })
+    setExpandedMap(next)
+  }
+
   if (!course) return <div className="center-loading">{error || <LoaderCircle className="spin" />}</div>
 
   const isWaiting = course.status === 'WAITING_PRODUCTION'
   const isProducing = course.status === 'IN_PRODUCTION'
   const isCompleted = course.status === 'COMPLETED'
+  const isDraft = !isWaiting && !isProducing && !isCompleted
+
+  // 全局资产(未关联 episodeId)
+  const globalAssets = course.assets?.filter((a) => !a.episodeId) || []
+  const episodes = course.episodes || []
+  const hasEpisodes = episodes.length > 0
+  const filledNotesCount = episodes.filter((e) => (e.lectureNotes || '').trim().length > 0).length
 
   return (
     <div className="page workspace-page">
@@ -111,6 +235,13 @@ export function CourseWorkspace() {
         </div>
       </div>
 
+      {successNotice && (
+        <div className="filled-toast">
+          <CheckCircle2 size={16} />
+          <span>{successNotice}</span>
+        </div>
+      )}
+
       {/* 制作完成交付成果面板 */}
       {isCompleted && (
         <section className="success-panel completed-panel">
@@ -121,7 +252,7 @@ export function CourseWorkspace() {
             <Sparkles size={14} /> PRODUCTION COMPLETED
           </p>
           <h2>课程制作已完成!</h2>
-          <p>制作专家已完成课程打磨与成片制作,请查收下方交付成果及制作说明。</p>
+          <p>制作专家已完成全套课程打磨与成片制作,请查收下方各分集交付成果及制作说明。</p>
 
           {course.productionNotes && (
             <div className="production-notes-box">
@@ -135,29 +266,74 @@ export function CourseWorkspace() {
               <FileCheck size={18} />
               <h4>交付成品文件 ({course.deliverables?.length || 0})</h4>
             </div>
+
             {course.deliverables && course.deliverables.length > 0 ? (
-              <div className="deliverable-list">
-                {course.deliverables.map((deliv) => (
-                  <div className="deliverable-row" key={deliv.id}>
-                    <FileText size={20} />
-                    <div className="deliv-info">
-                      <strong>{deliv.originalName}</strong>
-                      <span>{size(deliv.size)}</span>
+              <div className="deliverable-category-wrapper">
+                {episodes.map((ep) => {
+                  const epDelivs = course.deliverables?.filter((d) => d.episodeId === ep.id) || []
+                  if (!epDelivs.length) return null
+                  return (
+                    <div key={ep.id} className="episode-deliverable-group">
+                      <h5 className="group-title">
+                        <span className="ep-badge-small">EP {String(ep.episodeNumber).padStart(2, '0')}</span>
+                        {ep.title} ({epDelivs.length})
+                      </h5>
+                      <div className="deliverable-list">
+                        {epDelivs.map((deliv) => (
+                          <div className="deliverable-row" key={deliv.id}>
+                            <FileText size={20} />
+                            <div className="deliv-info">
+                              <strong>{deliv.originalName}</strong>
+                              <span>{size(deliv.size)}</span>
+                            </div>
+                            <a
+                              href={deliv.url}
+                              download={deliv.originalName}
+                              className="primary-button small-button"
+                              target="_blank"
+                              rel="noreferrer"
+                            >
+                              <Download size={14} /> 下载
+                            </a>
+                          </div>
+                        ))}
+                      </div>
                     </div>
-                    <a
-                      href={deliv.url}
-                      download={deliv.originalName}
-                      className="primary-button small-button"
-                      target="_blank"
-                      rel="noreferrer"
-                    >
-                      <Download size={14} /> 下载
-                    </a>
-                  </div>
-                ))}
+                  )
+                })}
+
+                {(() => {
+                  const globalDelivs = course.deliverables?.filter((d) => !d.episodeId) || []
+                  if (!globalDelivs.length) return null
+                  return (
+                    <div className="episode-deliverable-group">
+                      <h5 className="group-title">全套课程通用交付物 ({globalDelivs.length})</h5>
+                      <div className="deliverable-list">
+                        {globalDelivs.map((deliv) => (
+                          <div className="deliverable-row" key={deliv.id}>
+                            <FileText size={20} />
+                            <div className="deliv-info">
+                              <strong>{deliv.originalName}</strong>
+                              <span>{size(deliv.size)}</span>
+                            </div>
+                            <a
+                              href={deliv.url}
+                              download={deliv.originalName}
+                              className="primary-button small-button"
+                              target="_blank"
+                              rel="noreferrer"
+                            >
+                              <Download size={14} /> 下载
+                            </a>
+                          </div>
+                        ))}
+                      </div>
+                    </div>
+                  )
+                })()}
               </div>
             ) : (
-              <p className="empty-tip">未附加文件附件,请参考上方交付说明中的链接或信息。</p>
+              <p className="empty-tip">未附加文件附件,请参考上方交付说明中的网盘链接或信息。</p>
             )}
           </div>
 
@@ -167,8 +343,8 @@ export function CourseWorkspace() {
               <strong style={{ color: '#2e7d32' }}>已完成交付</strong>
             </span>
             <span>
-              <small>提交时间</small>
-              <strong>{course.submittedAt ? new Date(course.submittedAt).toLocaleString('zh-CN') : '—'}</strong>
+              <small>总分集数</small>
+              <strong>{episodes.length || 1} 集</strong>
             </span>
             <span>
               <small>交付时间</small>
@@ -191,7 +367,7 @@ export function CourseWorkspace() {
           <p className="eyebrow">IN PRODUCTION</p>
           <h2>课程正在精心制作中</h2>
           <p>
-            平台课程制作师已接单并正在处理您的课件素材与讲师信息,制作完成后将第一时间在此处交付成品。
+            平台课程制作师已接单并正在处理您的 {episodes.length > 0 ? `${episodes.length} 个分集讲稿与课件素材` : '课件素材'},制作完成后将第一时间在此处按集交付成品。
           </p>
           <div className="receipt">
             <span>
@@ -199,8 +375,12 @@ export function CourseWorkspace() {
               <strong style={{ color: '#245bd6' }}>制作中</strong>
             </span>
             <span>
-              <small>课程资产</small>
-              <strong>{course.assets.length} 个文件</strong>
+              <small>课程分集</small>
+              <strong>{episodes.length} 集</strong>
+            </span>
+            <span>
+              <small>素材文件</small>
+              <strong>{course.assets.length} 份</strong>
             </span>
             <span>
               <small>提交时间</small>
@@ -219,12 +399,16 @@ export function CourseWorkspace() {
           <CheckCircle2 size={48} />
           <p className="eyebrow">SUBMISSION RECEIVED</p>
           <h2>课程已提交,等待排期制作</h2>
-          <p>已收录 {course.assets.length} 份课程资产和 {course.instructors.length} 位讲师信息,制作团队即将接单。</p>
+          <p>已收录 {episodes.length} 个分集、{course.assets.length} 份课程资产和 {course.instructors.length} 位讲师信息。</p>
           <div className="receipt">
             <span>
               <small>课程状态</small>
               <strong>等待制作</strong>
             </span>
+            <span>
+              <small>课程分集</small>
+              <strong>{episodes.length} 集</strong>
+            </span>
             <span>
               <small>课程资产</small>
               <strong>{course.assets.length} 个文件</strong>
@@ -240,100 +424,362 @@ export function CourseWorkspace() {
         </section>
       )}
 
-      {/* 草稿阶段素材上传与讲师编辑 */}
-      {!isWaiting && !isProducing && !isCompleted && (
-        <div className="asset-layout">
-          <section className="upload-panel">
-            <div className="panel-heading">
-              <span className="step-number">02</span>
-              <div>
-                <p className="eyebrow">课程内容</p>
-                <h2>课程资产</h2>
-                <p>可上传 PPT、PDF、Word、TXT、图片、音视频等多个文件,也可以跳过。</p>
-              </div>
-            </div>
-            <div className="asset-list">
-              {course.assets.map((a) => (
-                <div className="asset-row" key={a.id}>
-                  <FileText />
-                  <div>
-                    <strong>{a.originalName}</strong>
-                    <span>{size(a.size)}</span>
-                  </div>
+      {/* 草稿阶段分集管理、素材上传与讲师编辑 */}
+      {isDraft && (
+        <div className="asset-layout workspace-grid">
+          {/* 左侧主要区域:分集架构与资产上传 */}
+          <main className="episodes-workspace-main">
+            {/* 顶部统计与操作卡 */}
+            <section className="paper-form episodes-header-panel">
+              <div className="panel-heading-row">
+                <div>
+                  <span className="step-number">02</span>
+                  <p className="eyebrow">EPISODES & ASSETS</p>
+                  <h2>课程分集与素材管理</h2>
+                  <p className="panel-intro">
+                    课程默认已包含第 1 集,可按需添加更多分集(如 12 集微积分),为每一集在线录入口播讲稿,并上传该集的专属课件与素材文件。
+                  </p>
+                </div>
+
+                <div className="episodes-actions-toolbar">
                   <button
-                    title="删除"
-                    onClick={async () => setCourse((await api.deleteAsset(id, a.id)).course)}
+                    type="button"
+                    className="primary-button small-button highlight-button"
+                    onClick={() => {
+                      setEditingEpisode(null)
+                      setShowEpisodeModal(true)
+                    }}
                   >
-                    <Trash2 size={16} />
+                    <Plus size={14} /> 添加 / 批量规划分集
                   </button>
                 </div>
-              ))}
-            </div>
-            <div className="drop-zone compact" onClick={() => input.current?.click()}>
-              <FilePlus2 />
-              <h3>选择课程资产</h3>
-              <small>支持任意常见课程文件,单个文件不超过 200 MB</small>
-            </div>
-            <input
-              hidden
-              multiple
-              ref={input}
-              type="file"
-              onChange={(e) => setFiles(Array.from(e.target.files || []))}
-            />
-            {files.length > 0 && (
-              <div className="selected-stack">
-                {files.map((f, i) => (
-                  <div className="selected-file" key={i}>
+              </div>
+
+              {/* 统计指标 */}
+              <div className="episodes-metrics-bar">
+                <div className="metric-item">
+                  <span className="metric-num">{episodes.length}</span>
+                  <small>课程分集</small>
+                </div>
+                <div className="metric-item">
+                  <span className="metric-num">{filledNotesCount} / {episodes.length}</span>
+                  <small>已录入讲稿</small>
+                </div>
+                <div className="metric-item">
+                  <span className="metric-num">{course.assets.length}</span>
+                  <small>课件与素材</small>
+                </div>
+                <div className="metric-item">
+                  <span className="metric-num">{course.instructors.length}</span>
+                  <small>主讲讲师</small>
+                </div>
+              </div>
+
+              {hasEpisodes && (
+                <div className="expand-collapse-strip">
+                  <span>分集列表 ({episodes.length} 集):</span>
+                  <div className="expand-links">
+                    <button type="button" onClick={() => toggleExpandAll(true)}>
+                      全部展开
+                    </button>
+                    <span>·</span>
+                    <button type="button" onClick={() => toggleExpandAll(false)}>
+                      全部折叠
+                    </button>
+                  </div>
+                </div>
+              )}
+            </section>
+
+            {/* 分集卡片列表 */}
+            <section className="episodes-list-container">
+              {episodes.length === 0 && (
+                <div className="episodes-empty-card">
+                  <Layers className="empty-icon" size={36} />
+                  <h3>暂未添加任何分集</h3>
+                  <p>您可以点击下方按钮添加单集,或使用批量生成功能快速创建微课分集大纲。</p>
+                  <div className="empty-actions">
+                    <button
+                      type="button"
+                      className="primary-button small-button"
+                      onClick={() => {
+                        setEditingEpisode(null)
+                        setShowEpisodeModal(true)
+                      }}
+                    >
+                      <Plus size={14} /> 添加第一集
+                    </button>
+                  </div>
+                </div>
+              )}
+              {episodes.map((ep) => {
+                const isExpanded = !!expandedMap[ep.id]
+                const epAssets = course.assets?.filter((a) => a.episodeId === ep.id) || []
+                const draftNotes = notesDraftMap[ep.id] ?? (ep.lectureNotes || '')
+                const isNotesDirty = draftNotes !== (ep.lectureNotes || '')
+                const pendingFiles = epFilesMap[ep.id] || []
+
+                return (
+                  <div className={'episode-card ' + (isExpanded ? 'expanded' : 'collapsed')} key={ep.id}>
+                    {/* 分集卡片顶部栏 */}
+                    <div className="episode-card-header" onClick={() => toggleExpand(ep.id)}>
+                      <div className="header-left">
+                        <span className="ep-toggle-arrow">
+                          {isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
+                        </span>
+                        <span className="ep-number-tag">EP {String(ep.episodeNumber).padStart(2, '0')}</span>
+                        <h3 className="ep-title">{ep.title}</h3>
+                      </div>
+
+                      <div className="header-right" onClick={(e) => e.stopPropagation()}>
+                        {ep.lectureNotes?.trim() ? (
+                          <span className="tag-pill tag-pill-success" title="已录入讲稿">
+                            讲稿 ({ep.lectureNotes.length}字)
+                          </span>
+                        ) : (
+                          <span className="tag-pill tag-pill-dim">待补充讲稿</span>
+                        )}
+
+                        <span className="tag-pill tag-pill-info">
+                          {epAssets.length} 份素材
+                        </span>
+
+                        <button
+                          type="button"
+                          className="icon-action-btn"
+                          title="编辑分集信息"
+                          onClick={() => {
+                            setEditingEpisode(ep)
+                            setShowEpisodeModal(true)
+                          }}
+                        >
+                          <Edit3 size={15} />
+                        </button>
+
+                        {episodes.length > 1 && (
+                          <button
+                            type="button"
+                            className="icon-action-btn delete-btn"
+                            title="删除该分集"
+                            onClick={() => handleDeleteEpisode(ep)}
+                          >
+                            <Trash2 size={15} />
+                          </button>
+                        )}
+                      </div>
+                    </div>
+
+                    {/* 展开后的分集详情:讲稿正文编辑 + 分集素材上传 */}
+                    {isExpanded && (
+                      <div className="episode-card-body">
+                        {ep.summary && <p className="ep-summary-text"><strong>分集要点:</strong>{ep.summary}</p>}
+
+                        <div className="ep-body-grid">
+                          {/* 左半:口播讲稿正文 */}
+                          <div className="ep-notes-box">
+                            <div className="box-title-row">
+                              <div className="title-left">
+                                <FileText size={15} />
+                                <strong>本集口播讲稿正文</strong>
+                              </div>
+                              {isNotesDirty && <span className="dirty-badge">未保存</span>}
+                            </div>
+                            <textarea
+                              rows={5}
+                              className="ep-notes-textarea"
+                              value={draftNotes}
+                              onChange={(e) => {
+                                const val = e.target.value
+                                setNotesDraftMap((prev) => ({ ...prev, [ep.id]: val }))
+                              }}
+                              placeholder="直接在此输入或粘贴本集的口播讲稿/字幕文稿(如第一讲引入、推导、例题等)…"
+                            />
+                            <div className="notes-footer-row">
+                              <small>共 {draftNotes.length} 字</small>
+                              <button
+                                type="button"
+                                className="primary-button small-button save-notes-btn"
+                                disabled={savingNotesId === ep.id || !isNotesDirty}
+                                onClick={() => handleSaveLectureNotes(ep)}
+                              >
+                                <Save size={13} />
+                                {savingNotesId === ep.id ? '保存中…' : '保存讲稿'}
+                              </button>
+                            </div>
+                          </div>
+
+                          {/* 右半:本集专属资产上传与管理 */}
+                          <div className="ep-assets-box">
+                            <div className="box-title-row">
+                              <div className="title-left">
+                                <FileSpreadsheet size={15} />
+                                <strong>本集专属素材 ({epAssets.length})</strong>
+                              </div>
+                            </div>
+
+                            {/* 本集已有素材列表 */}
+                            <div className="ep-assets-list">
+                              {epAssets.map((a) => (
+                                <div className="ep-asset-item" key={a.id}>
+                                  <FileText size={16} className="asset-item-icon" />
+                                  <div className="ep-asset-info">
+                                    <strong title={a.originalName}>{a.originalName}</strong>
+                                    <span>{size(a.size)}</span>
+                                  </div>
+                                  <button
+                                    type="button"
+                                    className="icon-action-btn delete-btn"
+                                    title="删除素材"
+                                    onClick={() => handleDeleteAsset(a.id)}
+                                  >
+                                    <Trash2 size={13} />
+                                  </button>
+                                </div>
+                              ))}
+                              {!epAssets.length && (
+                                <p className="empty-sub-tip">本集暂无独立附件文件,可上传课件 PPT、Word 讲稿等。</p>
+                              )}
+                            </div>
+
+                            {/* 本集文件选择区 */}
+                            <div className="ep-upload-zone">
+                              <label className="ep-file-label">
+                                <Upload size={14} />
+                                <span>选择本集课件/讲稿附件</span>
+                                <input
+                                  type="file"
+                                  multiple
+                                  hidden
+                                  onChange={(e) => {
+                                    const selected = Array.from(e.target.files || [])
+                                    setEpFilesMap((prev) => ({ ...prev, [ep.id]: selected }))
+                                  }}
+                                />
+                              </label>
+
+                              {pendingFiles.length > 0 && (
+                                <div className="ep-pending-files">
+                                  <div className="pending-chips">
+                                    {pendingFiles.map((f, i) => (
+                                      <span key={i} className="pending-chip">
+                                        {f.name} ({size(f.size)})
+                                        <button
+                                          type="button"
+                                          onClick={() =>
+                                            setEpFilesMap((prev) => ({
+                                              ...prev,
+                                              [ep.id]: pendingFiles.filter((_, x) => x !== i)
+                                            }))
+                                          }
+                                        >
+                                          <X size={12} />
+                                        </button>
+                                      </span>
+                                    ))}
+                                  </div>
+                                  <button
+                                    type="button"
+                                    className="primary-button small-button full-button"
+                                    disabled={uploadingEpId === ep.id}
+                                    onClick={() => handleUploadEpisodeAssets(ep.id)}
+                                  >
+                                    {uploadingEpId === ep.id ? '正在上传…' : ('上传 ' + pendingFiles.length + ' 份文件至本集')}
+                                  </button>
+                                </div>
+                              )}
+                            </div>
+                          </div>
+                        </div>
+                      </div>
+                    )}
+                  </div>
+                )
+              })}
+            </section>
+
+            {/* 课程通用 / 全局素材专区 */}
+            <section className="paper-form global-assets-panel">
+              <div className="panel-heading-row">
+                <div>
+                  <span className="section-kicker">GLOBAL ASSETS</span>
+                  <h3>全套课程通用资产 ({globalAssets.length})</h3>
+                  <p className="panel-intro">
+                    如全课程大纲、全套通用 PPT 模板、讲师全局参考音频等不区分分集的通用资料。
+                  </p>
+                </div>
+              </div>
+
+              <div className="asset-list">
+                {globalAssets.map((a) => (
+                  <div className="asset-row" key={a.id}>
                     <FileText />
                     <div>
-                      <strong>{f.name}</strong>
-                      <span>{size(f.size)}</span>
+                      <strong>{a.originalName}</strong>
+                      <span>{size(a.size)} · 全局通用</span>
                     </div>
-                    <button onClick={() => setFiles(files.filter((_, x) => x !== i))}>
-                      <X size={16} />
+                    <button title="删除" onClick={() => handleDeleteAsset(a.id)}>
+                      <Trash2 size={16} />
                     </button>
                   </div>
                 ))}
-                <button className="primary-button full-button" disabled={busy} onClick={upload}>
-                  {busy ? '正在上传…' : `上传 ${files.length} 个文件`}
-                </button>
               </div>
-            )}
-          </section>
-          <aside className="instructor-panel">
+
+              <div className="drop-zone compact" onClick={() => globalInputRef.current?.click()}>
+                <FilePlus2 />
+                <h3>选择全局通用资产</h3>
+                <small>支持任意常见课件/文档文件,单个文件不超过 200 MB</small>
+              </div>
+              <input
+                hidden
+                multiple
+                ref={globalInputRef}
+                type="file"
+                onChange={(e) => setGlobalFiles(Array.from(e.target.files || []))}
+              />
+
+              {globalFiles.length > 0 && (
+                <div className="selected-stack">
+                  {globalFiles.map((f, i) => (
+                    <div className="selected-file" key={i}>
+                      <FileText />
+                      <div>
+                        <strong>{f.name}</strong>
+                        <span>{size(f.size)}</span>
+                      </div>
+                      <button onClick={() => setGlobalFiles(globalFiles.filter((_, x) => x !== i))}>
+                        <X size={16} />
+                      </button>
+                    </div>
+                  ))}
+                  <button
+                    className="primary-button full-button"
+                    disabled={busy}
+                    onClick={handleUploadGlobalAssets}
+                  >
+                    {busy ? '正在上传…' : ('上传 ' + globalFiles.length + ' 个全局文件')}
+                  </button>
+                </div>
+              )}
+            </section>
+          </main>
+
+          {/* 右侧边栏:讲师信息与提交制作 */}
+          <aside className="instructor-panel workspace-sidebar">
             <div className="aside-title">
               <div>
                 <span className="section-kicker">INSTRUCTORS</span>
-                <h3>讲师信息</h3>
+                <h3>讲师信息 ({course.instructors.length})</h3>
               </div>
-              <button onClick={() => setShowInstructor(!showInstructor)}>
+              <button
+                type="button"
+                className="add-instructor-btn"
+                onClick={() => setShowAddInstructorModal(true)}
+              >
                 <UserPlus size={16} />
                 添加
               </button>
             </div>
-            {showInstructor && (
-              <form className="instructor-form" onSubmit={addInstructor}>
-                <label>
-                  讲师姓名 *<input name="name" required />
-                </label>
-                <label>
-                  单位
-                  <input name="organization" />
-                </label>
-                <label>
-                  讲师介绍
-                  <textarea name="introduction" rows={3} />
-                </label>
-                <label>
-                  讲师图片
-                  <input name="image" type="file" accept="image/*" />
-                </label>
-                <button className="primary-button full-button" disabled={busy}>
-                  保存讲师
-                </button>
-              </form>
-            )}
+
             <div className="instructor-list">
               {course.instructors.map((t) => (
                 <div className="instructor-card" key={t.id}>
@@ -344,19 +790,32 @@ export function CourseWorkspace() {
                     <p>{t.introduction || '暂无介绍'}</p>
                   </div>
                   <button
+                    title="删除讲师"
                     onClick={async () => setCourse((await api.deleteInstructor(id, t.id)).course)}
                   >
                     <Trash2 size={14} />
                   </button>
                 </div>
               ))}
-              {!course.instructors.length && !showInstructor && (
-                <p className="empty-tip">还没有讲师,可按需添加</p>
+              {!course.instructors.length && (
+                <div className="empty-instructor-box">
+                  <p>还没有讲师信息,可按需添加</p>
+                  <button
+                    type="button"
+                    className="add-instructor-trigger-btn"
+                    onClick={() => setShowAddInstructorModal(true)}
+                  >
+                    <UserPlus size={14} /> 点击添加讲师
+                  </button>
+                </div>
               )}
             </div>
+
             <div className="submit-box">
-              <p>课程资产和讲师信息均为可选项,可以稍后补充或直接提交。</p>
-              <button className="primary-button full-button" onClick={submit} disabled={busy}>
+              <div className="submit-summary-info">
+                <p>已划分 <strong>{episodes.length}</strong> 个分集,收录 <strong>{course.assets.length}</strong> 份素材文件。</p>
+              </div>
+              <button className="primary-button full-button highlight-button" onClick={submit} disabled={busy}>
                 <Send size={16} />
                 提交制作
               </button>
@@ -365,6 +824,80 @@ export function CourseWorkspace() {
         </div>
       )}
 
+      {/* 课程详情最底部:危险操作区 / 删除课程 */}
+      <section className="course-danger-section">
+        <div className="danger-zone-card">
+          <div className="danger-zone-info">
+            <div className="danger-zone-title-row">
+              <Trash2 size={18} className="danger-icon" />
+              <h4>删除课程</h4>
+            </div>
+            <p>
+              {isDraft
+                ? '彻底删除该课程及其包含的所有分集内容、口播讲稿和上传的素材文件。此操作无法撤销。'
+                : '当前课程已提交制作排期或已制作完成,处于制作归档与受保护状态,无法删除。'}
+            </p>
+          </div>
+          <div className="danger-zone-action">
+            {isDraft ? (
+              <button
+                type="button"
+                className="danger-button"
+                onClick={() => setShowDeleteModal(true)}
+              >
+                <Trash2 size={15} />
+                删除课程
+              </button>
+            ) : (
+              <button
+                type="button"
+                className="danger-button disabled"
+                disabled
+                title="已等待制作或制作完成的课程不能删除"
+              >
+                <Trash2 size={15} />
+                不可删除
+              </button>
+            )}
+          </div>
+        </div>
+      </section>
+
+      {/* 独立添加讲师信息弹窗 */}
+      <AddInstructorModal
+        isOpen={showAddInstructorModal}
+        onClose={() => setShowAddInstructorModal(false)}
+        courseId={id}
+        onSuccess={(updatedCourse) => setCourse(updatedCourse)}
+      />
+
+      {/* 单集添加/编辑弹窗 */}
+      <EpisodeModal
+        isOpen={showEpisodeModal}
+        onClose={() => {
+          setShowEpisodeModal(false)
+          setEditingEpisode(null)
+        }}
+        courseId={id}
+        episode={editingEpisode}
+        nextNumber={(episodes.length || 0) + 1}
+        onSuccess={(updatedCourse) => {
+          setCourse(updatedCourse)
+          notify(editingEpisode ? '分集修改已保存' : '分集添加成功')
+        }}
+      />
+
+      {/* 课程删除确认弹窗 */}
+      <DeleteCourseModal
+        isOpen={showDeleteModal}
+        onClose={() => setShowDeleteModal(false)}
+        course={course}
+        onSuccess={() => {
+          setShowDeleteModal(false)
+          navigate('/', { replace: true })
+        }}
+      />
+
       {error && <div className="notice notice-error">{error}</div>}
     </div>
   )

+ 11 - 7
src/pages/Dashboard.tsx

@@ -38,9 +38,11 @@ export function Dashboard() {
             <em>留下清晰的星痕。</em>
           </h1>
           <p>归集课程素材,建立制作任务。简单几步,让创意从这里启程。</p>
-          <Link className="primary-button" to="/courses/new">
-            <Plus size={18} /> 创建新课程
-          </Link>
+          <div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
+            <Link className="primary-button" to="/courses/new">
+              <Plus size={18} /> 创建新课程
+            </Link>
+          </div>
         </div>
         <div className="hero-note">
           <span>LESS AND COSMOS</span>
@@ -89,10 +91,12 @@ export function Dashboard() {
               <i />
             </span>
             <h3>还没有课程</h3>
-            <p>创建第一门课程,上传课件素材后即可提交制作。</p>
-            <Link to="/courses/new">
-              开始创建 <ArrowRight size={16} />
-            </Link>
+            <p>创建第一门课程。</p>
+            <div style={{ display: 'flex', gap: '10px', marginTop: '4px' }}>
+              <Link className="primary-button small-button" to="/courses/new">
+                <Plus size={15} /> 创建课程或使用示例
+              </Link>
+            </div>
           </div>
         ) : (
           <div className="course-grid">

+ 415 - 44
src/pages/NewCourse.tsx

@@ -1,28 +1,96 @@
-import { ArrowLeft, ArrowRight } from 'lucide-react'
-import { FormEvent, useState } from 'react'
+import {
+  ArrowLeft,
+  ArrowRight,
+  BookOpen,
+  Check,
+  CheckCircle2,
+  FileCheck,
+  FileSpreadsheet,
+  FileText,
+  Flame,
+  Info,
+  Layers,
+  Sparkles,
+  UserCheck,
+  Users,
+  Video,
+  Volume2,
+  Wand2,
+  X
+} from 'lucide-react'
+import { FormEvent, useEffect, useState } from 'react'
 import { Link, useNavigate } from 'react-router-dom'
 import { api } from '../api'
+import { COURSE_TEMPLATES, type CourseTemplate } from '../data/courseTemplates'
 
 export function NewCourse() {
   const navigate = useNavigate()
+  const [templates, setTemplates] = useState<CourseTemplate[]>(COURSE_TEMPLATES)
+  const [selectedTemplateId, setSelectedTemplateId] = useState<string>(COURSE_TEMPLATES[0].id)
+  const [activeTab, setActiveTab] = useState<'form' | 'templates'>('form')
+
+  // 表单状态
+  const [name, setName] = useState('')
+  const [category, setCategory] = useState('党政专题')
+  const [description, setDescription] = useState('')
+
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState('')
+  const [filledNotice, setFilledNotice] = useState('')
+  const [detailModalOpen, setDetailModalOpen] = useState(false)
+
+  const currentTemplate = templates.find((t) => t.id === selectedTemplateId) || templates[0]
+
+  useEffect(() => {
+    api
+      .getTemplates()
+      .then((r) => {
+        if (r.templates && r.templates.length > 0) {
+          setTemplates(r.templates)
+        }
+      })
+      .catch(() => {
+        // 使用默认本地配置
+      })
+  }, [])
+
+  // 1. 一键填入表单
+  function handleFillTemplate(tmpl: CourseTemplate = currentTemplate) {
+    setName(tmpl.name)
+    setCategory(tmpl.category)
+    setDescription(tmpl.description)
+    setFilledNotice(`已填入示例《${tmpl.name}》内容,您可按需进行调整。`)
+    setTimeout(() => setFilledNotice(''), 4500)
+  }
+
+  // 2. 一键以此模板创建完整项目
+  async function handleCreateFromTemplate(tmpl: CourseTemplate = currentTemplate) {
+    setBusy(true)
+    setError('')
+    try {
+      const { course } = await api.createFromTemplate(tmpl.id)
+      navigate(`/courses/${course.id}`)
+    } catch (x) {
+      setError((x as Error).message)
+      setBusy(false)
+    }
+  }
 
+  // 3. 普通手动表单提交
   async function submit(e: FormEvent<HTMLFormElement>) {
     e.preventDefault()
     setBusy(true)
-    const f = new FormData(e.currentTarget)
+    setError('')
     try {
       const { course } = await api.createCourse({
-        name: String(f.get('name')),
-        category: String(f.get('category')),
+        name: name.trim(),
+        category,
         audience: '',
-        description: String(f.get('description')),
+        description: description.trim()
       })
       navigate(`/courses/${course.id}`)
     } catch (x) {
       setError((x as Error).message)
-    } finally {
       setBusy(false)
     }
   }
@@ -30,51 +98,354 @@ export function NewCourse() {
   return (
     <div className="page form-page">
       <Link to="/" className="back-link">
-        <ArrowLeft size={16} />返回课程
+        <ArrowLeft size={16} />返回我的课程
       </Link>
-      <div className="form-layout">
-        <aside>
+
+      <div className="form-layout new-course-layout">
+        {/* 左侧说明与精选示例区域 */}
+        <aside className="new-course-aside">
           <span className="step-number">01</span>
-          <p className="eyebrow">创建课程</p>
+          <p className="eyebrow">
+            <Sparkles size={13} /> 创建课程
+          </p>
           <h1>
             先告诉我们,<br />
             这是一门什么课。
           </h1>
-          <p>填写基本信息后,可添加课程资产和讲师,也可以直接跳过。</p>
-        </aside>
-        <form className="paper-form" onSubmit={submit}>
-          <div className="form-title">
-            <span>COURSE PROFILE</span>
-            <h2>课程基本信息</h2>
-          </div>
-          <label>
-            课程名称 <b>*</b>
-            <input name="name" required maxLength={100} />
-          </label>
-          <label>
-            课程分类
-            <select name="category" defaultValue="党政专题">
-              <option>党政专题</option>
-              <option>高校公开课</option>
-              <option>企业培训</option>
-              <option>职业教育</option>
-              <option>其他</option>
-            </select>
-          </label>
-          <label>
-            课程简介
-            <textarea name="description" rows={4} maxLength={500} />
-          </label>
-          {error && <p className="form-error">{error}</p>}
-          <div className="form-footer">
-            <span>下一步:课程资产与讲师</span>
-            <button className="primary-button" disabled={busy}>
-              {busy ? '正在创建…' : '继续'}
-              <ArrowRight size={17} />
-            </button>
+          <p className="aside-subtext">
+            填写基本信息后,可添加课程资产和讲师;您也可以直接选择下方官方示范任务书一键初始化。
+          </p>
+
+          {/* 示范模版精选卡片 */}
+          <div className="template-showcase-box">
+            <div className="template-box-header">
+              <div className="header-left">
+                <Flame size={16} className="flame-icon" />
+                <strong>制作示例</strong>
+              </div>
+              <span className="sample-count-tag">{templates.length} 个模版可直接使用</span>
+            </div>
+
+            {/* 模板切换标签(支持未来扩展) */}
+            <div className="template-pills">
+              {templates.map((tmpl) => (
+                <button
+                  key={tmpl.id}
+                  type="button"
+                  className={`template-pill ${tmpl.id === currentTemplate.id ? 'active' : ''}`}
+                  onClick={() => setSelectedTemplateId(tmpl.id)}
+                >
+                  {tmpl.badge && <span className="pill-badge">{tmpl.badge}</span>}
+                  {tmpl.category}
+                </button>
+              ))}
+            </div>
+
+            {/* 当前选中模版内容卡片 */}
+            <div className="selected-template-card">
+              <div className="card-top">
+                <div className="card-tags">
+                  {currentTemplate.tags.map((tag, idx) => (
+                    <span key={idx} className="sample-tag">
+                      {tag}
+                    </span>
+                  ))}
+                </div>
+                <h3 className="sample-title">{currentTemplate.name}</h3>
+                <p className="sample-desc">{currentTemplate.summary}</p>
+              </div>
+
+              {/* 关键规格参数列表 */}
+              <div className="specs-grid">
+                <div className="spec-item">
+                  <Video size={13} />
+                  <div>
+                    <small>分辨率与画面</small>
+                    <span>{currentTemplate.keySpecs.resolution}</span>
+                  </div>
+                </div>
+                <div className="spec-item">
+                  <Layers size={13} />
+                  <div>
+                    <small>讲师与课件比例</small>
+                    <span>{currentTemplate.keySpecs.presenterRatio}</span>
+                  </div>
+                </div>
+                <div className="spec-item">
+                  <Volume2 size={13} />
+                  <div>
+                    <small>语速与节奏控制</small>
+                    <span>{currentTemplate.keySpecs.speechSpeed}</span>
+                  </div>
+                </div>
+                <div className="spec-item">
+                  <UserCheck size={13} />
+                  <div>
+                    <small>预置示范讲师</small>
+                    <span>{currentTemplate.instructors.map((i) => i.name).join('、')}</span>
+                  </div>
+                </div>
+              </div>
+
+              {/* 预置素材与任务书一览 */}
+              <div className="preset-assets-strip">
+                <div className="strip-title">
+                  <FileSpreadsheet size={13} />
+                  <span>包含课件与制作资料 ({currentTemplate.assets.length} 份)</span>
+                </div>
+                <div className="preset-pill-list">
+                  {currentTemplate.assets.map((ast, i) => (
+                    <span key={i} className="asset-minitag" title={ast.description}>
+                      <FileText size={11} /> {ast.originalName}
+                    </span>
+                  ))}
+                </div>
+              </div>
+
+              {/* 模版操作按钮组 */}
+              <div className="template-actions">
+                <button
+                  type="button"
+                  className="secondary-button small-button action-btn"
+                  onClick={() => handleFillTemplate(currentTemplate)}
+                  title="将该示例的内容填入右侧表单,可自行修改"
+                >
+                  <Wand2 size={14} /> 填入当前表单
+                </button>
+                <button
+                  type="button"
+                  className="primary-button small-button action-btn highlight-sample-btn"
+                  disabled={busy}
+                  onClick={() => handleCreateFromTemplate(currentTemplate)}
+                  title="直接创建完整示范课程,并自动导入讲师与课件素材"
+                >
+                  <Sparkles size={14} /> 一键使用此示例
+                </button>
+              </div>
+
+              <div className="template-card-footer">
+                <button
+                  type="button"
+                  className="detail-link-btn"
+                  onClick={() => setDetailModalOpen(true)}
+                >
+                  <Info size={13} /> 查看完整任务书制作规范与验收标准 &raquo;
+                </button>
+              </div>
+            </div>
           </div>
-        </form>
+        </aside>
+
+        {/* 右侧表单区域 */}
+        <div className="new-course-main">
+          {filledNotice && (
+            <div className="filled-toast">
+              <CheckCircle2 size={16} />
+              <span>{filledNotice}</span>
+            </div>
+          )}
+
+          <form className="paper-form" onSubmit={submit}>
+            <div className="form-title">
+              <div className="title-left">
+                <span>COURSE PROFILE</span>
+                <h2>课程基本信息</h2>
+              </div>
+              <button
+                type="button"
+                className="quick-sample-pill-btn"
+                onClick={() => handleFillTemplate(currentTemplate)}
+                title="快速载入精选示范内容"
+              >
+                <Wand2 size={13} /> 载入精选示例
+              </button>
+            </div>
+
+            <label>
+              课程名称 <b>*</b>
+              <input
+                name="name"
+                required
+                maxLength={100}
+                placeholder="例如:20260715《坚持人民至上根本立场》党课视频"
+                value={name}
+                onChange={(e) => setName(e.target.value)}
+              />
+            </label>
+
+            <label>
+              课程分类
+              <select
+                name="category"
+                value={category}
+                onChange={(e) => setCategory(e.target.value)}
+              >
+                <option value="党政专题">党政专题</option>
+                <option value="高校公开课">高校公开课</option>
+                <option value="企业培训">企业培训</option>
+                <option value="职业教育">职业教育</option>
+                <option value="其他">其他</option>
+              </select>
+            </label>
+
+            <label>
+              课程简介与制作要求
+              <textarea
+                name="description"
+                rows={7}
+                maxLength={1000}
+                placeholder="填写课程概述、目标人群、运镜风格(如30%出镜/70%课件)、语速要求(如210-225字/分)或技术规格要求等..."
+                value={description}
+                onChange={(e) => setDescription(e.target.value)}
+              />
+            </label>
+
+            {error && <p className="form-error">{error}</p>}
+
+            <div className="form-footer">
+              <span>下一步:添加课程资产与讲师</span>
+              <button className="primary-button" disabled={busy || !name.trim()}>
+                {busy ? '正在创建…' : '继续'}
+                <ArrowRight size={17} />
+              </button>
+            </div>
+          </form>
+        </div>
       </div>
+
+      {/* 完整制作任务书与技术标准弹窗 */}
+      {detailModalOpen && (
+        <div className="modal-backdrop" onClick={() => setDetailModalOpen(false)}>
+          <div className="modal-dialog brief-modal" onClick={(e) => e.stopPropagation()}>
+            <div className="modal-header">
+              <div>
+                <span className="modal-badge">{currentTemplate.badge || '制作示范'}</span>
+                <h3>{currentTemplate.name} · 制作任务书</h3>
+              </div>
+              <button
+                type="button"
+                className="close-modal-btn"
+                onClick={() => setDetailModalOpen(false)}
+              >
+                <X size={18} />
+              </button>
+            </div>
+
+            <div className="modal-body brief-modal-content">
+              {/* 核心重点 */}
+              <div className="brief-section focus-card">
+                <h4>
+                  <Flame size={15} /> 核心重点要求
+                </h4>
+                <ul>
+                  {currentTemplate.taskBrief.focalPoints.map((pt, i) => (
+                    <li key={i}>{pt}</li>
+                  ))}
+                </ul>
+              </div>
+
+              {/* 项目目标 */}
+              <div className="brief-section">
+                <h4>
+                  <FileCheck size={15} /> 项目目标与规格
+                </h4>
+                <ul>
+                  {currentTemplate.taskBrief.goals.map((g, i) => (
+                    <li key={i}>{g}</li>
+                  ))}
+                </ul>
+              </div>
+
+              {/* 画面与运镜节奏 */}
+              <div className="brief-section">
+                <h4>
+                  <Video size={15} /> 画面运镜与语速控制
+                </h4>
+                <ul>
+                  {currentTemplate.taskBrief.cameraAndPacing.map((c, i) => (
+                    <li key={i}>{c}</li>
+                  ))}
+                </ul>
+              </div>
+
+              {/* 验收标准 */}
+              <div className="brief-section">
+                <h4>
+                  <CheckCircle2 size={15} /> 交付与验收标准
+                </h4>
+                <ul>
+                  {currentTemplate.taskBrief.acceptanceCriteria.map((a, i) => (
+                    <li key={i}>{a}</li>
+                  ))}
+                </ul>
+              </div>
+
+              {/* 预置素材与讲师概览 */}
+              <div className="brief-section">
+                <h4>
+                  <Users size={15} /> 示范配置一览
+                </h4>
+                <div className="brief-preset-grid">
+                  <div className="brief-subcard">
+                    <strong>示范讲师</strong>
+                    {currentTemplate.instructors.map((inst, i) => (
+                      <div key={i} className="inst-subrow">
+                        <span>{inst.name}</span>
+                        <small>{inst.organization}</small>
+                        <p>{inst.introduction}</p>
+                      </div>
+                    ))}
+                  </div>
+                  {currentTemplate.episodes && currentTemplate.episodes.length > 0 && (
+                    <div className="brief-subcard">
+                      <strong>示范课程分集 ({currentTemplate.episodes.length} 讲/集)</strong>
+                      {currentTemplate.episodes.map((ep, i) => (
+                        <div key={i} className="ep-subrow">
+                          <span>{ep.title}</span>
+                          <small>{ep.summary || '包含完整口播讲稿与课件'}</small>
+                        </div>
+                      ))}
+                    </div>
+                  )}
+                  <div className="brief-subcard">
+                    <strong>示范课件与讲稿</strong>
+                    {currentTemplate.assets.map((ast, i) => (
+                      <div key={i} className="asset-subrow">
+                        <span>{ast.originalName}</span>
+                        <small>{ast.sizeText} · {ast.description}</small>
+                      </div>
+                    ))}
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            <div className="modal-footer">
+              <button
+                type="button"
+                className="secondary-button"
+                onClick={() => {
+                  handleFillTemplate(currentTemplate)
+                  setDetailModalOpen(false)
+                }}
+              >
+                <Wand2 size={15} /> 填入当前表单
+              </button>
+              <button
+                type="button"
+                className="primary-button highlight-sample-btn"
+                disabled={busy}
+                onClick={() => {
+                  setDetailModalOpen(false)
+                  handleCreateFromTemplate(currentTemplate)
+                }}
+              >
+                <Sparkles size={15} /> 一键使用此示例创建课程
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
     </div>
   )
 }

+ 266 - 79
src/pages/admin/AdminCourseDetail.tsx

@@ -1,23 +1,22 @@
 import {
   ArrowLeft,
   CheckCircle2,
+  ChevronDown,
+  ChevronRight,
   Download,
   FileCheck,
-  FilePlus2,
+  FileSpreadsheet,
   FileText,
+  Layers,
   Loader2,
   PackageCheck,
   Play,
   RotateCcw,
-  Save,
-  Send,
   Sparkles,
   Trash2,
   Upload,
   User,
-  Users,
-  Wrench,
-  X
+  Users
 } from 'lucide-react'
 import { FormEvent, useEffect, useRef, useState } from 'react'
 import { Link, useParams } from 'react-router-dom'
@@ -39,8 +38,12 @@ export function AdminCourseDetail() {
   // 交付物上传与说明
   const deliverableInput = useRef<HTMLInputElement>(null)
   const [delivFiles, setDelivFiles] = useState<File[]>([])
+  const [targetEpisodeId, setTargetEpisodeId] = useState<string>('')
   const [productionNotes, setProductionNotes] = useState('')
 
+  // 管理端分集讲稿展开状态
+  const [expandedNotesMap, setExpandedNotesMap] = useState<Record<string, boolean>>({})
+
   const loadCourse = () => {
     setLoading(true)
     api.admin
@@ -48,6 +51,12 @@ export function AdminCourseDetail() {
       .then((r) => {
         setCourse(r.course)
         setProductionNotes(r.course.productionNotes || '')
+        // 默认展开前 2 集讲稿
+        const initExpand: Record<string, boolean> = {}
+        r.course.episodes?.forEach((ep, i) => {
+          if (i < 2) initExpand[ep.id] = true
+        })
+        setExpandedNotesMap(initExpand)
       })
       .catch((err) => setError(err.message))
       .finally(() => setLoading(false))
@@ -73,15 +82,16 @@ export function AdminCourseDetail() {
     }
   }
 
-  // 上传交付成果附件
+  // 上传交付成果附件(支持选择归属分集)
   const handleUploadDeliverables = async () => {
     if (!delivFiles.length) return
     setBusy(true)
     setError('')
     try {
-      const res = await api.admin.uploadDeliverables(id, delivFiles)
+      const res = await api.admin.uploadDeliverables(id, delivFiles, targetEpisodeId || undefined)
       setCourse(res.course)
       setDelivFiles([])
+      setTargetEpisodeId('')
       setSuccessMsg('交付成果文件上传成功')
     } catch (err: any) {
       setError(err.message || '上传交付文件失败')
@@ -126,6 +136,10 @@ export function AdminCourseDetail() {
   if (loading) return <div className="center-loading">正在读取课程制作信息…</div>
   if (!course) return <div className="center-loading">未找到该课程信息</div>
 
+  const episodes = course.episodes || []
+  const globalAssets = course.assets?.filter((a) => !a.episodeId) || []
+  const globalDeliverables = course.deliverables?.filter((d) => !d.episodeId) || []
+
   return (
     <div className="page admin-page">
       <Link to="/admin/courses" className="back-link">
@@ -142,6 +156,7 @@ export function AdminCourseDetail() {
           <p className="admin-subtitle">
             分类:<strong>{course.category || '未分类'}</strong>
             {course.audience ? <> · 目标受众:<strong>{course.audience}</strong></> : null}
+            {episodes.length > 0 ? <> · 分集规模:<strong>共 {episodes.length} 集</strong></> : null}
           </p>
         </div>
 
@@ -205,7 +220,7 @@ export function AdminCourseDetail() {
       {successMsg && <div className="notice notice-success">{successMsg}</div>}
 
       <div className="admin-detail-grid">
-        {/* 左侧栏:制作与交付操作中心 */}
+        {/* 左侧栏:制作与交付操作中心 + 用户素材全貌 */}
         <div className="admin-detail-left">
           {/* 制作交付成果面板 */}
           <section className="paper-form admin-panel">
@@ -215,39 +230,91 @@ export function AdminCourseDetail() {
             </div>
 
             <p className="panel-desc">
-              在此上传制作完成的成品文件(如成片视频、课件等),并填写交付说明。提交后,用户可在其课程主页查收成果。
+              在此上传制作完成的成品文件(如各集成片视频、全套打包课件等),并填写交付说明。提交后,用户可在其课程主页查收成果。
             </p>
 
-            {/* 已上传的交付文件列表 */}
+            {/* 已上传的交付文件列表(按分集分类展示) */}
             <div className="deliverable-list-section">
               <h4>已附带的交付成果文件 ({course.deliverables?.length || 0})</h4>
+
               {course.deliverables && course.deliverables.length > 0 ? (
-                <div className="deliverable-items">
-                  {course.deliverables.map((deliv) => (
-                    <div className="deliv-item" key={deliv.id}>
-                      <FileCheck size={18} className="icon-success" />
-                      <div className="deliv-meta">
-                        <strong>{deliv.originalName}</strong>
-                        <small>{size(deliv.size)}</small>
+                <div className="admin-deliverable-groups">
+                  {/* 分集成品 */}
+                  {episodes.map((ep) => {
+                    const epDelivs = course.deliverables?.filter((d) => d.episodeId === ep.id) || []
+                    if (!epDelivs.length) return null
+                    return (
+                      <div key={ep.id} className="admin-deliv-group-card">
+                        <div className="group-header">
+                          <span className="ep-badge-small">EP {String(ep.episodeNumber).padStart(2, '0')}</span>
+                          <strong>{ep.title} ({epDelivs.length})</strong>
+                        </div>
+                        <div className="deliverable-items">
+                          {epDelivs.map((deliv) => (
+                            <div className="deliv-item" key={deliv.id}>
+                              <FileCheck size={18} className="icon-success" />
+                              <div className="deliv-meta">
+                                <strong>{deliv.originalName}</strong>
+                                <small>{size(deliv.size)}</small>
+                              </div>
+                              <a
+                                href={deliv.url}
+                                download={deliv.originalName}
+                                className="secondary-button small-button"
+                                target="_blank"
+                                rel="noreferrer"
+                              >
+                                <Download size={13} /> 下载
+                              </a>
+                              <button
+                                className="icon-button delete-button"
+                                onClick={() => handleDeleteDeliverable(deliv.id)}
+                                title="删除此交付文件"
+                              >
+                                <Trash2 size={15} />
+                              </button>
+                            </div>
+                          ))}
+                        </div>
+                      </div>
+                    )
+                  })}
+
+                  {/* 全套通用成果 */}
+                  {globalDeliverables.length > 0 && (
+                    <div className="admin-deliv-group-card">
+                      <div className="group-header">
+                        <strong>全套课程通用交付物 ({globalDeliverables.length})</strong>
+                      </div>
+                      <div className="deliverable-items">
+                        {globalDeliverables.map((deliv) => (
+                          <div className="deliv-item" key={deliv.id}>
+                            <FileCheck size={18} className="icon-success" />
+                            <div className="deliv-meta">
+                              <strong>{deliv.originalName}</strong>
+                              <small>{size(deliv.size)}</small>
+                            </div>
+                            <a
+                              href={deliv.url}
+                              download={deliv.originalName}
+                              className="secondary-button small-button"
+                              target="_blank"
+                              rel="noreferrer"
+                            >
+                              <Download size={13} /> 下载
+                            </a>
+                            <button
+                              className="icon-button delete-button"
+                              onClick={() => handleDeleteDeliverable(deliv.id)}
+                              title="删除此交付文件"
+                            >
+                              <Trash2 size={15} />
+                            </button>
+                          </div>
+                        ))}
                       </div>
-                      <a
-                        href={deliv.url}
-                        download={deliv.originalName}
-                        className="secondary-button small-button"
-                        target="_blank"
-                        rel="noreferrer"
-                      >
-                        <Download size={13} /> 预览/下载
-                      </a>
-                      <button
-                        className="icon-button delete-button"
-                        onClick={() => handleDeleteDeliverable(deliv.id)}
-                        title="删除此交付文件"
-                      >
-                        <Trash2 size={15} />
-                      </button>
                     </div>
-                  ))}
+                  )}
                 </div>
               ) : (
                 <p className="empty-sub-tip">暂未上传交付成品文件,请点击下方按钮上传。</p>
@@ -255,25 +322,44 @@ export function AdminCourseDetail() {
             </div>
 
             {/* 上传新交付成品 */}
-            <div className="upload-deliv-trigger">
-              <input
-                hidden
-                multiple
-                ref={deliverableInput}
-                type="file"
-                onChange={(e) => setDelivFiles(Array.from(e.target.files || []))}
-              />
-              <button
-                type="button"
-                className="secondary-button"
-                onClick={() => deliverableInput.current?.click()}
-              >
-                <Upload size={15} /> 选择要交付的成品文件
-              </button>
+            <div className="upload-deliv-trigger admin-upload-box">
+              <div className="deliv-upload-row">
+                <input
+                  hidden
+                  multiple
+                  ref={deliverableInput}
+                  type="file"
+                  onChange={(e) => setDelivFiles(Array.from(e.target.files || []))}
+                />
+                <button
+                  type="button"
+                  className="secondary-button"
+                  onClick={() => deliverableInput.current?.click()}
+                >
+                  <Upload size={15} /> 选择要交付的成品文件
+                </button>
+
+                {episodes.length > 0 && (
+                  <label className="target-ep-select-label">
+                    <span>交付归属分集:</span>
+                    <select
+                      value={targetEpisodeId}
+                      onChange={(e) => setTargetEpisodeId(e.target.value)}
+                    >
+                      <option value="">全套通用成果 / 未指定分集</option>
+                      {episodes.map((ep) => (
+                        <option key={ep.id} value={ep.id}>
+                          EP {String(ep.episodeNumber).padStart(2, '0')} · {ep.title}
+                        </option>
+                      ))}
+                    </select>
+                  </label>
+                )}
+              </div>
 
               {delivFiles.length > 0 && (
                 <div className="pending-upload-stack">
-                  <span>已选 {delivFiles.length} 个文件待上传:</span>
+                  <span>已选 {delivFiles.length} 个文件(将交付至:{targetEpisodeId ? episodes.find(e => e.id === targetEpisodeId)?.title : '全套通用'}):</span>
                   {delivFiles.map((f, i) => (
                     <span className="file-chip" key={i}>
                       {f.name} ({size(f.size)})
@@ -314,43 +400,121 @@ export function AdminCourseDetail() {
             </form>
           </section>
 
-          {/* 用户提交的原始素材库 */}
+          {/* 用户提交的课程分集讲稿与原始素材全貌 */}
           <section className="paper-form admin-panel">
             <div className="form-title">
-              <span>CUSTOMER ASSETS</span>
-              <h3>用户上传的原始素材 ({course.assets?.length || 0})</h3>
+              <span>EPISODES & MATERIALS</span>
+              <h3>课程分集讲稿与素材库 ({episodes.length} 集 · {course.assets?.length || 0} 份文件)</h3>
             </div>
 
-            {course.assets && course.assets.length > 0 ? (
-              <div className="asset-download-grid">
-                {course.assets.map((asset) => (
-                  <div className="asset-download-card" key={asset.id}>
-                    <FileText size={24} className="tone-primary-icon" />
-                    <div className="asset-download-info">
-                      <strong>{asset.originalName}</strong>
-                      <small>
-                        {size(asset.size)} · {new Date(asset.createdAt).toLocaleDateString('zh-CN')}
-                      </small>
+            {episodes.length > 0 ? (
+              <div className="admin-episodes-list">
+                {episodes.map((ep) => {
+                  const isExpanded = !!expandedNotesMap[ep.id]
+                  const epAssets = course.assets?.filter((a) => a.episodeId === ep.id) || []
+
+                  return (
+                    <div className="admin-episode-card" key={ep.id}>
+                      <div className="admin-ep-header" onClick={() => setExpandedNotesMap(prev => ({ ...prev, [ep.id]: !prev[ep.id] }))}>
+                        <div className="header-left">
+                          <span className="ep-badge-pill">EP {String(ep.episodeNumber).padStart(2, '0')}</span>
+                          <strong>{ep.title}</strong>
+                        </div>
+                        <div className="header-right">
+                          <span className="text-pill">{ep.lectureNotes ? `${ep.lectureNotes.length} 字讲稿` : '未录入讲稿'}</span>
+                          <span className="text-pill">{epAssets.length} 份课件</span>
+                          {isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
+                        </div>
+                      </div>
+
+                      {isExpanded && (
+                        <div className="admin-ep-body">
+                          {ep.summary && (
+                            <p className="admin-ep-summary">
+                              <strong>分集要求:</strong>{ep.summary}
+                            </p>
+                          )}
+
+                          {/* 讲稿正文展示 */}
+                          <div className="admin-ep-notes-display">
+                            <div className="notes-label">
+                              <FileText size={14} />
+                              <span>口播讲稿 / 字幕文稿正文</span>
+                            </div>
+                            {ep.lectureNotes?.trim() ? (
+                              <pre className="notes-pre-content">{ep.lectureNotes}</pre>
+                            ) : (
+                              <p className="empty-sub-tip">该集用户未录入纯文本讲稿,请查阅下方附件文件。</p>
+                            )}
+                          </div>
+
+                          {/* 本集附件素材下载 */}
+                          {epAssets.length > 0 && (
+                            <div className="admin-ep-assets-grid">
+                              {epAssets.map((asset) => (
+                                <div className="admin-mini-asset-card" key={asset.id}>
+                                  <FileSpreadsheet size={18} className="tone-primary-icon" />
+                                  <div className="mini-asset-info">
+                                    <strong title={asset.originalName}>{asset.originalName}</strong>
+                                    <small>{size(asset.size)}</small>
+                                  </div>
+                                  <a
+                                    href={asset.url}
+                                    download={asset.originalName}
+                                    className="secondary-button small-button"
+                                    target="_blank"
+                                    rel="noreferrer"
+                                  >
+                                    <Download size={12} /> 下载
+                                  </a>
+                                </div>
+                              ))}
+                            </div>
+                          )}
+                        </div>
+                      )}
                     </div>
-                    <a
-                      href={asset.url}
-                      download={asset.originalName}
-                      className="primary-button small-button"
-                      target="_blank"
-                      rel="noreferrer"
-                    >
-                      <Download size={14} /> 下载
-                    </a>
-                  </div>
-                ))}
+                  )
+                })}
               </div>
-            ) : (
-              <div className="empty-sub-tip">用户尚未上传任何课程资产文件。</div>
+            ) : null}
+
+            {/* 全局通用原始素材 */}
+            {globalAssets.length > 0 && (
+              <div className="admin-global-assets-section">
+                <h4 className="sub-section-title">全套课程通用素材 ({globalAssets.length})</h4>
+                <div className="asset-download-grid">
+                  {globalAssets.map((asset) => (
+                    <div className="asset-download-card" key={asset.id}>
+                      <FileText size={24} className="tone-primary-icon" />
+                      <div className="asset-download-info">
+                        <strong>{asset.originalName}</strong>
+                        <small>
+                          {size(asset.size)} · 通用素材
+                        </small>
+                      </div>
+                      <a
+                        href={asset.url}
+                        download={asset.originalName}
+                        className="primary-button small-button"
+                        target="_blank"
+                        rel="noreferrer"
+                      >
+                        <Download size={14} /> 下载
+                      </a>
+                    </div>
+                  ))}
+                </div>
+              </div>
+            )}
+
+            {!episodes.length && !globalAssets.length && (
+              <div className="empty-sub-tip">用户尚未录入分集或上传任何素材文件。</div>
             )}
           </section>
         </div>
 
-        {/* 右侧栏:用户诉求与讲师信息 */}
+        {/* 右侧栏:用户诉求、分集大纲与讲师信息 */}
         <div className="admin-detail-right">
           {/* 用户画像与联系方式 */}
           <section className="paper-form admin-sidebar-card">
@@ -391,11 +555,34 @@ export function AdminCourseDetail() {
             </div>
           </section>
 
+          {/* 课程分集目录大纲 */}
+          {episodes.length > 0 && (
+            <section className="paper-form admin-sidebar-card">
+              <div className="form-title">
+                <span>EPISODES OUTLINE</span>
+                <h4>课程分集目录 ({episodes.length} 集)</h4>
+              </div>
+              <div className="sidebar-episode-outline">
+                {episodes.map((ep) => (
+                  <div className="outline-row" key={ep.id}>
+                    <span className="outline-badge">EP {String(ep.episodeNumber).padStart(2, '0')}</span>
+                    <div className="outline-info">
+                      <strong>{ep.title}</strong>
+                      <small>
+                        {ep.lectureNotes ? `${ep.lectureNotes.length} 字讲稿` : '无文本讲稿'} · {course.assets?.filter(a => a.episodeId === ep.id).length || 0} 份附件
+                      </small>
+                    </div>
+                  </div>
+                ))}
+              </div>
+            </section>
+          )}
+
           {/* 课程简介 */}
           <section className="paper-form admin-sidebar-card">
             <div className="form-title">
               <span>COURSE INTRO</span>
-              <h4>课程详情与需求说明</h4>
+              <h4>课程制作要求与需求说明</h4>
             </div>
             <p className="course-full-desc">{course.description || '用户未填写详细课程简介。'}</p>
             <div className="time-meta-box">

+ 2487 - 30
src/styles.css

@@ -1,10 +1,11 @@
-:root { font-family: Inter,'SF Pro Display','PingFang SC','Microsoft YaHei',sans-serif; color:#18233a; background:#f5f7fb; font-synthesis:none; --blue:#245bd6; --blue-dark:#1646b5; --ink:#17213a; --muted:#78839a; --line:#e4e8f0; --paper:#fff; }
+:root { font-family: Inter,'SF Pro Display','PingFang SC','Microsoft YaHei',sans-serif; color:#18233a; background:#f5f7fb; font-synthesis:none; --blue:#245bd6; --blue-dark:#1646b5; --ink:#17213a; --muted:#78839a; --line:#e4e8f0; --paper:#fff; --button-radius:8px; --button-height:44px; --button-small-height:34px; --button-shadow:0 8px 25px rgba(36,91,214,.18); }
 * { box-sizing:border-box; }
 body { margin:0; min-width:320px; min-height:100vh; background:radial-gradient(circle at 85% 6%,rgba(56,107,228,.06),transparent 24%),#f5f7fb; }
 button,input,textarea,select { font:inherit; }
 button,a { -webkit-tap-highlight-color:transparent; }
 a { color:inherit; text-decoration:none; }
 button { cursor:pointer; }
+button:focus-visible,a:focus-visible { outline:3px solid rgba(36,91,214,.22); outline-offset:2px; }
 .app-shell { min-height:100vh; }
 .topbar { height:72px; padding:0 clamp(24px,5vw,76px); display:flex; align-items:center; border-bottom:1px solid rgba(220,225,235,.8); background:rgba(248,249,252,.82); backdrop-filter:blur(14px); position:sticky; top:0; z-index:20; }
 .logo-link { display:flex; }
@@ -21,8 +22,8 @@ button { cursor:pointer; }
 .topbar nav a { color:#5b6880; font-weight:500; height:100%; display:flex; align-items:center; position:relative; }
 .topbar nav a.active { color:var(--blue); font-weight:600; }
 .topbar nav a.active:after { content:''; position:absolute; bottom:0; left:3px; right:3px; height:2px; background:var(--blue); }
-.admin-nav-link { color:#805ad5!important; display:inline-flex; align-items:center; gap:6px; font-weight:600!important; }
-.admin-tag { background:#805ad5; color:#fff; font-size:10px; padding:2px 6px; border-radius:4px; margin-left:4px; font-weight:500; }
+.admin-nav-link { color:var(--blue)!important; display:inline-flex; align-items:center; gap:6px; font-weight:600!important; }
+.admin-tag { background:var(--blue); color:#fff; font-size:10px; padding:2px 6px; border-radius:4px; margin-left:4px; font-weight:500; }
 .nav-muted { cursor:default; }
 .top-actions { margin-left:auto; display:flex; align-items:center; gap:13px; }
 .icon-button { border:0; background:transparent; color:#8d96a7; padding:7px; display:grid; place-items:center; border-radius:6px; }
@@ -35,15 +36,17 @@ button { cursor:pointer; }
 .hero h1 { color:#19233a; font-size:clamp(39px,4.3vw,64px); line-height:1.18; letter-spacing:-.045em; margin:0; font-weight:300; }
 .hero h1 em { font-style:normal; color:var(--blue); }
 .hero-copy>p:not(.eyebrow) { color:#7b8699; font-size:15px; margin:24px 0 30px; letter-spacing:.02em; }
-.primary-button,.secondary-button { border:0; border-radius:8px; min-height:44px; padding:0 19px; display:inline-flex; align-items:center; justify-content:center; gap:9px; font-weight:500; font-size:14px; transition:.2s ease; }
-.primary-button { background:var(--blue); color:#fff; box-shadow:0 8px 25px rgba(36,91,214,.18); }
+.primary-button,.secondary-button { border:0; border-radius:var(--button-radius); min-height:var(--button-height); padding:0 19px; display:inline-flex; align-items:center; justify-content:center; gap:9px; font-weight:500; font-size:14px; line-height:1; transition:background-color .2s ease,border-color .2s ease,color .2s ease,box-shadow .2s ease,transform .2s ease; }
+.primary-button { background:var(--blue); color:#fff; box-shadow:var(--button-shadow); }
 .primary-button:hover:not(:disabled) { background:var(--blue-dark); transform:translateY(-1px); }
-.primary-button:disabled { opacity:.48; cursor:not-allowed; box-shadow:none; }
+.primary-button:active:not(:disabled) { transform:translateY(0); box-shadow:0 4px 14px rgba(36,91,214,.18); }
+.primary-button:disabled,.secondary-button:disabled { opacity:.48; cursor:not-allowed; box-shadow:none; }
 .secondary-button { border:1px solid #dce2ed; color:#536077; background:#fff; }
-.secondary-button:hover { border-color:#bfc9da; }
-.small-button { min-height:34px; padding:0 12px; font-size:12px; }
-.highlight-button { background:linear-gradient(135deg,#245bd6,#5e3cd4); color:#fff; box-shadow:0 8px 25px rgba(94,60,212,.25); }
-.highlight-button:hover:not(:disabled) { background:linear-gradient(135deg,#1b4dbd,#4a2cae); }
+.secondary-button:hover:not(:disabled) { border-color:#bfc9da; background:#f8faff; color:#35435b; transform:translateY(-1px); }
+.secondary-button:active:not(:disabled) { transform:translateY(0); }
+.small-button { min-height:var(--button-small-height); padding:0 12px; font-size:12px; }
+.highlight-button { background:var(--blue); color:#fff; box-shadow:var(--button-shadow); }
+.highlight-button:hover:not(:disabled) { background:var(--blue-dark); }
 .orbit { position:absolute; border:1px solid rgba(72,91,125,.18); border-color:rgba(72,91,125,.22) transparent transparent transparent; border-radius:50%; transform:rotate(-12deg); }
 .orbit-one { width:570px; height:280px; right:1%; top:99px; }
 .orbit-two { width:440px; height:210px; right:6%; top:135px; border-color:rgba(36,91,214,.14) transparent transparent transparent; }
@@ -60,14 +63,17 @@ button { cursor:pointer; }
 .course-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; }
 .course-card { min-height:158px; border:1px solid #e5e9f1; background:rgba(255,255,255,.82); border-radius:12px; display:flex; padding:22px; position:relative; transition:.2s; overflow:hidden; }
 .course-card:hover { border-color:#cbd7ee; box-shadow:0 12px 40px rgba(24,48,91,.07); transform:translateY(-2px); }
-.course-number { width:70px; height:100%; min-height:110px; display:grid; place-items:center; border-radius:8px; color:#2d62ce; font-size:18px; font-weight:500; background:linear-gradient(145deg,#edf3ff,#f7f9fd); margin-right:20px; position:relative; overflow:hidden; }
-.course-number:after { content:''; position:absolute; width:80px; height:35px; border:1px solid rgba(44,95,200,.2); border-radius:50%; top:37px; left:11px; }
+.course-number { width:70px; min-width:70px; flex-shrink:0; height:100%; min-height:110px; display:grid; place-items:center; border-radius:8px; color:#2d62ce; font-size:18px; font-weight:600; background:linear-gradient(145deg,#edf3ff,#f7f9fd); margin-right:20px; position:relative; overflow:hidden; }
+.course-number:after { content:''; position:absolute; width:80px; height:36px; border:1px solid rgba(44,95,200,.2); border-radius:50%; top:50%; left:50%; transform:translate(-50%,-50%) rotate(-15deg); pointer-events:none; }
+.tone-0 { color:#2d62ce; background:linear-gradient(145deg,#edf3ff,#f7f9fd); }
 .tone-1 { color:#6885a9; background:#f1f4f7; }
+.tone-1:after { border-color:rgba(104,133,169,.22); }
 .tone-2 { color:#b09052; background:#f8f5ed; }
-.course-card-main { min-width:0; padding-top:1px; }
+.tone-2:after { border-color:rgba(176,144,82,.25); }
+.course-card-main { flex:1; min-width:0; padding-top:1px; padding-right:28px; }
 .course-card h3 { margin:11px 0 6px; font-size:17px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
 .course-card-main>p { font-size:12px; color:#8c96a8; margin:0 0 17px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
-.course-meta { display:flex; gap:8px; }
+.course-meta { display:flex; gap:8px; flex-wrap:wrap; }
 .course-meta span { font-size:10px; color:#7d899c; background:#f4f6f9; padding:5px 8px; border-radius:4px; }
 .card-arrow { position:absolute; right:20px; top:24px; color:#b6bfce; }
 
@@ -211,14 +217,14 @@ button { cursor:pointer; }
 .admin-shell { min-height:100vh; background:#f4f6fa; }
 .admin-topbar { height:70px; padding:0 clamp(20px,4vw,60px); display:flex; align-items:center; border-bottom:1px solid #dde3ed; background:#ffffff; box-shadow:0 1px 4px rgba(0,0,0,.03); position:sticky; top:0; z-index:20; }
 .admin-brand-wrap { display:flex; align-items:center; gap:14px; }
-.admin-badge { display:inline-flex; align-items:center; gap:5px; background:linear-gradient(135deg,#742a2a,#9b2c2c); color:#fff; font-size:11px; font-weight:600; padding:4px 9px; border-radius:6px; letter-spacing:.05em; }
+.admin-badge { display:inline-flex; align-items:center; gap:5px; background:var(--blue); color:#fff; font-size:11px; font-weight:600; padding:4px 9px; border-radius:6px; letter-spacing:.05em; }
 .admin-nav { display:flex; height:100%; gap:26px; align-items:center; margin-left:45px; }
 .admin-nav a { color:#64748b; font-size:14px; font-weight:500; height:100%; display:flex; align-items:center; gap:7px; position:relative; }
 .admin-nav a:hover { color:#1e293b; }
 .admin-nav a.active { color:var(--blue); font-weight:600; }
 .admin-nav a.active:after { content:''; position:absolute; bottom:0; left:0; right:0; height:2px; background:var(--blue); }
 .admin-top-actions { margin-left:auto; display:flex; align-items:center; gap:16px; }
-.client-switch-button { display:inline-flex; align-items:center; gap:6px; font-size:12px; color:#475569; background:#f1f5f9; border:1px solid #cbd5e1; padding:6px 12px; border-radius:6px; font-weight:500; }
+.client-switch-button { min-height:var(--button-small-height); display:inline-flex; align-items:center; gap:6px; font-size:12px; color:#536077; background:#fff; border:1px solid #dce2ed; padding:0 12px; border-radius:var(--button-radius); font-weight:500; transition:.2s ease; }
 .client-switch-button:hover { background:#e2e8f0; color:#0f172a; }
 .admin-user-info { display:flex; flex-direction:column; text-align:right; font-size:12px; }
 .admin-user-info strong { color:#1e293b; }
@@ -236,7 +242,7 @@ button { cursor:pointer; }
 .tone-blue { background:#ebf4ff; color:#2563eb; }
 .tone-slate { background:#f1f5f9; color:#475569; }
 .tone-gold { background:#fef3c7; color:#d97706; }
-.tone-primary { background:#ede9fe; color:#7c3aed; }
+.tone-primary { background:#edf3ff; color:var(--blue); }
 .tone-green { background:#dcfce7; color:#16a34a; }
 .stat-content { display:flex; flex-direction:column; }
 .stat-content small { font-size:12px; color:#64748b; margin-bottom:4px; }
@@ -314,7 +320,7 @@ button { cursor:pointer; }
 /* 右侧侧边栏卡片 */
 .admin-sidebar-card { padding:24px; margin-bottom:20px; }
 .user-profile-summary { display:flex; align-items:center; gap:14px; margin-bottom:18px; padding-bottom:16px; border-bottom:1px solid #f1f5f9; }
-.user-avatar-large { width:48px; height:48px; border-radius:50%; background:#e0e7ff; color:#4338ca; display:grid; place-items:center; flex-shrink:0; }
+.user-avatar-large { width:48px; height:48px; border-radius:50%; background:#edf3ff; color:var(--blue); display:grid; place-items:center; flex-shrink:0; }
 .user-summary-text strong { display:block; font-size:15px; color:#0f172a; }
 .user-summary-text small { color:#64748b; font-size:12px; }
 .contact-list { display:flex; flex-direction:column; gap:10px; font-size:13px; }
@@ -340,19 +346,2470 @@ button { cursor:pointer; }
 }
 
 /* ==================== ADMIN 专属登录页样式 ==================== */
-.admin-login-page { min-height:100vh; display:grid; place-items:center; background:radial-gradient(circle at 50% 20%,rgba(36,91,214,.18),transparent 45%),radial-gradient(circle at 80% 80%,rgba(94,60,212,.12),transparent 40%),#0e1526; padding:24px; }
-.admin-login-card { width:min(440px,100%); background:rgba(19,28,49,.85); backdrop-filter:blur(20px); border:1px solid rgba(255,255,255,.1); border-radius:16px; padding:44px 38px; box-shadow:0 30px 80px rgba(0,0,0,.45); }
+.admin-login-page { min-height:100vh; display:grid; place-items:center; background:radial-gradient(circle at 22% 28%,rgba(36,91,214,.10),transparent 38%),#f5f7fb; padding:24px; }
+.admin-login-card { width:min(440px,100%); background:#fff; border:1px solid var(--line); border-radius:14px; padding:44px 38px; box-shadow:0 20px 60px rgba(40,57,90,.08); }
 .admin-login-head { text-align:center; margin-bottom:28px; }
-.admin-login-brand { display:flex; justify-content:center; margin-bottom:18px; filter:invert(1) hue-rotate(180deg); }
-.admin-login-badge { display:inline-flex; align-items:center; gap:6px; background:linear-gradient(135deg,#991b1b,#b91c1c); color:#fff; font-size:11px; font-weight:600; padding:4px 11px; border-radius:20px; letter-spacing:.08em; margin-bottom:14px; }
-.admin-login-head h2 { margin:0 0 8px; font-size:24px; font-weight:600; color:#f8fafc; }
-.admin-login-head p { margin:0; font-size:13px; color:#94a3b8; }
-.admin-login-form label { display:flex; flex-direction:column; gap:8px; font-size:12px; color:#cbd5e1; margin-bottom:20px; }
-.admin-login-form input { width:100%; border:1px solid rgba(255,255,255,.15); border-radius:8px; padding:12px 14px 12px 42px; background:rgba(15,23,42,.6); color:#f8fafc; font-size:14px; outline:none; transition:.2s; }
-.admin-login-form input:focus { border-color:#38bdf8; box-shadow:0 0 0 3px rgba(56,189,248,.15); background:rgba(15,23,42,.9); }
+.admin-login-brand { display:flex; justify-content:center; margin-bottom:18px; }
+.admin-login-badge { display:inline-flex; align-items:center; gap:6px; background:#edf3ff; color:var(--blue); font-size:11px; font-weight:600; padding:4px 11px; border-radius:20px; letter-spacing:.08em; margin-bottom:14px; }
+.admin-login-head h2 { margin:0 0 8px; font-size:24px; font-weight:500; color:var(--ink); }
+.admin-login-head p { margin:0; font-size:13px; color:var(--muted); }
+.admin-login-form label { display:flex; flex-direction:column; gap:8px; font-size:12px; color:#59657b; margin-bottom:20px; }
+.admin-login-form input { width:100%; border:1px solid #dfe4ec; border-radius:7px; padding:12px 14px 12px 42px; background:#fbfcfe; color:#253149; font-size:14px; outline:none; transition:.2s; }
+.admin-login-form input:focus { border-color:#7599e8; box-shadow:0 0 0 3px rgba(36,91,214,.08); background:#fff; }
 .admin-login-form .input-with-icon svg { color:#64748b; top:14px; }
 .admin-login-footer { margin-top:24px; text-align:center; display:flex; flex-direction:column; gap:14px; }
-.admin-tip-box { background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.08); border-radius:8px; padding:8px 12px; font-size:11px; color:#94a3b8; display:inline-flex; align-items:center; justify-content:center; gap:6px; }
-.back-to-client-link { font-size:12px; color:#60a5fa; transition:.15s; }
-.back-to-client-link:hover { color:#93c5fd; text-decoration:underline; }
+.admin-tip-box { background:#f7f9fc; border:1px solid #edf0f5; border-radius:8px; padding:8px 12px; font-size:11px; color:#8b96a9; display:inline-flex; align-items:center; justify-content:center; gap:6px; }
+.back-to-client-link { font-size:12px; color:var(--blue); transition:.15s; }
+.back-to-client-link:hover { color:var(--blue-dark); text-decoration:underline; }
+
+/* ==================== 课程创建页 - 示例模板与示范卡片 ==================== */
+.new-course-layout {
+  grid-template-columns: minmax(340px, 480px) minmax(420px, 620px);
+  gap: clamp(30px, 5vw, 60px);
+}
+.new-course-aside {
+  padding-top: 10px;
+}
+.aside-subtext {
+  color: #7b889d;
+  font-size: 13px;
+  line-height: 1.7;
+  margin: 16px 0 24px;
+}
+.template-showcase-box {
+  background: #ffffff;
+  border: 1px solid #dfe5f0;
+  border-radius: 14px;
+  padding: 20px;
+  box-shadow: 0 10px 30px rgba(30, 48, 80, 0.05);
+  margin-top: 10px;
+}
+.template-box-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 14px;
+  padding-bottom: 12px;
+  border-bottom: 1px solid #edf1f7;
+}
+.header-left {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 13px;
+  color: #1a253c;
+}
+.flame-icon { color: var(--blue); }
+.sample-count-tag {
+  font-size: 11px;
+  color: #64748b;
+  background: #f1f5f9;
+  padding: 3px 8px;
+  border-radius: 12px;
+}
+.template-pills {
+  display: flex;
+  gap: 8px;
+  margin-bottom: 16px;
+  overflow-x: auto;
+  padding-bottom: 2px;
+}
+.template-pill {
+  border: 1px solid #e2e8f0;
+  background: #f8fafc;
+  color: #475569;
+  font-size: 12px;
+  font-weight: 500;
+  padding: 6px 12px;
+  border-radius: 20px;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  transition: all 0.2s ease;
+  white-space: nowrap;
+}
+.template-pill:hover {
+  border-color: #cbd5e1;
+  background: #f1f5f9;
+}
+.template-pill.active {
+  background: #eff6ff;
+  border-color: #3b82f6;
+  color: #1d4ed8;
+  font-weight: 600;
+  box-shadow: 0 2px 8px rgba(37, 99, 235, 0.12);
+}
+.pill-badge {
+  background: #dbeafe;
+  color: #1e40af;
+  font-size: 9px;
+  padding: 1px 5px;
+  border-radius: 4px;
+}
+.selected-template-card {
+  background: linear-gradient(180deg, #fbfcfe 0%, #f8fafc 100%);
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  padding: 16px;
+}
+.card-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  margin-bottom: 10px;
+}
+.sample-tag {
+  font-size: 10px;
+  font-weight: 500;
+  color: #2563eb;
+  background: #eef4ff;
+  border: 1px solid #dbe6fe;
+  padding: 2px 7px;
+  border-radius: 4px;
+}
+.sample-title {
+  margin: 0 0 6px;
+  font-size: 16px;
+  font-weight: 600;
+  color: #0f172a;
+}
+.sample-desc {
+  margin: 0 0 14px;
+  font-size: 12px;
+  line-height: 1.6;
+  color: #64748b;
+}
+.specs-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 8px;
+  margin-bottom: 14px;
+  background: #ffffff;
+  padding: 10px 12px;
+  border-radius: 8px;
+  border: 1px solid #edf2f7;
+}
+.spec-item {
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+  font-size: 11px;
+  color: #475569;
+}
+.spec-item svg {
+  color: #3b82f6;
+  margin-top: 2px;
+  flex-shrink: 0;
+}
+.spec-item small {
+  display: block;
+  font-size: 10px;
+  color: #94a3b8;
+}
+.spec-item span {
+  display: block;
+  font-weight: 500;
+  color: #1e293b;
+  line-height: 1.3;
+}
+.preset-assets-strip {
+  margin-bottom: 14px;
+  background: #f1f5f9;
+  border-radius: 8px;
+  padding: 10px 12px;
+}
+.strip-title {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 11px;
+  font-weight: 600;
+  color: #334155;
+  margin-bottom: 8px;
+}
+.preset-pill-list {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.asset-minitag {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  font-size: 11px;
+  color: #475569;
+  background: #ffffff;
+  padding: 4px 8px;
+  border-radius: 4px;
+  border: 1px solid #e2e8f0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.asset-minitag svg {
+  color: #64748b;
+  flex-shrink: 0;
+}
+.template-actions {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 10px;
+  margin-bottom: 10px;
+}
+.action-btn {
+  width: 100%;
+  padding: 0 10px;
+}
+.highlight-sample-btn { background:var(--blue); color:#fff; box-shadow:var(--button-shadow); }
+.highlight-sample-btn:hover:not(:disabled) { background:var(--blue-dark); transform:translateY(-1px); }
+.template-card-footer {
+  text-align: center;
+  padding-top: 6px;
+}
+.detail-link-btn {
+  border: 0;
+  background: transparent;
+  color: #2563eb;
+  font-size: 11px;
+  font-weight: 500;
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 8px;
+  border-radius: 4px;
+  transition: all 0.15s;
+}
+.detail-link-btn:hover {
+  background: #eff6ff;
+  text-decoration: underline;
+}
+
+/* 表单顶部快速载入按钮 */
+.title-left {
+  display: flex;
+  flex-direction: column;
+}
+.quick-sample-pill-btn {
+  border: 1px solid #bfdbfe;
+  background: #eff6ff;
+  color: #1d4ed8;
+  font-size: 11px;
+  font-weight: 500;
+  padding: 5px 10px;
+  border-radius: 20px;
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  transition: all 0.15s;
+}
+.quick-sample-pill-btn:hover {
+  background: #dbeafe;
+  border-color: #93c5fd;
+}
+.form-title {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-end;
+}
+.filled-toast {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  background: #f0fdf4;
+  border: 1px solid #bbf7d0;
+  color: #15803d;
+  padding: 10px 14px;
+  border-radius: 8px;
+  font-size: 12px;
+  margin-bottom: 16px;
+  animation: fadeIn 0.25s ease-out;
+}
+@keyframes fadeIn {
+  from { opacity: 0; transform: translateY(-4px); }
+  to { opacity: 1; transform: translateY(0); }
+}
+
+/* ==================== 制作任务书弹窗 Modal ==================== */
+.modal-backdrop {
+  position: fixed;
+  inset: 0;
+  background: rgba(15, 23, 42, 0.55);
+  backdrop-filter: blur(4px);
+  z-index: 999;
+  display: grid;
+  place-items: center;
+  padding: 20px;
+  animation: fadeIn 0.2s ease-out;
+}
+.modal-dialog.brief-modal {
+  width: min(720px, 95vw);
+  max-height: 88vh;
+  background: #ffffff;
+  border-radius: 16px;
+  box-shadow: 0 25px 60px rgba(0, 0, 0, 0.2);
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  border: 1px solid #e2e8f0;
+}
+.modal-header {
+  padding: 20px 24px;
+  border-bottom: 1px solid #edf2f7;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  background: #f8fafc;
+}
+.modal-header h3 {
+  margin: 4px 0 0;
+  font-size: 18px;
+  font-weight: 600;
+  color: #0f172a;
+}
+.modal-badge {
+  font-size: 10px;
+  color: #1d4ed8;
+  background: #dbeafe;
+  padding: 2px 7px;
+  border-radius: 4px;
+  font-weight: 600;
+}
+.close-modal-btn {
+  border: 0;
+  background: transparent;
+  color: #64748b;
+  padding: 6px;
+  border-radius: 6px;
+  display: grid;
+  place-items: center;
+}
+.close-modal-btn:hover {
+  background: #e2e8f0;
+  color: #0f172a;
+}
+.modal-body.brief-modal-content {
+  padding: 22px 24px;
+  overflow-y: auto;
+  display: flex;
+  flex-direction: column;
+  gap: 18px;
+}
+.brief-section {
+  background: #f8fafc;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  padding: 16px 18px;
+}
+.brief-section.focus-card {
+  background: #fffbeb;
+  border-color: #fde68a;
+}
+.brief-section.focus-card h4 {
+  color: #92400e;
+}
+.brief-section h4 {
+  margin: 0 0 10px;
+  font-size: 14px;
+  font-weight: 600;
+  color: #1e293b;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.brief-section ul {
+  margin: 0;
+  padding-left: 20px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  font-size: 13px;
+  color: #334155;
+  line-height: 1.6;
+}
+.brief-preset-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 12px;
+}
+.brief-subcard {
+  background: #ffffff;
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  padding: 12px;
+}
+.brief-subcard strong {
+  display: block;
+  font-size: 12px;
+  color: #0f172a;
+  margin-bottom: 8px;
+  padding-bottom: 6px;
+  border-bottom: 1px solid #f1f5f9;
+}
+.inst-subrow, .asset-subrow {
+  margin-bottom: 8px;
+}
+.inst-subrow span, .asset-subrow span {
+  display: block;
+  font-size: 12px;
+  font-weight: 600;
+  color: #1e293b;
+}
+.inst-subrow small, .asset-subrow small {
+  display: block;
+  font-size: 11px;
+  color: #64748b;
+  margin: 2px 0;
+}
+.inst-subrow p {
+  margin: 0;
+  font-size: 11px;
+  color: #475569;
+  line-height: 1.4;
+}
+.modal-footer {
+  padding: 16px 24px;
+  border-top: 1px solid #edf2f7;
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+  background: #f8fafc;
+}
+
+/* ==================== 讲师出镜照片上传与规范示例 ==================== */
+.instructor-image-field-wrapper {
+  margin: 18px 0;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.field-label-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.field-main-label {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 13px;
+  font-weight: 500;
+  color: #334155;
+  margin: 0 !important;
+}
+
+.field-sub-label {
+  font-size: 11px;
+  color: #94a3b8;
+  font-weight: normal;
+}
+
+.guide-toggle-btn {
+  border: 0;
+  background: transparent;
+  color: #2563eb;
+  font-size: 11px;
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  cursor: pointer;
+  padding: 2px 6px;
+  border-radius: 4px;
+  transition: all 0.15s ease;
+}
+
+.guide-toggle-btn:hover {
+  background: #eff6ff;
+  color: #1d4ed8;
+}
+
+/* 上传虚线框 */
+.instructor-upload-zone {
+  border: 1.5px dashed #cbd5e1;
+  border-radius: 10px;
+  background: #ffffff;
+  padding: 20px 16px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  text-align: center;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  gap: 8px;
+}
+
+.instructor-upload-zone:hover,
+.instructor-upload-zone.dragging {
+  border-color: #3b82f6;
+  background: #eff6ff;
+  transform: translateY(-1px);
+}
+
+.upload-zone-icon {
+  width: 44px;
+  height: 44px;
+  border-radius: 50%;
+  background: #eff6ff;
+  color: #2563eb;
+  display: grid;
+  place-items: center;
+  transition: transform 0.2s ease;
+}
+
+.instructor-upload-zone:hover .upload-zone-icon {
+  transform: scale(1.08);
+}
+
+.upload-zone-text strong {
+  display: block;
+  font-size: 13px;
+  font-weight: 600;
+  color: #1e293b;
+  margin-bottom: 3px;
+}
+
+.upload-zone-text span {
+  font-size: 11px;
+  color: #64748b;
+}
+
+/* 已选图片预览卡片 */
+.instructor-image-preview-card {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 10px 14px;
+  background: #ffffff;
+  border: 1px solid #93c5fd;
+  border-radius: 10px;
+  box-shadow: 0 1px 3px rgba(37, 99, 235, 0.08);
+}
+
+.preview-thumbnail {
+  width: 52px;
+  height: 64px;
+  object-fit: cover;
+  border-radius: 6px;
+  border: 1px solid #e2e8f0;
+  background: #f1f5f9;
+  flex-shrink: 0;
+}
+
+.preview-info {
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  flex: 1;
+}
+
+.preview-info .file-name {
+  font-size: 13px;
+  font-weight: 500;
+  color: #0f172a;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.preview-info .file-size {
+  font-size: 11px;
+  color: #16a34a;
+  margin-top: 3px;
+  font-weight: 500;
+}
+
+.preview-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.preview-actions .change-btn {
+  border: 1px solid #cbd5e1;
+  background: #ffffff;
+  color: #334155;
+  font-size: 11px;
+  padding: 5px 10px;
+  border-radius: 6px;
+  cursor: pointer;
+  transition: all 0.15s ease;
+}
+
+.preview-actions .change-btn:hover {
+  background: #f1f5f9;
+  border-color: #94a3b8;
+}
+
+.preview-actions .remove-btn {
+  border: 0;
+  background: #fee2e2;
+  color: #ef4444;
+  width: 28px;
+  height: 28px;
+  border-radius: 6px;
+  display: grid;
+  place-items: center;
+  cursor: pointer;
+  transition: all 0.15s ease;
+}
+
+.preview-actions .remove-btn:hover {
+  background: #fecaca;
+  color: #dc2626;
+}
+
+/* 规范示例展示区域 */
+.sample-guide-section {
+  background: #ffffff;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  padding: 14px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.sample-guide-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.guide-title {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 12px;
+  font-weight: 600;
+  color: #1e293b;
+}
+
+.guide-icon {
+  color: #2563eb;
+}
+
+.guide-tag {
+  font-size: 10px;
+  background: #dcfce7;
+  color: #15803d;
+  font-weight: 600;
+  padding: 2px 7px;
+  border-radius: 4px;
+}
+
+/* 示例卡片两列布局 */
+.sample-cards-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 10px;
+}
+
+.sample-card {
+  background: #f8fafc;
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  overflow: hidden;
+  cursor: pointer;
+  transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
+  display: flex;
+  flex-direction: column;
+}
+
+.sample-card:hover {
+  transform: translateY(-2px);
+  border-color: #93c5fd;
+  box-shadow: 0 6px 16px rgba(37, 99, 235, 0.08);
+}
+
+.sample-img-box {
+  position: relative;
+  width: 100%;
+  aspect-ratio: 4 / 3;
+  background: #000000;
+  overflow: hidden;
+}
+
+.sample-img-box img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  object-position: top center;
+  transition: transform 0.3s ease;
+}
+
+.sample-card:hover .sample-img-box img {
+  transform: scale(1.05);
+}
+
+.sample-pose-badge {
+  position: absolute;
+  top: 6px;
+  left: 6px;
+  background: rgba(15, 23, 42, 0.7);
+  backdrop-filter: blur(4px);
+  color: #ffffff;
+  font-size: 10px;
+  font-weight: 600;
+  padding: 2px 6px;
+  border-radius: 4px;
+}
+
+.zoom-hint {
+  position: absolute;
+  inset: 0;
+  background: rgba(15, 23, 42, 0.45);
+  backdrop-filter: blur(2px);
+  color: #ffffff;
+  font-size: 11px;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 4px;
+  opacity: 0;
+  transition: opacity 0.2s ease;
+}
+
+.sample-card:hover .zoom-hint {
+  opacity: 1;
+}
+
+.sample-card-desc {
+  padding: 8px 10px;
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+}
+
+.sample-card-desc strong {
+  font-size: 12px;
+  color: #0f172a;
+  font-weight: 600;
+}
+
+.sample-card-desc p {
+  margin: 0;
+  font-size: 10.5px;
+  color: #64748b;
+  line-height: 1.4;
+}
+
+/* 文字要求清单 */
+.guide-requirements-list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  border-top: 1px dashed #e2e8f0;
+  padding-top: 10px;
+}
+
+.req-item {
+  display: flex;
+  gap: 8px;
+  align-items: flex-start;
+}
+
+.req-icon {
+  width: 18px;
+  height: 18px;
+  border-radius: 50%;
+  display: grid;
+  place-items: center;
+  flex-shrink: 0;
+  margin-top: 1px;
+}
+
+.green-dot {
+  color: #16a34a;
+  background: #dcfce7;
+}
+
+.blue-dot {
+  color: #2563eb;
+  background: #dbeafe;
+}
+
+.amber-dot {
+  color: #d97706;
+  background: #fef3c7;
+}
+
+.red-dot {
+  color: #dc2626;
+  background: #fee2e2;
+}
+
+.req-content {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.req-content strong {
+  font-size: 11.5px;
+  color: #1e293b;
+  font-weight: 600;
+}
+
+.req-content p {
+  margin: 0;
+  font-size: 11px;
+  color: #475569;
+  line-height: 1.45;
+}
+
+/* 示例大图弹窗 Modal */
+.sample-modal-overlay {
+  position: fixed;
+  inset: 0;
+  background: rgba(15, 23, 42, 0.65);
+  backdrop-filter: blur(6px);
+  z-index: 1000;
+  display: grid;
+  place-items: center;
+  padding: 20px;
+  animation: fadeIn 0.2s ease-out;
+}
+
+.sample-modal-box {
+  width: min(820px, 95vw);
+  max-height: 90vh;
+  background: #ffffff;
+  border-radius: 16px;
+  box-shadow: 0 25px 60px rgba(0, 0, 0, 0.25);
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  border: 1px solid #cbd5e1;
+}
+
+.sample-modal-header {
+  padding: 16px 22px;
+  background: #f8fafc;
+  border-bottom: 1px solid #e2e8f0;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.modal-title-group {
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+}
+
+.modal-title-group h3 {
+  margin: 0;
+  font-size: 17px;
+  font-weight: 600;
+  color: #0f172a;
+}
+
+.modal-sub-tag {
+  font-size: 11px;
+  color: #2563eb;
+  font-weight: 500;
+}
+
+.modal-close-btn {
+  border: 0;
+  background: transparent;
+  color: #64748b;
+  width: 32px;
+  height: 32px;
+  border-radius: 8px;
+  display: grid;
+  place-items: center;
+  cursor: pointer;
+  transition: all 0.15s ease;
+}
+
+.modal-close-btn:hover {
+  background: #e2e8f0;
+  color: #0f172a;
+}
+
+.sample-modal-body {
+  display: grid;
+  grid-template-columns: 340px 1fr;
+  padding: 20px 24px;
+  gap: 24px;
+  overflow-y: auto;
+  align-items: start;
+}
+
+@media (max-width: 720px) {
+  .sample-modal-body {
+    grid-template-columns: 1fr;
+  }
+}
+
+.sample-modal-view {
+  background: #0f172a;
+  border-radius: 12px;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 400px;
+  box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.4);
+}
+
+.modal-large-img {
+  max-width: 100%;
+  max-height: 100%;
+  object-fit: contain;
+}
+
+.sample-modal-spec {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+.sample-modal-spec h4 {
+  margin: 0;
+  font-size: 15px;
+  font-weight: 600;
+  color: #0f172a;
+}
+
+.spec-checklist {
+  list-style: none;
+  padding: 0;
+  margin: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.spec-checklist li {
+  position: relative;
+  padding-left: 18px;
+  font-size: 12.5px;
+  color: #334155;
+  line-height: 1.5;
+}
+
+.spec-checklist li::before {
+  content: "•";
+  position: absolute;
+  left: 4px;
+  color: #2563eb;
+  font-size: 18px;
+  line-height: 1;
+}
+
+.spec-checklist strong {
+  color: #0f172a;
+}
+
+.sample-modal-switch {
+  margin-top: 12px;
+  padding: 12px 14px;
+  background: #f1f5f9;
+  border-radius: 10px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.switch-label {
+  font-size: 11.5px;
+  font-weight: 600;
+  color: #475569;
+}
+
+.switch-buttons {
+  display: flex;
+  gap: 8px;
+}
+
+.switch-btn {
+  flex: 1;
+  padding: 8px 12px;
+  border: 1px solid #cbd5e1;
+  background: #ffffff;
+  border-radius: 6px;
+  font-size: 12px;
+  font-weight: 500;
+  color: #334155;
+  cursor: pointer;
+  transition: all 0.15s ease;
+}
+
+.switch-btn:hover {
+  border-color: #93c5fd;
+  color: #2563eb;
+}
+
+.switch-btn.active {
+  background: #2563eb;
+  color: #ffffff;
+  border-color: #2563eb;
+  font-weight: 600;
+}
+
+.sample-modal-footer {
+  padding: 14px 22px;
+  background: #f8fafc;
+  border-top: 1px solid #e2e8f0;
+  display: flex;
+  justify-content: flex-end;
+}
+
+/* 讲师表单操作按钮 */
+.instructor-form-actions {
+  display: flex;
+  gap: 10px;
+  margin-top: 16px;
+}
+
+.instructor-form-actions .cancel-btn {
+  flex: 1;
+  height: 40px;
+  border: 1px solid #cbd5e1;
+  background: #ffffff;
+  color: #475569;
+  border-radius: 8px;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.15s ease;
+}
+
+.instructor-form-actions .cancel-btn:hover {
+  background: #f1f5f9;
+  color: #1e293b;
+}
+
+.instructor-form-actions .primary-button {
+  flex: 2;
+  margin-top: 0;
+  height: 40px;
+}
+
+/* ==================== 讲师添加弹窗 Modal ==================== */
+.modal-dialog.instructor-modal {
+  width: min(680px, 95vw);
+  max-height: 90vh;
+  background: #ffffff;
+  border-radius: 16px;
+  box-shadow: 0 25px 60px rgba(0, 0, 0, 0.22);
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  border: 1px solid #e2e8f0;
+}
+
+.modal-form-content {
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  flex: 1;
+}
+
+.instructor-modal-body {
+  padding: 22px 24px;
+  overflow-y: auto;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  flex: 1;
+}
+
+.instructor-modal-body .form-group-row {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 16px;
+}
+
+@media (max-width: 600px) {
+  .instructor-modal-body .form-group-row {
+    grid-template-columns: 1fr;
+  }
+}
+
+.instructor-modal-body .form-label {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  font-size: 13px;
+  font-weight: 500;
+  color: #334155;
+}
+
+.instructor-modal-body .form-label span {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.instructor-modal-body .form-label.required span::after {
+  content: " *";
+  color: #ef4444;
+}
+
+.instructor-modal-body input,
+.instructor-modal-body textarea {
+  width: 100%;
+  border: 1px solid #cbd5e1;
+  border-radius: 8px;
+  padding: 10px 14px;
+  background: #f8fafc;
+  font-size: 14px;
+  color: #0f172a;
+  transition: all 0.15s ease;
+  box-sizing: border-box;
+  font-family: inherit;
+}
+
+.instructor-modal-body input:focus,
+.instructor-modal-body textarea:focus {
+  outline: none;
+  border-color: #3b82f6;
+  background: #ffffff;
+  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
+}
+
+.instructor-modal-body textarea {
+  resize: vertical;
+  min-height: 76px;
+  line-height: 1.5;
+}
+
+.instructor-modal-body .instructor-image-field-wrapper {
+  margin: 4px 0 0;
+}
+
+/* 侧边栏空讲师状态 */
+.empty-instructor-box {
+  padding: 22px 16px;
+  background: #f8fafc;
+  border: 1px dashed #cbd5e1;
+  border-radius: 10px;
+  text-align: center;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 10px;
+  margin-top: 10px;
+}
+
+.empty-instructor-box p {
+  margin: 0;
+  font-size: 12px;
+  color: #64748b;
+}
+
+.add-instructor-trigger-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 6px 14px;
+  font-size: 12px;
+  font-weight: 500;
+  border-radius: 6px;
+  color: #2563eb;
+  background: #eff6ff;
+  border: 1px solid #bfdbfe;
+  cursor: pointer;
+  transition: all 0.15s ease;
+}
+
+.add-instructor-trigger-btn:hover {
+  background: #dbeafe;
+  color: #1d4ed8;
+  border-color: #93c5fd;
+}
+
+.add-instructor-btn:hover {
+  background: #f1f5f9 !important;
+  color: #2563eb;
+  border-color: #cbd5e1 !important;
+}
+
+/* ==================== 课程分集与素材管理 UI ==================== */
+
+.workspace-grid {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 340px;
+  gap: 24px;
+  align-items: start;
+}
+
+.episodes-workspace-main {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+
+.episodes-header-panel {
+  padding: 24px 28px;
+  background: #ffffff;
+  border-radius: 14px;
+  border: 1px solid #e2e8f0;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
+}
+
+.panel-heading-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 20px;
+}
+
+.panel-intro {
+  color: #64748b;
+  font-size: 13px;
+  margin: 6px 0 0 0;
+  line-height: 1.5;
+}
+
+.episodes-actions-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-shrink: 0;
+}
+
+.episodes-metrics-bar {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 12px;
+  margin-top: 20px;
+  padding: 14px 18px;
+  background: #f8fafc;
+  border-radius: 10px;
+  border: 1px solid #edf2f7;
+}
+
+.metric-item {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.metric-num {
+  font-size: 20px;
+  font-weight: 700;
+  color: #0f172a;
+  letter-spacing: -0.02em;
+}
+
+.metric-item small {
+  font-size: 12px;
+  color: #64748b;
+}
+
+.expand-collapse-strip {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 16px;
+  padding-top: 14px;
+  border-top: 1px dashed #e2e8f0;
+  font-size: 12px;
+  color: #475569;
+}
+
+.expand-links {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.expand-links button {
+  background: transparent;
+  border: 0;
+  color: #2563eb;
+  font-size: 12px;
+  cursor: pointer;
+  padding: 0;
+  font-weight: 500;
+}
+
+.expand-links button:hover {
+  text-decoration: underline;
+}
+
+/* 分集卡片容器 */
+.episodes-list-container {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+.episode-card {
+  background: #ffffff;
+  border-radius: 12px;
+  border: 1px solid #e2e8f0;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
+  overflow: hidden;
+  transition: all 0.2s ease;
+}
+
+.episode-card:hover {
+  border-color: #cbd5e1;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+}
+
+.episode-card.expanded {
+  border-color: #93c5fd;
+  box-shadow: 0 4px 16px rgba(37, 99, 235, 0.08);
+}
+
+.episode-card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 14px 18px;
+  cursor: pointer;
+  user-select: none;
+  background: #ffffff;
+  transition: background 0.15s ease;
+}
+
+.episode-card.expanded .episode-card-header {
+  background: #f8fbff;
+  border-bottom: 1px solid #e2e8f0;
+}
+
+.header-left {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex: 1;
+  min-width: 0;
+}
+
+.ep-toggle-arrow {
+  color: #64748b;
+  display: flex;
+  align-items: center;
+}
+
+.ep-number-tag {
+  display: inline-flex;
+  align-items: center;
+  padding: 3px 8px;
+  font-size: 11px;
+  font-weight: 700;
+  color: #1d4ed8;
+  background: #eff6ff;
+  border-radius: 6px;
+  border: 1px solid #bfdbfe;
+  flex-shrink: 0;
+  letter-spacing: 0.02em;
+}
+
+.ep-title {
+  margin: 0;
+  font-size: 15px;
+  font-weight: 600;
+  color: #1e293b;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.header-right {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-shrink: 0;
+}
+
+.tag-pill {
+  display: inline-flex;
+  align-items: center;
+  padding: 3px 8px;
+  border-radius: 4px;
+  font-size: 11px;
+  font-weight: 500;
+}
+
+.tag-pill-success {
+  background: #f0fdf4;
+  color: #166534;
+  border: 1px solid #bbf7d0;
+}
+
+.tag-pill-dim {
+  background: #f1f5f9;
+  color: #64748b;
+  border: 1px solid #e2e8f0;
+}
+
+.tag-pill-info {
+  background: #eff6ff;
+  color: #1e40af;
+  border: 1px solid #dbeafe;
+}
+
+.icon-action-btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  border-radius: 6px;
+  border: 1px solid transparent;
+  background: transparent;
+  color: #64748b;
+  cursor: pointer;
+  transition: all 0.15s ease;
+}
+
+.icon-action-btn:hover {
+  background: #f1f5f9;
+  color: #1e293b;
+  border-color: #e2e8f0;
+}
+
+.icon-action-btn.delete-btn:hover {
+  background: #fef2f2;
+  color: #dc2626;
+  border-color: #fecaca;
+}
+
+/* 展开后的分集主体 */
+.episode-card-body {
+  padding: 18px 20px;
+  background: #ffffff;
+}
+
+.ep-summary-text {
+  margin: 0 0 16px 0;
+  padding: 8px 12px;
+  background: #f8fafc;
+  border-radius: 6px;
+  font-size: 12px;
+  color: #475569;
+  line-height: 1.5;
+  border-left: 3px solid #3b82f6;
+}
+
+.ep-body-grid {
+  display: grid;
+  grid-template-columns: 1.1fr 0.9fr;
+  gap: 18px;
+}
+
+.ep-notes-box,
+.ep-assets-box {
+  display: flex;
+  flex-direction: column;
+  background: #fbfcfe;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  padding: 14px 16px;
+}
+
+.box-title-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 10px;
+}
+
+.box-title-row .title-left {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 6px;
+  color: #1e293b;
+  font-size: 13px;
+  font-weight: 600;
+}
+
+.dirty-badge {
+  font-size: 11px;
+  padding: 2px 6px;
+  background: #fef3c7;
+  color: #b45309;
+  border-radius: 4px;
+  font-weight: 600;
+}
+
+.ep-notes-textarea {
+  width: 100%;
+  border: 1px solid #cbd5e1;
+  border-radius: 6px;
+  padding: 10px 12px;
+  font-size: 13px;
+  line-height: 1.6;
+  color: #1e293b;
+  background: #ffffff;
+  resize: vertical;
+  min-height: 120px;
+  transition: border-color 0.15s ease;
+}
+
+.ep-notes-textarea:focus {
+  outline: none;
+  border-color: #2563eb;
+  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
+}
+
+.notes-footer-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 8px;
+}
+
+.notes-footer-row small {
+  font-size: 11px;
+  color: #94a3b8;
+}
+
+.save-notes-btn {
+  padding: 4px 12px;
+  font-size: 12px;
+  gap: 4px;
+}
+
+/* 分集素材列表 */
+.ep-assets-list {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  margin-bottom: 12px;
+  max-height: 160px;
+  overflow-y: auto;
+}
+
+.ep-asset-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 6px 10px;
+  background: #ffffff;
+  border: 1px solid #e2e8f0;
+  border-radius: 6px;
+  transition: all 0.15s ease;
+}
+
+.ep-asset-item:hover {
+  border-color: #cbd5e1;
+  background: #f8fafc;
+}
+
+.asset-item-icon {
+  color: #2563eb;
+  flex-shrink: 0;
+}
+
+.ep-asset-info {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.ep-asset-info strong {
+  font-size: 12px;
+  font-weight: 500;
+  color: #1e293b;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.ep-asset-info span {
+  font-size: 10px;
+  color: #94a3b8;
+}
+
+.ep-upload-zone {
+  margin-top: auto;
+}
+
+.ep-file-label {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  padding: 8px 12px;
+  background: #eff6ff;
+  border: 1px dashed #93c5fd;
+  border-radius: 6px;
+  font-size: 12px;
+  font-weight: 500;
+  color: #1d4ed8;
+  cursor: pointer;
+  transition: all 0.15s ease;
+}
+
+.ep-file-label:hover {
+  background: #dbeafe;
+  border-color: #60a5fa;
+}
+
+.ep-pending-files {
+  margin-top: 8px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.pending-chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+
+.pending-chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 3px 8px;
+  background: #f1f5f9;
+  border-radius: 4px;
+  font-size: 11px;
+  color: #334155;
+}
+
+.pending-chip button {
+  background: transparent;
+  border: 0;
+  color: #94a3b8;
+  cursor: pointer;
+  display: flex;
+  padding: 0;
+}
+
+.pending-chip button:hover {
+  color: #ef4444;
+}
+
+/* 空分集提示卡片 */
+.episodes-empty-card {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 48px 24px;
+  background: #ffffff;
+  border: 2px dashed #cbd5e1;
+  border-radius: 12px;
+  text-align: center;
+}
+
+.episodes-empty-card .empty-icon {
+  color: #94a3b8;
+  margin-bottom: 12px;
+}
+
+.episodes-empty-card h3 {
+  margin: 0 0 6px 0;
+  font-size: 16px;
+  color: #1e293b;
+}
+
+.episodes-empty-card p {
+  margin: 0 0 20px 0;
+  font-size: 13px;
+  color: #64748b;
+  max-width: 440px;
+}
+
+.empty-actions {
+  display: flex;
+  gap: 12px;
+}
+
+/* 全局资产卡片 */
+.global-assets-panel {
+  padding: 24px 28px;
+  background: #ffffff;
+  border-radius: 14px;
+  border: 1px solid #e2e8f0;
+}
+
+/* 提交统计提示 */
+.submit-summary-info {
+  margin-bottom: 10px;
+}
+
+.submit-summary-info p {
+  margin: 0;
+  font-size: 12px;
+  color: #64748b;
+  line-height: 1.5;
+}
+
+/* ==================== 分集弹窗 EpisodeModal 样式 ==================== */
+.modal-dialog.episode-modal {
+  width: min(680px, 95vw);
+  max-height: 90vh;
+  background: #ffffff;
+  border-radius: 16px;
+  box-shadow: 0 25px 60px rgba(0, 0, 0, 0.22);
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  border: 1px solid #e2e8f0;
+}
+
+.modal-tabs-header {
+  display: flex;
+  background: #f1f5f9;
+  padding: 6px 8px;
+  gap: 6px;
+  border-bottom: 1px solid #e2e8f0;
+}
+
+.modal-tab-btn {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 7px;
+  padding: 8px 14px;
+  font-size: 13px;
+  font-weight: 500;
+  color: #64748b;
+  background: transparent;
+  border: 0;
+  border-radius: 7px;
+  cursor: pointer;
+  transition: all 0.15s ease;
+}
+
+.modal-tab-btn:hover {
+  color: #1e293b;
+  background: rgba(255, 255, 255, 0.6);
+}
+
+.modal-tab-btn.active {
+  background: #ffffff;
+  color: #2563eb;
+  font-weight: 600;
+  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
+}
+
+.episode-modal-body {
+  padding: 22px 24px;
+  overflow-y: auto;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  flex: 1;
+}
+
+.grid-two-inputs {
+  display: grid;
+  grid-template-columns: 120px 1fr;
+  gap: 14px;
+}
+
+@media (max-width: 540px) {
+  .grid-two-inputs {
+    grid-template-columns: 1fr;
+  }
+}
+
+.episode-modal-body .form-label {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  font-size: 13px;
+  font-weight: 500;
+  color: #334155;
+}
+
+.episode-modal-body .form-label span {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.episode-modal-body .form-label.required span::after {
+  content: " *";
+  color: #ef4444;
+}
+
+.episode-modal-body input,
+.episode-modal-body textarea {
+  width: 100%;
+  border: 1px solid #cbd5e1;
+  border-radius: 8px;
+  padding: 10px 14px;
+  background: #f8fafc;
+  font-size: 14px;
+  color: #0f172a;
+  transition: all 0.15s ease;
+  box-sizing: border-box;
+  font-family: inherit;
+}
+
+.episode-modal-body input:focus,
+.episode-modal-body textarea:focus {
+  outline: none;
+  border-color: #3b82f6;
+  background: #ffffff;
+  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
+}
+
+.episode-modal-body textarea {
+  resize: vertical;
+  min-height: 100px;
+  line-height: 1.55;
+}
+
+.label-with-badge {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.optional-fields-toggle {
+  display: flex;
+  justify-content: flex-start;
+  padding-top: 2px;
+}
+
+.toggle-optional-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  background: transparent;
+  border: 1px dashed #cbd5e1;
+  border-radius: 8px;
+  padding: 8px 14px;
+  font-size: 12.5px;
+  color: #64748b;
+  cursor: pointer;
+  transition: all 0.15s ease;
+  font-weight: 500;
+  width: 100%;
+  justify-content: center;
+}
+
+.toggle-optional-btn:hover {
+  background: #f8fafc;
+  border-color: #94a3b8;
+  color: #334155;
+}
+
+.optional-fields-container {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  padding: 14px;
+  background: #f8fafc;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  animation: fadeIn 0.18s ease-out;
+}
+
+.lecture-stats-badge {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  font-size: 11px;
+  color: #2563eb;
+  background: #eff6ff;
+  border: 1px solid #dbeafe;
+  padding: 3px 8px;
+  border-radius: 6px;
+  font-weight: 500;
+}
+
+.batch-notice-banner {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+  padding: 12px 14px;
+  background: #eff6ff;
+  border: 1px solid #bfdbfe;
+  border-radius: 10px;
+  color: #1e40af;
+}
+
+.batch-notice-banner svg {
+  flex-shrink: 0;
+  margin-top: 2px;
+  color: #2563eb;
+}
+
+.batch-notice-banner strong {
+  display: block;
+  font-size: 13px;
+  margin-bottom: 2px;
+  color: #1e3a8a;
+}
+
+.batch-notice-banner p {
+  margin: 0;
+  font-size: 12px;
+  color: #3b82f6;
+  line-height: 1.45;
+}
+
+.quick-count-tags {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-top: 4px;
+}
+
+.count-pill {
+  padding: 6px 12px;
+  font-size: 12px;
+  font-weight: 600;
+  border-radius: 6px;
+  border: 1px solid #cbd5e1;
+  background: #ffffff;
+  color: #334155;
+  cursor: pointer;
+  transition: all 0.15s ease;
+}
+
+.count-pill:hover {
+  border-color: #94a3b8;
+  background: #f8fafc;
+}
+
+.count-pill.active {
+  background: #2563eb;
+  color: #ffffff;
+  border-color: #2563eb;
+  box-shadow: 0 2px 6px rgba(37, 99, 235, 0.25);
+}
+
+.batch-preview-box {
+  background: #f8fafc;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  padding: 12px 16px;
+}
+
+.preview-title {
+  display: block;
+  font-size: 12px;
+  font-weight: 600;
+  color: #475569;
+  margin-bottom: 8px;
+}
+
+.batch-preview-list {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  max-height: 160px;
+  overflow-y: auto;
+}
+
+.preview-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  background: #ffffff;
+  border: 1px solid #edf2f7;
+  padding: 6px 10px;
+  border-radius: 6px;
+}
+
+.preview-ep-tag {
+  font-size: 10px;
+  font-weight: 700;
+  color: #2563eb;
+  background: #eff6ff;
+  border: 1px solid #dbeafe;
+  padding: 2px 6px;
+  border-radius: 4px;
+  flex-shrink: 0;
+}
+
+.preview-ep-title {
+  font-size: 12px;
+  color: #1e293b;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.preview-row-more {
+  font-size: 11px;
+  color: #94a3b8;
+  text-align: center;
+  padding: 4px 0;
+}
+
+/* ==================== 制作交付与管理后台分集面板 ==================== */
+
+.deliverable-category-wrapper {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+.episode-deliverable-group {
+  background: #ffffff;
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  padding: 12px 14px;
+}
+
+.episode-deliverable-group .group-title {
+  margin: 0 0 10px 0;
+  font-size: 13px;
+  font-weight: 600;
+  color: #1e293b;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.ep-badge-small {
+  font-size: 10px;
+  padding: 2px 6px;
+  background: #dbeafe;
+  color: #1e40af;
+  border-radius: 4px;
+  font-weight: 700;
+}
+
+/* 管理员端分集列表与交付 */
+.admin-deliv-group-card {
+  background: #f8fafc;
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  padding: 12px 14px;
+  margin-bottom: 10px;
+}
+
+.admin-deliv-group-card .group-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 8px;
+}
+
+.deliv-upload-row {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  flex-wrap: wrap;
+}
+
+.target-ep-select-label {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 12px;
+  color: #475569;
+}
+
+.target-ep-select-label select {
+  padding: 6px 10px;
+  border-radius: 6px;
+  border: 1px solid #cbd5e1;
+  font-size: 12px;
+  background: #ffffff;
+  color: #1e293b;
+}
+
+.admin-episodes-list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  margin-bottom: 16px;
+}
+
+.admin-episode-card {
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  overflow: hidden;
+  background: #ffffff;
+}
+
+.admin-ep-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 10px 14px;
+  background: #f8fafc;
+  cursor: pointer;
+  user-select: none;
+}
+
+.admin-ep-header .header-left {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.ep-badge-pill {
+  font-size: 10px;
+  font-weight: 700;
+  padding: 2px 6px;
+  background: #edf3ff;
+  color: var(--blue);
+  border-radius: 4px;
+}
+
+.text-pill {
+  font-size: 11px;
+  color: #64748b;
+  background: #ffffff;
+  padding: 2px 6px;
+  border: 1px solid #e2e8f0;
+  border-radius: 4px;
+}
+
+.admin-ep-body {
+  padding: 14px;
+  border-top: 1px solid #e2e8f0;
+  background: #ffffff;
+}
+
+.admin-ep-summary {
+  margin: 0 0 10px 0;
+  font-size: 12px;
+  color: #475569;
+}
+
+.admin-ep-notes-display {
+  margin-bottom: 12px;
+}
+
+.notes-label {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 12px;
+  font-weight: 600;
+  color: #334155;
+  margin-bottom: 6px;
+}
+
+.notes-pre-content {
+  margin: 0;
+  padding: 10px 12px;
+  background: #f8fafc;
+  border: 1px solid #e2e8f0;
+  border-radius: 6px;
+  font-family: inherit;
+  font-size: 12px;
+  line-height: 1.6;
+  color: #1e293b;
+  white-space: pre-wrap;
+  word-break: break-word;
+  max-height: 200px;
+  overflow-y: auto;
+}
+
+.admin-ep-assets-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+  gap: 8px;
+}
+
+.admin-mini-asset-card {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 10px;
+  background: #f8fafc;
+  border: 1px solid #e2e8f0;
+  border-radius: 6px;
+}
+
+.mini-asset-info {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.mini-asset-info strong {
+  font-size: 11px;
+  font-weight: 500;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.mini-asset-info small {
+  font-size: 10px;
+  color: #94a3b8;
+}
+
+.sidebar-episode-outline {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  max-height: 260px;
+  overflow-y: auto;
+}
+
+.outline-row {
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+  padding: 6px 8px;
+  background: #f8fafc;
+  border-radius: 6px;
+  border: 1px solid #edf2f7;
+}
+
+.outline-badge {
+  font-size: 10px;
+  font-weight: 700;
+  padding: 2px 5px;
+  background: #eff6ff;
+  color: #1d4ed8;
+  border-radius: 4px;
+  flex-shrink: 0;
+}
+
+.outline-info {
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+}
+
+.outline-info strong {
+  font-size: 11px;
+  color: #1e293b;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.outline-info small {
+  font-size: 10px;
+  color: #94a3b8;
+}
+
+.sub-section-title {
+  margin: 16px 0 10px 0;
+  font-size: 13px;
+  color: #334155;
+}
+
+.ep-subrow {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  padding: 6px 8px;
+  background: #ffffff;
+  border-radius: 6px;
+  border: 1px solid #e2e8f0;
+  margin-bottom: 4px;
+}
+
+.ep-subrow span {
+  font-size: 12px;
+  font-weight: 600;
+  color: #1e293b;
+}
+
+.ep-subrow small {
+  font-size: 11px;
+  color: #64748b;
+}
+
+/* ==================== 危险操作区 (Danger Zone) & 删除课程弹窗 ==================== */
+.course-danger-section {
+  margin-top: 36px;
+  margin-bottom: 24px;
+}
+
+.danger-zone-card {
+  background: #ffffff;
+  border: 1px solid #fee2e2;
+  border-radius: 14px;
+  padding: 20px 24px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 20px;
+  box-shadow: 0 2px 10px rgba(239, 68, 68, 0.04);
+}
+
+@media (max-width: 640px) {
+  .danger-zone-card {
+    flex-direction: column;
+    align-items: flex-start;
+  }
+}
+
+.danger-zone-title-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 4px;
+}
+
+.danger-icon {
+  color: #dc2626;
+}
+
+.danger-zone-info h4 {
+  margin: 0;
+  font-size: 15px;
+  font-weight: 600;
+  color: #991b1b;
+}
+
+.danger-zone-info p {
+  margin: 0;
+  font-size: 13px;
+  color: #64748b;
+  line-height: 1.5;
+}
+
+.danger-button {
+  background: #ef4444;
+  color: #ffffff;
+  border: 1px solid #dc2626;
+  border-radius: var(--button-radius, 8px);
+  min-height: 38px;
+  padding: 0 18px;
+  font-size: 13px;
+  font-weight: 500;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 7px;
+  cursor: pointer;
+  white-space: nowrap;
+  transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
+  box-shadow: 0 2px 8px rgba(239, 68, 68, 0.22);
+}
+
+.danger-button:hover:not(:disabled) {
+  background: #dc2626;
+  border-color: #b91c1c;
+  transform: translateY(-1px);
+  box-shadow: 0 4px 12px rgba(220, 38, 38, 0.28);
+}
+
+.danger-button:active:not(:disabled) {
+  transform: translateY(0);
+}
+
+.danger-button:disabled,
+.danger-button.disabled {
+  background: #f1f5f9 !important;
+  border-color: #e2e8f0 !important;
+  color: #94a3b8 !important;
+  opacity: 0.8 !important;
+  cursor: not-allowed !important;
+  box-shadow: none !important;
+  transform: none !important;
+}
+
+/* 删除课程弹窗 */
+.modal-dialog.delete-course-modal {
+  width: min(480px, 95vw);
+  background: #ffffff;
+  border-radius: 16px;
+  box-shadow: 0 25px 60px rgba(0, 0, 0, 0.2);
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  border: 1px solid #fee2e2;
+  animation: modalPop 0.22s cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.modal-header.danger-header {
+  background: #fff5f5;
+  border-bottom: 1px solid #fee2e2;
+  padding: 18px 22px;
+}
+
+.danger-header-title {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.danger-icon-badge {
+  width: 38px;
+  height: 38px;
+  border-radius: 50%;
+  background: #fee2e2;
+  color: #dc2626;
+  display: grid;
+  place-items: center;
+  flex-shrink: 0;
+}
+
+.modal-badge.danger-badge {
+  background: #fee2e2;
+  color: #b91c1c;
+}
+
+.delete-modal-body {
+  padding: 20px 22px;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.danger-alert-card {
+  background: #fff5f5;
+  border: 1px solid #fed7d7;
+  border-radius: 8px;
+  padding: 12px 14px;
+}
+
+.danger-alert-card p {
+  margin: 0;
+  font-size: 13px;
+  color: #742a2a;
+  line-height: 1.55;
+}
+
+.target-course-name-box {
+  margin: 8px 0 10px 0;
+  padding: 8px 12px;
+  background: #f8fafc;
+  border: 1px dashed #cbd5e1;
+  border-radius: 6px;
+  user-select: all;
+}
+
+.target-course-name-box code {
+  color: #0f172a;
+  font-weight: 600;
+  font-size: 13px;
+  font-family: inherit;
+  word-break: break-all;
+}
+
+.delete-confirm-input {
+  width: 100%;
+  height: 40px;
+  border-radius: 8px;
+  border: 1.5px solid #cbd5e1;
+  padding: 0 12px;
+  font-size: 14px;
+  background: #ffffff;
+  color: #0f172a;
+  transition: border-color 0.2s, box-shadow 0.2s;
+  box-sizing: border-box;
+}
+
+.delete-confirm-input:focus {
+  border-color: #ef4444;
+  outline: none;
+  box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15);
+}
+
+.danger-submit-btn {
+  min-height: 38px;
+  padding: 0 20px;
+}
 

+ 68 - 0
src/types.ts

@@ -18,6 +18,8 @@ export interface User {
 
 export interface CourseAsset {
   id: string
+  courseId?: string
+  episodeId?: string | null
   originalName: string
   size: number
   mimeType: string
@@ -35,6 +37,8 @@ export interface Instructor {
 
 export interface CourseDeliverable {
   id: string
+  courseId?: string
+  episodeId?: string | null
   originalName: string
   size: number
   mimeType: string
@@ -42,6 +46,19 @@ export interface CourseDeliverable {
   createdAt: string
 }
 
+export interface Episode {
+  id: string
+  courseId: string
+  episodeNumber: number
+  title: string
+  summary: string
+  lectureNotes: string
+  createdAt: string
+  updatedAt?: string
+  assets?: CourseAsset[]
+  deliverables?: CourseDeliverable[]
+}
+
 export interface Course {
   id: string
   userId?: string
@@ -52,6 +69,7 @@ export interface Course {
   status: CourseStatus
   productionNotes?: string
   assets: CourseAsset[]
+  episodes: Episode[]
   instructors: Instructor[]
   deliverables?: CourseDeliverable[]
   creator?: User
@@ -68,3 +86,53 @@ export interface AdminStats {
   completedCount: number
   draftCount: number
 }
+
+export interface PresetInstructor {
+  name: string
+  organization: string
+  introduction: string
+  sampleImageFile?: string
+}
+
+export interface PresetAsset {
+  originalName: string
+  mimeType: string
+  sizeText: string
+  description?: string
+  episodeNumber?: number
+}
+
+export interface PresetEpisode {
+  episodeNumber: number
+  title: string
+  summary?: string
+  lectureNotes?: string
+  assets?: PresetAsset[]
+}
+
+export interface CourseTemplate {
+  id: string
+  name: string
+  category: string
+  audience?: string
+  tags: string[]
+  badge?: string
+  summary: string
+  description: string
+  keySpecs: {
+    resolution: string
+    aspectRatio: string
+    speechSpeed: string
+    presenterRatio: string
+    voiceTone: string
+  }
+  taskBrief: {
+    goals: string[]
+    focalPoints: string[]
+    cameraAndPacing: string[]
+    acceptanceCriteria: string[]
+  }
+  instructors: PresetInstructor[]
+  assets: PresetAsset[]
+  episodes?: PresetEpisode[]
+}

+ 6 - 0
src/vite-env.d.ts

@@ -0,0 +1,6 @@
+/// <reference types="vite/client" />
+
+declare module "*.jpg" { const src: string; export default src; }
+declare module "*.jpeg" { const src: string; export default src; }
+declare module "*.png" { const src: string; export default src; }
+declare module "*.webp" { const src: string; export default src; }

+ 1 - 1
tsconfig.app.tsbuildinfo

@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/authcontext.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/components/brand.tsx","./src/components/layout.tsx","./src/components/statusbadge.tsx","./src/pages/authpage.tsx","./src/pages/courseworkspace.tsx","./src/pages/dashboard.tsx","./src/pages/newcourse.tsx","./src/pages/profile.tsx","./src/pages/admin/admincoursedetail.tsx","./src/pages/admin/admincourses.tsx","./src/pages/admin/admindashboard.tsx","./src/pages/admin/adminlayout.tsx","./src/pages/admin/adminloginpage.tsx","./src/pages/admin/adminusers.tsx"],"version":"5.7.3"}
+{"root":["./src/app.tsx","./src/authcontext.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/addinstructormodal.tsx","./src/components/brand.tsx","./src/components/deletecoursemodal.tsx","./src/components/episodemodal.tsx","./src/components/instructorimagefield.tsx","./src/components/layout.tsx","./src/components/statusbadge.tsx","./src/data/coursetemplates.ts","./src/pages/authpage.tsx","./src/pages/courseworkspace.tsx","./src/pages/dashboard.tsx","./src/pages/newcourse.tsx","./src/pages/profile.tsx","./src/pages/admin/admincoursedetail.tsx","./src/pages/admin/admincourses.tsx","./src/pages/admin/admindashboard.tsx","./src/pages/admin/adminlayout.tsx","./src/pages/admin/adminloginpage.tsx","./src/pages/admin/adminusers.tsx"],"version":"5.7.3"}

+ 1 - 1
tsconfig.node.tsbuildinfo

@@ -1 +1 @@
-{"root":["./vite.config.ts","./server/db.ts","./server/index.ts","./server/migrate.ts"],"version":"5.7.3"}
+{"root":["./vite.config.ts","./server/db.ts","./server/index.ts","./server/migrate.ts","./server/templates.ts"],"version":"5.7.3"}

+ 1 - 1
vite.config.ts

@@ -3,5 +3,5 @@ import react from '@vitejs/plugin-react'
 
 export default defineConfig({
   plugins: [react()],
-  server: { port: 5173, proxy: { '/api': 'http://localhost:3001', '/uploads': 'http://localhost:3001' } },
+  server: { port: 5173, proxy: { '/api': 'http://localhost:3001', '/uploads': 'http://localhost:3001', '/sample': 'http://localhost:3001' } },
 })