commit 17f0c8c8ff441eba73b59b4872e840adde403bf0 Author: ogt Date: Fri Jul 3 00:36:01 2026 +0800 chore: restore initial vtuber source snapshot diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5e20f52 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/vtuber" +API_PORT=4000 +NEXT_PUBLIC_API_BASE_URL=http://localhost:4000/api +APP_DOMAIN=vtuber.wooo.work +NEXT_PUBLIC_LIVE_STREAM_URL=https://videos.pexels.com/video-files/7297923/7297923-uhd_3840_2160_30fps.mp4 +NEXT_PUBLIC_REAL_HOST_AVATAR_URL=https://videos.pexels.com/video-files/7297923/7297923-uhd_3840_2160_30fps.mp4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..054aa5e --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +node_modules +.next +dist +coverage +.env +.env.* +!/.env.example +!.env.example +!/.env.sample +!/.env.local +*.tsbuildinfo +pnpm-lock.yaml +yarn.lock +package-lock.json +tmp +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..acf723e --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# VTuber + +VTuber monorepo demo for live commerce (virtual streamer) workflow: + +- `apps/web`:Next.js 前台(商品瀏覽、直播頁、購物車) +- `apps/admin`:Next.js 後台(商品、訂單、庫存) +- `apps/api`:NestJS 後端 API +- `packages/db`:Prisma schema 與資料庫工具 +- `packages/ui`:共享 UI 元件 + +## 快速開始 + +```bash +cd VTuber +git init +npm install +npm run db:generate +npm run db:push # 需先啟動 PostgreSQL 並設定 DATABASE_URL +npm run seed + +npm run dev +``` + +### 常用指令 + +- `npm run dev`:啟動 web/admin/api 開發伺服器 +- 開發與示範域名: + - `APP_DOMAIN=vtuber.wooo.work`(預設生產網域) + +- `npm run build`:打包三個 app +- `npm run lint`:執行 lint +- `npm run test`:執行 API 測試(目前為 API 空殼,日後補上) +- `npm run seed`:建立 demo 資料(10 商品、1 直播間、20 則留言) +- `npm run db:generate`:重新產生 Prisma client +- `npm run db:push`:依 schema 同步 DB 結構 +- `npm run db:migrate`:產生 migration 檔(可選) + +## 日誌總覽 + +- Day 1:Monorepo + Prisma + Seed(10 商品、20 留言) +- Day 2:前台商品頁、商品頁、購物車與結帳 +- Day 3:直播 Demo(左主播、右留言、下方主推) +- Day 4:後台商品/訂單/庫存/作業台 +- Day 5:AI 主播問答 API(價格、庫存、尺寸、物流) +- Day 6:真人 / 虛擬直播場景與動畫 placeholder +- Day 7:部署 + 端到端流程驗證 + +### Day 3:直播 Demo + +- 開啟 `/live/demo` 進入直播頁(預設會讀取直播間場景流與作業資訊) +- 左側為虛擬主播區(可替換 video/avatar) +- 右側為留言輸入與列表 +- 下方為主推商品與「立即購買」 + +## 主播角色與作業流程(MVP) + +- 主持人(HOST):直播主講、回覆問題、引導下單 +- 場控/導播(PRODUCER):轉場與畫面節奏控制 +- 導演(DIRECTOR):逐字稿節奏、回合節奏與上鏡順序控管 +- 小編/編輯(EDITOR):腳本文案、備稿、字幕與直播素材 +- 小助手(SUPPORT):留言回覆、加購/售後疑問回應 +- 倉儲(WAREHOUSE):實體庫存核對、出貨前置 +- 行銷(MARKETING):促銷節奏、優惠提醒、活動執行 +- 運維(OPERATOR):直播間啟關、連線監控(預留) + +主流程(可操作): +1) 開播前:建立直播間與角色指派,完成 3~5 個主打腳本節點與核對清單; +2) 直播中:開播/留言互動/AI 詢問回覆; +3) 結帳:在直播頁可直接「加入購物車」或「立即購買」建立 mock 訂單; +4) 結束後:在後台回顧腳本勾選與清單完成度。 + +## 正式環境推版(建議 188) + +1. 在正式機先放好 `VTuber` repo,並複製 `deploy/.env.prod.example` 為 `deploy/.env.prod` +2. 填寫 `deploy/.env.prod` +3. 本機執行推版腳本: +```bash +chmod +x deploy/deploy-prod.sh +cp deploy/.env.prod.example deploy/.env.prod +./deploy/deploy-prod.sh +``` + +若你要直接做到「推版 + 內外網可用性檢查」一次完成,可直接執行: + +```bash +./scripts/deploy-and-verify-vtuber110.sh +``` + +這支指令會先跑 `deploy-prod.sh`,再自動做 `/live/demo` 與 DNS/憑證健康檢查,部署完直接知道是否已恢復。 +若外網 DNS 還是指到舊站,腳本會直接回傳失敗並停止,避免把錯誤版本上線。 + +4. 正式環境驗證: + - `curl http://localhost:3000`(前台) + - `curl http://localhost:3001`(後台) + - `curl http://localhost:4000/api/health`(API) + - `curl http://localhost:3000/live/demo`(直播頁) +- `curl -ksS https://vtuber.wooo.work/live/demo`(正式網域直播頁) +- `curl -ksS https://vtuber.wooo.work/zh-TW/live/demo`(正式網域繁中直播頁) + +或者直接一鍵執行(包含部署驗證+兩條正式網域路徑): + +```bash +./scripts/verify-live-domain-110.sh +``` + + 如果你要直接在正式網域驗證,請先確認反向代理已將 443/80 轉到對應服務。 + +### 110 正式站快速可用性檢查 + +可直接執行: + +```bash +./scripts/check-vtuber-offline-110.sh vtuber.wooo.work 114.32.151.246 +``` + +內容會回報: +- DNS A/AAAA 解析是否指到 114.32.151.246 +- TLS 憑證 Subject / Issuer / 到期日 / SAN +- `/live/demo` 及 `/zh-TW/live/demo` 直連、SNI 指向、IP+Host 直走、純 IP 訪問 5 種路徑結果 +- 若回應非 200,腳本會直接列為失敗(`404` 不會再被誤判為通過) +- 失敗項目對應的提醒(例如要檢查哪個 DNS A Record) + +可加上 `CHECK_STRICT_MODE=1` 強制回傳非 0 讓流水線判斷失敗(預設已啟用)。 + +### 110 Nginx 404 保底導回設定 + +如果你要直接在 110 套用 404 fallback,可先確認 `deploy/.env.prod` 已填好,再執行: + +```bash +./deploy/apply-nginx-110-live-fallback.sh +``` + +腳本行為: +- 將 `deploy/vtuber-nginx-110-live-fallback.conf` 上傳到 + `/etc/nginx/snippets/vtuber-nginx-110-live-fallback.conf` +- 於 `NGINX_SERVER_CONFIG`(預設 `/etc/nginx/sites-available/${APP_DOMAIN}`) + 插入 snippet include +- `nginx -t` 後重新載入 Nginx + +## 環境變數 + +請複製根目錄 `.env.example` 為 `.env` 並修改: + +- `DATABASE_URL` +- `API_PORT` +- `NEXT_PUBLIC_API_BASE_URL` + +各 app 也保留 `.env.example` 於專案下可作為參考。 diff --git a/apps/admin/.env.example b/apps/admin/.env.example new file mode 100644 index 0000000..7d39d6b --- /dev/null +++ b/apps/admin/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_API_BASE_URL=http://localhost:4000/api +NEXT_PUBLIC_APP_DOMAIN=vtuber.wooo.work diff --git a/apps/admin/.eslintrc.json b/apps/admin/.eslintrc.json new file mode 100644 index 0000000..957cd15 --- /dev/null +++ b/apps/admin/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals"] +} diff --git a/apps/admin/Dockerfile b/apps/admin/Dockerfile new file mode 100644 index 0000000..577fecf --- /dev/null +++ b/apps/admin/Dockerfile @@ -0,0 +1,27 @@ +FROM node:20 AS builder +WORKDIR /app + +COPY package.json ./ +COPY tsconfig.base.json ./ +COPY apps/web ./apps/web +COPY apps/admin ./apps/admin +COPY packages ./packages + +RUN npm install +RUN npm run build:admin + +FROM node:20 +WORKDIR /app +ENV NODE_ENV=production + +COPY --from=builder /app/package.json /app/package.json +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/apps/admin/.next ./apps/admin/.next +COPY --from=builder /app/apps/admin/package.json ./apps/admin/package.json +COPY --from=builder /app/apps/admin/public ./apps/admin/public +COPY --from=builder /app/apps/admin/next.config.js ./apps/admin/next.config.js +COPY --from=builder /app/apps/admin/next-env.d.ts ./apps/admin/next-env.d.ts + +ENV NEXT_TELEMETRY_DISABLED=1 +EXPOSE 3001 +CMD ["npm","run","start","-w","apps/admin"] diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/apps/admin/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js new file mode 100644 index 0000000..91ef62f --- /dev/null +++ b/apps/admin/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, +}; + +module.exports = nextConfig; diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..d6601dd --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,25 @@ +{ + "name": "@vtuber/admin", + "private": true, + "version": "0.1.0", + "scripts": { + "dev": "next dev -p 3001", + "build": "next build", + "start": "next start -p 3001", + "lint": "next lint" + }, + "dependencies": { + "@vtuber/ui": "*", + "next": "^14.2.5", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "eslint": "^8.57.0", + "eslint-config-next": "^14.2.5", + "typescript": "^5.5.0" + } +} diff --git a/apps/admin/public/.gitkeep b/apps/admin/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/admin/src/app/globals.css b/apps/admin/src/app/globals.css new file mode 100644 index 0000000..10f7c7d --- /dev/null +++ b/apps/admin/src/app/globals.css @@ -0,0 +1,14 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: #222; + background: #f4f6ff; +} + +main { + max-width: 960px; + margin: 0 auto; +} diff --git a/apps/admin/src/app/inventory/page.tsx b/apps/admin/src/app/inventory/page.tsx new file mode 100644 index 0000000..85324b8 --- /dev/null +++ b/apps/admin/src/app/inventory/page.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { FormEvent, useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; +import { apiFetch } from '../../lib/api'; + +type Product = { + id: string; + name: string; + slug: string; +}; + +type InventoryRecord = { + id: string; + quantity: number; + reserved: number; +}; + +type ProductVariant = { + id: string; + sku: string; + name: string; + size: string | null; + price: number; + product: Product; + inventory: InventoryRecord | null; +}; + +export default function InventoryPage() { + const [variants, setVariants] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [quantityInputs, setQuantityInputs] = useState>({}); + + const loadInventory = useCallback(async () => { + setLoading(true); + setError(''); + try { + const data = await apiFetch('/admin/inventories'); + setVariants(data); + setQuantityInputs((prev) => { + const next = { ...prev }; + data.forEach((variant) => { + const key = variant.id; + const quantity = variant.inventory?.quantity ?? 0; + next[key] = String(quantity); + }); + return next; + }); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '載入庫存失敗'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadInventory(); + }, [loadInventory]); + + async function handleSubmit(event: FormEvent, variantId: string) { + event.preventDefault(); + setError(''); + const quantity = Number.parseInt(quantityInputs[variantId] ?? '0', 10); + if (Number.isNaN(quantity) || quantity < 0) { + setError('庫存必須是非負整數'); + return; + } + try { + await apiFetch(`/admin/inventories/${variantId}`, { + method: 'PUT', + body: JSON.stringify({ quantity }), + }); + await loadInventory(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '更新庫存失敗'); + } + } + + return ( +
+

