chore: restore initial vtuber source snapshot

This commit is contained in:
ogt
2026-07-03 00:36:01 +08:00
commit 17f0c8c8ff
85 changed files with 7677 additions and 0 deletions

6
.env.example Normal file
View File

@@ -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

16
.gitignore vendored Normal file
View File

@@ -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

148
README.md Normal file
View File

@@ -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 1Monorepo + Prisma + Seed10 商品、20 留言)
- Day 2前台商品頁、商品頁、購物車與結帳
- Day 3直播 Demo左主播、右留言、下方主推
- Day 4後台商品/訂單/庫存/作業台
- Day 5AI 主播問答 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` 於專案下可作為參考。

2
apps/admin/.env.example Normal file
View File

@@ -0,0 +1,2 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:4000/api
NEXT_PUBLIC_APP_DOMAIN=vtuber.wooo.work

View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}

27
apps/admin/Dockerfile Normal file
View File

@@ -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"]

5
apps/admin/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
module.exports = nextConfig;

25
apps/admin/package.json Normal file
View File

@@ -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"
}
}

View File

View File

@@ -0,0 +1,14 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #222;
background: #f4f6ff;
}
main {
max-width: 960px;
margin: 0 auto;
}

View File

@@ -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<ProductVariant[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [quantityInputs, setQuantityInputs] = useState<Record<string, string>>({});
const loadInventory = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await apiFetch<ProductVariant[]>('/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<HTMLFormElement>, 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 (
<main style={{ padding: 24, fontFamily: 'system-ui' }}>
<h1></h1>
<Link href="/admin"></Link>
{error ? <p style={{ color: 'crimson' }}>{error}</p> : null}
{loading ? <p>...</p> : null}
{variants.length === 0 ? (
<p></p>
) : (
<div style={{ display: 'grid', gap: 12 }}>
{variants.map((variant) => (
<article
key={variant.id}
style={{
border: '1px solid #ddd',
borderRadius: 10,
padding: 12,
}}
>
<h3>{variant.product.name}</h3>
<p>
{variant.name}
{' / '}
SKU: {variant.sku}
{' / '}
{variant.size || '-'}
{' / '}
NT$ {variant.price}
</p>
<p>{variant.inventory?.quantity ?? 0}</p>
<p>{variant.inventory?.reserved ?? 0}</p>
<form
onSubmit={(event) => handleSubmit(event, variant.id)}
style={{ display: 'flex', gap: 8, alignItems: 'center' }}
>
<input
value={quantityInputs[variant.id] ?? '0'}
onChange={(event) =>
setQuantityInputs((prev) => ({
...prev,
[variant.id]: event.target.value,
}))
}
type="number"
min={0}
placeholder="庫存數"
/>
<button type="submit"></button>
</form>
</article>
))}
</div>
)}
</main>
);
}

View File

@@ -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 (
<html lang="zh-Hant">
<body>{children}</body>
</html>
);
}

View File

@@ -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<LiveRoom[]>([]);
const [members, setMembers] = useState<TeamMember[]>([]);
const [selectedRoomId, setSelectedRoomId] = useState('');
const [roomOps, setRoomOps] = useState<RoomOps | null>(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<LiveRoom[]>('/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<TeamMember[]>('/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<RoomOps>(`/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<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 (
<main style={{ padding: 24, fontFamily: 'system-ui' }}>
<h1></h1>
<Link href="/admin"></Link>
{error ? <p style={{ color: 'crimson' }}>{error}</p> : null}
<section style={{ marginBottom: 20 }}>
<h2></h2>
<select
value={selectedRoomId}
onChange={(event) => setSelectedRoomId(event.target.value)}
>
<option value=""></option>
{rooms.map((room) => (
<option key={room.id} value={room.id}>
{room.title}
{' / '}
{room.hostName}
{' / '}
{room.status}
</option>
))}
</select>
{selectedRoom ? (
<div style={{ marginTop: 8 }}>
<strong></strong>
{selectedRoom.title}
{' / '}
{selectedRoom.status}
{' / '}
{selectedRoom.isActive ? '開啟' : '關閉'}
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{['PLANNING', 'LIVE', 'FINISHED', 'CANCELLED'].map((status) => (
<button
key={status}
type="button"
onClick={() => handleRoomStatusUpdate(status)}
disabled={selectedRoom.status === status}
>
{status}
</button>
))}
</div>
</div>
) : null}
</section>
<section style={{ marginBottom: 28, borderBottom: '1px solid #ddd', paddingBottom: 12 }}>
<h2></h2>
<form onSubmit={handleCreateMember} style={{ display: 'grid', gap: 8, maxWidth: 680 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<input
value={newMember.displayName}
onChange={(event) =>
setNewMember((prev) => ({ ...prev, displayName: event.target.value }))
}
placeholder="顯示名稱"
/>
<select
value={newMember.role}
onChange={(event) =>
setNewMember((prev) => ({ ...prev, role: event.target.value as TeamRole }))
}
>
{TEAM_ROLES.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
<input
value={newMember.phone}
onChange={(event) =>
setNewMember((prev) => ({ ...prev, phone: event.target.value }))
}
placeholder="電話(可空)"
/>
<input
value={newMember.email}
onChange={(event) =>
setNewMember((prev) => ({ ...prev, email: event.target.value }))
}
placeholder="信箱(可空)"
/>
<button type="submit"></button>
</div>
</form>
<div style={{ marginTop: 12 }}>
{members.length === 0 ? <p></p> : null}
{members.map((member) => (
<article
key={member.id}
style={{
border: '1px solid #ddd',
borderRadius: 8,
padding: 8,
marginBottom: 8,
}}
>
<div>
{member.displayName}
{' / '}
{member.role}
{' / '}
{member.isActive ? '啟用' : '停用'}
</div>
<div style={{ color: '#666', fontSize: 13 }}>
{member.phone ? `${member.phone} / ` : ''}
{member.email || '-'}
</div>
<button type="button" onClick={() => handleDeleteMember(member.id)}>
</button>
</article>
))}
</div>
</section>
<section style={{ marginBottom: 28 }}>
<h2></h2>
<form onSubmit={assignMember} style={{ display: 'grid', gap: 8, maxWidth: 760 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<select
value={newAssignment.teamMemberId}
onChange={(event) =>
setNewAssignment((prev) => ({ ...prev, teamMemberId: event.target.value }))
}
>
<option value=""></option>
{members.map((member) => (
<option key={member.id} value={member.id}>
{member.displayName}
{''}
{member.role}
{''}
</option>
))}
</select>
<select
value={newAssignment.role}
onChange={(event) =>
setNewAssignment((prev) => ({ ...prev, role: event.target.value as TeamRole }))
}
>
{TEAM_ROLES.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
<label style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
<input
type="checkbox"
checked={newAssignment.isPrimary}
onChange={(event) =>
setNewAssignment((prev) => ({ ...prev, isPrimary: event.target.checked }))
}
/>
</label>
</div>
<input
value={newAssignment.note}
onChange={(event) =>
setNewAssignment((prev) => ({ ...prev, note: event.target.value }))
}
placeholder="派工備註"
/>
<button type="submit"></button>
</form>
<div style={{ marginTop: 12 }}>
{roomOps?.assignments.map((assignment) => (
<article
key={assignment.id}
style={{
border: '1px solid #ddd',
borderRadius: 8,
padding: 8,
marginBottom: 8,
}}
>
<div>
{assignment.teamMember.displayName}
{' / '}
{assignment.role}
{assignment.isPrimary ? '(主責)' : ''}
</div>
<div style={{ color: '#666', fontSize: 13 }}>
{assignment.teamMember.phone || '-'}
{' / '}
{assignment.teamMember.email || '-'}
{' / '}
{assignment.note || '無備註'}
</div>
<button type="button" onClick={() => handleRemoveAssignment(assignment.id)}>
</button>
</article>
))}
{roomOps?.assignments.length === 0 ? <p></p> : null}
</div>
</section>
<section style={{ marginBottom: 28, display: 'grid', gap: 20, gridTemplateColumns: '1fr 1fr' }}>
<div>
<h2></h2>
<form onSubmit={handleCreateScript} style={{ display: 'grid', gap: 8 }}>
<div style={{ display: 'flex', gap: 8 }}>
<input
value={newScript.sequence}
onChange={(event) =>
setNewScript((prev) => ({ ...prev, sequence: event.target.value }))
}
placeholder="順序"
type="number"
min={1}
/>
<input
value={newScript.cue}
onChange={(event) =>
setNewScript((prev) => ({ ...prev, cue: event.target.value }))
}
placeholder="時間點"
/>
<select
value={newScript.ownerRole}
onChange={(event) =>
setNewScript((prev) => ({ ...prev, ownerRole: event.target.value as TeamRole }))
}
>
{TEAM_ROLES.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
</div>
<input
value={newScript.title}
onChange={(event) =>
setNewScript((prev) => ({ ...prev, title: event.target.value }))
}
placeholder="標題"
/>
<textarea
value={newScript.content}
onChange={(event) =>
setNewScript((prev) => ({ ...prev, content: event.target.value }))
}
rows={3}
placeholder="台本內容"
/>
<input
value={newScript.targetProductId}
onChange={(event) =>
setNewScript((prev) => ({ ...prev, targetProductId: event.target.value }))
}
placeholder="目標商品ID可空"
/>
<button type="submit"></button>
</form>
{roomOps?.scripts.map((script) => (
<article
key={script.id}
style={{
border: '1px solid #ddd',
borderRadius: 8,
padding: 8,
marginBottom: 8,
marginTop: 12,
}}
>
<div>
{script.sequence}
{' / '}
{script.cue || '-'}
{' / '}
{script.title}
</div>
<p style={{ marginTop: 4 }}>{script.content}</p>
<p style={{ color: '#666', fontSize: 12 }}>
{script.ownerRole || '-'} / ID
{script.targetProductId || '-'}
</p>
<button
type="button"
onClick={() => handleToggleScript(script.id, !script.isDone)}
>
{script.isDone ? '標記未完成' : '標記完成'}
</button>
</article>
))}
{roomOps?.scripts.length === 0 ? <p></p> : null}
</div>
<div>
<h2></h2>
<form onSubmit={handleCreateChecklist} style={{ display: 'grid', gap: 8 }}>
<div style={{ display: 'flex', gap: 8 }}>
<input
value={newChecklist.title}
onChange={(event) =>
setNewChecklist((prev) => ({ ...prev, title: event.target.value }))
}
placeholder="任務標題"
/>
<select
value={newChecklist.ownerRole}
onChange={(event) =>
setNewChecklist((prev) => ({ ...prev, ownerRole: event.target.value as TeamRole }))
}
>
{TEAM_ROLES.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
</div>
<textarea
value={newChecklist.description}
onChange={(event) =>
setNewChecklist((prev) => ({ ...prev, description: event.target.value }))
}
rows={2}
placeholder="任務描述"
/>
<input
value={newChecklist.note}
onChange={(event) =>
setNewChecklist((prev) => ({ ...prev, note: event.target.value }))
}
placeholder="備註"
/>
<label style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
<input
type="checkbox"
checked={newChecklist.isRequired}
onChange={(event) =>
setNewChecklist((prev) => ({ ...prev, isRequired: event.target.checked }))
}
/>
</label>
<button type="submit"></button>
</form>
{roomOps?.checklists.map((item) => (
<article
key={item.id}
style={{
border: '1px solid #ddd',
borderRadius: 8,
padding: 8,
marginBottom: 8,
marginTop: 12,
}}
>
<div>
{item.title}
{' / '}
{item.ownerRole}
{' / '}
{item.isRequired ? '必要' : '非必要'}
</div>
{item.description ? <p style={{ marginTop: 4 }}>{item.description}</p> : null}
<p style={{ color: '#666', fontSize: 12 }}>{item.note || ''}</p>
<button
type="button"
onClick={() => handleToggleChecklist(item.id, !item.isDone)}
>
{item.isDone ? '取消完成' : '標記完成'}
</button>
</article>
))}
{roomOps?.checklists.length === 0 ? <p></p> : null}
</div>
</section>
{loading ? <p>...</p> : null}
</main>
);
}

View File

@@ -0,0 +1,137 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../lib/api';
type Product = {
id: string;
name: string;
};
type Variant = {
id: string;
name: string;
product: Product;
};
type OrderItem = {
id: string;
quantity: number;
unitPrice: number;
variant: Variant;
};
type Order = {
id: string;
orderNumber: string;
status: string;
totalAmount: number;
createdAt: string;
items: OrderItem[];
};
const ORDER_STATUS_OPTIONS = ['PENDING', 'PAID', 'SHIPPED', 'CANCELLED'] as const;
export default function AdminOrdersPage() {
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const loadOrders = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await apiFetch<Order[]>('/admin/orders');
setOrders(data);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '載入訂單失敗');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadOrders();
}, [loadOrders]);
async function handleStatusChange(orderId: string, status: string) {
setError('');
try {
await apiFetch(`/admin/orders/${orderId}/status`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
await loadOrders();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '更新訂單狀態失敗');
}
}
return (
<main style={{ padding: 24, fontFamily: 'system-ui' }}>
<h1></h1>
<Link href="/admin"></Link>
{error ? <p style={{ color: 'crimson' }}>{error}</p> : null}
{loading ? <p>...</p> : null}
{orders.length === 0 ? (
<p></p>
) : (
orders.map((order) => (
<article
key={order.id}
style={{
border: '1px solid #ddd',
borderRadius: 10,
padding: 12,
marginBottom: 12,
}}
>
<h3>
{order.orderNumber}
{' '}
/
{' '}
{new Date(order.createdAt).toLocaleString()}
</h3>
<p>
NT$
{order.totalAmount}
</p>
<div>
<select
value={order.status}
onChange={(event) => handleStatusChange(order.id, event.target.value)}
>
{ORDER_STATUS_OPTIONS.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</div>
<ul>
{order.items.map((item) => (
<li key={item.id}>
{item.variant.product.name}
{' '}
/ {item.variant.name}
{' '}
x
{' '}
{item.quantity}
{' '}
NT$
{item.unitPrice}
/
</li>
))}
</ul>
</article>
))
)}
</main>
);
}

View File

@@ -0,0 +1,24 @@
import Link from 'next/link';
export default function AdminHomePage() {
return (
<main style={{ padding: 32, fontFamily: 'system-ui' }}>
<h1>VTuber Demo</h1>
<p>Day 4 </p>
<ul>
<li>
<Link href="/products"></Link>
</li>
<li>
<Link href="/orders"></Link>
</li>
<li>
<Link href="/inventory"></Link>
</li>
<li>
<Link href="/operations"></Link>
</li>
</ul>
</main>
);
}

View File

@@ -0,0 +1,321 @@
'use client';
import { useCallback, FormEvent, useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../lib/api';
type Inventory = {
quantity: number;
};
type ProductVariant = {
id: string;
sku: string;
name: string;
size: string | null;
price: number;
inventory: Inventory | null;
};
type Product = {
id: string;
name: string;
slug: string;
description: string | null;
imageUrl: string | null;
basePrice: number;
isActive: boolean;
variants: ProductVariant[];
};
type NewProductInput = {
name: string;
slug: string;
description: string;
imageUrl: string;
basePrice: string;
};
type EditProductInput = {
name: string;
slug: string;
description: string;
imageUrl: string;
basePrice: string;
isActive: boolean;
};
const toNumber = (value: string) => Number.parseInt(value, 10);
export default function AdminProductsPage() {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState<NewProductInput>({
name: '',
slug: '',
description: '',
imageUrl: '',
basePrice: '0',
});
const [editingId, setEditingId] = useState<string | null>(null);
const [editing, setEditing] = useState<EditProductInput>({
name: '',
slug: '',
description: '',
imageUrl: '',
basePrice: '0',
isActive: true,
});
const loadProducts = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await apiFetch<Product[]>('/admin/products');
setProducts(data);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '載入商品失敗');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadProducts();
}, [loadProducts]);
async function handleCreate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError('');
const payload = {
name: form.name.trim(),
slug: form.slug.trim(),
description: form.description.trim() || undefined,
imageUrl: form.imageUrl.trim() || undefined,
basePrice: toNumber(form.basePrice),
};
if (!payload.name || !payload.slug || Number.isNaN(payload.basePrice) || payload.basePrice < 0) {
setError('請填寫名稱、slug 且售價需為非負整數');
return;
}
try {
await apiFetch<Product>('/admin/products', {
method: 'POST',
body: JSON.stringify(payload),
});
setForm({
name: '',
slug: '',
description: '',
imageUrl: '',
basePrice: '0',
});
await loadProducts();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '建立商品失敗');
}
}
function startEdit(product: Product) {
setEditingId(product.id);
setEditing({
name: product.name,
slug: product.slug,
description: product.description ?? '',
imageUrl: product.imageUrl ?? '',
basePrice: String(product.basePrice),
isActive: product.isActive,
});
}
async function handleUpdate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!editingId) return;
setError('');
const basePrice = toNumber(editing.basePrice);
if (!editing.name.trim() || !editing.slug.trim() || Number.isNaN(basePrice) || basePrice < 0) {
setError('請填寫名稱、slug 且售價需為非負整數');
return;
}
try {
await apiFetch<Product>(`/admin/products/${editingId}`, {
method: 'PUT',
body: JSON.stringify({
name: editing.name.trim(),
slug: editing.slug.trim(),
description: editing.description.trim() || null,
imageUrl: editing.imageUrl.trim() || null,
basePrice,
isActive: editing.isActive,
}),
});
setEditingId(null);
await loadProducts();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '更新商品失敗');
}
}
async function handleDelete(productId: string) {
setError('');
if (!confirm('要刪除這筆商品?')) return;
try {
await apiFetch<unknown>(`/admin/products/${productId}`, {
method: 'DELETE',
});
await loadProducts();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '刪除商品失敗');
}
}
return (
<main style={{ padding: 24, fontFamily: 'system-ui' }}>
<h1></h1>
<Link href="/admin"></Link>
{error ? <p style={{ color: 'crimson' }}>{error}</p> : null}
{loading ? <p>...</p> : null}
<section style={{ marginBottom: 24 }}>
<h2></h2>
<form onSubmit={handleCreate} style={{ display: 'grid', gap: 8, maxWidth: 540 }}>
<input
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder="商品名稱"
/>
<input
value={form.slug}
onChange={(event) => setForm((prev) => ({ ...prev, slug: event.target.value }))}
placeholder="slug唯一"
/>
<input
value={form.description}
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
placeholder="描述"
/>
<input
value={form.imageUrl}
onChange={(event) => setForm((prev) => ({ ...prev, imageUrl: event.target.value }))}
placeholder="圖片網址(可空)"
/>
<input
value={form.basePrice}
onChange={(event) => setForm((prev) => ({ ...prev, basePrice: event.target.value }))}
type="number"
min={0}
placeholder="售價"
/>
<button type="submit"></button>
</form>
</section>
<section style={{ borderTop: '1px solid #ddd', paddingTop: 20 }}>
<h2></h2>
{products.length === 0 ? (
<p></p>
) : (
products.map((product) => (
<article
key={product.id}
style={{
border: '1px solid #ddd',
borderRadius: 10,
padding: 12,
marginBottom: 12,
}}
>
<h3>
{product.name}
{' '}
({product.slug})
</h3>
<p>NT$ {product.basePrice}</p>
<p>{product.description || '-'}</p>
<p>{product.isActive ? '啟用' : '停用'}</p>
<p>{product.variants.length}</p>
<ul>
{product.variants.map((variant) => (
<li key={variant.id}>
{variant.name} / {variant.size || '-'} / NT$ {variant.price} / SKU:
{variant.sku} / {variant.inventory?.quantity ?? 0}
</li>
))}
</ul>
{editingId === product.id ? (
<form onSubmit={handleUpdate} style={{ display: 'grid', gap: 8, marginTop: 12 }}>
<h4></h4>
<input
value={editing.name}
onChange={(event) => setEditing((prev) => ({ ...prev, name: event.target.value }))}
placeholder="商品名稱"
/>
<input
value={editing.slug}
onChange={(event) => setEditing((prev) => ({ ...prev, slug: event.target.value }))}
placeholder="slug"
/>
<input
value={editing.description}
onChange={(event) =>
setEditing((prev) => ({ ...prev, description: event.target.value }))
}
placeholder="描述"
/>
<input
value={editing.imageUrl}
onChange={(event) => setEditing((prev) => ({ ...prev, imageUrl: event.target.value }))}
placeholder="圖片網址"
/>
<input
value={editing.basePrice}
onChange={(event) =>
setEditing((prev) => ({ ...prev, basePrice: event.target.value }))
}
type="number"
min={0}
placeholder="售價"
/>
<label>
<input
type="checkbox"
checked={editing.isActive}
onChange={(event) =>
setEditing((prev) => ({ ...prev, isActive: event.target.checked }))
}
/>
</label>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit"></button>
<button type="button" onClick={() => setEditingId(null)}>
</button>
</div>
</form>
) : (
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" onClick={() => startEdit(product)}>
</button>
<button type="button" onClick={() => void handleDelete(product.id)}>
</button>
</div>
)}
</article>
))
)}
</section>
</main>
);
}

