chore: restore initial vtuber source snapshot
This commit is contained in:
6
.env.example
Normal file
6
.env.example
Normal 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
16
.gitignore
vendored
Normal 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
148
README.md
Normal 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 1:Monorepo + Prisma + Seed(10 商品、20 留言)
|
||||
- Day 2:前台商品頁、商品頁、購物車與結帳
|
||||
- Day 3:直播 Demo(左主播、右留言、下方主推)
|
||||
- Day 4:後台商品/訂單/庫存/作業台
|
||||
- Day 5:AI 主播問答 API(價格、庫存、尺寸、物流)
|
||||
- Day 6:真人 / 虛擬直播場景與動畫 placeholder
|
||||
- Day 7:部署 + 端到端流程驗證
|
||||
|
||||
### Day 3:直播 Demo
|
||||
|
||||
- 開啟 `/live/demo` 進入直播頁(預設會讀取直播間場景流與作業資訊)
|
||||
- 左側為虛擬主播區(可替換 video/avatar)
|
||||
- 右側為留言輸入與列表
|
||||
- 下方為主推商品與「立即購買」
|
||||
|
||||
## 主播角色與作業流程(MVP)
|
||||
|
||||
- 主持人(HOST):直播主講、回覆問題、引導下單
|
||||
- 場控/導播(PRODUCER):轉場與畫面節奏控制
|
||||
- 導演(DIRECTOR):逐字稿節奏、回合節奏與上鏡順序控管
|
||||
- 小編/編輯(EDITOR):腳本文案、備稿、字幕與直播素材
|
||||
- 小助手(SUPPORT):留言回覆、加購/售後疑問回應
|
||||
- 倉儲(WAREHOUSE):實體庫存核對、出貨前置
|
||||
- 行銷(MARKETING):促銷節奏、優惠提醒、活動執行
|
||||
- 運維(OPERATOR):直播間啟關、連線監控(預留)
|
||||
|
||||
主流程(可操作):
|
||||
1) 開播前:建立直播間與角色指派,完成 3~5 個主打腳本節點與核對清單;
|
||||
2) 直播中:開播/留言互動/AI 詢問回覆;
|
||||
3) 結帳:在直播頁可直接「加入購物車」或「立即購買」建立 mock 訂單;
|
||||
4) 結束後:在後台回顧腳本勾選與清單完成度。
|
||||
|
||||
## 正式環境推版(建議 188)
|
||||
|
||||
1. 在正式機先放好 `VTuber` repo,並複製 `deploy/.env.prod.example` 為 `deploy/.env.prod`
|
||||
2. 填寫 `deploy/.env.prod`
|
||||
3. 本機執行推版腳本:
|
||||
```bash
|
||||
chmod +x deploy/deploy-prod.sh
|
||||
cp deploy/.env.prod.example deploy/.env.prod
|
||||
./deploy/deploy-prod.sh
|
||||
```
|
||||
|
||||
若你要直接做到「推版 + 內外網可用性檢查」一次完成,可直接執行:
|
||||
|
||||
```bash
|
||||
./scripts/deploy-and-verify-vtuber110.sh
|
||||
```
|
||||
|
||||
這支指令會先跑 `deploy-prod.sh`,再自動做 `/live/demo` 與 DNS/憑證健康檢查,部署完直接知道是否已恢復。
|
||||
若外網 DNS 還是指到舊站,腳本會直接回傳失敗並停止,避免把錯誤版本上線。
|
||||
|
||||
4. 正式環境驗證:
|
||||
- `curl http://localhost:3000`(前台)
|
||||
- `curl http://localhost:3001`(後台)
|
||||
- `curl http://localhost:4000/api/health`(API)
|
||||
- `curl http://localhost:3000/live/demo`(直播頁)
|
||||
- `curl -ksS https://vtuber.wooo.work/live/demo`(正式網域直播頁)
|
||||
- `curl -ksS https://vtuber.wooo.work/zh-TW/live/demo`(正式網域繁中直播頁)
|
||||
|
||||
或者直接一鍵執行(包含部署驗證+兩條正式網域路徑):
|
||||
|
||||
```bash
|
||||
./scripts/verify-live-domain-110.sh
|
||||
```
|
||||
|
||||
如果你要直接在正式網域驗證,請先確認反向代理已將 443/80 轉到對應服務。
|
||||
|
||||
### 110 正式站快速可用性檢查
|
||||
|
||||
可直接執行:
|
||||
|
||||
```bash
|
||||
./scripts/check-vtuber-offline-110.sh vtuber.wooo.work 114.32.151.246
|
||||
```
|
||||
|
||||
內容會回報:
|
||||
- DNS A/AAAA 解析是否指到 114.32.151.246
|
||||
- TLS 憑證 Subject / Issuer / 到期日 / SAN
|
||||
- `/live/demo` 及 `/zh-TW/live/demo` 直連、SNI 指向、IP+Host 直走、純 IP 訪問 5 種路徑結果
|
||||
- 若回應非 200,腳本會直接列為失敗(`404` 不會再被誤判為通過)
|
||||
- 失敗項目對應的提醒(例如要檢查哪個 DNS A Record)
|
||||
|
||||
可加上 `CHECK_STRICT_MODE=1` 強制回傳非 0 讓流水線判斷失敗(預設已啟用)。
|
||||
|
||||
### 110 Nginx 404 保底導回設定
|
||||
|
||||
如果你要直接在 110 套用 404 fallback,可先確認 `deploy/.env.prod` 已填好,再執行:
|
||||
|
||||
```bash
|
||||
./deploy/apply-nginx-110-live-fallback.sh
|
||||
```
|
||||
|
||||
腳本行為:
|
||||
- 將 `deploy/vtuber-nginx-110-live-fallback.conf` 上傳到
|
||||
`/etc/nginx/snippets/vtuber-nginx-110-live-fallback.conf`
|
||||
- 於 `NGINX_SERVER_CONFIG`(預設 `/etc/nginx/sites-available/${APP_DOMAIN}`)
|
||||
插入 snippet include
|
||||
- `nginx -t` 後重新載入 Nginx
|
||||
|
||||
## 環境變數
|
||||
|
||||
請複製根目錄 `.env.example` 為 `.env` 並修改:
|
||||
|
||||
- `DATABASE_URL`
|
||||
- `API_PORT`
|
||||
- `NEXT_PUBLIC_API_BASE_URL`
|
||||
|
||||
各 app 也保留 `.env.example` 於專案下可作為參考。
|
||||
2
apps/admin/.env.example
Normal file
2
apps/admin/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:4000/api
|
||||
NEXT_PUBLIC_APP_DOMAIN=vtuber.wooo.work
|
||||
3
apps/admin/.eslintrc.json
Normal file
3
apps/admin/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
27
apps/admin/Dockerfile
Normal file
27
apps/admin/Dockerfile
Normal 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
5
apps/admin/next-env.d.ts
vendored
Normal 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.
|
||||
6
apps/admin/next.config.js
Normal file
6
apps/admin/next.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
25
apps/admin/package.json
Normal file
25
apps/admin/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
0
apps/admin/public/.gitkeep
Normal file
0
apps/admin/public/.gitkeep
Normal file
14
apps/admin/src/app/globals.css
Normal file
14
apps/admin/src/app/globals.css
Normal file
@@ -0,0 +1,14 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: #222;
|
||||
background: #f4f6ff;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
136
apps/admin/src/app/inventory/page.tsx
Normal file
136
apps/admin/src/app/inventory/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
apps/admin/src/app/layout.tsx
Normal file
19
apps/admin/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
785
apps/admin/src/app/operations/page.tsx
Normal file
785
apps/admin/src/app/operations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
apps/admin/src/app/orders/page.tsx
Normal file
137
apps/admin/src/app/orders/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
apps/admin/src/app/page.tsx
Normal file
24
apps/admin/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
321
apps/admin/src/app/products/page.tsx
Normal file
321
apps/admin/src/app/products/page.tsx
Normal 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
23
apps/admin/src/lib/api.ts
Normal 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
19
apps/admin/tsconfig.json
Normal 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
2
apps/api/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
PORT=4000
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/vtuber
|
||||
8
apps/api/.eslintrc.json
Normal file
8
apps/api/.eslintrc.json
Normal 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
15
apps/api/Dockerfile
Normal 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
8
apps/api/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"assets": ["**/*.json"],
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
35
apps/api/package.json
Normal file
35
apps/api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
494
apps/api/src/app.controller.ts
Normal file
494
apps/api/src/app.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
12
apps/api/src/app.module.ts
Normal file
12
apps/api/src/app.module.ts
Normal 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
1219
apps/api/src/app.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
9
apps/api/src/database/database.module.ts
Normal file
9
apps/api/src/database/database.module.ts
Normal 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 {}
|
||||
13
apps/api/src/database/prisma.service.ts
Normal file
13
apps/api/src/database/prisma.service.ts
Normal 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
14
apps/api/src/main.ts
Normal 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();
|
||||
4
apps/api/tsconfig.build.json
Normal file
4
apps/api/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "**/*spec.ts"]
|
||||
}
|
||||
19
apps/api/tsconfig.json
Normal file
19
apps/api/tsconfig.json
Normal 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
4
apps/web/.env.example
Normal 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
3
apps/web/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
26
apps/web/Dockerfile
Normal file
26
apps/web/Dockerfile
Normal 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
5
apps/web/next-env.d.ts
vendored
Normal 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
14
apps/web/next.config.js
Normal 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
28
apps/web/package.json
Normal 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
0
apps/web/public/.gitkeep
Normal file
BIN
apps/web/public/models/realistic-host.glb
Normal file
BIN
apps/web/public/models/realistic-host.glb
Normal file
Binary file not shown.
BIN
apps/web/public/models/xiaoyai.vrm
Normal file
BIN
apps/web/public/models/xiaoyai.vrm
Normal file
Binary file not shown.
5
apps/web/src/app/[locale]/live/demo/page.tsx
Normal file
5
apps/web/src/app/[locale]/live/demo/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import LiveDemoPage from '../../../live/demo/page';
|
||||
|
||||
export default function LocaleLiveDemoPage() {
|
||||
return <LiveDemoPage />;
|
||||
}
|
||||
89
apps/web/src/app/cart/page.tsx
Normal file
89
apps/web/src/app/cart/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
apps/web/src/app/checkout/page.tsx
Normal file
121
apps/web/src/app/checkout/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/app/en/live/demo/page.tsx
Normal file
5
apps/web/src/app/en/live/demo/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import LiveDemoPage from '../../../live/demo/page';
|
||||
|
||||
export default function EnLiveDemoPage() {
|
||||
return <LiveDemoPage />;
|
||||
}
|
||||
5
apps/web/src/app/en/live/page.tsx
Normal file
5
apps/web/src/app/en/live/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function EnLivePageRedirect() {
|
||||
redirect('/en/live/demo');
|
||||
}
|
||||
14
apps/web/src/app/globals.css
Normal file
14
apps/web/src/app/globals.css
Normal file
@@ -0,0 +1,14 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: #222;
|
||||
background: #f8f8fb;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
5
apps/web/src/app/ja/live/demo/page.tsx
Normal file
5
apps/web/src/app/ja/live/demo/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import LiveDemoPage from '../../../live/demo/page';
|
||||
|
||||
export default function JaLiveDemoPage() {
|
||||
return <LiveDemoPage />;
|
||||
}
|
||||
5
apps/web/src/app/ja/live/page.tsx
Normal file
5
apps/web/src/app/ja/live/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function JaLivePageRedirect() {
|
||||
redirect('/ja/live/demo');
|
||||
}
|
||||
19
apps/web/src/app/layout.tsx
Normal file
19
apps/web/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1878
apps/web/src/app/live/demo/page.tsx
Normal file
1878
apps/web/src/app/live/demo/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
5
apps/web/src/app/live/page.tsx
Normal file
5
apps/web/src/app/live/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function LivePageRedirect() {
|
||||
redirect('/live/demo');
|
||||
}
|
||||
5
apps/web/src/app/not-found.tsx
Normal file
5
apps/web/src/app/not-found.tsx
Normal 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
16
apps/web/src/app/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main style={{ padding: 32, fontFamily: 'system-ui' }}>
|
||||
<h1>VTuber 前台 Demo</h1>
|
||||
<p>Day 2:商品清單、商品頁、購物車、mock 結帳已可展示。</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>
|
||||
);
|
||||
}
|
||||
73
apps/web/src/app/products/[slug]/page.tsx
Normal file
73
apps/web/src/app/products/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
apps/web/src/app/products/page.tsx
Normal file
93
apps/web/src/app/products/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/app/zh-CN/live/demo/page.tsx
Normal file
5
apps/web/src/app/zh-CN/live/demo/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import LiveDemoPage from '../../../live/demo/page';
|
||||
|
||||
export default function ZhCnLiveDemoPage() {
|
||||
return <LiveDemoPage />;
|
||||
}
|
||||
5
apps/web/src/app/zh-CN/live/page.tsx
Normal file
5
apps/web/src/app/zh-CN/live/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function ZhCnLivePageRedirect() {
|
||||
redirect('/zh-CN/live/demo');
|
||||
}
|
||||
5
apps/web/src/app/zh-TW/live/demo/page.tsx
Normal file
5
apps/web/src/app/zh-TW/live/demo/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import LiveDemoPage from '../../../live/demo/page';
|
||||
|
||||
export default function ZhTwLiveDemoPage() {
|
||||
return <LiveDemoPage />;
|
||||
}
|
||||
5
apps/web/src/app/zh-TW/live/page.tsx
Normal file
5
apps/web/src/app/zh-TW/live/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function ZhTwLivePageRedirect() {
|
||||
redirect('/zh-TW/live/demo');
|
||||
}
|
||||
50
apps/web/src/components/add-to-cart-button.tsx
Normal file
50
apps/web/src/components/add-to-cart-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
286
apps/web/src/components/virtual-host.tsx
Normal file
286
apps/web/src/components/virtual-host.tsx
Normal 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
31
apps/web/src/lib/api.ts
Normal 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>;
|
||||
}
|
||||
41
apps/web/src/middleware.ts
Normal file
41
apps/web/src/middleware.ts
Normal 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
19
apps/web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
79
deploy/apply-nginx-110-live-fallback.sh
Executable file
79
deploy/apply-nginx-110-live-fallback.sh
Executable 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
65
deploy/deploy-prod.sh
Executable 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}"
|
||||
69
deploy/docker-compose.prod.yml
Normal file
69
deploy/docker-compose.prod.yml
Normal 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:
|
||||
11
deploy/vtuber-nginx-110-live-fallback.conf
Normal file
11
deploy/vtuber-nginx-110-live-fallback.conf
Normal 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
22
docs/PRD.md
Normal 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
37
docs/TASKS.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# VTuber 任務拆解(7 天)
|
||||
|
||||
## Day 1
|
||||
- 建立 monorepo 架構
|
||||
- 建立 Prisma schema 與 seed
|
||||
|
||||
## Day 2
|
||||
- 建立前台商品列表、商品頁、購物車、結帳頁(mock)
|
||||
|
||||
## Day 3
|
||||
- 建立直播 demo 頁:左主播、右留言、下方主推商品
|
||||
|
||||
## Day 4
|
||||
- 建立後台商品、訂單、庫存管理
|
||||
|
||||
## Day 5
|
||||
- 建立 AI 主播問答 API:POST `/api/ai/product-chat`
|
||||
|
||||
## Day 6
|
||||
- 虛擬主播影片 / 真人場景 placeholder 與直播 UI 優化
|
||||
|
||||
### 直播作業角色模組(已加入)
|
||||
|
||||
- 後台新增「直播作業台」:建立直播人員、角色指派、主播腳本、核對清單。
|
||||
- API 新增作業 API:
|
||||
- 團隊成員 CRUD
|
||||
- 直播作業(分工/腳本/清單)管理
|
||||
- 直播狀態更新
|
||||
|
||||
### 交付可操作閉環
|
||||
|
||||
- 直播前:建立角色分工與作業流程。
|
||||
- 直播中:在 `/live/demo` 留言 + AI 互動 + 加入購物車 / 立即購買。
|
||||
- 直播後:後台觀測訂單、庫存扣減、作業完成度。
|
||||
|
||||
## Day 7
|
||||
- 部署 demo(local->staging->公開)
|
||||
34
package.json
Normal file
34
package.json
Normal 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
22
packages/db/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
245
packages/db/prisma/schema.prisma
Normal file
245
packages/db/prisma/schema.prisma
Normal 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
354
packages/db/prisma/seed.ts
Normal 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
11
packages/db/src/index.ts
Normal 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 };
|
||||
9
packages/db/tsconfig.json
Normal file
9
packages/db/tsconfig.json
Normal 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
14
packages/ui/package.json
Normal 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
15
packages/ui/src/index.ts
Normal 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;
|
||||
138
scripts/check-vtuber-offline-110.sh
Executable file
138
scripts/check-vtuber-offline-110.sh
Executable 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 " 請檢查 DNS:A 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
|
||||
44
scripts/deploy-and-verify-vtuber110.sh
Executable file
44
scripts/deploy-and-verify-vtuber110.sh
Executable 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 "✅ 推版+驗證流程完成。"
|
||||
33
scripts/verify-live-domain-110.sh
Executable file
33
scripts/verify-live-domain-110.sh
Executable 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
13
test-browser.js
Normal 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
11
test_glb.js
Normal 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
18
tsconfig.base.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user