庫存管理

+ 回後台首頁 + {error ?

{error}

: null} + {loading ?

載入中...

: null} + {variants.length === 0 ? ( +

目前無庫存資料

+ ) : ( +
+ {variants.map((variant) => ( +
+

{variant.product.name}

+

+ 規格: + {variant.name} + {' / '} + SKU: {variant.sku} + {' / '} + 尺寸:{variant.size || '-'} + {' / '} + 售價:NT$ {variant.price} +

+

剩餘庫存:{variant.inventory?.quantity ?? 0}

+

預留:{variant.inventory?.reserved ?? 0}

+
handleSubmit(event, variant.id)} + style={{ display: 'flex', gap: 8, alignItems: 'center' }} + > + + setQuantityInputs((prev) => ({ + ...prev, + [variant.id]: event.target.value, + })) + } + type="number" + min={0} + placeholder="庫存數" + /> + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx new file mode 100644 index 0000000..7872eaf --- /dev/null +++ b/apps/admin/src/app/layout.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import '../app/globals.css'; + +export const metadata = { + title: 'VTuber Admin', + description: 'VTuber Admin Console', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/apps/admin/src/app/operations/page.tsx b/apps/admin/src/app/operations/page.tsx new file mode 100644 index 0000000..08b90d6 --- /dev/null +++ b/apps/admin/src/app/operations/page.tsx @@ -0,0 +1,785 @@ +'use client'; + +import { FormEvent, useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; +import { apiFetch } from '../../lib/api'; + +type TeamRole = + | 'HOST' + | 'PRODUCER' + | 'DIRECTOR' + | 'EDITOR' + | 'SUPPORT' + | 'WAREHOUSE' + | 'MARKETING' + | 'OPERATOR'; + +type TeamMember = { + id: string; + displayName: string; + role: TeamRole; + phone: string | null; + email: string | null; + isActive: boolean; +}; + +type LiveRoom = { + id: string; + title: string; + hostName: string; + status: string; + isActive: boolean; + streamUrl: string | null; +}; + +type LiveRoomAssignment = { + id: string; + role: TeamRole; + isPrimary: boolean; + shiftStartAt: string | null; + shiftEndAt: string | null; + note: string | null; + teamMember: { + id: string; + displayName: string; + role: TeamRole; + phone: string | null; + email: string | null; + }; +}; + +type LiveRoomScript = { + id: string; + sequence: number; + cue: string | null; + title: string; + content: string; + ownerRole: TeamRole | null; + targetProductId: string | null; + isDone: boolean; +}; + +type LiveRoomChecklistItem = { + id: string; + title: string; + ownerRole: TeamRole; + description: string | null; + isRequired: boolean; + isDone: boolean; + note: string | null; +}; + +type RoomOps = { + room: { + id: string; + title: string; + hostName: string; + status: string; + liveGoal: string | null; + streamUrl: string | null; + }; + assignments: LiveRoomAssignment[]; + scripts: LiveRoomScript[]; + checklists: LiveRoomChecklistItem[]; +}; + +const TEAM_ROLES: TeamRole[] = [ + 'HOST', + 'PRODUCER', + 'DIRECTOR', + 'EDITOR', + 'SUPPORT', + 'WAREHOUSE', + 'MARKETING', + 'OPERATOR', +]; + +export default function OperationsPage() { + const [rooms, setRooms] = useState([]); + const [members, setMembers] = useState([]); + const [selectedRoomId, setSelectedRoomId] = useState(''); + const [roomOps, setRoomOps] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const [newMember, setNewMember] = useState({ + displayName: '', + role: TEAM_ROLES[0] as TeamRole, + phone: '', + email: '', + }); + + const [newAssignment, setNewAssignment] = useState({ + teamMemberId: '', + role: TEAM_ROLES[0] as TeamRole, + isPrimary: false, + note: '', + }); + + const [newScript, setNewScript] = useState({ + sequence: '1', + cue: '', + title: '', + content: '', + ownerRole: TEAM_ROLES[0] as TeamRole, + targetProductId: '', + }); + + const [newChecklist, setNewChecklist] = useState({ + title: '', + ownerRole: TEAM_ROLES[0] as TeamRole, + description: '', + isRequired: true, + note: '', + }); + + const loadRooms = useCallback(async () => { + try { + const data = await apiFetch('/live-rooms'); + setRooms(data); + if (!selectedRoomId && data.length > 0) { + const initial = data.find((room) => room.isActive) || data[0]; + setSelectedRoomId(initial.id); + } + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '載入直播間失敗'); + } + }, [selectedRoomId]); + + const loadMembers = useCallback(async () => { + try { + const data = await apiFetch('/admin/team-members'); + setMembers(data); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '載入人員失敗'); + } + }, []); + + const loadOperations = useCallback(async () => { + if (!selectedRoomId) return; + setLoading(true); + setError(''); + try { + const data = await apiFetch(`/admin/live-rooms/${selectedRoomId}/operations`); + setRoomOps(data); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '載入直播作業失敗'); + } finally { + setLoading(false); + } + }, [selectedRoomId]); + + useEffect(() => { + void loadRooms(); + void loadMembers(); + }, [loadRooms, loadMembers]); + + useEffect(() => { + void loadOperations(); + }, [loadOperations]); + + const selectedRoom = rooms.find((room) => room.id === selectedRoomId) ?? null; + + async function handleCreateMember(event: FormEvent) { + event.preventDefault(); + setError(''); + + if (!newMember.displayName.trim()) { + setError('請輸入人員姓名'); + return; + } + + try { + await apiFetch('/admin/team-members', { + method: 'POST', + body: JSON.stringify({ + displayName: newMember.displayName.trim(), + role: newMember.role, + phone: newMember.phone.trim() || undefined, + email: newMember.email.trim() || undefined, + }), + }); + setNewMember({ + displayName: '', + role: TEAM_ROLES[0], + phone: '', + email: '', + }); + await loadMembers(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '新增人員失敗'); + } + } + + async function handleDeleteMember(id: string) { + setError(''); + if (!confirm('要移除這位人員?')) return; + try { + await apiFetch(`/admin/team-members/${id}`, { method: 'DELETE' }); + await loadMembers(); + if (selectedRoomId) { + await loadOperations(); + } + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '刪除人員失敗'); + } + } + + async function assignMember(event: FormEvent) { + event.preventDefault(); + setError(''); + + if (!selectedRoomId || !newAssignment.teamMemberId) { + setError('請先選直播間並指定人員'); + return; + } + + try { + await apiFetch(`/admin/live-rooms/${selectedRoomId}/operations/assignments`, { + method: 'POST', + body: JSON.stringify({ + teamMemberId: newAssignment.teamMemberId, + role: newAssignment.role, + isPrimary: newAssignment.isPrimary, + note: newAssignment.note || undefined, + }), + }); + setNewAssignment((prev) => ({ + ...prev, + teamMemberId: '', + isPrimary: false, + note: '', + })); + await loadOperations(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '指派角色失敗'); + } + } + + async function handleRemoveAssignment(assignmentId: string) { + setError(''); + if (!selectedRoomId) return; + if (!confirm('要移除這筆分工?')) return; + + try { + await apiFetch(`/admin/live-rooms/${selectedRoomId}/operations/assignments/${assignmentId}`, { + method: 'DELETE', + }); + await loadOperations(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '移除分工失敗'); + } + } + + async function handleCreateScript(event: FormEvent) { + event.preventDefault(); + setError(''); + + if (!selectedRoomId) { + setError('請先選直播間'); + return; + } + + const sequence = Number.parseInt(newScript.sequence, 10); + if (!newScript.title.trim() || !newScript.content.trim() || Number.isNaN(sequence) || sequence <= 0) { + setError('請輸入腳本標題/內容,並填入正整數序號'); + return; + } + + try { + await apiFetch(`/admin/live-rooms/${selectedRoomId}/operations/scripts`, { + method: 'POST', + body: JSON.stringify({ + sequence, + cue: newScript.cue.trim() || undefined, + title: newScript.title.trim(), + content: newScript.content.trim(), + ownerRole: newScript.ownerRole, + targetProductId: newScript.targetProductId.trim() || undefined, + }), + }); + setNewScript((prev) => ({ + ...prev, + cue: '', + title: '', + content: '', + targetProductId: '', + })); + await loadOperations(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '新增腳本失敗'); + } + } + + async function handleToggleScript(scriptId: string, isDone: boolean) { + setError(''); + try { + await apiFetch(`/admin/live-ops/scripts/${scriptId}`, { + method: 'PATCH', + body: JSON.stringify({ isDone }), + }); + await loadOperations(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '更新腳本狀態失敗'); + } + } + + async function handleCreateChecklist(event: FormEvent) { + event.preventDefault(); + setError(''); + + if (!selectedRoomId) { + setError('請先選直播間'); + return; + } + + if (!newChecklist.title.trim()) { + setError('任務標題必填'); + return; + } + + try { + await apiFetch(`/admin/live-rooms/${selectedRoomId}/operations/checklists`, { + method: 'POST', + body: JSON.stringify({ + title: newChecklist.title.trim(), + ownerRole: newChecklist.ownerRole, + description: newChecklist.description.trim() || undefined, + isRequired: newChecklist.isRequired, + note: newChecklist.note.trim() || undefined, + }), + }); + setNewChecklist({ + title: '', + ownerRole: TEAM_ROLES[0], + description: '', + isRequired: true, + note: '', + }); + await loadOperations(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '新增任務失敗'); + } + } + + async function handleToggleChecklist(itemId: string, isDone: boolean) { + setError(''); + try { + await apiFetch(`/admin/live-ops/checklists/${itemId}`, { + method: 'PATCH', + body: JSON.stringify({ isDone }), + }); + await loadOperations(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '任務勾選失敗'); + } + } + + async function handleRoomStatusUpdate(status: string) { + if (!selectedRoomId) return; + setError(''); + try { + await apiFetch(`/admin/live-rooms/${selectedRoomId}/status`, { + method: 'PATCH', + body: JSON.stringify({ status }), + }); + await loadRooms(); + await loadOperations(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '更新直播狀態失敗'); + } + } + + return ( +
+

直播作業台

+ 回後台首頁 + {error ?

{error}

: null} + +
+

直播間

+ + + {selectedRoom ? ( +
+ 目前直播: + {selectedRoom.title} + {' / '} + 狀態: + {selectedRoom.status} + {' / '} + 人員區: + {selectedRoom.isActive ? '開啟' : '關閉'} +
+ {['PLANNING', 'LIVE', 'FINISHED', 'CANCELLED'].map((status) => ( + + ))} +
+
+ ) : null} +
+ +
+

人員管理

+
+
+ + setNewMember((prev) => ({ ...prev, displayName: event.target.value })) + } + placeholder="顯示名稱" + /> + + + setNewMember((prev) => ({ ...prev, phone: event.target.value })) + } + placeholder="電話(可空)" + /> + + setNewMember((prev) => ({ ...prev, email: event.target.value })) + } + placeholder="信箱(可空)" + /> + +
+
+ +
+ {members.length === 0 ?

目前無可用人員

: null} + {members.map((member) => ( +
+
+ {member.displayName} + {' / '} + 角色: + {member.role} + {' / '} + 狀態: + {member.isActive ? '啟用' : '停用'} +
+
+ {member.phone ? `${member.phone} / ` : ''} + {member.email || '-'} +
+ +
+ ))} +
+
+ +
+

直播角色分工(實時作業)

+
+
+ + + +
+ + setNewAssignment((prev) => ({ ...prev, note: event.target.value })) + } + placeholder="派工備註" + /> + +
+ +
+ {roomOps?.assignments.map((assignment) => ( +
+
+ {assignment.teamMember.displayName} + {' / '} + 指派為: + {assignment.role} + {assignment.isPrimary ? '(主責)' : ''} +
+
+ {assignment.teamMember.phone || '-'} + {' / '} + {assignment.teamMember.email || '-'} + {' / '} + {assignment.note || '無備註'} +
+ +
+ ))} + {roomOps?.assignments.length === 0 ?

尚未指派角色

: null} +
+
+ +
+
+

直播腳本

+
+
+ + setNewScript((prev) => ({ ...prev, sequence: event.target.value })) + } + placeholder="順序" + type="number" + min={1} + /> + + setNewScript((prev) => ({ ...prev, cue: event.target.value })) + } + placeholder="時間點" + /> + +
+ + setNewScript((prev) => ({ ...prev, title: event.target.value })) + } + placeholder="標題" + /> +