23
apps/admin/src/lib/api.ts Normal file
View File

@@ -0,0 +1,23 @@
export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:4000/api';
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: {
'Content-Type': 'application/json',
...(init?.headers || {}),
},
...init,
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `API error: ${response.status}`);
}
if (response.status === 204) {
return {} as T;
}
return response.json() as Promise<T>;
}

19
apps/admin/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"strict": true,
"noEmit": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

2
apps/api/.env.example Normal file
View File

@@ -0,0 +1,2 @@
PORT=4000
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/vtuber

8
apps/api/.eslintrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"parserOptions": {
"project": ["./tsconfig.json"]
},
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"]
}

15
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:20
WORKDIR /app
COPY package.json ./
COPY tsconfig.base.json ./
COPY packages ./packages
COPY apps/api ./apps/api
RUN npm install
RUN npm run db:generate -w packages/db
RUN npm run build:api
ENV NODE_ENV=production
EXPOSE 4000
CMD ["npm","run","start","-w","apps/api"]

8
apps/api/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"assets": ["**/*.json"],
"watchAssets": true
}
}

35
apps/api/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "@vtuber/api",
"private": true,
"version": "0.1.0",
"scripts": {
"start": "node dist/main.js",
"build": "tsc -p tsconfig.build.json",
"start:dev": "ts-node -r tsconfig-paths/register src/main.ts",
"lint": "eslint '{src,test}/**/*.ts' --fix",
"test": "echo \"No tests yet\""
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"@vtuber/db": "*",
"@prisma/client": "^5.18.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"@typescript-eslint/eslint-plugin": "^8.21.0",
"@typescript-eslint/parser": "^8.21.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.1",
"tsconfig-paths": "^4.2.0",
"prisma": "^5.18.0",
"ts-node": "^10.9.2",
"typescript": "^5.5.0"
}
}

View File

@@ -0,0 +1,494 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Patch,
Query,
} from '@nestjs/common';
import { AppService } from './app.service';
type OrderStatus = 'PENDING' | 'PAID' | 'SHIPPED' | 'CANCELLED';
type LiveRoomStatus = 'PLANNING' | 'LIVE' | 'FINISHED' | 'CANCELLED';
type TeamRole =
| 'HOST'
| 'PRODUCER'
| 'DIRECTOR'
| 'EDITOR'
| 'SUPPORT'
| 'WAREHOUSE'
| 'MARKETING'
| 'OPERATOR';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('health')
health() {
return this.appService.health();
}
@Get('products')
async listProducts() {
return this.appService.listProducts();
}
@Get('products/:id')
async getProduct(@Param('id') id: string) {
const product = await this.appService.getProduct(id);
if (!product) {
throw new BadRequestException('找不到商品');
}
return product;
}
@Get('live-rooms')
async listLiveRooms() {
return this.appService.listLiveRooms();
}
@Get('live-rooms/:id/messages')
async listLiveMessages(
@Param('id') id: string,
@Query('limit') limit?: string,
) {
const parsedLimit = Number(limit ?? 30);
const validLimit = Number.isNaN(parsedLimit) ? 30 : parsedLimit;
return this.appService.listLiveMessages(id, validLimit);
}
@Get('live-rooms/:id/operations')
async getLiveRoomOperations(@Param('id') id: string) {
return this.appService.getLiveRoomOperations(id);
}
@Post('live-rooms/:id/messages')
async postLiveMessage(
@Param('id') id: string,
@Body()
body: {
userName?: string;
message?: string;
productVariantId?: string;
},
) {
const userName = body.userName?.trim() ?? '';
const message = body.message?.trim() ?? '';
if (!userName || !message) {
throw new BadRequestException('userName 與 message 是必要欄位');
}
return this.appService.postLiveMessage({
liveRoomId: id,
userName,
message,
productVariantId: body.productVariantId?.trim(),
});
}
@Get('admin/team-members')
async listTeamMembers() {
return this.appService.listTeamMembers();
}
@Post('admin/team-members')
async createTeamMember(
@Body()
body: {
displayName?: string;
role?: TeamRole;
nickname?: string;
phone?: string;
email?: string;
notes?: string;
},
) {
const displayName = body.displayName?.trim();
if (!displayName || !body.role) {
throw new BadRequestException('displayName 與 role 是必要欄位');
}
const result = await this.appService.createTeamMember({
displayName,
role: body.role,
nickname: body.nickname?.trim(),
phone: body.phone?.trim(),
email: body.email?.trim(),
notes: body.notes?.trim(),
});
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Delete('admin/team-members/:id')
async deleteTeamMember(@Param('id') id: string) {
return this.appService.removeTeamMember(id);
}
@Get('admin/products')
async listAdminProducts() {
return this.appService.listAdminProducts();
}
@Post('admin/products')
async createProduct(
@Body()
body: {
name: string;
slug: string;
description?: string;
imageUrl?: string;
basePrice: number;
liveRoomId?: string | null;
variants?: Array<{
sku: string;
name: string;
size?: string | null;
price: number;
stock: number;
}>;
},
) {
if (!body?.name?.trim() || !body?.slug?.trim()) {
throw new BadRequestException('name/slug/basePrice 為必要欄位');
}
const basePrice = Number(body.basePrice);
if (Number.isNaN(basePrice) || basePrice < 0) {
throw new BadRequestException('basePrice 必須是非負數');
}
return this.appService.createProduct({
name: body.name,
slug: body.slug,
description: body.description,
imageUrl: body.imageUrl,
basePrice,
liveRoomId: body.liveRoomId ?? null,
variants: body.variants,
});
}
@Put('admin/products/:id')
async updateProduct(
@Param('id') id: string,
@Body()
body: {
name?: string;
slug?: string;
description?: string | null;
imageUrl?: string | null;
basePrice?: number;
isActive?: boolean;
liveRoomId?: string | null;
},
) {
return this.appService.updateProduct(id, body);
}
@Delete('admin/products/:id')
async deleteProduct(@Param('id') id: string) {
return this.appService.deleteProduct(id);
}
@Get('admin/orders')
async listAdminOrders() {
return this.appService.listAdminOrders();
}
@Patch('admin/orders/:id/status')
async updateOrderStatus(
@Param('id') id: string,
@Body() body: { status: string },
) {
const normalizedStatus = body.status?.toUpperCase();
if (
!normalizedStatus ||
!(['PENDING', 'PAID', 'SHIPPED', 'CANCELLED'] as const).includes(
normalizedStatus as OrderStatus,
)
) {
throw new BadRequestException('status 僅能為 PENDING/PAID/SHIPPED/CANCELLED');
}
return this.appService.updateOrderStatus(id, normalizedStatus as OrderStatus);
}
@Get('admin/inventories')
async listInventories() {
return this.appService.listInventories();
}
@Put('admin/inventories/:variantId')
async updateInventory(
@Param('variantId') variantId: string,
@Body() body: { quantity: number },
) {
const quantity = Number(body.quantity);
if (Number.isNaN(quantity)) {
throw new BadRequestException('quantity 必須為數字');
}
const result = await this.appService.updateInventory(variantId, quantity);
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Get('admin/live-rooms/:id/operations')
async getLiveRoomOperationsAdmin(@Param('id') id: string) {
return this.appService.getLiveRoomOperations(id);
}
@Post('admin/live-rooms/:id/operations/assignments')
async createLiveRoomAssignment(
@Param('id') id: string,
@Body()
body: {
teamMemberId?: string;
role?: TeamRole;
isPrimary?: boolean;
shiftStartAt?: string;
shiftEndAt?: string;
note?: string;
},
) {
if (!body.teamMemberId || !body.role) {
throw new BadRequestException('teamMemberId 與 role 是必要欄位');
}
const result = await this.appService.createLiveRoomAssignment(id, {
teamMemberId: body.teamMemberId,
role: body.role,
isPrimary: !!body.isPrimary,
shiftStartAt: body.shiftStartAt,
shiftEndAt: body.shiftEndAt,
note: body.note?.trim(),
});
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Delete('admin/live-rooms/:id/operations/assignments/:assignmentId')
async removeLiveRoomAssignment(
@Param('id') liveRoomId: string,
@Param('assignmentId') assignmentId: string,
) {
return this.appService.removeLiveRoomAssignment(liveRoomId, assignmentId);
}
@Patch('admin/live-rooms/:id/status')
async updateLiveRoomStatus(
@Param('id') id: string,
@Body() body: { status?: LiveRoomStatus },
) {
if (!body.status) {
throw new BadRequestException('status 是必要欄位');
}
const result = await this.appService.updateLiveRoomStatus(id, body.status);
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Post('admin/live-rooms/:id/operations/scripts')
async createLiveRoomScript(
@Param('id') id: string,
@Body()
body: {
sequence?: number;
cue?: string;
title?: string;
content?: string;
ownerRole?: TeamRole;
targetProductId?: string;
},
) {
const payload = {
sequence: Number(body.sequence ?? 0),
cue: body.cue?.trim(),
title: body.title?.trim() ?? '',
content: body.content?.trim() ?? '',
ownerRole: body.ownerRole,
targetProductId: body.targetProductId?.trim(),
};
if (!payload.title || !payload.content) {
throw new BadRequestException('title/content 是必要欄位');
}
if (Number.isNaN(payload.sequence) || payload.sequence <= 0) {
throw new BadRequestException('sequence 必須是正整數');
}
const result = await this.appService.createLiveRoomScript(id, payload);
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Patch('admin/live-ops/scripts/:id')
async updateLiveRoomScript(
@Param('id') id: string,
@Body()
body: {
sequence?: number;
cue?: string;
title?: string;
content?: string;
ownerRole?: TeamRole;
targetProductId?: string;
isDone?: boolean;
},
) {
const result = await this.appService.updateLiveRoomScript(id, {
sequence: body.sequence,
cue: body.cue?.trim(),
title: body.title?.trim(),
content: body.content?.trim(),
ownerRole: body.ownerRole,
targetProductId: body.targetProductId?.trim(),
isDone: body.isDone,
});
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Post('admin/live-rooms/:id/operations/checklists')
async createLiveRoomChecklistItem(
@Param('id') id: string,
@Body()
body: {
title?: string;
ownerRole?: TeamRole;
description?: string;
isRequired?: boolean;
note?: string;
},
) {
const title = body.title?.trim() ?? '';
const ownerRole = body.ownerRole;
if (!title || !ownerRole) {
throw new BadRequestException('title/ownerRole 是必要欄位');
}
const payload = {
title,
description: body.description?.trim(),
isRequired: body.isRequired ?? true,
note: body.note?.trim(),
ownerRole,
};
const result = await this.appService.createLiveRoomChecklistItem(id, payload);
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Patch('admin/live-ops/checklists/:id')
async updateLiveRoomChecklistItem(
@Param('id') id: string,
@Body() body: { isDone?: boolean },
) {
const result = await this.appService.updateLiveRoomChecklistItem(id, {
isDone: body.isDone,
});
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Get('cart')
async getCart(@Query('sessionId') sessionId: string) {
return this.appService.getCartBySession(sessionId);
}
@Post('cart/items')
async addCartItem(
@Body()
body: {
sessionId?: string;
productVariantId?: string;
quantity?: number;
},
) {
if (!body?.sessionId) {
throw new BadRequestException('sessionId 是必要參數');
}
if (!body?.productVariantId) {
throw new BadRequestException('productVariantId 是必要參數');
}
const quantity = Number(body.quantity ?? 1);
if (Number.isNaN(quantity) || quantity <= 0) {
throw new BadRequestException('quantity 需為正整數');
}
const result = await this.appService.addCartItem({
sessionId: body.sessionId,
productVariantId: body.productVariantId,
quantity,
});
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Delete('cart/items/:id')
async removeCartItem(@Param('id') id: string) {
return this.appService.removeCartItem(id);
}
@Post('checkout')
async checkout(@Body() body: { sessionId?: string; liveRoomId?: string }) {
if (!body?.sessionId) {
throw new BadRequestException('sessionId 是必要參數');
}
const result = await this.appService.createMockCheckout({
sessionId: body.sessionId,
liveRoomId: body.liveRoomId,
});
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Post('ai/product-chat')
async productChat(
@Body()
body: {
userMessage?: string;
currentProductId?: string;
liveRoomId?: string;
},
) {
if (!body?.userMessage?.trim()) {
throw new BadRequestException('userMessage 是必要參數');
}
const result = await this.appService.answerProductChat({
userMessage: body.userMessage,
currentProductId: body.currentProductId,
liveRoomId: body.liveRoomId,
});
return result;
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { DatabaseModule } from './database/database.module';
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true }), DatabaseModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

1219
apps/api/src/app.service.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class DatabaseModule {}

View File

@@ -0,0 +1,13 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

14
apps/api/src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
const port = process.env.PORT ? Number(process.env.PORT) : 4000;
await app.listen(port);
Logger.log(`VTuber API running on http://localhost:${port}/api`, 'Bootstrap');
}
bootstrap();

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "**/*spec.ts"]
}

19
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"lib": ["es2022", "dom"],
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"noEmit": false,
"outDir": "./dist",
"rootDir": "./src",
"sourceMap": true
},
"include": ["src/**/*.ts", "src/**/*.js"]
}

4
apps/web/.env.example Normal file
View File

@@ -0,0 +1,4 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:4000/api
NEXT_PUBLIC_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

3
apps/web/.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}

26
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
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:web
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/web/.next ./apps/web/.next
COPY --from=builder /app/apps/web/package.json ./apps/web/package.json
COPY --from=builder /app/apps/web/public ./apps/web/public
COPY --from=builder /app/apps/web/next.config.js ./apps/web/next.config.js
COPY --from=builder /app/apps/web/next-env.d.ts ./apps/web/next-env.d.ts
ENV NEXT_TELEMETRY_DISABLED=1
EXPOSE 3000
CMD ["npm","run","start","-w","apps/web"]

5
apps/web/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

14
apps/web/next.config.js Normal file
View File

@@ -0,0 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'picsum.photos',
},
],
},
};
module.exports = nextConfig;

28
apps/web/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "@vtuber/web",
"private": true,
"version": "0.1.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@pixiv/three-vrm": "^3.5.3",
"@types/three": "^0.184.1",
"@vtuber/ui": "*",
"next": "^14.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"three": "^0.184.0"
},
"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"
}
}

0
apps/web/public/.gitkeep Normal file
View File

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,5 @@
import LiveDemoPage from '../../../live/demo/page';
export default function LocaleLiveDemoPage() {
return <LiveDemoPage />;
}

View File

@@ -0,0 +1,89 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { API_BASE_URL, getSessionId } from '../../lib/api';
type Product = {
name: string;
id: string;
imageUrl: string | null;
};
type Variant = {
id: string;
name: string;
price: number;
product: Product;
};
type CartItem = {
id: string;
quantity: number;
variant: Variant;
};
type CartData = {
id: string;
items: CartItem[];
};
export default function CartPage() {
const [sessionId, setSessionId] = useState('');
const [cart, setCart] = useState<CartData>({ id: '', items: [] });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const refresh = useCallback(async () => {
if (!sessionId) return;
setLoading(true);
setError('');
try {
const response = await fetch(`${API_BASE_URL}/cart?sessionId=${sessionId}`, {
cache: 'no-store',
});
if (!response.ok) {
throw new Error('購物車讀取失敗');
}
const data = (await response.json()) as CartData;
setCart(data);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '購物車讀取失敗');
} finally {
setLoading(false);
}
}, [sessionId]);
useEffect(() => {
const sid = getSessionId();
setSessionId(sid);
}, []);
useEffect(() => {
if (!sessionId) return;
void refresh();
}, [sessionId, refresh]);
const total = cart.items.reduce((acc, item) => acc + item.quantity * item.variant.price, 0);
return (
<main style={{ padding: 24, fontFamily: 'system-ui' }}>
<h1></h1>
<p>
<Link href="/products"></Link> | <Link href="/checkout"></Link>
</p>
{error ? <p style={{ color: 'crimson' }}>{error}</p> : null}
{loading ? <p>...</p> : null}
<ul>
{cart.items.map((item) => (
<li key={item.id} style={{ marginBottom: 12 }}>
<strong>{item.variant.product.name}</strong> / {item.variant.name}
{' - '}
{item.quantity} x NT$ {item.variant.price}
</li>
))}
</ul>
<div>NT$ {total}</div>
</main>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { API_BASE_URL, getSessionId } from '../../lib/api';
type Product = {
name: string;
id: string;
};
type Variant = {
id: string;
name: string;
price: number;
product: Product;
};
type CartItem = {
id: string;
quantity: number;
variant: Variant;
};
type CartData = {
id: string;
items: CartItem[];
};
type OrderResult = {
ok: boolean;
order?: {
id: string;
orderNumber: string;
totalAmount: number;
status: string;
};
};
export default function CheckoutPage() {
const [sessionId, setSessionId] = useState('');
const [cart, setCart] = useState<CartData>({ id: '', items: [] });
const [checking, setChecking] = useState(false);
const [result, setResult] = useState<OrderResult | null>(null);
const [error, setError] = useState('');
const total = cart.items.reduce((acc, item) => acc + item.quantity * item.variant.price, 0);
async function loadCart(sid: string) {
const response = await fetch(`${API_BASE_URL}/cart?sessionId=${sid}`, {
cache: 'no-store',
});
if (!response.ok) throw new Error('購物車讀取失敗');
const data = (await response.json()) as CartData;
setCart(data);
}
async function handleCheckout() {
setChecking(true);
setError('');
setResult(null);
try {
const response = await fetch(`${API_BASE_URL}/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId }),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || '結帳失敗');
}
const data = (await response.json()) as OrderResult;
setResult(data);
await loadCart(sessionId);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '結帳失敗');
} finally {
setChecking(false);
}
}
useEffect(() => {
const sid = getSessionId();
setSessionId(sid);
}, []);
useEffect(() => {
if (!sessionId) return;
void loadCart(sessionId);
}, [sessionId]);
return (
<main style={{ padding: 24, fontFamily: 'system-ui' }}>
<h1></h1>
<p>
<Link href="/cart"></Link>
</p>
{error ? <p style={{ color: 'crimson' }}>{error}</p> : null}
{result ? (
<div>
<h3>Mock</h3>
<p>{result.order?.orderNumber}</p>
<p>{result.order?.status}</p>
<p>NT$ {result.order?.totalAmount}</p>
</div>
) : null}
<div style={{ marginBottom: 16 }}>
{cart.items.map((item) => (
<div key={item.id}>
{item.variant.product.name} x {item.quantity} = NT${' '}
{item.quantity * item.variant.price}
</div>
))}
</div>
<div style={{ marginBottom: 12 }}>NT$ {total}</div>
<button type="button" onClick={handleCheckout} disabled={checking || cart.items.length === 0}>
{checking ? '送出中...' : '模擬結帳'}
</button>
</main>
);
}

View File

@@ -0,0 +1,5 @@
import LiveDemoPage from '../../../live/demo/page';
export default function EnLiveDemoPage() {
return <LiveDemoPage />;
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function EnLivePageRedirect() {
redirect('/en/live/demo');
}

View File

@@ -0,0 +1,14 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #222;
background: #f8f8fb;
}
main {
max-width: 960px;
margin: 0 auto;
}

View File

@@ -0,0 +1,5 @@
import LiveDemoPage from '../../../live/demo/page';
export default function JaLiveDemoPage() {
return <LiveDemoPage />;
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function JaLivePageRedirect() {
redirect('/ja/live/demo');
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import './globals.css';
export const metadata = {
title: 'VTuber Web',
description: 'VTuber Live Commerce Frontend Demo',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-Hant">
<body>{children}</body>
</html>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function LivePageRedirect() {
redirect('/live/demo');
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function NotFound() {
redirect('/live/demo');
}

16
apps/web/src/app/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
export default function HomePage() {
return (
<main style={{ padding: 32, fontFamily: 'system-ui' }}>
<h1>VTuber Demo</h1>
<p>Day 2mock </p>
<a href="/products"></a>
<div>
<a href="/live/demo"> Demo</a>
</div>
<ul>
<li><a href="/cart"></a></li>
<li><a href="/checkout"></a></li>
</ul>
</main>
);
}

View File

@@ -0,0 +1,73 @@
import Image from 'next/image';
import AddToCartButton from '../../../components/add-to-cart-button';
import { API_BASE_URL } from '../../../lib/api';
type Inventory = {
quantity: number;
};
type ProductVariant = {
id: string;
name: string;
price: number;
size: string | null;
sku: string;
inventory: Inventory | null;
};
type Product = {
id: string;
name: string;
slug: string;
description: string | null;
imageUrl: string | null;
basePrice: number;
variants: ProductVariant[];
};
async function fetchProduct(slug: string): Promise<Product | null> {
const response = await fetch(`${API_BASE_URL}/products/${slug}`, {
cache: 'no-store',
});
if (!response.ok) return null;
return response.json();
}
export default async function ProductDetailPage({
params,
}: {
params: { slug: string };
}) {
const product = await fetchProduct(params.slug);
if (!product) {
return (
<main style={{ padding: 24, fontFamily: 'system-ui' }}>
<h1></h1>
</main>
);
}
const variant = product.variants[0];
return (
<main style={{ padding: 24, fontFamily: 'system-ui' }}>
<h1>{product.name}</h1>
{product.imageUrl ? (
<Image
src={product.imageUrl}
alt={product.name}
width={360}
height={360}
style={{ width: '100%', maxWidth: 360, height: 'auto' }}
/>
) : null}
<p>{product.description ?? '無描述'}</p>
<h3>NT$ {product.basePrice}</h3>
<p>{variant?.name ?? '暫無款式'}</p>
<p>{variant?.size ?? '-'}</p>
<p>{variant?.inventory?.quantity ?? 0}</p>
{variant ? <AddToCartButton variantId={variant.id} /> : <p></p>}
</main>
);
}

View File

@@ -0,0 +1,93 @@
import Image from 'next/image';
import Link from 'next/link';
import { API_BASE_URL } from '../../lib/api';
type Inventory = {
quantity: number;
};
type ProductVariant = {
id: string;
name: string;
price: number;
size: string | null;
inventory: Inventory | null;
};
type Product = {
id: string;
name: string;
slug: string;
description: string | null;
imageUrl: string | null;
basePrice: number;
variants: ProductVariant[];
};
async function fetchProducts(): Promise<Product[]> {
const response = await fetch(`${API_BASE_URL}/products`, {
cache: 'no-store',
});
if (!response.ok) {
return [];
}
return response.json();
}
export default async function ProductsPage() {
const products = await fetchProducts();
return (
<main style={{ padding: 24, fontFamily: 'system-ui' }}>
<h1>VTuber </h1>
<p>
<Link href="/cart"></Link>
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: 16,
}}
>
{products.map((product) => (
<article
key={product.id}
style={{
border: '1px solid #ddd',
borderRadius: 8,
padding: 12,
}}
>
<h3>{product.name}</h3>
{product.imageUrl ? (
<Image
src={product.imageUrl}
alt={product.name}
width={320}
height={320}
style={{
width: '100%',
height: 'auto',
borderRadius: 8,
marginBottom: 8,
}}
/>
) : null}
<p>{product.description ?? '無描述'}</p>
<p>NT$ {product.basePrice}</p>
<p>
{product.variants.length}
</p>
<p>
{product.variants[0]?.inventory?.quantity ?? 0}
</p>
<Link href={`/products/${product.slug}`}></Link>
</article>
))}
</div>
</main>
);
}

View File

@@ -0,0 +1,5 @@
import LiveDemoPage from '../../../live/demo/page';
export default function ZhCnLiveDemoPage() {
return <LiveDemoPage />;
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function ZhCnLivePageRedirect() {
redirect('/zh-CN/live/demo');
}

View File

@@ -0,0 +1,5 @@
import LiveDemoPage from '../../../live/demo/page';
export default function ZhTwLiveDemoPage() {
return <LiveDemoPage />;
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function ZhTwLivePageRedirect() {
redirect('/zh-TW/live/demo');
}

View File

@@ -0,0 +1,50 @@
'use client';
import { useState } from 'react';
import { API_BASE_URL, getSessionId } from '../lib/api';
type AddToCartButtonProps = {
variantId: string;
};
export default function AddToCartButton({ variantId }: AddToCartButtonProps) {
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
async function handleAdd() {
setLoading(true);
setMessage('');
try {
const sessionId = getSessionId();
const response = await fetch(`${API_BASE_URL}/cart/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId,
productVariantId: variantId,
quantity: 1,
}),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || '加入購物車失敗');
}
setMessage('已加入購物車');
} catch (error) {
setMessage(
error instanceof Error ? error.message : '加入購物車發生錯誤',
);
} finally {
setLoading(false);
}
}
return (
<div style={{ display: 'grid', gap: 8 }}>
<button type="button" onClick={handleAdd} disabled={loading}>
{loading ? '加入中...' : '加入購物車'}
</button>
{message ? <div>{message}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,286 @@
'use client';
import React, { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
export interface VirtualHostRef {
speak: (text: string) => void;
}
const VirtualHost = forwardRef<VirtualHostRef, {}>((props, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const [isLoaded, setIsLoaded] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [isSpeaking, setIsSpeaking] = useState(false);
const headMeshRef = useRef<THREE.SkinnedMesh | null>(null);
const isSpeakingRef = useRef(false);
useImperativeHandle(ref, () => ({
speak: (text: string) => {
if (!('speechSynthesis' in window)) {
console.warn('SpeechSynthesis is not supported in this browser.');
return;
}
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'zh-TW';
utterance.rate = 1.1;
utterance.pitch = 1.1;
const voices = window.speechSynthesis.getVoices();
const femaleVoice = voices.find(v => v.lang.includes('zh') && (v.name.includes('Yating') || v.name.includes('Xiaoxiao') || v.name.includes('female') || v.name.includes('Mei-Jia')));
if (femaleVoice) {
utterance.voice = femaleVoice;
}
utterance.onstart = () => {
setIsSpeaking(true);
isSpeakingRef.current = true;
};
utterance.onend = () => {
setIsSpeaking(false);
isSpeakingRef.current = false;
if (headMeshRef.current?.morphTargetInfluences) {
const dict = headMeshRef.current.morphTargetDictionary;
if (dict) {
['viseme_aa', 'viseme_E', 'viseme_I', 'viseme_O', 'viseme_U'].forEach(v => {
if (dict[v] !== undefined) headMeshRef.current!.morphTargetInfluences![dict[v]] = 0;
});
}
}
};
window.speechSynthesis.speak(utterance);
}
}));
useEffect(() => {
const currentContainer = containerRef.current;
if (!currentContainer) return;
const width = currentContainer.clientWidth;
const height = currentContainer.clientHeight;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf1f5f9); // Clean slate-100 background
const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 20.0);
camera.position.set(0.0, 1.5, 1.8);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
currentContainer.appendChild(renderer.domElement);
// Hemisphere light for soft natural outdoor/studio look
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 2.0);
hemiLight.position.set(0, 20, 0);
scene.add(hemiLight);
// Main directional light to highlight face clearly
const dirLight = new THREE.DirectionalLight(0xffffff, 2.5);
dirLight.position.set(0, 2, 4);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 1024;
dirLight.shadow.mapSize.height = 1024;
scene.add(dirLight);
// Soft fill light from the opposite side
const fillLight = new THREE.DirectionalLight(0xd0e0ff, 1.0);
fillLight.position.set(-2, 1, 2);
scene.add(fillLight);
// Boost overall brightness
const ambientLight = new THREE.AmbientLight(0xffffff, 1.5);
scene.add(ambientLight);
let modelGroup: THREE.Group | null = null;
const loader = new GLTFLoader();
// Using a reliable Ready Player Me demo avatar (female) hosted locally due to DNS issues
const avatarSources = ['/models/realistic-host.glb?v=1', '/models/xiaoyai.vrm?v=3'];
const loadAvatar = (sourceIndex = 0) => {
const source = avatarSources[sourceIndex];
if (!source) {
setIsLoaded(true);
setLoadError('已嘗試所有模型來源,仍無法載入虛擬主播模型');
return;
}
loader.load(
source,
(gltf) => {
const model = gltf.scene;
model.traverse((child) => {
if (child instanceof THREE.Mesh || child instanceof THREE.SkinnedMesh) {
child.castShadow = true;
child.receiveShadow = true;
if (child.name === 'Wolf3D_Head' || child.name === 'Wolf3D_Avatar') {
headMeshRef.current = child as THREE.SkinnedMesh;
}
}
});
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
// Auto position model to center its bounds
model.position.x += (model.position.x - center.x);
model.position.y += (model.position.y - center.y);
model.position.z += (model.position.z - center.z);
// Offset Y down slightly so it looks like a bust
model.position.y -= size.y * 0.15;
// Ensure camera looks at the center of the model
camera.position.set(0, size.y * 0.1, size.y * 1.3);
camera.lookAt(0, 0, 0);
scene.add(model);
modelGroup = model;
setIsLoaded(true);
},
undefined,
(error) => {
if (sourceIndex + 1 < avatarSources.length) {
loadAvatar(sourceIndex + 1);
return;
}
console.error('Failed to load RPM Avatar:', error);
setLoadError(error instanceof Error ? error.message : String(error));
setIsLoaded(true); // Set to true to hide the spinner and show the error message
}
);
};
loadAvatar();
const clock = new THREE.Clock();
let animationId: number;
const animate = () => {
animationId = requestAnimationFrame(animate);
const time = clock.getElapsedTime();
if (modelGroup) {
const spine = modelGroup.getObjectByName('Spine');
const head = modelGroup.getObjectByName('Head');
const rightArm = modelGroup.getObjectByName('RightArm');
const leftArm = modelGroup.getObjectByName('LeftArm');
if (spine) spine.rotation.x = Math.sin(time * 1.5) * 0.02;
if (head) {
head.rotation.y = Math.sin(time * 0.5) * 0.05;
head.rotation.x = Math.sin(time * 0.8) * 0.02;
}
if (rightArm && rightArm.rotation.z > -1.0) rightArm.rotation.z = -1.2;
if (leftArm && leftArm.rotation.z < 1.0) leftArm.rotation.z = 1.2;
}
if (headMeshRef.current?.morphTargetDictionary) {
const dict = headMeshRef.current.morphTargetDictionary;
const blinkIdx = dict['eyeBlink_L'] ?? dict['eyesClosed'];
if (blinkIdx !== undefined && headMeshRef.current.morphTargetInfluences) {
const isBlinking = (Math.sin(time * 4) > 0.98);
headMeshRef.current.morphTargetInfluences[blinkIdx] = isBlinking ? 1 : 0;
const blinkIdxR = dict['eyeBlink_R'];
if (blinkIdxR !== undefined) headMeshRef.current.morphTargetInfluences[blinkIdxR] = isBlinking ? 1 : 0;
}
}
if (isSpeakingRef.current && headMeshRef.current?.morphTargetDictionary && headMeshRef.current?.morphTargetInfluences) {
const dict = headMeshRef.current.morphTargetDictionary;
const t = time * 15;
const v = (Math.sin(t) + Math.sin(t * 1.5) + Math.sin(t * 2.3)) / 3;
const open = Math.max(0, v * 1.2);
['viseme_aa', 'viseme_E', 'viseme_I', 'viseme_O', 'viseme_U'].forEach(vis => {
if (dict[vis] !== undefined) headMeshRef.current!.morphTargetInfluences![dict[vis]] = 0;
});
if (dict['viseme_aa'] !== undefined) headMeshRef.current.morphTargetInfluences[dict['viseme_aa']] = open;
if (Math.random() < 0.2 && dict['viseme_O'] !== undefined) {
headMeshRef.current.morphTargetInfluences[dict['viseme_O']] = Math.random() * 0.5;
}
}
renderer.render(scene, camera);
};
animate();
const handleResize = () => {
if (!currentContainer) return;
const w = currentContainer.clientWidth;
const h = currentContainer.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
cancelAnimationFrame(animationId);
if (currentContainer && renderer.domElement) {
currentContainer.removeChild(renderer.domElement);
}
renderer.dispose();
scene.clear();
};
}, []);
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<div
ref={containerRef}
style={{ width: '100%', height: '100%', overflow: 'hidden', position: 'relative', zIndex: 10 }}
/>
{!isLoaded && !loadError && (
<div style={{
position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
backgroundColor: '#f1f5f9', color: '#1e293b', zIndex: 20
}}>
<div style={{
width: 40, height: 40, border: '3px solid rgba(0,0,0,0.1)',
borderTopColor: '#3b82f6', borderRadius: '50%', animation: 'spin 1s linear infinite',
marginBottom: 16
}} />
<style>{`
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
`}</style>
<span style={{ fontSize: '1rem', letterSpacing: '1px' }}>... (v2)</span>
</div>
)}
{loadError && (
<div style={{
position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
backgroundColor: '#f1f5f9', color: '#ef4444', zIndex: 20, padding: 20, textAlign: 'center'
}}>
<span style={{ fontSize: '1.2rem', fontWeight: 'bold', marginBottom: 8 }}></span>
<span style={{ fontSize: '0.9rem' }}>{loadError}</span>
</div>
)}
</div>
);
});
VirtualHost.displayName = 'VirtualHost';
export default VirtualHost;

31
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,31 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:4000/api';
export function getSessionId(): string {
if (typeof window === 'undefined') {
return '';
}
const key = 'vtuber-session-id';
const existing = localStorage.getItem(key);
if (existing) return existing;
const next = `v-${Math.random().toString(16).slice(2)}-${Date.now()}`;
localStorage.setItem(key, next);
return next;
}
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: {
'Content-Type': 'application/json',
...(init?.headers as Record<string, string>),
},
...init,
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `API error: ${response.status}`);
}
if (response.status === 204) {
return {} as T;
}
return response.json() as Promise<T>;
}

View File

@@ -0,0 +1,41 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const SUPPORTED_LOCALES = new Set(['zh-TW', 'en', 'zh-CN', 'ja']);
function shouldSkipLocaleRewrite(pathname: string) {
return (
pathname.startsWith('/_next') ||
pathname.startsWith('/api') ||
pathname.startsWith('/favicon.ico') ||
pathname.includes('.')
);
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (shouldSkipLocaleRewrite(pathname)) {
return NextResponse.next();
}
const match = pathname.match(/^\/([^/]+)(\/.*)?$/);
if (!match) {
return NextResponse.next();
}
const locale = match[1];
if (!SUPPORTED_LOCALES.has(locale)) {
return NextResponse.next();
}
const nextPath = pathname.replace(`/${locale}`, '') || '/';
const nextUrl = request.nextUrl.clone();
nextUrl.pathname = nextPath;
return NextResponse.rewrite(nextUrl);
}
export const config = {
matcher: '/:path*',
};

19
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"strict": true,
"noEmit": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${SCRIPT_DIR}/.env.prod"
if [ ! -f "${ENV_FILE}" ]; then
echo "❌ 找不到 deploy/.env.prod請先建立後再執行。"
exit 1
fi
source "${ENV_FILE}"
DEPLOY_HOST="${DEPLOY_HOST:-}"
DEPLOY_USER="${DEPLOY_USER:-}"
DEPLOY_PORT="${DEPLOY_PORT:-22}"
APP_DOMAIN="${APP_DOMAIN:-vtuber.wooo.work}"
NGINX_SERVER_CONFIG="${NGINX_SERVER_CONFIG:-}"
SNIPPET_SRC="${SCRIPT_DIR}/vtuber-nginx-110-live-fallback.conf"
SNIPPET_REMOTE="/etc/nginx/snippets/vtuber-nginx-110-live-fallback.conf"
if [ -z "${DEPLOY_HOST}" ] || [ -z "${DEPLOY_USER}" ]; then
echo "❌ deploy/.env.prod 缺少 DEPLOY_HOST / DEPLOY_USER"
exit 1
fi
REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}"
SSH_OPTS=(-p "${DEPLOY_PORT}")
SCP_OPTS=(-P "${DEPLOY_PORT}")
NGINX_SNIPPET_TAG="vtuber-nginx-110-live-fallback.conf"
TMP_SNIPPET="/tmp/vtuber-nginx-110-live-fallback.conf"
if [ -z "${NGINX_SERVER_CONFIG}" ]; then
echo "未指定 NGINX_SERVER_CONFIG開始自動偵測..."
NGINX_SERVER_CONFIG="$(
ssh "${SSH_OPTS[@]}" "${REMOTE}" "
if [ -f '/etc/nginx/conf.d/vtuber.wooo.conf' ]; then
echo '/etc/nginx/conf.d/vtuber.wooo.conf'
elif [ -f \"/etc/nginx/sites-available/${APP_DOMAIN}\" ]; then
echo '/etc/nginx/sites-available/${APP_DOMAIN}'
elif [ -f '/etc/nginx/sites-enabled/vtuber.wooo.work' ]; then
echo '/etc/nginx/sites-enabled/vtuber.wooo.work'
else
grep -R -l \"server_name[[:space:]]\\+.*${APP_DOMAIN}\" /etc/nginx/sites-enabled /etc/nginx/sites-available /etc/nginx/conf.d 2>/dev/null | head -n 1
fi
"
)"
fi
if [ -z "${NGINX_SERVER_CONFIG}" ]; then
echo "❌ 無法自動找出 vtuber 的 nginx 設定檔,請手動指定 NGINX_SERVER_CONFIG 環境變數"
exit 1
fi
echo "目標主機:${REMOTE}"
echo "目標網域:${APP_DOMAIN}"
echo "目標 Nginx 設定:${NGINX_SERVER_CONFIG}"
ssh "${SSH_OPTS[@]}" "${REMOTE}" "test -f '${SNIPPET_REMOTE}' && sudo chmod 644 '${SNIPPET_REMOTE}' || true"
ssh "${SSH_OPTS[@]}" "${REMOTE}" "test -f '${NGINX_SERVER_CONFIG}' || { echo 'Nginx 設定檔不存在:${NGINX_SERVER_CONFIG}' >&2; exit 1; }"
scp "${SCP_OPTS[@]}" "${SNIPPET_SRC}" "${REMOTE}:${TMP_SNIPPET}"
ssh "${SSH_OPTS[@]}" "${REMOTE}" "sudo cp '${TMP_SNIPPET}' '${SNIPPET_REMOTE}' && sudo chmod 644 '${SNIPPET_REMOTE}'"
ssh "${SSH_OPTS[@]}" "${REMOTE}" "
set -euo pipefail
sudo cp '${NGINX_SERVER_CONFIG}' '${NGINX_SERVER_CONFIG}.bak.\$(date +%F_%H%M%S)'
if ! sudo grep -q '${NGINX_SNIPPET_TAG}' '${NGINX_SERVER_CONFIG}'; then
sudo sed -i '/^[[:space:]]*server_name[[:space:]]\\+[^;]*;/ a\\
include /etc/nginx/snippets/vtuber-nginx-110-live-fallback.conf;\\
' '${NGINX_SERVER_CONFIG}'
fi
sudo nginx -t
if command -v systemctl >/dev/null 2>&1; then
sudo systemctl reload nginx
else
sudo service nginx reload
fi
"
echo "✅ 110 Nginx 404 fallback 已提交。"

65
deploy/deploy-prod.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
source "${SCRIPT_DIR}/.env.prod"
if [ -z "${DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_USER:-}" ] || [ -z "${DEPLOY_DIR:-}" ]; then
echo "請先建立 deploy/.env.prod 並設定 DEPLOY_HOST / DEPLOY_USER / DEPLOY_DIR" >&2
exit 1
fi
SSH_OPTS=(-p "${DEPLOY_PORT:-22}")
REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}"
echo "Preparing deployment content to ${REMOTE}:${DEPLOY_DIR}"
synced=0
if ssh "${SSH_OPTS[@]}" "$REMOTE" "[ -d \"${DEPLOY_DIR}/.git\" ]"; then
ssh "${SSH_OPTS[@]}" "$REMOTE" "
set -euo pipefail
cd \"${DEPLOY_DIR}\"
if git show-ref --verify --quiet \"refs/heads/${DEPLOY_BRANCH}\"; then
git fetch --all || true
git checkout \"${DEPLOY_BRANCH}\" || true
git pull origin \"${DEPLOY_BRANCH}\" || true
exit 0
else
echo \"[warn] 遠端 git 無法找到 ${DEPLOY_BRANCH},改用 rsync 同步部署檔案。\"\n
exit 1
fi
" && synced=1 || synced=0
# 分支存在但拉取失敗時fallback 改為 rsync
if [ ${synced} -ne 1 ]; then
ssh "${SSH_OPTS[@]}" "$REMOTE" "[ -d \"${DEPLOY_DIR}\" ] || mkdir -p \"${DEPLOY_DIR}\""
fi
else
synced=0
fi
if [ ${synced} -ne 1 ]; then
echo "遠端未完成 git 同步,改用 rsync 直接推送部署內容..."
rsync -av --delete \
--exclude 'node_modules' \
--exclude '.next' \
--exclude '.turbo' \
--exclude 'dist' \
--exclude 'coverage' \
--exclude '.env' \
--exclude '*.log' \
-e "ssh ${SSH_OPTS[*]}" \
"${ROOT_DIR}/" "$REMOTE:${DEPLOY_DIR}/"
fi
ssh "${SSH_OPTS[@]}" "$REMOTE" "
set -euo pipefail
cd \"${DEPLOY_DIR}\"
cp deploy/.env.prod .env
docker compose -f deploy/docker-compose.prod.yml --env-file deploy/.env.prod down || true
docker compose -f deploy/docker-compose.prod.yml --env-file deploy/.env.prod up -d --build
"
echo "VTuber production deploy sent to ${DEPLOY_USER}@${DEPLOY_HOST}"

View File

@@ -0,0 +1,69 @@
version: "3.9"
services:
db:
image: postgres:16
container_name: vtuber-db
environment:
- POSTGRES_DB=vtuber
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=vtuber_local_pwd
volumes:
- vtuber-pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d vtuber"]
interval: 5s
timeout: 5s
retries: 10
restart: always
api:
build:
context: ..
dockerfile: apps/api/Dockerfile
container_name: vtuber-api
depends_on:
db:
condition: service_healthy
environment:
- NODE_ENV=production
- PORT=${API_PORT:-4000}
- DATABASE_URL=${DATABASE_URL}
ports:
- "${API_PORT:-4000}:4000"
restart: always
web:
build:
context: ..
dockerfile: apps/web/Dockerfile
container_name: vtuber-web
depends_on:
- api
environment:
- NODE_ENV=production
- NEXT_PUBLIC_API_BASE_URL=http://api:4000/api
- NEXT_PUBLIC_APP_DOMAIN=${APP_DOMAIN}
- PORT=3000
ports:
- "${WEB_PORT:-3200}:3000"
restart: always
admin:
build:
context: ..
dockerfile: apps/admin/Dockerfile
container_name: vtuber-admin
depends_on:
- api
environment:
- NODE_ENV=production
- NEXT_PUBLIC_API_BASE_URL=http://api:4000/api
- NEXT_PUBLIC_APP_DOMAIN=${APP_DOMAIN}
- PORT=3001
ports:
- "${ADMIN_PORT:-3201}:3001"
restart: always
volumes:
vtuber-pg-data:

View File

@@ -0,0 +1,11 @@
# 追加到正式主機前台 Nginx server 區塊內
# vtuber-nginx-110-live-fallback
# 功能:發生 404 時自動導回首頁,避免走錯 host/path 看到空白頁
# 引用方式:在 server { ... } 中加
# include /etc/nginx/snippets/vtuber-nginx-110-live-fallback.conf;
error_page 404 = @vtuber_live_demo_fallback;
location @vtuber_live_demo_fallback {
return 302 /live/demo;
}

22
docs/PRD.md Normal file
View File

@@ -0,0 +1,22 @@
# VTuber PRD
## 目標
建立可 demo 的虛擬主播直播電商系統,使用者可在直播頁看到主播推薦商品、留言互動,並完成加入購物車與下單流程。
## 核心場景
1. 前台商品頁可看到商品、商品細節、加購流程。
2. 直播頁顯示主播區塊 + 留言牆 + 主推商品。
3. 用戶輸入問題可看到 AI 直播話術式回答。
4. 後台能看到商品、訂單、庫存。
5. 無需真金流,以 mock checkout 完成閉環。
## 非功能
- 前後端解耦 monorepo
- PostgreSQL + Prisma
- 可重複部署的最小可 demo 架構
## 平台主流參考與角色建議
- 平台取樣Amazon Live、TikTok Shop Live、YouTube Shopping、Shopee Live。
- 共通作業角色:主播、助播/場控、導播、編導、小編、客服、倉儲、行銷。
- 建議在本 Demo 套用「主講 + 作業台」模式:後台先配置角色與腳本,直播中以留言即時回覆與購物按鈕收斂閉環。

37
docs/TASKS.md Normal file
View File

@@ -0,0 +1,37 @@
# VTuber 任務拆解7 天)
## Day 1
- 建立 monorepo 架構
- 建立 Prisma schema 與 seed
## Day 2
- 建立前台商品列表、商品頁、購物車、結帳頁mock
## Day 3
- 建立直播 demo 頁:左主播、右留言、下方主推商品
## Day 4
- 建立後台商品、訂單、庫存管理
## Day 5
- 建立 AI 主播問答 APIPOST `/api/ai/product-chat`
## Day 6
- 虛擬主播影片 / 真人場景 placeholder 與直播 UI 優化
### 直播作業角色模組(已加入)
- 後台新增「直播作業台」:建立直播人員、角色指派、主播腳本、核對清單。
- API 新增作業 API
- 團隊成員 CRUD
- 直播作業(分工/腳本/清單)管理
- 直播狀態更新
### 交付可操作閉環
- 直播前:建立角色分工與作業流程。
- 直播中:在 `/live/demo` 留言 + AI 互動 + 加入購物車 / 立即購買。
- 直播後:後台觀測訂單、庫存扣減、作業完成度。
## Day 7
- 部署 demolocal->staging->公開)

34
package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "vtuber",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev:web": "npm run dev -w apps/web",
"dev:admin": "npm run dev -w apps/admin",
"dev:api": "npm run start:dev -w apps/api",
"dev": "npm run dev:web & npm run dev:admin & npm run dev:api",
"build:web": "npm run build -w apps/web",
"build:admin": "npm run build -w apps/admin",
"build:api": "npm run build -w apps/api",
"build": "npm run build:web && npm run build:admin && npm run build:api",
"lint:web": "npm run lint -w apps/web",
"lint:admin": "npm run lint -w apps/admin",
"lint:api": "npm run lint -w apps/api",
"lint": "npm run lint:web && npm run lint:admin && npm run lint:api",
"test": "npm run test -w apps/api",
"seed": "npm run seed -w packages/db",
"db:generate": "npm run db:generate -w packages/db",
"db:push": "npm run db:push -w packages/db",
"db:migrate": "npm run db:migrate -w packages/db"
},
"packageManager": "npm@10.0.0",
"engines": {
"node": ">=18"
},
"devDependencies": {
"typescript": "^5.5.0"
}
}

22
packages/db/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "@vtuber/db",
"version": "0.1.0",
"private": true,
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"seed": "tsx prisma/seed.ts",
"db:generate": "prisma generate --schema prisma/schema.prisma",
"db:push": "prisma db push --accept-data-loss --schema prisma/schema.prisma",
"db:migrate": "prisma migrate dev --name init --schema prisma/schema.prisma"
},
"dependencies": {
"@prisma/client": "^5.18.0",
"prisma": "^5.18.0"
},
"devDependencies": {
"tsx": "^4.19.1",
"typescript": "^5.5.0"
}
}

View File

@@ -0,0 +1,245 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
enum OrderStatus {
PENDING
PAID
SHIPPED
CANCELLED
}
enum TeamRole {
HOST
PRODUCER
DIRECTOR
EDITOR
SUPPORT
WAREHOUSE
MARKETING
OPERATOR
}
enum LiveRoomStatus {
PLANNING
LIVE
FINISHED
CANCELLED
}
enum OrderSource {
WEB
LIVE
}
model User {
id String @id @default(cuid())
email String @unique
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
carts Cart[]
messages LiveMessage[]
}
model LiveTeamMember {
id String @id @default(cuid())
displayName String
nickname String?
role TeamRole
phone String?
email String?
notes String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignments LiveRoomRoleAssignment[]
}
model Product {
id String @id @default(cuid())
name String
slug String @unique
description String?
imageUrl String?
basePrice Int
isActive Boolean @default(true)
liveRoomId String?
liveRoom LiveRoom? @relation(fields: [liveRoomId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
variants ProductVariant[]
}
model ProductVariant {
id String @id @default(cuid())
productId String
sku String @unique
name String
size String?
price Int
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
inventory Inventory?
orderItems OrderItem[]
cartItems CartItem[]
messages LiveMessage[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Inventory {
id String @id @default(cuid())
productVariantId String @unique
productVariant ProductVariant @relation(fields: [productVariantId], references: [id], onDelete: Cascade)
quantity Int @default(0)
reserved Int @default(0)
updatedAt DateTime @updatedAt
}
model Order {
id String @id @default(cuid())
orderNumber String @unique @default(cuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
status OrderStatus @default(PENDING)
orderSource OrderSource @default(WEB)
totalAmount Int
liveRoomId String?
liveRoom LiveRoom? @relation(fields: [liveRoomId], references: [id], onDelete: SetNull)
items OrderItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OrderItem {
id String @id @default(cuid())
orderId String
productVariantId String
quantity Int
unitPrice Int
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
variant ProductVariant @relation(fields: [productVariantId], references: [id], onDelete: Cascade)
}
model LiveRoom {
id String @id @default(cuid())
title String
hostName String
description String?
status LiveRoomStatus @default(PLANNING)
streamUrl String?
liveGoal String?
plannedStartAt DateTime?
plannedEndAt DateTime?
isActive Boolean @default(true)
startedAt DateTime?
endedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
products Product[]
messages LiveMessage[]
assignments LiveRoomRoleAssignment[]
scripts LiveRoomScript[]
checklists LiveRoomChecklistItem[]
orders Order[]
}
model LiveMessage {
id String @id @default(cuid())
liveRoomId String
userId String?
userName String
message String
productVariantId String?
createdAt DateTime @default(now())
liveRoom LiveRoom @relation(fields: [liveRoomId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
productVariant ProductVariant? @relation(fields: [productVariantId], references: [id], onDelete: SetNull)
}
model Cart {
id String @id @default(cuid())
userId String?
sessionId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
items CartItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, sessionId])
}
model CartItem {
id String @id @default(cuid())
cartId String
productVariantId String
quantity Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade)
variant ProductVariant @relation(fields: [productVariantId], references: [id], onDelete: Cascade)
@@unique([cartId, productVariantId])
}
model LiveRoomRoleAssignment {
id String @id @default(cuid())
liveRoomId String
teamMemberId String
role TeamRole
isPrimary Boolean @default(false)
shiftStartAt DateTime?
shiftEndAt DateTime?
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
liveRoom LiveRoom @relation(fields: [liveRoomId], references: [id], onDelete: Cascade)
teamMember LiveTeamMember @relation(fields: [teamMemberId], references: [id], onDelete: Cascade)
@@unique([liveRoomId, teamMemberId])
}
model LiveRoomScript {
id String @id @default(cuid())
liveRoomId String
sequence Int
cue String?
title String
content String
ownerRole TeamRole?
targetProductId String?
isDone Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
liveRoom LiveRoom @relation(fields: [liveRoomId], references: [id], onDelete: Cascade)
}
model LiveRoomChecklistItem {
id String @id @default(cuid())
liveRoomId String
title String
ownerRole TeamRole
description String?
isRequired Boolean @default(true)
isDone Boolean @default(false)
note String?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
liveRoom LiveRoom @relation(fields: [liveRoomId], references: [id], onDelete: Cascade)
}

354
packages/db/prisma/seed.ts Normal file
View File

@@ -0,0 +1,354 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.cartItem.deleteMany();
await prisma.cart.deleteMany();
await prisma.orderItem.deleteMany();
await prisma.order.deleteMany();
await prisma.inventory.deleteMany();
await prisma.productVariant.deleteMany();
await prisma.product.deleteMany();
await prisma.liveMessage.deleteMany();
await prisma.liveRoomChecklistItem.deleteMany();
await prisma.liveRoomScript.deleteMany();
await prisma.liveRoomRoleAssignment.deleteMany();
await prisma.liveRoom.deleteMany();
await prisma.liveTeamMember.deleteMany();
await prisma.user.deleteMany();
const user = await prisma.user.create({
data: {
email: 'streamer-fan@vtuber.local',
name: 'Demo User',
},
});
const room = await prisma.liveRoom.create({
data: {
title: 'VTuber Demo Live',
hostName: 'Ari',
description: '歡迎來到 VTuber Demo 直播間!',
isActive: true,
status: 'PLANNING',
streamUrl: 'https://videos.pexels.com/video-files/7297923/7297923-uhd_3840_2160_30fps.mp4',
liveGoal: '示範真人+主播互動+主推商品成交閉環',
plannedStartAt: new Date(),
plannedEndAt: new Date(Date.now() + 90 * 60 * 1000),
},
});
const teamMembers = await Promise.all([
prisma.liveTeamMember.create({
data: {
displayName: '主持人-Ari',
role: 'HOST',
phone: '0911000001',
email: 'host@vtuber.local',
},
}),
prisma.liveTeamMember.create({
data: {
displayName: '編導-Jess',
role: 'DIRECTOR',
phone: '0911000002',
email: 'director@vtuber.local',
},
}),
prisma.liveTeamMember.create({
data: {
displayName: '導播-Kim',
role: 'PRODUCER',
phone: '0911000003',
email: 'producer@vtuber.local',
},
}),
prisma.liveTeamMember.create({
data: {
displayName: '小編-Lina',
role: 'EDITOR',
phone: '0911000004',
email: 'editor@vtuber.local',
},
}),
prisma.liveTeamMember.create({
data: {
displayName: '客服-Ann',
role: 'SUPPORT',
phone: '0911000005',
email: 'support@vtuber.local',
},
}),
prisma.liveTeamMember.create({
data: {
displayName: '物流-Wei',
role: 'WAREHOUSE',
phone: '0911000006',
email: 'warehouse@vtuber.local',
},
}),
prisma.liveTeamMember.create({
data: {
displayName: '行銷-May',
role: 'MARKETING',
phone: '0911000007',
email: 'marketing@vtuber.local',
},
}),
]);
const [hostMember, directorMember, producerMember, editorMember, supportMember, warehouseMember, marketingMember] = teamMembers;
await prisma.liveRoomRoleAssignment.create({
data: {
liveRoomId: room.id,
teamMemberId: hostMember.id,
role: 'HOST',
isPrimary: true,
note: '直播前3分鐘啟播確認',
shiftStartAt: new Date(Date.now() - 30 * 60 * 1000),
shiftEndAt: new Date(Date.now() + 120 * 60 * 1000),
},
});
await prisma.liveRoomRoleAssignment.create({
data: {
liveRoomId: room.id,
teamMemberId: directorMember.id,
role: 'DIRECTOR',
note: '節奏控場,提醒商品切換順序',
shiftStartAt: new Date(Date.now() - 20 * 60 * 1000),
shiftEndAt: new Date(Date.now() + 120 * 60 * 1000),
},
});
await prisma.liveRoomRoleAssignment.create({
data: {
liveRoomId: room.id,
teamMemberId: producerMember.id,
role: 'PRODUCER',
note: '音畫輸出、畫面切換',
shiftStartAt: new Date(Date.now() - 20 * 60 * 1000),
shiftEndAt: new Date(Date.now() + 120 * 60 * 1000),
},
});
await prisma.liveRoomRoleAssignment.create({
data: {
liveRoomId: room.id,
teamMemberId: editorMember.id,
role: 'EDITOR',
note: '補充彈幕腳本文案',
shiftStartAt: new Date(Date.now() - 10 * 60 * 1000),
shiftEndAt: new Date(Date.now() + 120 * 60 * 1000),
},
});
await prisma.liveRoomRoleAssignment.create({
data: {
liveRoomId: room.id,
teamMemberId: supportMember.id,
role: 'SUPPORT',
note: '回覆 DM 與加購問題',
shiftStartAt: new Date(Date.now() - 10 * 60 * 1000),
shiftEndAt: new Date(Date.now() + 120 * 60 * 1000),
},
});
await prisma.liveRoomRoleAssignment.create({
data: {
liveRoomId: room.id,
teamMemberId: warehouseMember.id,
role: 'WAREHOUSE',
note: '監控出貨、保留庫存',
shiftStartAt: new Date(Date.now() - 10 * 60 * 1000),
shiftEndAt: new Date(Date.now() + 120 * 60 * 1000),
},
});
await prisma.liveRoomRoleAssignment.create({
data: {
liveRoomId: room.id,
teamMemberId: marketingMember.id,
role: 'MARKETING',
note: '活動主打標語與互動口號',
shiftStartAt: new Date(Date.now() - 10 * 60 * 1000),
shiftEndAt: new Date(Date.now() + 120 * 60 * 1000),
},
});
const productsCreated: {
id: string;
variants: Array<{ id: string }>;
}[] = [];
for (let i = 1; i <= 10; i += 1) {
const basePrice = 250 + i * 80;
const product = await prisma.product.create({
data: {
name: `Demo Product ${i}`,
slug: `demo-product-${i}`,
description: `這是第 ${i} 個直播間示範商品`,
imageUrl: `https://picsum.photos/seed/vtuber-${i}/640/640`,
basePrice,
liveRoomId: room.id,
},
});
const variant = await prisma.productVariant.create({
data: {
productId: product.id,
sku: `VT-P-${i.toString().padStart(3, '0')}`,
name: '標準款',
size: 'M',
price: basePrice,
},
});
await prisma.inventory.create({
data: {
productVariantId: variant.id,
quantity: 50 + i * 10,
reserved: 0,
},
});
productsCreated.push({
id: product.id,
variants: [{ id: variant.id }],
});
}
await prisma.liveRoomScript.create({
data: {
liveRoomId: room.id,
sequence: 1,
cue: '00:00',
title: '開場破冰與今晚主推',
content:
'先報告福利機制、引導觀眾先看 1~3 號主推商品,最後加入購物車可贈運費券。',
ownerRole: 'HOST',
isDone: false,
targetProductId: productsCreated[0]?.id,
},
});
await prisma.liveRoomScript.create({
data: {
liveRoomId: room.id,
sequence: 2,
cue: '00:20',
title: '第一輪上鏡',
content:
'切到主推款 Demo Product 1補充材質、顏色、穿搭組合並引導觀眾留言問價。',
ownerRole: 'HOST',
isDone: false,
targetProductId: productsCreated[1]?.id,
},
});
await prisma.liveRoomScript.create({
data: {
liveRoomId: room.id,
sequence: 3,
cue: '01:20',
title: '加碼互動與秒殺',
content: '導播轉場到購物掛件,客服同步回覆尺寸、庫存問題,提醒直播限時加價購。',
ownerRole: 'PRODUCER',
isDone: false,
targetProductId: productsCreated[2]?.id,
},
});
await prisma.liveRoomScript.create({
data: {
liveRoomId: room.id,
sequence: 4,
cue: '02:10',
title: '結尾總結',
content: '複盤今晚三款重點,提示未付款者可直接在結帳頁完成,下播後將開啟補貨預購名單。',
ownerRole: 'HOST',
isDone: false,
targetProductId: productsCreated[3]?.id,
},
});
await prisma.liveRoomChecklistItem.create({
data: {
liveRoomId: room.id,
title: '直播前 15 分鐘檢查音訊與畫面',
ownerRole: 'PRODUCER',
description: '確認鏡頭、麥克風、延遲與貨幣換算腳本無誤。',
isRequired: true,
isDone: true,
completedAt: new Date(Date.now() - 10 * 60 * 1000),
},
});
await prisma.liveRoomChecklistItem.create({
data: {
liveRoomId: room.id,
title: '補貨規則與庫存同步',
ownerRole: 'WAREHOUSE',
description: '確認各款庫存不低於 10 件,避免前 10 分鐘售罄。',
isRequired: true,
isDone: false,
},
});
await prisma.liveRoomChecklistItem.create({
data: {
liveRoomId: room.id,
title: '客服值守回覆',
ownerRole: 'SUPPORT',
description: '監看留言問題回覆:價格、尺寸、運送、下單步驟。',
isRequired: true,
isDone: false,
},
});
const sampleMessages = [
'主播你好,這款材質是什麼?',
'有沒有打折?',
'今晚能配這個紅色款嗎?',
'有現貨嗎?',
'這件能搭配什麼配件?',
'有貨到付款嗎?',
'什麼時候會補貨?',
'寄送多久可以到?',
'有試穿影片嗎?',
'價格會再便宜一點嗎?',
'尺寸表可以再清楚一點嗎?',
'包裝是不是有品牌紙袋?',
'可以直接加到購物車嗎?',
'有其他顏色嗎?',
'這張是第一個回應!',
'直播間氣氛很棒',
'主播的穿搭很有氛圍',
'我先看一下其他款',
'結帳流程有點複雜',
'今晚還會有其他新品嗎?',
];
for (const msg of sampleMessages) {
await prisma.liveMessage.create({
data: {
liveRoomId: room.id,
userName: `觀眾-${Math.floor(Math.random() * 99) + 1}`,
message: msg,
userId: user.id,
},
});
}
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (error) => {
console.error(error);
await prisma.$disconnect();
process.exit(1);
});

11
packages/db/src/index.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
export default prisma;
export type { PrismaClient };

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"rootDir": "src"
},
"include": ["src/**/*", "prisma/**/*"]
}

14
packages/ui/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "@vtuber/ui",
"version": "0.1.0",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
"peerDependencies": {
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
},
"devDependencies": {
"typescript": "^5.5.0"
}
}

15
packages/ui/src/index.ts Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react';
export function SectionCard({
title,
children,
}: React.PropsWithChildren<{ title: string }>) {
return (
<section style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16, marginBottom: 16 }}>
<h2 style={{ marginTop: 0 }}>{title}</h2>
{children}
</section>
);
}
export default SectionCard;

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env bash
set -euo pipefail
DOMAIN="${1:-vtuber.wooo.work}"
TARGET_IP="${2:-114.32.151.246}"
DNS_RESOLVER="${3:-8.8.8.8}"
STRICT_MODE="${4:-1}"
strict_mode="${CHECK_STRICT_MODE:-${STRICT_MODE}}"
fail_count=0
resolved_count=0
need_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "❌ 缺少指令:$1"
exit 1
fi
}
need_cmd dig
need_cmd openssl
need_cmd curl
echo "===== DNS 解析檢查 ====="
echo "網域:${DOMAIN}"
echo "預期 A 紀錄指向:${TARGET_IP}"
echo "採用解析器:${DNS_RESOLVER}"
echo "嚴格檢查:${strict_mode}"
a_records="$(dig +short A "${DOMAIN}" @"${DNS_RESOLVER}" | awk 'NF {print $1}' | sort -u)"
aaaa_records="$(dig +short AAAA "${DOMAIN}" @"${DNS_RESOLVER}" | awk 'NF {print $1}' | sort -u)"
if [ -z "${a_records}" ] && [ -z "${aaaa_records}" ]; then
echo "⚠️ 無法解析到 A/AAAA請檢查 DNS zone 是否有建立 ${DOMAIN} 的 A/AAAA 紀錄。"
fail_count=$((fail_count + 1))
else
echo "A 紀錄:${a_records:-(未設定)}"
echo "AAAA 紀錄:${aaaa_records:-(未設定)}"
if printf '%s\n' "${a_records}" | grep -Fxq "${TARGET_IP}"; then
echo "✅ A 紀錄包含 ${TARGET_IP}"
resolved_count=$((resolved_count + 1))
else
echo "⚠️ ${DOMAIN} 的 A 紀錄未看到 ${TARGET_IP}"
echo " 請檢查 DNSA Record (${DOMAIN}) = ${TARGET_IP}"
echo " 修正指引:在 DNS 面板把 vtuber.wooo.work 的 A Record 指到 ${TARGET_IP}"
fail_count=$((fail_count + 1))
fi
fi
if command -v host >/dev/null 2>&1; then
host "${DOMAIN}" | sed -n '1,4p' || true
else
echo "(已略過 host 查詢,未安裝 host 指令)"
fi
echo
echo "===== HTTPS 憑證檢查 ====="
cert_pem="$(echo | openssl s_client -connect "${DOMAIN}:443" -servername "${DOMAIN}" 2>/dev/null | openssl x509 2>/dev/null || true)"
if [ -z "${cert_pem}" ]; then
echo "❌ 無法抓到 TLS 憑證(連線或憑證有問題)"
fail_count=$((fail_count + 1))
else
subject="$(printf '%s\n' "${cert_pem}" | openssl x509 -noout -subject)"
issuer="$(printf '%s\n' "${cert_pem}" | openssl x509 -noout -issuer)"
start_date="$(printf '%s\n' "${cert_pem}" | openssl x509 -noout -startdate)"
end_date="$(printf '%s\n' "${cert_pem}" | openssl x509 -noout -enddate)"
san="$(printf '%s\n' "${cert_pem}" | openssl x509 -noout -ext subjectAltName 2>/dev/null || true)"
echo "Subject: ${subject}"
echo "Issuer: ${issuer}"
echo "起始: ${start_date}"
echo "到期: ${end_date}"
echo "SAN: ${san:-(未提供)}"
if printf '%s\n' "${san}" | grep -q "DNS:${DOMAIN}"; then
echo "✅ 憑證 SAN 包含 ${DOMAIN}"
else
echo "⚠️ 憑證 SAN 未明確列出 ${DOMAIN}"
fail_count=$((fail_count + 1))
fi
if printf '%s\n' "${cert_pem}" | openssl x509 -checkend $((7*24*60*60)) >/dev/null 2>&1; then
echo "✅ 憑證未於 7 日內到期"
else
echo "⚠️ 憑證疑似 7 日內到期"
fail_count=$((fail_count + 1))
fi
fi
echo
echo "===== 路徑可達性檢查 ====="
check_url() {
local label="$1"
local url="$2"
local expect_http="${3:-200}"
local output
shift 3
output="$(curl -ksS -m 12 -o /dev/null -w 'HTTP %{http_code} | total=%{time_total}s | remote=%{remote_ip}' "$@" "$url" || true)"
local http_code
http_code="$(printf '%s\n' "${output}" | awk '{print $2}')"
if [ -z "${output}" ] || [ "${http_code}" = "000" ] || [ -z "${http_code}" ]; then
echo "${label}:連線失敗"
fail_count=$((fail_count + 1))
elif [ "${http_code}" != "${expect_http}" ]; then
echo "${label}HTTP ${http_code}(預期 ${expect_http}"
fail_count=$((fail_count + 1))
else
echo "${label}${output}"
fi
}
check_url "域名直連" "https://${DOMAIN}/live/demo" 200
check_url "域名繁中直連" "https://${DOMAIN}/zh-TW/live/demo" 200
check_url "SNI 直定向到 ${TARGET_IP}" "https://${DOMAIN}/live/demo" \
--resolve "${DOMAIN}:443:${TARGET_IP}" 200
check_url "IP + Host 直走 (防止 host 漏配)" "http://${TARGET_IP}/live/demo" \
-H "Host: ${DOMAIN}" 200
check_url "純 IP 訪問(不帶 Host" "http://${TARGET_IP}/live/demo" 200
echo
if [ "${fail_count}" -eq 0 ]; then
echo "總結:✅ 全部檢查通過。"
else
echo "總結:⚠️ ${fail_count} 項提醒,需先補上對應修正。"
fi
if [ "${strict_mode}" != "0" ] && [ "${fail_count}" -gt 0 ]; then
echo "部署門檻:❌ 因嚴格模式,非 0 值結果將阻止後續流程。"
exit 1
fi
if [ "${fail_count}" -gt 0 ] && [ "${resolved_count}" -eq 0 ]; then
echo "DNS 門檻:❌ 外網無法直接導向目標主機,這通常代表流量仍在舊站。"
fi
exit 0

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
ENV_FILE="${ROOT_DIR}/deploy/.env.prod"
DEFAULT_ENV_FILE="${ROOT_DIR}/deploy/.env.prod.example"
if [ ! -f "${ENV_FILE}" ]; then
if [ -f "${DEFAULT_ENV_FILE}" ]; then
echo "⚠️ 找不到 deploy/.env.prod先複製並填寫預設值"
echo "cp deploy/.env.prod.example deploy/.env.prod"
else
echo "❌ 找不到 deploy/.env.prod"
fi
exit 1
fi
source "${ENV_FILE}"
TARGET_DOMAIN="${APP_DOMAIN:-vtuber.wooo.work}"
TARGET_HOST="${DEPLOY_HOST:-114.32.151.246}"
cd "${ROOT_DIR}"
echo "=== Step 1: 正式版推版到 ${DEPLOY_USER}@${TARGET_HOST} ==="
./deploy/deploy-prod.sh
echo
echo "=== Step 2: 內網服務健康檢查 ==="
curl -ksS -o /dev/null -w "[內網] /live/demo => HTTP %{http_code} | 花費 %{time_total}s\n" "http://${TARGET_HOST}:3200/live/demo"
if [ -n "${TARGET_DOMAIN}" ]; then
echo
echo "=== Step 3: 外網導向一致性檢查 ==="
./scripts/check-vtuber-offline-110.sh "${TARGET_DOMAIN}" "${TARGET_HOST}" 8.8.8.8 1
else
echo
echo "=== Step 3: 外網導向檢查已略過(缺少 APP_DOMAIN ==="
fi
echo
echo "✅ 推版+驗證流程完成。"

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
DOMAIN="${1:-vtuber.wooo.work}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${ROOT_DIR}"
echo "=== Step 1: 執行正式站推版+外網可用性基礎檢查 ==="
./scripts/deploy-and-verify-vtuber110.sh
echo
echo "=== Step 2: 正式網域直播頁路由逐一驗證 ==="
for path in "/live/demo" "/zh-TW/live/demo"; do
url="https://${DOMAIN}${path}"
http_code="$(curl -ksS -m 12 -o /dev/null -w '%{http_code}' "$url" || true)"
if [ -z "${http_code}" ] || [ "${http_code}" = "000" ]; then
echo "${url}:連線失敗"
exit 1
elif [ "${http_code}" != "200" ]; then
echo "${url}HTTP ${http_code}(預期 200"
exit 1
else
echo "${url}HTTP ${http_code}"
fi
done
echo
echo "✅ 所有正式網域直播路由驗證通過。"

13
test-browser.js Normal file
View File

@@ -0,0 +1,13 @@
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] });
const page = await browser.newPage();
page.on('console', msg => console.log('BROWSER CONSOLE:', msg.text()));
page.on('pageerror', error => console.log('BROWSER ERROR:', error.message));
page.on('requestfailed', request => console.log('BROWSER REQUEST FAILED:', request.url(), request.failure().errorText));
await page.goto('https://vtuber.wooo.work/live/demo', { waitUntil: 'networkidle2' });
await new Promise(r => setTimeout(r, 5000));
await browser.close();
})();

11
test_glb.js Normal file
View File

@@ -0,0 +1,11 @@
const fs = require('fs');
const buffer = fs.readFileSync('./apps/web/public/models/realistic-host.glb');
const chunkLength = buffer.readUInt32LE(12);
const jsonChunk = buffer.toString('utf8', 20, 20 + chunkLength);
const json = JSON.parse(jsonChunk);
if (json.meshes) {
console.log('Meshes:', json.meshes.map(m => m.name));
}
if (json.nodes) {
console.log('Mesh Nodes:', json.nodes.filter(n => n.mesh !== undefined).map(n => n.name));
}

18
tsconfig.base.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@vtuber/db": ["./packages/db/src"],
"@vtuber/ui": ["./packages/ui/src"]
}
},
"exclude": ["node_modules", "dist"]
}