Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eba067088e | |||
| ab6a2ffb08 | |||
| 3be0bc68a0 | |||
| 1883d9ba06 | |||
| 2b883b5468 | |||
| 5e0c2f2654 | |||
| 550b6cb73c | |||
| 6c5e76d32e | |||
| b1e85b68df | |||
| 21a66be8a6 | |||
| 4a848247fa | |||
| af38a296c5 | |||
| f021ff2a74 | |||
| 0fc133798b | |||
| 61117e50bc | |||
| ad902105f5 | |||
| f83c96bb77 | |||
| 6930d4543f | |||
| 7b2f35ad3d | |||
| 5256bd0d8b | |||
| 33270dd026 | |||
| 0f27bfa999 | |||
| f15bc2f2a2 | |||
| 2273fb329f | |||
| 4bd3da73e3 | |||
| 7674a04a33 | |||
| 9d97e8aacc | |||
| 89639a6d3e | |||
| c4144d9e3f | |||
| de201436ea | |||
| 77a49e3365 | |||
| d7e362a637 | |||
| 028c6613ce | |||
| 696cdda87b | |||
| a85167a529 | |||
| e0b95e69d4 | |||
| 63d4048354 | |||
| e811516c6e | |||
| f9a106f36b | |||
| e902fbd65e | |||
| e8352227af | |||
| ef7596739c | |||
| c2329f3f98 | |||
| 0a0914322c | |||
| 042cae7a61 | |||
| be885eb8f0 | |||
| fd61800be9 | |||
| 183681c07d | |||
| 8d906bac72 | |||
| 8f925e6c65 | |||
| 9732cc51ee | |||
| 815b3758a7 | |||
| eb73eeab69 | |||
| 2325ffbc57 | |||
| faeed6f014 | |||
| 6dc388a794 | |||
| 7ca5b3732f | |||
| 7cdf56a1ac | |||
| fac825b60b | |||
| 409efe141a | |||
| 6e676a07f7 | |||
| e629cf0e69 | |||
| dd17312181 | |||
| 05fbf64697 | |||
| 096dc66e2c | |||
| 1230eaa24b | |||
| 0d7effe88f | |||
| 76fdab4f63 | |||
| 4c31026f81 | |||
| 44ee898aa0 | |||
| 049dd028c0 | |||
| e64e1c2938 | |||
| 93e59df866 | |||
| 577f744912 | |||
| d727220562 | |||
| 0ce9a22b91 | |||
| 28173f1713 | |||
| 23edd869c6 | |||
| a863324942 | |||
| 3859ce1750 | |||
| ba35e3f518 | |||
| ec231ac88f | |||
| ccfaf9aa7e | |||
| fe97f016b2 | |||
| 8a0b9b8878 | |||
| 15f12ead3f | |||
| cfabbd3d61 | |||
| 19ca924087 | |||
| 0830b3ac45 | |||
| cb7daa0674 | |||
| 33fd762eea | |||
| 3eb541ca39 | |||
| b353ac0328 | |||
| 0638c685b6 | |||
| fdcdd4c6ef | |||
| 8378c2e8ff | |||
| 575a83fcaf | |||
| 17ad2235b6 | |||
| 7c47856671 | |||
| 8fa161cfb8 | |||
| cbf481a7fb | |||
| fe485cf0fc | |||
| 04886e0b30 | |||
| cbc7b48a05 | |||
| f10f691e40 | |||
| 4c2509648e | |||
| e6006e849b | |||
| 1076ce4216 | |||
| 39187a0660 | |||
| f0bbedab23 | |||
| 750444c8bb | |||
| 581a146d2f | |||
| eab45e0819 | |||
| e243d70082 | |||
| d4c3743e45 | |||
| 07d9b56fe7 | |||
| 11b3f83368 | |||
| 1dc40b4760 | |||
| 29448aaf9b | |||
| c73c4be367 | |||
| c31bc96855 | |||
| 002384bad4 | |||
| db6b742ebb | |||
| 88ca8b4406 | |||
| 005b34d0dd | |||
| e2ddc679bb | |||
| 39990f7a07 | |||
| 6fcb4cd70c | |||
| 14fefc7b1a | |||
| fa8ab58fc0 | |||
| 4765d83c8b | |||
| 395bc37cf0 | |||
| 15b6e17552 | |||
| a6e147ed30 | |||
| 0d46d71b2d |
@@ -458,6 +458,8 @@ jobs:
|
||||
name: Frontend Lint
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
needs: check-frontend-only
|
||||
if: needs.check-frontend-only.outputs.skip_frontend != 'true'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -576,7 +578,12 @@ jobs:
|
||||
name: PR Build ${{ matrix.service_display }} Image
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
if: github.event_name == 'pull_request'
|
||||
needs: check-frontend-only
|
||||
if: |
|
||||
github.event_name == 'pull_request' && (
|
||||
(matrix.service == 'web' && needs.check-frontend-only.outputs.skip_frontend != 'true') ||
|
||||
(matrix.service != 'web' && needs.check-frontend-only.outputs.skip_backend != 'true')
|
||||
)
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -1084,6 +1091,8 @@ jobs:
|
||||
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||
CONTAINER_NAME="staging-e2e-$$"
|
||||
# 强制清理可能残留的同名容器(上一次异常退出时未清理)
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker create --name "$CONTAINER_NAME" --ipc=host \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
@@ -1192,6 +1201,11 @@ jobs:
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
needs:
|
||||
- validate-code-quality
|
||||
- validate-type-check
|
||||
- unit-tests
|
||||
- frontend-lint
|
||||
- frontend-unit-test
|
||||
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref_name == 'main')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1265,7 +1279,7 @@ jobs:
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- name: Build and push production ${{ matrix.service_display }} image (with retry)
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
@@ -1718,6 +1732,30 @@ jobs:
|
||||
echo " frontend-lint: $RESULT_FRONTEND_LINT"
|
||||
echo " frontend-unit-test: $RESULT_FRONTEND_UNIT"
|
||||
echo " build-pr: $RESULT_BUILD_PR"
|
||||
|
||||
# 查询 AI Code Review 状态(跨workflow,读commit status)
|
||||
AI_REVIEW_STATUS="pending"
|
||||
AI_REVIEW_DESC=""
|
||||
STATUS_JSON=$(curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/commits/${PR_HEAD_SHA}/status" 2>/dev/null || true)
|
||||
if [ -n "$STATUS_JSON" ]; then
|
||||
AI_STATUS=$(echo "$STATUS_JSON" | python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
data=json.load(sys.stdin)
|
||||
for s in data.get('statuses',[]):
|
||||
if 'AI Code Review' in s.get('context',''):
|
||||
print(s['state']+'|'+s.get('description',''))
|
||||
break
|
||||
except: pass
|
||||
" 2>/dev/null)
|
||||
if [ -n "$AI_STATUS" ]; then
|
||||
AI_REVIEW_STATUS="${AI_STATUS%%|*}"
|
||||
AI_REVIEW_DESC="${AI_STATUS#*|}"
|
||||
fi
|
||||
fi
|
||||
echo " ai-code-review: $AI_REVIEW_STATUS ($AI_REVIEW_DESC)"
|
||||
|
||||
echo ""
|
||||
|
||||
# 判断PR类型
|
||||
@@ -1733,11 +1771,13 @@ jobs:
|
||||
"validate-migration:$RESULT_MIGRATION"
|
||||
"frontend-lint:$RESULT_FRONTEND_LINT"
|
||||
"build-pr:$RESULT_BUILD_PR"
|
||||
"ai-code-review:$AI_REVIEW_STATUS"
|
||||
)
|
||||
|
||||
# 后端检查
|
||||
REQUIRED_BACKEND=(
|
||||
"unit-tests:$RESULT_UNIT_TESTS"
|
||||
"integration-tests:$RESULT_INTEGRATION"
|
||||
)
|
||||
|
||||
# 前端检查
|
||||
@@ -1767,6 +1807,11 @@ jobs:
|
||||
for item in "${REQUIRED_GENERAL[@]}"; do
|
||||
name="${item%%:*}"
|
||||
result="${item##*:}"
|
||||
# AI Code Review pending时不阻塞(可能还在跑),等它跑完自然会重跑Gate
|
||||
if [ "$name" = "ai-code-review" ] && [ "$result" = "pending" ]; then
|
||||
echo " ⏳ $name: pending(审查中,暂不阻塞)"
|
||||
continue
|
||||
fi
|
||||
check_job "$name" "$result"
|
||||
done
|
||||
|
||||
@@ -1805,7 +1850,7 @@ jobs:
|
||||
echo "❌ CI Gate: FAILED"
|
||||
echo "失败项: ${FAILED_ITEMS[*]}"
|
||||
echo "gate_result=failure" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Report CI trace
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
report: ${{ steps.report.outputs.report }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
report: ${{ steps.e2e.outputs.report }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
Executable → Regular
+6
-1
@@ -8,6 +8,11 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
concurrency:
|
||||
group: pr-automation-${{ gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
auto-approve:
|
||||
name: Auto Approve on CI Green
|
||||
@@ -56,7 +61,7 @@ jobs:
|
||||
name: Auto Merge on CI Green + Approved
|
||||
runs-on: ci-check
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
|
||||
timeout-minutes: 45 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
|
||||
timeout-minutes: 3 # 短作业模式:检查一次,不满足就退出,由pr-auto-scan每5分钟定时兜底
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
||||
run: |
|
||||
set -eux
|
||||
preview_host="${PREVIEW_SSH_HOST:-172.30.18.197}"
|
||||
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
|
||||
preview_user="${PREVIEW_SSH_USER:-deploy}"
|
||||
preview_port="${PREVIEW_SSH_PORT:-22222}"
|
||||
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
# Install dependencies with retry
|
||||
for i in 1 2 3; do
|
||||
npm ci --no-audit --no-fund && break
|
||||
echo "npm ci failed, retry $i/3..."
|
||||
echo "npm install failed, retry $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
rm -rf node_modules
|
||||
sleep 5
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
*/
|
||||
import axios from "axios"
|
||||
import apiClient from "./client"
|
||||
|
||||
// 类型定义
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified: boolean
|
||||
email_verified: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id?: string
|
||||
user_id?: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified?: boolean
|
||||
email_verified?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export const normalizeUser = (data: UserResponse): User => {
|
||||
const userId = data.id ?? data.user_id ?? ""
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
user_id: userId,
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
is_email_verified: emailVerified,
|
||||
email_verified: emailVerified,
|
||||
created_at: data.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post("/auth/login", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 刷新 access_token(使用裸 axios 避免拦截器递归)
|
||||
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? ""
|
||||
const response = await axios.post(`${baseURL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 注册
|
||||
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/register", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 登出
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post("/auth/logout")
|
||||
}
|
||||
|
||||
// 获取当前用户
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get<UserResponse>("/auth/me")
|
||||
return normalizeUser(response.data)
|
||||
}
|
||||
|
||||
// 请求密码重置
|
||||
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/forgot-password", { email })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
export const resetPassword = async (
|
||||
token: string,
|
||||
newPassword: string,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/reset-password", {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 验证邮箱
|
||||
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/verify-email", { token })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ========== 微信登录 ========== */
|
||||
|
||||
export interface WechatAuthUrlResponse {
|
||||
auth_url: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface WechatCallbackResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
user_id: string
|
||||
display_name: string
|
||||
avatar_url: string
|
||||
is_new_user: boolean
|
||||
binding_complete: boolean
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
target: "email" | "phone"
|
||||
value: string
|
||||
purpose: "bind" | "login" | "reset_password"
|
||||
}
|
||||
|
||||
export interface BindContactRequest {
|
||||
email?: string
|
||||
email_code?: string
|
||||
phone?: string
|
||||
phone_code?: string
|
||||
}
|
||||
|
||||
export interface BindContactResponse {
|
||||
success: boolean
|
||||
user: User
|
||||
}
|
||||
|
||||
// 获取微信授权链接
|
||||
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
|
||||
const response = await apiClient.get("/auth/wechat/url")
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 微信回调登录
|
||||
export const wechatCallback = async (
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<WechatCallbackResponse> => {
|
||||
const response = await apiClient.post("/auth/wechat/callback", { code, state })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
export const sendVerificationCode = async (
|
||||
data: SendVerificationCodeRequest,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/send-verification-code", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 绑定联系方式
|
||||
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
|
||||
const response = await apiClient.post("/auth/bind-contact", data)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import apiClient from "../client"
|
||||
import type { SendVerificationCodeRequest, BindContactRequest, BindContactResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
*/
|
||||
export const sendVerificationCode = async (
|
||||
data: SendVerificationCodeRequest,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/send-verification-code", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定联系方式
|
||||
*/
|
||||
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
|
||||
const response = await apiClient.post("/auth/bind-contact", data)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import apiClient from "../client"
|
||||
import type { User, UserResponse } from "./types"
|
||||
import { normalizeUser } from "./user"
|
||||
|
||||
/**
|
||||
* 获取当前用户
|
||||
*/
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get<UserResponse>("/auth/me")
|
||||
return normalizeUser(response.data)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
/**
|
||||
* 验证邮箱
|
||||
*/
|
||||
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/verify-email", { token })
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
* 保持向后兼容,从子模块 re-export
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
User,
|
||||
UserResponse,
|
||||
WechatAuthUrlResponse,
|
||||
WechatCallbackResponse,
|
||||
SendVerificationCodeRequest,
|
||||
BindContactRequest,
|
||||
BindContactResponse,
|
||||
} from "./types"
|
||||
|
||||
// 用户工具函数
|
||||
export { normalizeUser } from "./user"
|
||||
|
||||
// 登录/注册/登出/刷新
|
||||
export { login, refreshAccessToken, register, logout } from "./login"
|
||||
|
||||
// 当前用户
|
||||
export { getCurrentUser } from "./currentUser"
|
||||
|
||||
// 密码重置
|
||||
export { requestPasswordReset, resetPassword } from "./password"
|
||||
|
||||
// 邮箱验证
|
||||
export { verifyEmail } from "./email"
|
||||
|
||||
// 微信登录
|
||||
export { getWechatAuthUrl, wechatCallback } from "./wechat"
|
||||
|
||||
// 联系方式
|
||||
export { sendVerificationCode, bindContact } from "./contact"
|
||||
@@ -0,0 +1,37 @@
|
||||
import axios from "axios"
|
||||
import apiClient from "../client"
|
||||
import type { LoginRequest, LoginResponse, RegisterRequest } from "./types"
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post("/auth/login", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 access_token(使用裸 axios 避免拦截器递归)
|
||||
*/
|
||||
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? ""
|
||||
const response = await axios.post(`${baseURL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册
|
||||
*/
|
||||
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/register", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post("/auth/logout")
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
/**
|
||||
* 请求密码重置
|
||||
*/
|
||||
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/forgot-password", { email })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
*/
|
||||
export const resetPassword = async (
|
||||
token: string,
|
||||
newPassword: string,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/reset-password", {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 认证相关类型定义
|
||||
*/
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified: boolean
|
||||
email_verified: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id?: string
|
||||
user_id?: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified?: boolean
|
||||
email_verified?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface WechatAuthUrlResponse {
|
||||
auth_url: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface WechatCallbackResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
user_id: string
|
||||
display_name: string
|
||||
avatar_url: string
|
||||
is_new_user: boolean
|
||||
binding_complete: boolean
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
target: "email" | "phone"
|
||||
value: string
|
||||
purpose: "bind" | "login" | "reset_password"
|
||||
}
|
||||
|
||||
export interface BindContactRequest {
|
||||
email?: string
|
||||
email_code?: string
|
||||
phone?: string
|
||||
phone_code?: string
|
||||
}
|
||||
|
||||
export interface BindContactResponse {
|
||||
success: boolean
|
||||
user: User
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { User, UserResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 规范化用户数据,兼容不同后端返回格式
|
||||
*/
|
||||
export const normalizeUser = (data: UserResponse): User => {
|
||||
const userId = data.id ?? data.user_id ?? ""
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
user_id: userId,
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
is_email_verified: emailVerified,
|
||||
email_verified: emailVerified,
|
||||
created_at: data.created_at,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import apiClient from "../client"
|
||||
import type { WechatAuthUrlResponse, WechatCallbackResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 获取微信授权链接
|
||||
*/
|
||||
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
|
||||
const response = await apiClient.get("/auth/wechat/url")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信回调登录
|
||||
*/
|
||||
export const wechatCallback = async (
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<WechatCallbackResponse> => {
|
||||
const response = await apiClient.post("/auth/wechat/callback", { code, state })
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Form, message } from "antd"
|
||||
import { sendVerificationCode, bindContact, type BindContactResponse } from "@/api/auth"
|
||||
import { useCountdown } from "./useCountdown"
|
||||
|
||||
type ContactType = "email" | "phone"
|
||||
|
||||
interface UseBindContactFormOptions {
|
||||
open: boolean
|
||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定联系方式表单 Hook
|
||||
* 封装表单状态、验证码发送、提交绑定等业务逻辑
|
||||
*/
|
||||
export const useBindContactForm = ({ open, onSuccess }: UseBindContactFormOptions) => {
|
||||
const [activeTab, setActiveTab] = useState<ContactType>("email")
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [codeLoading, setCodeLoading] = useState(false)
|
||||
const { countdown, isRunning, start: startCountdown, reset: resetCountdown } = useCountdown(60)
|
||||
|
||||
/* ── 打开时重置表单 ── */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields()
|
||||
resetCountdown()
|
||||
}
|
||||
}, [open, form, resetCountdown])
|
||||
|
||||
/* ── 发送验证码 ── */
|
||||
const handleSendCode = async () => {
|
||||
try {
|
||||
const fieldName = activeTab === "email" ? "email" : "phone"
|
||||
const value = form.getFieldValue(fieldName)
|
||||
if (!value) {
|
||||
message.warning(activeTab === "email" ? "请输入邮箱" : "请输入手机号")
|
||||
return
|
||||
}
|
||||
setCodeLoading(true)
|
||||
await sendVerificationCode({
|
||||
target: activeTab,
|
||||
value,
|
||||
purpose: "bind",
|
||||
})
|
||||
message.success("验证码已发送")
|
||||
startCountdown()
|
||||
} catch {
|
||||
// error handled by interceptor
|
||||
} finally {
|
||||
setCodeLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 提交绑定 ── */
|
||||
const handleSubmit = async () => {
|
||||
// 1. 表单验证(失败时 Ant Design Form 会自动展示错误信息)
|
||||
let values
|
||||
try {
|
||||
values = await form.validateFields()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 调用绑定接口
|
||||
setLoading(true)
|
||||
try {
|
||||
const payload =
|
||||
activeTab === "email"
|
||||
? { email: values.email, email_code: values.email_code }
|
||||
: { phone: values.phone, phone_code: values.phone_code }
|
||||
|
||||
const result = await bindContact(payload)
|
||||
|
||||
message.success("绑定成功")
|
||||
onSuccess?.(result.user)
|
||||
} catch {
|
||||
// API 错误由全局拦截器统一处理提示
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
form,
|
||||
loading,
|
||||
codeLoading,
|
||||
countdown,
|
||||
codeDisabled: isRunning,
|
||||
handleSendCode,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
|
||||
/**
|
||||
* 倒计时 Hook
|
||||
* 用于验证码发送等场景的倒计时控制
|
||||
*/
|
||||
export const useCountdown = (initialSeconds = 60) => {
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
timerRef.current = setInterval(() => {
|
||||
setCountdown((prev) => prev - 1)
|
||||
}, 1000)
|
||||
} else if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [countdown])
|
||||
|
||||
const start = useCallback(() => {
|
||||
setCountdown(initialSeconds)
|
||||
}, [initialSeconds])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setCountdown(0)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
countdown,
|
||||
isRunning: countdown > 0,
|
||||
start,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
+20
-83
@@ -1,84 +1,20 @@
|
||||
import React, { useState, useEffect, useRef } from "react"
|
||||
import { Modal, Tabs, Form, Input, Button, message } from "antd"
|
||||
import { sendVerificationCode, bindContact, type BindContactResponse } from "@/api/auth"
|
||||
|
||||
interface BindContactModalProps {
|
||||
open: boolean
|
||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
import React from "react"
|
||||
import { Modal, Tabs, Form, Input, Button } from "antd"
|
||||
import { useBindContactForm } from "./hooks/useBindContactForm"
|
||||
import type { BindContactModalProps } from "./types"
|
||||
|
||||
const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, onCancel }) => {
|
||||
const [activeTab, setActiveTab] = useState<"email" | "phone">("email")
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [codeLoading, setCodeLoading] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
timerRef.current = setInterval(() => {
|
||||
setCountdown((prev) => prev - 1)
|
||||
}, 1000)
|
||||
} else if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [countdown])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields()
|
||||
setCountdown(0)
|
||||
}
|
||||
}, [open, form])
|
||||
|
||||
const handleSendCode = async () => {
|
||||
try {
|
||||
const value = form.getFieldValue(activeTab === "email" ? "email" : "phone")
|
||||
if (!value) {
|
||||
message.warning(activeTab === "email" ? "请输入邮箱" : "请输入手机号")
|
||||
return
|
||||
}
|
||||
setCodeLoading(true)
|
||||
await sendVerificationCode({
|
||||
target: activeTab,
|
||||
value,
|
||||
purpose: "bind",
|
||||
})
|
||||
message.success("验证码已发送")
|
||||
setCountdown(60)
|
||||
} catch (error) {
|
||||
// error handled by interceptor
|
||||
} finally {
|
||||
setCodeLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setLoading(true)
|
||||
|
||||
const payload =
|
||||
activeTab === "email"
|
||||
? { email: values.email, email_code: values.code }
|
||||
: { phone: values.phone, phone_code: values.code }
|
||||
|
||||
const result = await bindContact(payload)
|
||||
|
||||
message.success("绑定成功")
|
||||
onSuccess?.(result.user)
|
||||
} catch (error) {
|
||||
// error handled by interceptor
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
const {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
form,
|
||||
loading,
|
||||
codeLoading,
|
||||
countdown,
|
||||
codeDisabled,
|
||||
handleSendCode,
|
||||
handleSubmit,
|
||||
} = useBindContactForm({ open, onSuccess, onCancel })
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -111,7 +47,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
<Input placeholder="请输入邮箱地址" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
name="email_code"
|
||||
label="验证码"
|
||||
rules={[{ required: true, message: "请输入验证码" }]}
|
||||
>
|
||||
@@ -121,7 +57,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
size="large"
|
||||
onClick={handleSendCode}
|
||||
loading={codeLoading}
|
||||
disabled={countdown > 0}
|
||||
disabled={codeDisabled}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
||||
</Button>
|
||||
@@ -146,7 +82,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
<Input placeholder="请输入手机号" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
name="phone_code"
|
||||
label="验证码"
|
||||
rules={[{ required: true, message: "请输入验证码" }]}
|
||||
>
|
||||
@@ -156,7 +92,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
size="large"
|
||||
onClick={handleSendCode}
|
||||
loading={codeLoading}
|
||||
disabled={countdown > 0}
|
||||
disabled={codeDisabled}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
||||
</Button>
|
||||
@@ -178,3 +114,4 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
}
|
||||
|
||||
export default BindContactModal
|
||||
export type { BindContactModalProps } from "./types"
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { BindContactResponse } from "@/api/auth"
|
||||
|
||||
/** BindContactModal 组件属性 */
|
||||
export interface BindContactModalProps {
|
||||
/** 是否打开 */
|
||||
open: boolean
|
||||
/** 绑定成功回调 */
|
||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
||||
/** 取消回调 */
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
/** 联系方式类型 */
|
||||
export type ContactType = "email" | "phone"
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import type { ModalPhase } from "../types/cloneModal"
|
||||
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
|
||||
import useAudioRecorder from "./useAudioRecorder"
|
||||
|
||||
/**
|
||||
* 克隆弹窗表单状态 Hook
|
||||
* 管理表单字段、录音、文件选择、验证逻辑
|
||||
*/
|
||||
export function useCloneFormState({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [phase, setPhase] = useState<ModalPhase>("input")
|
||||
const [voiceName, setVoiceName] = useState("")
|
||||
const [voiceDescription, setVoiceDescription] = useState("")
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
|
||||
const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } =
|
||||
useAudioRecorder()
|
||||
|
||||
/** 默认音色名称计数器 */
|
||||
const cloneCounterRef = useRef(1)
|
||||
|
||||
const getNextDefaultName = useCallback((): string => {
|
||||
const name = `我的声音 ${cloneCounterRef.current}`
|
||||
cloneCounterRef.current += 1
|
||||
return name
|
||||
}, [])
|
||||
|
||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
||||
|
||||
const canSubmit =
|
||||
voiceName.trim().length >= MIN_VOICE_NAME_LENGTH &&
|
||||
voiceName.trim().length <= MAX_VOICE_NAME_LENGTH &&
|
||||
hasAudio
|
||||
|
||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
||||
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setPhase("input")
|
||||
setVoiceName(getNextDefaultName())
|
||||
setVoiceDescription("")
|
||||
setSelectedFile(null)
|
||||
setDragActive(false)
|
||||
setErrorMessage("")
|
||||
resetRecording()
|
||||
}, [getNextDefaultName, resetRecording])
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const handleClose = useCallback(() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}, [resetState, onClose])
|
||||
|
||||
/** 弹窗打开时重置状态 */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetState()
|
||||
}
|
||||
}, [open, resetState])
|
||||
|
||||
/** 选择文件(来自上传或拖拽) */
|
||||
const handleFileSelect = useCallback(
|
||||
(file: File | null, error: string) => {
|
||||
if (error) {
|
||||
setErrorMessage(error)
|
||||
setSelectedFile(null)
|
||||
} else {
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
// 清除录音
|
||||
resetRecording()
|
||||
}
|
||||
},
|
||||
[resetRecording],
|
||||
)
|
||||
|
||||
/** 录音切换 */
|
||||
const handleRecordToggle = useCallback(() => {
|
||||
setErrorMessage("")
|
||||
if (isRecording) {
|
||||
toggleRecording()
|
||||
} else {
|
||||
// 开始录制前清除已选文件
|
||||
setSelectedFile(null)
|
||||
toggleRecording()
|
||||
}
|
||||
}, [isRecording, toggleRecording])
|
||||
|
||||
/** 表单验证 */
|
||||
const validateForm = useCallback((): string | null => {
|
||||
const name = voiceName.trim()
|
||||
if (!name) {
|
||||
return "请输入音色名称"
|
||||
}
|
||||
if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) {
|
||||
return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间`
|
||||
}
|
||||
if (!hasAudio) {
|
||||
return "请上传音频文件或录制一段声音"
|
||||
}
|
||||
return null
|
||||
}, [voiceName, hasAudio])
|
||||
|
||||
return {
|
||||
// 状态
|
||||
phase,
|
||||
setPhase,
|
||||
voiceName,
|
||||
setVoiceName,
|
||||
voiceDescription,
|
||||
setVoiceDescription,
|
||||
selectedFile,
|
||||
dragActive,
|
||||
setDragActive,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
// 录音
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
// 计算属性
|
||||
hasAudio,
|
||||
canSubmit,
|
||||
isProcessing,
|
||||
// handlers
|
||||
handleFileSelect,
|
||||
handleRecordToggle,
|
||||
handleClose,
|
||||
validateForm,
|
||||
resetState,
|
||||
}
|
||||
}
|
||||
Regular → Executable
+33
-202
@@ -1,213 +1,44 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAsset } from "@/api/assets"
|
||||
import type { ModalPhase, CloneModalProps } from "../types/cloneModal"
|
||||
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
|
||||
import useAudioRecorder from "./useAudioRecorder"
|
||||
|
||||
interface UseCloneModalReturn {
|
||||
phase: ModalPhase
|
||||
voiceName: string
|
||||
voiceDescription: string
|
||||
selectedFile: File | null
|
||||
dragActive: boolean
|
||||
errorMessage: string
|
||||
isRecording: boolean
|
||||
recordTime: number
|
||||
recordedBlob: Blob | null
|
||||
canSubmit: boolean
|
||||
isProcessing: boolean
|
||||
setVoiceName: (value: string) => void
|
||||
setVoiceDescription: (value: string) => void
|
||||
setDragActive: (active: boolean) => void
|
||||
handleFileSelect: (file: File | null, error: string) => void
|
||||
handleRecordToggle: () => void
|
||||
handleClose: () => void
|
||||
handleSubmit: () => void
|
||||
}
|
||||
import type { CloneModalProps } from "../types/cloneModal"
|
||||
import { useCloneFormState } from "./useCloneFormState"
|
||||
import { useCloneSubmit } from "./useCloneSubmit"
|
||||
|
||||
/**
|
||||
* 音色克隆弹窗主业务 Hook
|
||||
* 组合表单状态 + 提交流程两个子 Hook
|
||||
*/
|
||||
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps): UseCloneModalReturn => {
|
||||
const [phase, setPhase] = useState<ModalPhase>("input")
|
||||
const [voiceName, setVoiceName] = useState("")
|
||||
const [voiceDescription, setVoiceDescription] = useState("")
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps) => {
|
||||
const formState = useCloneFormState({ open, onClose })
|
||||
|
||||
const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } =
|
||||
useAudioRecorder()
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
/** 默认音色名称计数器 */
|
||||
const cloneCounterRef = useRef(1)
|
||||
|
||||
const getNextDefaultName = useCallback((): string => {
|
||||
const name = `我的声音 ${cloneCounterRef.current}`
|
||||
cloneCounterRef.current += 1
|
||||
return name
|
||||
}, [])
|
||||
|
||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
||||
|
||||
const canSubmit =
|
||||
voiceName.trim().length >= MIN_VOICE_NAME_LENGTH &&
|
||||
voiceName.trim().length <= MAX_VOICE_NAME_LENGTH &&
|
||||
hasAudio
|
||||
|
||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
||||
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setPhase("input")
|
||||
setVoiceName(getNextDefaultName())
|
||||
setVoiceDescription("")
|
||||
setSelectedFile(null)
|
||||
setDragActive(false)
|
||||
setErrorMessage("")
|
||||
resetRecording()
|
||||
}, [getNextDefaultName, resetRecording])
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const handleClose = useCallback(() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}, [resetState, onClose])
|
||||
|
||||
/** 弹窗打开时重置状态 */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetState()
|
||||
}
|
||||
}, [open, resetState])
|
||||
|
||||
/** 组件卸载时清理定时器 */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 选择文件(来自上传或拖拽) */
|
||||
const handleFileSelect = useCallback(
|
||||
(file: File | null, error: string) => {
|
||||
if (error) {
|
||||
setErrorMessage(error)
|
||||
setSelectedFile(null)
|
||||
} else {
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
// 清除录音
|
||||
resetRecording()
|
||||
}
|
||||
},
|
||||
[resetRecording],
|
||||
)
|
||||
|
||||
/** 录音切换 */
|
||||
const handleRecordToggle = useCallback(() => {
|
||||
setErrorMessage("")
|
||||
if (isRecording) {
|
||||
toggleRecording()
|
||||
} else {
|
||||
// 开始录制前清除已选文件
|
||||
setSelectedFile(null)
|
||||
toggleRecording()
|
||||
}
|
||||
}, [isRecording, toggleRecording])
|
||||
|
||||
/** 表单验证 */
|
||||
const validateForm = useCallback((): string | null => {
|
||||
const name = voiceName.trim()
|
||||
if (!name) {
|
||||
return "请输入音色名称"
|
||||
}
|
||||
if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) {
|
||||
return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间`
|
||||
}
|
||||
if (!hasAudio) {
|
||||
return "请上传音频文件或录制一段声音"
|
||||
}
|
||||
return null
|
||||
}, [voiceName, hasAudio])
|
||||
|
||||
/** 提交克隆 */
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const formError = validateForm()
|
||||
if (formError) {
|
||||
setErrorMessage(formError)
|
||||
return
|
||||
}
|
||||
|
||||
setErrorMessage("")
|
||||
|
||||
try {
|
||||
// 阶段 1:上传音频
|
||||
setPhase("uploading")
|
||||
|
||||
let fileToUpload: File
|
||||
if (selectedFile) {
|
||||
fileToUpload = selectedFile
|
||||
} else {
|
||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
||||
type: "audio/webm",
|
||||
})
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("file", fileToUpload)
|
||||
const uploadResult = await uploadAsset(formData)
|
||||
|
||||
// 阶段 2:克隆
|
||||
setPhase("cloning")
|
||||
const result = await createVoiceClone({
|
||||
name: voiceName.trim(),
|
||||
description: voiceDescription.trim() || undefined,
|
||||
audio_url: uploadResult.url,
|
||||
})
|
||||
|
||||
// 阶段 3:完成
|
||||
setPhase("done")
|
||||
|
||||
// 2秒后自动关闭
|
||||
timerRef.current = setTimeout(() => {
|
||||
onSuccess?.(toVoiceClone(result))
|
||||
handleClose()
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
setPhase("input")
|
||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
||||
}
|
||||
}, [
|
||||
validateForm,
|
||||
selectedFile,
|
||||
recordedBlob,
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
const { handleSubmit } = useCloneSubmit({
|
||||
voiceName: formState.voiceName,
|
||||
voiceDescription: formState.voiceDescription,
|
||||
selectedFile: formState.selectedFile,
|
||||
recordedBlob: formState.recordedBlob,
|
||||
setPhase: formState.setPhase,
|
||||
setErrorMessage: formState.setErrorMessage,
|
||||
validateForm: formState.validateForm,
|
||||
onSuccess,
|
||||
handleClose,
|
||||
])
|
||||
onClose: formState.handleClose,
|
||||
})
|
||||
|
||||
return {
|
||||
phase,
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
selectedFile,
|
||||
dragActive,
|
||||
errorMessage,
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
canSubmit,
|
||||
isProcessing,
|
||||
setVoiceName,
|
||||
setVoiceDescription,
|
||||
setDragActive,
|
||||
handleFileSelect,
|
||||
handleRecordToggle,
|
||||
handleClose,
|
||||
phase: formState.phase,
|
||||
voiceName: formState.voiceName,
|
||||
voiceDescription: formState.voiceDescription,
|
||||
selectedFile: formState.selectedFile,
|
||||
dragActive: formState.dragActive,
|
||||
errorMessage: formState.errorMessage,
|
||||
isRecording: formState.isRecording,
|
||||
recordTime: formState.recordTime,
|
||||
recordedBlob: formState.recordedBlob,
|
||||
canSubmit: formState.canSubmit,
|
||||
isProcessing: formState.isProcessing,
|
||||
setVoiceName: formState.setVoiceName,
|
||||
setVoiceDescription: formState.setVoiceDescription,
|
||||
setDragActive: formState.setDragActive,
|
||||
handleFileSelect: formState.handleFileSelect,
|
||||
handleRecordToggle: formState.handleRecordToggle,
|
||||
handleClose: formState.handleClose,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { useRef, useCallback, useEffect } from "react"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAsset, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
interface UseCloneSubmitOptions {
|
||||
voiceName: string
|
||||
voiceDescription: string
|
||||
selectedFile: File | null
|
||||
recordedBlob: Blob | null
|
||||
setPhase: (phase: "input" | "uploading" | "cloning" | "done") => void
|
||||
setErrorMessage: (msg: string) => void
|
||||
validateForm: () => string | null
|
||||
onSuccess?: (clone: VoiceClone) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 克隆提交流程 Hook
|
||||
* 封装上传 + 克隆 + 完成的三阶段流程
|
||||
*/
|
||||
export function useCloneSubmit({
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
selectedFile,
|
||||
recordedBlob,
|
||||
setPhase,
|
||||
setErrorMessage,
|
||||
validateForm,
|
||||
onSuccess,
|
||||
onClose,
|
||||
}: UseCloneSubmitOptions) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
/** 组件卸载时清理定时器 */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const formError = validateForm()
|
||||
if (formError) {
|
||||
setErrorMessage(formError)
|
||||
return
|
||||
}
|
||||
|
||||
setErrorMessage("")
|
||||
|
||||
try {
|
||||
// 阶段 1:上传音频
|
||||
setPhase("uploading")
|
||||
|
||||
let fileToUpload: File
|
||||
if (selectedFile) {
|
||||
fileToUpload = selectedFile
|
||||
} else {
|
||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
||||
type: "audio/webm",
|
||||
})
|
||||
}
|
||||
|
||||
// 获取默认项目和素材库(后端 /upload 接口必填)
|
||||
const project = await getOrCreateDefaultProject()
|
||||
const library = await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("file", fileToUpload)
|
||||
formData.append("project_id", project.id)
|
||||
formData.append("library_id", library.id)
|
||||
const uploadResult = await uploadAsset(formData)
|
||||
|
||||
// 阶段 2:克隆
|
||||
setPhase("cloning")
|
||||
const result = await createVoiceClone({
|
||||
name: voiceName.trim(),
|
||||
description: voiceDescription.trim() || undefined,
|
||||
audio_url: uploadResult.url,
|
||||
})
|
||||
|
||||
// 阶段 3:完成
|
||||
setPhase("done")
|
||||
|
||||
// 2秒后自动关闭
|
||||
timerRef.current = setTimeout(() => {
|
||||
onSuccess?.(toVoiceClone(result))
|
||||
onClose()
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
setPhase("input")
|
||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
||||
}
|
||||
}, [
|
||||
validateForm,
|
||||
selectedFile,
|
||||
recordedBlob,
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
setPhase,
|
||||
setErrorMessage,
|
||||
onSuccess,
|
||||
onClose,
|
||||
])
|
||||
|
||||
return { handleSubmit }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useBatchDelete } from "./useBatchDelete"
|
||||
export { useBatchTag } from "./useBatchTag"
|
||||
export { useBatchClassify } from "./useBatchClassify"
|
||||
export { useBatchMark } from "./useBatchMark"
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchClassifyOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchClassify = ({
|
||||
selectedIds,
|
||||
queryClient,
|
||||
showResult,
|
||||
}: UseBatchClassifyOptions) => {
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||
const [batchCategory, setBatchCategory] = useState("")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchClassify = useCallback(async () => {
|
||||
if (!batchCategory) {
|
||||
message.warning("请选择分类")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchClassifyAssets({
|
||||
asset_ids: ids,
|
||||
category: batchCategory,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量改分类")
|
||||
setClassifyModalOpen(false)
|
||||
setBatchCategory("")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
||||
} else {
|
||||
message.warning(
|
||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量改分类失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchCategory, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
classifyModalOpen,
|
||||
setClassifyModalOpen,
|
||||
batchCategory,
|
||||
setBatchCategory,
|
||||
batchLoading,
|
||||
handleBatchClassify,
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchDeleteOptions {
|
||||
selectedIds: Set<string>
|
||||
invalidateAssets: () => void
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchDelete = ({
|
||||
selectedIds,
|
||||
invalidateAssets,
|
||||
showResult,
|
||||
}: UseBatchDeleteOptions) => {
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids)
|
||||
invalidateAssets()
|
||||
showResult(result, "批量删除")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功删除 ${result.success_count} 个素材`)
|
||||
} else {
|
||||
message.warning(
|
||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量删除失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [selectedIds, invalidateAssets, showResult])
|
||||
|
||||
return { batchLoading, handleBatchDelete }
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
|
||||
import type { SmartViewType } from "../../../components/BatchMarkModal"
|
||||
import { SMART_VIEW_LABELS } from "../constants"
|
||||
|
||||
interface UseBatchMarkOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchMark = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchMarkAssets({
|
||||
asset_ids: ids,
|
||||
smart_view: batchSmartView,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量智能标记")
|
||||
setMarkModalOpen(false)
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}」`,
|
||||
)
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchSmartView, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
markModalOpen,
|
||||
setMarkModalOpen,
|
||||
batchSmartView,
|
||||
setBatchSmartView,
|
||||
batchLoading,
|
||||
handleBatchMark,
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchTagOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [batchTagInput, setBatchTagInput] = useState("")
|
||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchTag = useCallback(async () => {
|
||||
if (batchTags.length === 0) {
|
||||
message.warning("请至少输入一个标签")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchTagAssets({
|
||||
asset_ids: ids,
|
||||
tags: batchTags,
|
||||
mode: tagMode,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量打标签")
|
||||
setTagModalOpen(false)
|
||||
setBatchTags([])
|
||||
setBatchTagInput("")
|
||||
setTagMode("add")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
||||
} else {
|
||||
message.warning(
|
||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量打标签失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
|
||||
|
||||
const handleTagInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||
e.preventDefault()
|
||||
const tag = batchTagInput.trim()
|
||||
if (!batchTags.includes(tag)) {
|
||||
setBatchTags([...batchTags, tag])
|
||||
}
|
||||
setBatchTagInput("")
|
||||
}
|
||||
},
|
||||
[batchTagInput, batchTags],
|
||||
)
|
||||
|
||||
const removeBatchTag = useCallback(
|
||||
(tag: string) => {
|
||||
setBatchTags(batchTags.filter((t) => t !== tag))
|
||||
},
|
||||
[batchTags],
|
||||
)
|
||||
|
||||
return {
|
||||
tagModalOpen,
|
||||
setTagModalOpen,
|
||||
batchTagInput,
|
||||
setBatchTagInput,
|
||||
batchTags,
|
||||
setBatchTags,
|
||||
tagMode,
|
||||
setTagMode,
|
||||
batchLoading,
|
||||
handleBatchTag,
|
||||
handleTagInputKeyDown,
|
||||
removeBatchTag,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useBatchDelete } from "./useBatchDelete"
|
||||
export { useBatchTag } from "./useBatchTag"
|
||||
export { useBatchClassify } from "./useBatchClassify"
|
||||
export { useBatchMark } from "./useBatchMark"
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchClassifyOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchClassify = ({
|
||||
selectedIds,
|
||||
queryClient,
|
||||
showResult,
|
||||
}: UseBatchClassifyOptions) => {
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||
const [batchCategory, setBatchCategory] = useState("")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchClassify = useCallback(async () => {
|
||||
if (!batchCategory) {
|
||||
message.warning("请选择分类")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchClassifyAssets({
|
||||
asset_ids: ids,
|
||||
category: batchCategory,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量改分类")
|
||||
setClassifyModalOpen(false)
|
||||
setBatchCategory("")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
||||
} else {
|
||||
message.warning(
|
||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量改分类失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchCategory, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
classifyModalOpen,
|
||||
setClassifyModalOpen,
|
||||
batchCategory,
|
||||
setBatchCategory,
|
||||
batchLoading,
|
||||
handleBatchClassify,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchDeleteOptions {
|
||||
selectedIds: Set<string>
|
||||
invalidateAssets: () => void
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchDelete = ({
|
||||
selectedIds,
|
||||
invalidateAssets,
|
||||
showResult,
|
||||
}: UseBatchDeleteOptions) => {
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids)
|
||||
invalidateAssets()
|
||||
showResult(result, "批量删除")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功删除 ${result.success_count} 个素材`)
|
||||
} else {
|
||||
message.warning(
|
||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量删除失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [selectedIds, invalidateAssets, showResult])
|
||||
|
||||
return { batchLoading, handleBatchDelete }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
|
||||
import type { SmartViewType } from "../../../components/BatchMarkModal"
|
||||
import { SMART_VIEW_LABELS } from "../constants"
|
||||
|
||||
interface UseBatchMarkOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchMark = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchMarkAssets({
|
||||
asset_ids: ids,
|
||||
smart_view: batchSmartView,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量智能标记")
|
||||
setMarkModalOpen(false)
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}」`,
|
||||
)
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchSmartView, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
markModalOpen,
|
||||
setMarkModalOpen,
|
||||
batchSmartView,
|
||||
setBatchSmartView,
|
||||
batchLoading,
|
||||
handleBatchMark,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchTagOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [batchTagInput, setBatchTagInput] = useState("")
|
||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchTag = useCallback(async () => {
|
||||
if (batchTags.length === 0) {
|
||||
message.warning("请至少输入一个标签")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchTagAssets({
|
||||
asset_ids: ids,
|
||||
tags: batchTags,
|
||||
mode: tagMode,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量打标签")
|
||||
setTagModalOpen(false)
|
||||
setBatchTags([])
|
||||
setBatchTagInput("")
|
||||
setTagMode("add")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
||||
} else {
|
||||
message.warning(
|
||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量打标签失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
|
||||
|
||||
const handleTagInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||
e.preventDefault()
|
||||
const tag = batchTagInput.trim()
|
||||
if (!batchTags.includes(tag)) {
|
||||
setBatchTags([...batchTags, tag])
|
||||
}
|
||||
setBatchTagInput("")
|
||||
}
|
||||
},
|
||||
[batchTagInput, batchTags],
|
||||
)
|
||||
|
||||
const removeBatchTag = useCallback(
|
||||
(tag: string) => {
|
||||
setBatchTags(batchTags.filter((t) => t !== tag))
|
||||
},
|
||||
[batchTags],
|
||||
)
|
||||
|
||||
return {
|
||||
tagModalOpen,
|
||||
setTagModalOpen,
|
||||
batchTagInput,
|
||||
setBatchTagInput,
|
||||
batchTags,
|
||||
setBatchTags,
|
||||
tagMode,
|
||||
setTagMode,
|
||||
batchLoading,
|
||||
handleBatchTag,
|
||||
handleTagInputKeyDown,
|
||||
removeBatchTag,
|
||||
}
|
||||
}
|
||||
@@ -1,238 +1,8 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import {
|
||||
batchDeleteAssets,
|
||||
batchTagAssets,
|
||||
batchClassifyAssets,
|
||||
batchMarkAssets,
|
||||
type BatchOperationResult,
|
||||
} from "@/api/assets"
|
||||
import type { SmartViewType } from "../../components/BatchMarkModal"
|
||||
import { SMART_VIEW_LABELS } from "./constants"
|
||||
|
||||
/* ── 批量删除 ── */
|
||||
interface UseBatchDeleteOptions {
|
||||
selectedIds: Set<string>
|
||||
invalidateAssets: () => void
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchDelete = ({
|
||||
selectedIds,
|
||||
invalidateAssets,
|
||||
showResult,
|
||||
}: UseBatchDeleteOptions) => {
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids)
|
||||
invalidateAssets()
|
||||
showResult(result, "批量删除")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功删除 ${result.success_count} 个素材`)
|
||||
} else {
|
||||
message.warning(
|
||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量删除失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [selectedIds, invalidateAssets, showResult])
|
||||
|
||||
return { batchLoading, handleBatchDelete }
|
||||
}
|
||||
|
||||
/* ── 批量打标签 ── */
|
||||
interface UseBatchTagOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [batchTagInput, setBatchTagInput] = useState("")
|
||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchTag = useCallback(async () => {
|
||||
if (batchTags.length === 0) {
|
||||
message.warning("请至少输入一个标签")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchTagAssets({
|
||||
asset_ids: ids,
|
||||
tags: batchTags,
|
||||
mode: tagMode,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量打标签")
|
||||
setTagModalOpen(false)
|
||||
setBatchTags([])
|
||||
setBatchTagInput("")
|
||||
setTagMode("add")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
||||
} else {
|
||||
message.warning(
|
||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量打标签失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
|
||||
|
||||
const handleTagInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||
e.preventDefault()
|
||||
const tag = batchTagInput.trim()
|
||||
if (!batchTags.includes(tag)) {
|
||||
setBatchTags([...batchTags, tag])
|
||||
}
|
||||
setBatchTagInput("")
|
||||
}
|
||||
},
|
||||
[batchTagInput, batchTags],
|
||||
)
|
||||
|
||||
const removeBatchTag = useCallback(
|
||||
(tag: string) => {
|
||||
setBatchTags(batchTags.filter((t) => t !== tag))
|
||||
},
|
||||
[batchTags],
|
||||
)
|
||||
|
||||
return {
|
||||
tagModalOpen,
|
||||
setTagModalOpen,
|
||||
batchTagInput,
|
||||
setBatchTagInput,
|
||||
batchTags,
|
||||
setBatchTags,
|
||||
tagMode,
|
||||
setTagMode,
|
||||
batchLoading,
|
||||
handleBatchTag,
|
||||
handleTagInputKeyDown,
|
||||
removeBatchTag,
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 批量改分类 ── */
|
||||
interface UseBatchClassifyOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchClassify = ({
|
||||
selectedIds,
|
||||
queryClient,
|
||||
showResult,
|
||||
}: UseBatchClassifyOptions) => {
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||
const [batchCategory, setBatchCategory] = useState("")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchClassify = useCallback(async () => {
|
||||
if (!batchCategory) {
|
||||
message.warning("请选择分类")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchClassifyAssets({
|
||||
asset_ids: ids,
|
||||
category: batchCategory,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量改分类")
|
||||
setClassifyModalOpen(false)
|
||||
setBatchCategory("")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
||||
} else {
|
||||
message.warning(
|
||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量改分类失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchCategory, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
classifyModalOpen,
|
||||
setClassifyModalOpen,
|
||||
batchCategory,
|
||||
setBatchCategory,
|
||||
batchLoading,
|
||||
handleBatchClassify,
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 批量智能标记 ── */
|
||||
interface UseBatchMarkOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchMark = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchMarkAssets({
|
||||
asset_ids: ids,
|
||||
smart_view: batchSmartView,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量智能标记")
|
||||
setMarkModalOpen(false)
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}」`,
|
||||
)
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchSmartView, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
markModalOpen,
|
||||
setMarkModalOpen,
|
||||
batchSmartView,
|
||||
setBatchSmartView,
|
||||
batchLoading,
|
||||
handleBatchMark,
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @deprecated 请从 ./batch/ 目录导入子模块
|
||||
* 保持向后兼容,re-export 所有批量操作 Hook
|
||||
*/
|
||||
export { useBatchDelete } from "./batch/useBatchDelete"
|
||||
export { useBatchTag } from "./batch/useBatchTag"
|
||||
export { useBatchClassify } from "./batch/useBatchClassify"
|
||||
export { useBatchMark } from "./batch/useBatchMark"
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
useBatchTag,
|
||||
useBatchClassify,
|
||||
useBatchMark,
|
||||
} from "./asset-operations/batchOperations"
|
||||
} from "./asset-operations/batch-operations"
|
||||
import type { BatchOperationResult } from "@/api/assets"
|
||||
import type { SmartViewType } from "../components/BatchMarkModal"
|
||||
|
||||
|
||||
Regular → Executable
+36
-184
@@ -1,116 +1,35 @@
|
||||
/**
|
||||
* 查重上传页面 — V21 设计系统
|
||||
* 左右分栏:拖拽上传区 + 格式说明
|
||||
* 零 antd 依赖
|
||||
*/
|
||||
import React, { useState, useRef, useCallback } from "react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { Button, Card, Tag } from "@/components/ui"
|
||||
import { uploadForDuplication } from "@/api/duplication"
|
||||
import React from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import "./duplication.css"
|
||||
import { Card } from "@/components/ui"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
|
||||
/** 支持的视频格式 */
|
||||
const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
|
||||
const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
|
||||
/** 最大文件大小:2GB */
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
|
||||
|
||||
/** 简易 toast */
|
||||
interface ToastState {
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
}
|
||||
import UploadZone from "./duplication-upload/UploadZone"
|
||||
import UploadActions from "./duplication-upload/UploadActions"
|
||||
import UploadProgress from "./duplication-upload/UploadProgress"
|
||||
import UploadResultPanel from "./duplication-upload/UploadResultPanel"
|
||||
import InfoSidebar from "./duplication-upload/InfoSidebar"
|
||||
import { useDuplicationUpload } from "./duplication-upload/useDuplicationUpload"
|
||||
import { ACCEPT_FORMATS } from "./duplication-upload/constants"
|
||||
import "./duplication.css"
|
||||
|
||||
const DuplicationUpload: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadResult, setUploadResult] = useState<{
|
||||
id: string
|
||||
message: string
|
||||
} | null>(null)
|
||||
const [toast, setToast] = useState<ToastState | null>(null)
|
||||
|
||||
/** 显示 toast */
|
||||
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
|
||||
setToast({ message, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}, [])
|
||||
|
||||
// 上传查重 mutation
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => uploadForDuplication(file),
|
||||
onSuccess: (data) => {
|
||||
setUploading(false)
|
||||
setUploadResult({ id: data.id, message: data.message })
|
||||
showToast("查重任务已提交", "success")
|
||||
},
|
||||
onError: () => {
|
||||
setUploading(false)
|
||||
showToast("上传失败,请重试", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 校验并上传文件 */
|
||||
const handleFile = useCallback(
|
||||
(file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
showToast("文件大小不能超过 2GB", "error")
|
||||
return
|
||||
}
|
||||
const ext = file.name.toLowerCase().split(".").pop()
|
||||
const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",")
|
||||
if (!allowedExts.includes(ext || "")) {
|
||||
showToast(`不支持的文件格式,支持:${FORMAT_LIST.join("、")}`, "error")
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
setUploadResult(null)
|
||||
uploadMutation.mutate(file)
|
||||
},
|
||||
[uploadMutation, showToast],
|
||||
)
|
||||
|
||||
/** 拖拽事件 */
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFile(file)
|
||||
},
|
||||
[handleFile],
|
||||
)
|
||||
|
||||
/** 点击选择文件 */
|
||||
const handleSelectFile = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
// 重置 input 以便重复选择同一文件
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
/** 重置状态 */
|
||||
const handleReset = () => {
|
||||
setUploadResult(null)
|
||||
setUploading(false)
|
||||
}
|
||||
const {
|
||||
fileInputRef,
|
||||
dragging,
|
||||
uploading,
|
||||
uploadResult,
|
||||
toast,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleSelectFile,
|
||||
handleFileChange,
|
||||
handleReset,
|
||||
} = useDuplicationUpload()
|
||||
|
||||
return (
|
||||
<div className="dup-page">
|
||||
@@ -125,25 +44,14 @@ const DuplicationUpload: React.FC = () => {
|
||||
<div className="dup-upload-grid">
|
||||
{/* 左侧:上传区域 */}
|
||||
<Card>
|
||||
{/* 拖拽上传区 */}
|
||||
<div
|
||||
className={`dup-upload-zone ${dragging ? "dragging" : ""} ${uploading ? "disabled" : ""}`}
|
||||
<UploadZone
|
||||
dragging={dragging}
|
||||
uploading={uploading}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={uploading ? undefined : handleSelectFile}
|
||||
>
|
||||
<div className="dup-upload-icon">{uploading ? "⏳" : "📁"}</div>
|
||||
<h3>{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}</h3>
|
||||
<p>支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 2GB</p>
|
||||
<div className="dup-upload-formats">
|
||||
{FORMAT_LIST.map((fmt) => (
|
||||
<Tag key={fmt} variant="info">
|
||||
{fmt}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
onClick={handleSelectFile}
|
||||
/>
|
||||
|
||||
{/* 隐藏的文件 input */}
|
||||
<input
|
||||
@@ -154,77 +62,21 @@ const DuplicationUpload: React.FC = () => {
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{/* 上传按钮 */}
|
||||
<div className="dup-upload-actions">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={handleSelectFile}
|
||||
disabled={uploading}
|
||||
>
|
||||
📂 选择文件
|
||||
</Button>
|
||||
</div>
|
||||
<UploadActions uploading={uploading} onSelectFile={handleSelectFile} />
|
||||
|
||||
{/* 上传进度 */}
|
||||
{uploading && (
|
||||
<div className="dup-progress">
|
||||
<div className="dup-progress-circle">
|
||||
<span className="dup-progress-icon">⏳</span>
|
||||
<span className="dup-progress-text">查重中...</span>
|
||||
</div>
|
||||
<p>正在分析视频内容,请稍候...</p>
|
||||
</div>
|
||||
)}
|
||||
{uploading && <UploadProgress />}
|
||||
|
||||
{/* 上传结果 */}
|
||||
{uploadResult && !uploading && (
|
||||
<div className="dup-result">
|
||||
<div className="dup-result-icon">✅</div>
|
||||
<h3>查重任务已提交</h3>
|
||||
<p>{uploadResult.message}</p>
|
||||
<div className="dup-result-actions">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/app/duplication/results")}
|
||||
>
|
||||
查看结果
|
||||
</Button>
|
||||
<Button buttonType="secondary" buttonSize="md" onClick={handleReset}>
|
||||
继续上传
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<UploadResultPanel
|
||||
result={uploadResult}
|
||||
onViewResult={() => navigate("/app/duplication/results")}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 右侧:格式说明 + 提示 */}
|
||||
<div className="dup-info-card">
|
||||
<h3>📋 查重说明</h3>
|
||||
<ul className="dup-info-list">
|
||||
<li>系统会对比您上传的视频与视频库中的已有视频</li>
|
||||
<li>查重完成后,可查看重复片段的具体位置</li>
|
||||
<li>查重过程通常需要几分钟,取决于视频大小</li>
|
||||
<li>高相似度片段建议进行替换或裁剪</li>
|
||||
</ul>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>🎬 支持格式</h3>
|
||||
<div className="dup-format-tags">
|
||||
{FORMAT_LIST.map((fmt) => (
|
||||
<Tag key={fmt} variant="info">
|
||||
{fmt}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>💡 温馨提示</h3>
|
||||
<ul className="dup-info-list">
|
||||
<li>单个文件不超过 2GB</li>
|
||||
<li>视频时长建议不超过 60 分钟</li>
|
||||
<li>查重结果可在「查重记录」中随时查看</li>
|
||||
</ul>
|
||||
</div>
|
||||
<InfoSidebar />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Regular → Executable
+7
-1
@@ -51,7 +51,13 @@ export const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
|
||||
{ key: "high", label: "高风险" },
|
||||
]
|
||||
|
||||
/** Toast 类型 */
|
||||
/** 支持的视频格式 */
|
||||
export const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
|
||||
export const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
|
||||
/** 最大文件大小:2GB */
|
||||
export const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
|
||||
|
||||
/** 简易 toast */
|
||||
export interface ToastState {
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from "react"
|
||||
import { Tag } from "@/components/ui"
|
||||
import { FORMAT_LIST } from "./constants"
|
||||
|
||||
/** 右侧说明卡 */
|
||||
const InfoSidebar: React.FC = () => {
|
||||
return (
|
||||
<div className="dup-info-card">
|
||||
<h3>📋 查重说明</h3>
|
||||
<ul className="dup-info-list">
|
||||
<li>系统会对比您上传的视频与视频库中的已有视频</li>
|
||||
<li>查重完成后,可查看重复片段的具体位置</li>
|
||||
<li>查重过程通常需要几分钟,取决于视频大小</li>
|
||||
<li>高相似度片段建议进行替换或裁剪</li>
|
||||
</ul>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>🎬 支持格式</h3>
|
||||
<div className="dup-format-tags">
|
||||
{FORMAT_LIST.map((fmt) => (
|
||||
<Tag key={fmt} variant="info">
|
||||
{fmt}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>💡 温馨提示</h3>
|
||||
<ul className="dup-info-list">
|
||||
<li>单个文件不超过 2GB</li>
|
||||
<li>视频时长建议不超过 60 分钟</li>
|
||||
<li>查重结果可在「查重记录」中随时查看</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InfoSidebar
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
interface UploadActionsProps {
|
||||
uploading: boolean
|
||||
onSelectFile: () => void
|
||||
}
|
||||
|
||||
/** 上传按钮区 */
|
||||
const UploadActions: React.FC<UploadActionsProps> = ({ uploading, onSelectFile }) => {
|
||||
return (
|
||||
<div className="dup-upload-actions">
|
||||
<Button buttonType="primary" buttonSize="md" onClick={onSelectFile} disabled={uploading}>
|
||||
📂 选择文件
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadActions
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from "react"
|
||||
|
||||
/** 上传进度展示 */
|
||||
const UploadProgress: React.FC = () => {
|
||||
return (
|
||||
<div className="dup-progress">
|
||||
<div className="dup-progress-circle">
|
||||
<span className="dup-progress-icon">⏳</span>
|
||||
<span className="dup-progress-text">查重中...</span>
|
||||
</div>
|
||||
<p>正在分析视频内容,请稍候...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadProgress
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { UploadResult } from "./constants"
|
||||
|
||||
interface UploadResultPanelProps {
|
||||
result: UploadResult
|
||||
onViewResult: () => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
/** 上传结果展示 */
|
||||
const UploadResultPanel: React.FC<UploadResultPanelProps> = ({ result, onViewResult, onReset }) => {
|
||||
return (
|
||||
<div className="dup-result">
|
||||
<div className="dup-result-icon">✅</div>
|
||||
<h3>查重任务已提交</h3>
|
||||
<p>{result.message}</p>
|
||||
<div className="dup-result-actions">
|
||||
<Button buttonType="primary" buttonSize="md" onClick={onViewResult}>
|
||||
查看结果
|
||||
</Button>
|
||||
<Button buttonType="secondary" buttonSize="md" onClick={onReset}>
|
||||
继续上传
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadResultPanel
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react"
|
||||
import { Tag } from "@/components/ui"
|
||||
import { FORMAT_LIST } from "./constants"
|
||||
|
||||
interface UploadZoneProps {
|
||||
dragging: boolean
|
||||
uploading: boolean
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/** 拖拽上传区 */
|
||||
const UploadZone: React.FC<UploadZoneProps> = ({
|
||||
dragging,
|
||||
uploading,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`dup-upload-zone ${dragging ? "dragging" : ""} ${uploading ? "disabled" : ""}`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={uploading ? undefined : onClick}
|
||||
>
|
||||
<div className="dup-upload-icon">{uploading ? "⏳" : "📁"}</div>
|
||||
<h3>{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}</h3>
|
||||
<p>支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 2GB</p>
|
||||
<div className="dup-upload-formats">
|
||||
{FORMAT_LIST.map((fmt) => (
|
||||
<Tag key={fmt} variant="info">
|
||||
{fmt}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadZone
|
||||
@@ -0,0 +1,17 @@
|
||||
/** 支持的视频格式 */
|
||||
export const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
|
||||
export const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
|
||||
/** 最大文件大小:2GB */
|
||||
export const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
|
||||
|
||||
/** 简易 toast */
|
||||
export interface ToastState {
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
}
|
||||
|
||||
/** 上传结果 */
|
||||
export interface UploadResult {
|
||||
id: string
|
||||
message: string
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState, useRef, useCallback } from "react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { uploadForDuplication } from "@/api/duplication"
|
||||
import { ACCEPT_FORMATS, FORMAT_LIST, MAX_FILE_SIZE } from "./constants"
|
||||
import type { ToastState, UploadResult } from "./constants"
|
||||
|
||||
/**
|
||||
* 查重上传逻辑 Hook
|
||||
* 封装文件校验、上传 mutation、toast 提示
|
||||
*/
|
||||
export function useDuplicationUpload() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadResult, setUploadResult] = useState<UploadResult | null>(null)
|
||||
const [toast, setToast] = useState<ToastState | null>(null)
|
||||
|
||||
/** 显示 toast */
|
||||
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
|
||||
setToast({ message, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}, [])
|
||||
|
||||
// 上传查重 mutation
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => uploadForDuplication(file),
|
||||
onSuccess: (data) => {
|
||||
setUploading(false)
|
||||
setUploadResult({ id: data.id, message: data.message })
|
||||
showToast("查重任务已提交", "success")
|
||||
},
|
||||
onError: () => {
|
||||
setUploading(false)
|
||||
showToast("上传失败,请重试", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 校验并上传文件 */
|
||||
const handleFile = useCallback(
|
||||
(file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
showToast("文件大小不能超过 2GB", "error")
|
||||
return
|
||||
}
|
||||
const ext = file.name.toLowerCase().split(".").pop()
|
||||
const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",")
|
||||
if (!allowedExts.includes(ext || "")) {
|
||||
showToast(`不支持的文件格式,支持:${FORMAT_LIST.join("、")}`, "error")
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
setUploadResult(null)
|
||||
uploadMutation.mutate(file)
|
||||
},
|
||||
[uploadMutation, showToast],
|
||||
)
|
||||
|
||||
/** 拖拽事件 */
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFile(file)
|
||||
},
|
||||
[handleFile],
|
||||
)
|
||||
|
||||
/** 点击选择文件 */
|
||||
const handleSelectFile = useCallback(() => {
|
||||
fileInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
// 重置 input 以便重复选择同一文件
|
||||
e.target.value = ""
|
||||
},
|
||||
[handleFile],
|
||||
)
|
||||
|
||||
/** 重置状态 */
|
||||
const handleReset = useCallback(() => {
|
||||
setUploadResult(null)
|
||||
setUploading(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// refs
|
||||
fileInputRef,
|
||||
// 状态
|
||||
dragging,
|
||||
uploading,
|
||||
uploadResult,
|
||||
toast,
|
||||
// 事件
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleSelectFile,
|
||||
handleFileChange,
|
||||
handleReset,
|
||||
}
|
||||
}
|
||||
Regular → Executable
+21
-130
@@ -1,15 +1,11 @@
|
||||
/**
|
||||
* 绿幕抠像配置面板
|
||||
* 5 种颜色预设 + 自定义颜色 + 相似度/边缘平滑/溢色抑制
|
||||
*/
|
||||
import React, { useCallback } from "react"
|
||||
import { Drawer, Switch } from "antd"
|
||||
import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types"
|
||||
import {
|
||||
DEFAULT_CHROMA_KEY_CONFIG,
|
||||
CHROMA_KEY_PRESET_LABELS,
|
||||
CHROMA_KEY_PRESET_COLORS,
|
||||
} from "../types"
|
||||
import { DEFAULT_CHROMA_KEY_CONFIG, CHROMA_KEY_PRESET_COLORS } from "../types"
|
||||
import { GreenScreenPresets } from "./green-screen/GreenScreenPresets"
|
||||
import { GreenScreenCustomColor } from "./green-screen/GreenScreenCustomColor"
|
||||
import { GreenScreenSliders } from "./green-screen/GreenScreenSliders"
|
||||
import { GreenScreenPreview } from "./green-screen/GreenScreenPreview"
|
||||
|
||||
interface GreenScreenPanelProps {
|
||||
open: boolean
|
||||
@@ -18,9 +14,6 @@ interface GreenScreenPanelProps {
|
||||
onChange: (config: ChromaKeyConfig) => void
|
||||
}
|
||||
|
||||
/** 预设列表 */
|
||||
const PRESET_LIST: ChromaKeyColorPreset[] = ["green", "blue", "red", "pure_green", "soft_green"]
|
||||
|
||||
const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, config, onChange }) => {
|
||||
const update = useCallback(
|
||||
(partial: Partial<ChromaKeyConfig>) => {
|
||||
@@ -33,7 +26,6 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
|
||||
onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled })
|
||||
}, [config.enabled, onChange])
|
||||
|
||||
/** 选择颜色预设时同步更新 color 字段 */
|
||||
const handlePresetSelect = useCallback(
|
||||
(preset: ChromaKeyColorPreset) => {
|
||||
update({
|
||||
@@ -44,10 +36,9 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
|
||||
[update],
|
||||
)
|
||||
|
||||
/** 自定义颜色变化时清除预设标记 */
|
||||
const handleColorChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
update({ color: e.target.value })
|
||||
(color: string) => {
|
||||
update({ color })
|
||||
},
|
||||
[update],
|
||||
)
|
||||
@@ -61,7 +52,6 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
|
||||
onClose={onClose}
|
||||
className="green-screen-panel-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="green-header">
|
||||
<span className="green-header-label">启用绿幕抠像</span>
|
||||
<Switch
|
||||
@@ -71,123 +61,24 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 颜色预设 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">颜色预设</div>
|
||||
<div className="green-presets">
|
||||
{PRESET_LIST.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`green-preset-btn${config.color_preset === p ? " active" : ""}`}
|
||||
onClick={() => handlePresetSelect(p)}
|
||||
>
|
||||
<span
|
||||
className="green-preset-dot"
|
||||
style={{ background: CHROMA_KEY_PRESET_COLORS[p] }}
|
||||
/>
|
||||
<span className="green-preset-label">{CHROMA_KEY_PRESET_LABELS[p]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<GreenScreenPresets
|
||||
selectedPreset={config.color_preset}
|
||||
onPresetSelect={handlePresetSelect}
|
||||
/>
|
||||
|
||||
{/* 自定义颜色 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">自定义颜色</div>
|
||||
<div className="green-color-row">
|
||||
<input
|
||||
type="color"
|
||||
className="green-color-picker"
|
||||
value={config.color}
|
||||
onChange={handleColorChange}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="green-color-hex"
|
||||
value={config.color}
|
||||
onChange={handleColorChange}
|
||||
placeholder="#00FF00"
|
||||
/>
|
||||
<div className="green-color-swatch" style={{ background: config.color }} />
|
||||
</div>
|
||||
</div>
|
||||
<GreenScreenCustomColor color={config.color} onColorChange={handleColorChange} />
|
||||
|
||||
{/* 参数调节 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">参数调节</div>
|
||||
<GreenScreenSliders
|
||||
similarity={config.similarity}
|
||||
blend={config.blend}
|
||||
spill={config.spill}
|
||||
onSimilarityChange={(v) => update({ similarity: v })}
|
||||
onBlendChange={(v) => update({ blend: v })}
|
||||
onSpillChange={(v) => update({ spill: v })}
|
||||
/>
|
||||
|
||||
{/* 相似度 */}
|
||||
<div className="green-slider-row">
|
||||
<div className="green-slider-header">
|
||||
<span className="green-slider-label">相似度</span>
|
||||
<span className="green-slider-value">{config.similarity}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="green-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.similarity}
|
||||
onChange={(e) => update({ similarity: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="green-slider-desc">越大容忍的色差范围越广</div>
|
||||
</div>
|
||||
<GreenScreenPreview color={config.color} blend={config.blend} />
|
||||
|
||||
{/* 边缘平滑 */}
|
||||
<div className="green-slider-row">
|
||||
<div className="green-slider-header">
|
||||
<span className="green-slider-label">边缘平滑</span>
|
||||
<span className="green-slider-value">{config.blend}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="green-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.blend}
|
||||
onChange={(e) => update({ blend: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="green-slider-desc">越大边缘越柔和自然</div>
|
||||
</div>
|
||||
|
||||
{/* 溢色抑制 */}
|
||||
<div className="green-slider-row">
|
||||
<div className="green-slider-header">
|
||||
<span className="green-slider-label">溢色抑制</span>
|
||||
<span className="green-slider-value">{config.spill}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="green-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.spill}
|
||||
onChange={(e) => update({ spill: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="green-slider-desc">去除边缘颜色溢出</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预览 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">效果预览</div>
|
||||
<div className="green-preview-box">
|
||||
<div className="green-preview-bg" style={{ background: config.color, opacity: 0.3 }} />
|
||||
<div className="green-preview-subject">
|
||||
<div className="green-preview-circle" />
|
||||
<div className="green-preview-text">主体</div>
|
||||
</div>
|
||||
<div
|
||||
className="green-preview-edge"
|
||||
style={{
|
||||
borderColor: config.color,
|
||||
filter: `blur(${config.blend / 10}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部重置 */}
|
||||
<div className="green-footer">
|
||||
<button className="green-reset-btn" onClick={handleReset}>
|
||||
重置参数
|
||||
|
||||
Regular → Executable
+14
-58
@@ -1,18 +1,16 @@
|
||||
/**
|
||||
* 字幕样式配置面板 — Drawer 形式
|
||||
* 字幕开关(手动 / ASR 自动识别)、字体大小、颜色、描边/阴影、位置、ASR 语言
|
||||
*/
|
||||
import React from "react"
|
||||
import { Drawer, Slider, ColorPicker, Select } from "antd"
|
||||
import type { Color } from "antd/es/color-picker"
|
||||
import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle"
|
||||
import {
|
||||
POSITION_OPTIONS,
|
||||
FONT_OPTIONS,
|
||||
ANIMATION_OPTIONS,
|
||||
ASR_LANGUAGE_OPTIONS,
|
||||
} from "@/pages/editing-planner/constants/subtitleStyle"
|
||||
import SubtitlePreview from "./subtitle-style/SubtitlePreview"
|
||||
import { SubtitleModeSwitch } from "./subtitle-style/SubtitleModeSwitch"
|
||||
import { SubtitlePositionSelector } from "./subtitle-style/SubtitlePositionSelector"
|
||||
import { SubtitleEffectButtons } from "./subtitle-style/SubtitleEffectButtons"
|
||||
|
||||
interface SubtitleStylePanelProps {
|
||||
open: boolean
|
||||
@@ -40,7 +38,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
onClose={onClose}
|
||||
className="subtitle-style-drawer"
|
||||
>
|
||||
{/* ── 字幕开关 ── */}
|
||||
<div className="sub-field">
|
||||
<div className="sub-toggle-row">
|
||||
<span className="sub-label">启用字幕</span>
|
||||
@@ -55,26 +52,11 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
{/* ── 模式切换 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字幕来源</label>
|
||||
<div className="sub-mode-switch">
|
||||
<button
|
||||
className={`sub-mode-btn${config.mode === "manual" ? " active" : ""}`}
|
||||
onClick={() => update({ mode: "manual" })}
|
||||
>
|
||||
✏️ 手动输入
|
||||
</button>
|
||||
<button
|
||||
className={`sub-mode-btn${config.mode === "asr" ? " active" : ""}`}
|
||||
onClick={() => update({ mode: "asr" })}
|
||||
>
|
||||
🤖 ASR 识别
|
||||
</button>
|
||||
</div>
|
||||
<SubtitleModeSwitch mode={config.mode} onModeChange={(mode) => update({ mode })} />
|
||||
</div>
|
||||
|
||||
{/* ── ASR 语言(仅 ASR 模式) ── */}
|
||||
{config.mode === "asr" && (
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">识别语言</label>
|
||||
@@ -88,7 +70,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 字体大小 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">
|
||||
字体大小 <span className="sub-value">{config.fontSize}px</span>
|
||||
@@ -101,7 +82,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 字体颜色 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字体颜色</label>
|
||||
<div className="sub-color-row">
|
||||
@@ -113,7 +93,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 字体 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字体</label>
|
||||
<Select
|
||||
@@ -125,46 +104,24 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 字幕位置 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">位置</label>
|
||||
<div className="sub-position-group">
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`sub-position-btn${config.position === opt.value ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
update({
|
||||
position: opt.value as SubtitleStyleConfig["position"],
|
||||
})
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SubtitlePositionSelector
|
||||
position={config.position}
|
||||
onPositionChange={(position) => update({ position })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 描边 / 阴影 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">效果</label>
|
||||
<div className="sub-effect-btns">
|
||||
<button
|
||||
className={`sub-effect-btn${config.stroke ? " active" : ""}`}
|
||||
onClick={() => update({ stroke: !config.stroke })}
|
||||
>
|
||||
S 描边
|
||||
</button>
|
||||
<button
|
||||
className={`sub-effect-btn${config.shadow ? " active" : ""}`}
|
||||
onClick={() => update({ shadow: !config.shadow })}
|
||||
>
|
||||
☁ 阴影
|
||||
</button>
|
||||
</div>
|
||||
<SubtitleEffectButtons
|
||||
stroke={config.stroke}
|
||||
shadow={config.shadow}
|
||||
onStrokeChange={(stroke) => update({ stroke })}
|
||||
onShadowChange={(shadow) => update({ shadow })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 动画 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">动画效果</label>
|
||||
<Select
|
||||
@@ -179,7 +136,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 预览 ── */}
|
||||
<SubtitlePreview config={config} />
|
||||
</>
|
||||
)}
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import React from "react"
|
||||
|
||||
interface GreenScreenCustomColorProps {
|
||||
color: string
|
||||
onColorChange: (color: string) => void
|
||||
}
|
||||
|
||||
export const GreenScreenCustomColor: React.FC<GreenScreenCustomColorProps> = ({
|
||||
color,
|
||||
onColorChange,
|
||||
}) => {
|
||||
const handleColorInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onColorChange(e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">自定义颜色</div>
|
||||
<div className="green-color-row">
|
||||
<input
|
||||
type="color"
|
||||
className="green-color-picker"
|
||||
value={color}
|
||||
onChange={handleColorInput}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="green-color-hex"
|
||||
value={color}
|
||||
onChange={handleColorInput}
|
||||
placeholder="#00FF00"
|
||||
/>
|
||||
<div className="green-color-swatch" style={{ background: color }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import React from "react"
|
||||
import type { ChromaKeyColorPreset } from "../../types"
|
||||
import { CHROMA_KEY_PRESET_LABELS, CHROMA_KEY_PRESET_COLORS } from "../../types"
|
||||
|
||||
interface GreenScreenPresetsProps {
|
||||
selectedPreset: ChromaKeyColorPreset | null
|
||||
onPresetSelect: (preset: ChromaKeyColorPreset) => void
|
||||
}
|
||||
|
||||
const PRESET_LIST: ChromaKeyColorPreset[] = ["green", "blue", "red", "pure_green", "soft_green"]
|
||||
|
||||
export const GreenScreenPresets: React.FC<GreenScreenPresetsProps> = ({
|
||||
selectedPreset,
|
||||
onPresetSelect,
|
||||
}) => {
|
||||
return (
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">颜色预设</div>
|
||||
<div className="green-presets">
|
||||
{PRESET_LIST.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`green-preset-btn${selectedPreset === p ? " active" : ""}`}
|
||||
onClick={() => onPresetSelect(p)}
|
||||
>
|
||||
<span
|
||||
className="green-preset-dot"
|
||||
style={{ background: CHROMA_KEY_PRESET_COLORS[p] }}
|
||||
/>
|
||||
<span className="green-preset-label">{CHROMA_KEY_PRESET_LABELS[p]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import React from "react"
|
||||
|
||||
interface GreenScreenPreviewProps {
|
||||
color: string
|
||||
blend: number
|
||||
}
|
||||
|
||||
export const GreenScreenPreview: React.FC<GreenScreenPreviewProps> = ({ color, blend }) => {
|
||||
return (
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">效果预览</div>
|
||||
<div className="green-preview-box">
|
||||
<div className="green-preview-bg" style={{ background: color, opacity: 0.3 }} />
|
||||
<div className="green-preview-subject">
|
||||
<div className="green-preview-circle" />
|
||||
<div className="green-preview-text">主体</div>
|
||||
</div>
|
||||
<div
|
||||
className="green-preview-edge"
|
||||
style={{
|
||||
borderColor: color,
|
||||
filter: `blur(${blend / 10}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import React from "react"
|
||||
|
||||
interface SliderConfig {
|
||||
label: string
|
||||
value: number
|
||||
description: string
|
||||
onChange: (value: number) => void
|
||||
}
|
||||
|
||||
interface GreenScreenSlidersProps {
|
||||
similarity: number
|
||||
blend: number
|
||||
spill: number
|
||||
onSimilarityChange: (value: number) => void
|
||||
onBlendChange: (value: number) => void
|
||||
onSpillChange: (value: number) => void
|
||||
}
|
||||
|
||||
const SliderRow: React.FC<SliderConfig> = ({ label, value, description, onChange }) => (
|
||||
<div className="green-slider-row">
|
||||
<div className="green-slider-header">
|
||||
<span className="green-slider-label">{label}</span>
|
||||
<span className="green-slider-value">{value}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="green-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
/>
|
||||
<div className="green-slider-desc">{description}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const GreenScreenSliders: React.FC<GreenScreenSlidersProps> = ({
|
||||
similarity,
|
||||
blend,
|
||||
spill,
|
||||
onSimilarityChange,
|
||||
onBlendChange,
|
||||
onSpillChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">参数调节</div>
|
||||
<SliderRow
|
||||
label="相似度"
|
||||
value={similarity}
|
||||
description="越大容忍的色差范围越广"
|
||||
onChange={onSimilarityChange}
|
||||
/>
|
||||
<SliderRow
|
||||
label="边缘平滑"
|
||||
value={blend}
|
||||
description="越大边缘越柔和自然"
|
||||
onChange={onBlendChange}
|
||||
/>
|
||||
<SliderRow
|
||||
label="溢色抑制"
|
||||
value={spill}
|
||||
description="去除边缘颜色溢出"
|
||||
onChange={onSpillChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react"
|
||||
import type { StickerItem } from "@/pages/editing-planner/types"
|
||||
import { TEXT_PRESET_STYLES } from "@/pages/editing-planner/constants/sticker"
|
||||
|
||||
interface StickerPreviewProps {
|
||||
sticker: StickerItem
|
||||
}
|
||||
|
||||
export const StickerPreview: React.FC<StickerPreviewProps> = ({ sticker }) => (
|
||||
<div className="sticker-preview-box">
|
||||
<div
|
||||
className="sticker-preview-item"
|
||||
style={{
|
||||
left: `${sticker.x}%`,
|
||||
top: `${sticker.y}%`,
|
||||
width: `${sticker.width}%`,
|
||||
height: `${sticker.height}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
|
||||
opacity: sticker.opacity / 100,
|
||||
fontSize: sticker.type === "text" ? `${sticker.font_size}px` : undefined,
|
||||
...TEXT_PRESET_STYLES[sticker.text_preset],
|
||||
}}
|
||||
>
|
||||
{sticker.type === "emoji" && sticker.content}
|
||||
{sticker.type === "text" && sticker.content}
|
||||
{sticker.type === "image" && (
|
||||
<img
|
||||
src={sticker.content}
|
||||
alt="sticker"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
Regular → Executable
+5
-75
@@ -2,11 +2,9 @@
|
||||
* 选中贴纸的属性编辑器
|
||||
*/
|
||||
import React from "react"
|
||||
import type { StickerItem, TextStickerPreset } from "@/pages/editing-planner/types"
|
||||
import {
|
||||
TEXT_PRESET_STYLES,
|
||||
TEXT_STICKER_PRESET_LABELS,
|
||||
} from "@/pages/editing-planner/constants/sticker"
|
||||
import type { StickerItem } from "@/pages/editing-planner/types"
|
||||
import { StickerPreview } from "./StickerPreview"
|
||||
import { TextStickerPropsEditor } from "./TextStickerPropsEditor"
|
||||
|
||||
interface StickerPropsEditorProps {
|
||||
sticker: StickerItem
|
||||
@@ -121,78 +119,10 @@ const StickerPropsEditor: React.FC<StickerPropsEditorProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 文字贴纸特有属性 */}
|
||||
{sticker.type === "text" && (
|
||||
<>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">花字</span>
|
||||
<select
|
||||
className="sticker-prop-select"
|
||||
value={sticker.text_preset}
|
||||
onChange={(e) =>
|
||||
onUpdate(sticker.id, { text_preset: e.target.value as TextStickerPreset })
|
||||
}
|
||||
>
|
||||
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">字号</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={12}
|
||||
max={72}
|
||||
value={sticker.font_size}
|
||||
onChange={(e) => onUpdate(sticker.id, { font_size: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.font_size}px</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">颜色</span>
|
||||
<input
|
||||
type="color"
|
||||
className="sticker-prop-color"
|
||||
value={sticker.text_color}
|
||||
onChange={(e) => onUpdate(sticker.id, { text_color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<TextStickerPropsEditor sticker={sticker} onUpdate={onUpdate} />
|
||||
|
||||
{/* 预览 */}
|
||||
<div className="sticker-preview-box">
|
||||
<div
|
||||
className="sticker-preview-item"
|
||||
style={{
|
||||
left: `${sticker.x}%`,
|
||||
top: `${sticker.y}%`,
|
||||
width: `${sticker.width}%`,
|
||||
height: `${sticker.width}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
|
||||
opacity: sticker.opacity / 100,
|
||||
fontSize: sticker.type === "text" ? `${sticker.font_size}px` : undefined,
|
||||
...TEXT_PRESET_STYLES[sticker.text_preset],
|
||||
}}
|
||||
>
|
||||
{sticker.type === "emoji" && sticker.content}
|
||||
{sticker.type === "text" && sticker.content}
|
||||
{sticker.type === "image" && (
|
||||
<img
|
||||
src={sticker.content}
|
||||
alt="sticker"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StickerPreview sticker={sticker} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import React from "react"
|
||||
import type { StickerItem, TextStickerPreset } from "@/pages/editing-planner/types"
|
||||
import { TEXT_STICKER_PRESET_LABELS } from "@/pages/editing-planner/constants/sticker"
|
||||
|
||||
interface TextStickerPropsEditorProps {
|
||||
sticker: StickerItem
|
||||
onUpdate: (id: string, partial: Partial<StickerItem>) => void
|
||||
}
|
||||
|
||||
export const TextStickerPropsEditor: React.FC<TextStickerPropsEditorProps> = ({
|
||||
sticker,
|
||||
onUpdate,
|
||||
}) => {
|
||||
if (sticker.type !== "text") return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">花字</span>
|
||||
<select
|
||||
className="sticker-prop-select"
|
||||
value={sticker.text_preset}
|
||||
onChange={(e) =>
|
||||
onUpdate(sticker.id, { text_preset: e.target.value as TextStickerPreset })
|
||||
}
|
||||
>
|
||||
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">字号</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={12}
|
||||
max={72}
|
||||
value={sticker.font_size}
|
||||
onChange={(e) => onUpdate(sticker.id, { font_size: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.font_size}px</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">颜色</span>
|
||||
<input
|
||||
type="color"
|
||||
className="sticker-prop-color"
|
||||
value={sticker.text_color}
|
||||
onChange={(e) => onUpdate(sticker.id, { text_color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import React from "react"
|
||||
|
||||
interface SubtitleEffectButtonsProps {
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
onStrokeChange: (enabled: boolean) => void
|
||||
onShadowChange: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
export const SubtitleEffectButtons: React.FC<SubtitleEffectButtonsProps> = ({
|
||||
stroke,
|
||||
shadow,
|
||||
onStrokeChange,
|
||||
onShadowChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="sub-effect-btns">
|
||||
<button
|
||||
className={`sub-effect-btn${stroke ? " active" : ""}`}
|
||||
onClick={() => onStrokeChange(!stroke)}
|
||||
>
|
||||
S 描边
|
||||
</button>
|
||||
<button
|
||||
className={`sub-effect-btn${shadow ? " active" : ""}`}
|
||||
onClick={() => onShadowChange(!shadow)}
|
||||
>
|
||||
☁ 阴影
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import React from "react"
|
||||
|
||||
interface SubtitleModeSwitchProps {
|
||||
mode: "manual" | "asr"
|
||||
onModeChange: (mode: "manual" | "asr") => void
|
||||
}
|
||||
|
||||
export const SubtitleModeSwitch: React.FC<SubtitleModeSwitchProps> = ({ mode, onModeChange }) => {
|
||||
return (
|
||||
<div className="sub-mode-switch">
|
||||
<button
|
||||
className={`sub-mode-btn${mode === "manual" ? " active" : ""}`}
|
||||
onClick={() => onModeChange("manual")}
|
||||
>
|
||||
✏️ 手动输入
|
||||
</button>
|
||||
<button
|
||||
className={`sub-mode-btn${mode === "asr" ? " active" : ""}`}
|
||||
onClick={() => onModeChange("asr")}
|
||||
>
|
||||
🤖 ASR 识别
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
import React from "react"
|
||||
import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle"
|
||||
import { POSITION_OPTIONS } from "@/pages/editing-planner/constants/subtitleStyle"
|
||||
|
||||
interface SubtitlePositionSelectorProps {
|
||||
position: SubtitleStyleConfig["position"]
|
||||
onPositionChange: (position: SubtitleStyleConfig["position"]) => void
|
||||
}
|
||||
|
||||
export const SubtitlePositionSelector: React.FC<SubtitlePositionSelectorProps> = ({
|
||||
position,
|
||||
onPositionChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="sub-position-group">
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`sub-position-btn${position === opt.value ? " active" : ""}`}
|
||||
onClick={() => onPositionChange(opt.value as SubtitleStyleConfig["position"])}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from "react"
|
||||
import type { ClipType } from "../../types"
|
||||
import { DEFAULT_ADD_DURATION, ADD_PICKER_WIDTH, TRACK_GAP } from "../../constants/timeline"
|
||||
|
||||
interface UseAddPickerOptions {
|
||||
currentMode: string
|
||||
onAddClip: (type: ClipType, duration: number) => void
|
||||
}
|
||||
|
||||
export function useAddPicker({ currentMode, onAddClip }: UseAddPickerOptions) {
|
||||
const [showAddPicker, setShowAddPicker] = useState(false)
|
||||
const pickerRef = useRef<HTMLDivElement>(null)
|
||||
const addCardRef = useRef<HTMLDivElement>(null)
|
||||
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
|
||||
|
||||
const availableTypes: ClipType[] = useMemo(
|
||||
() =>
|
||||
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"],
|
||||
[currentMode],
|
||||
)
|
||||
|
||||
const defaultAddType: ClipType = useMemo(() => {
|
||||
if (currentMode === "voice_over") return "voice"
|
||||
if (currentMode === "pip") return "pip"
|
||||
return "voice"
|
||||
}, [currentMode])
|
||||
|
||||
const [addType, setAddType] = useState<ClipType>(defaultAddType)
|
||||
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableTypes.includes(addType)) {
|
||||
setAddType(defaultAddType)
|
||||
}
|
||||
}, [currentMode, addType, availableTypes, defaultAddType])
|
||||
|
||||
const updatePickerPosition = useCallback(() => {
|
||||
if (!addCardRef.current) return
|
||||
const rect = addCardRef.current.getBoundingClientRect()
|
||||
const vw = window.innerWidth
|
||||
const roughHeight = 180
|
||||
let top = rect.top - TRACK_GAP - roughHeight
|
||||
if (top < 8) top = 8
|
||||
let right = vw - rect.right
|
||||
if (rect.right - ADD_PICKER_WIDTH < 8) {
|
||||
right = vw - ADD_PICKER_WIDTH - 8
|
||||
}
|
||||
setPickerPos({ top, right })
|
||||
}, [])
|
||||
|
||||
const handleTogglePicker = useCallback(() => {
|
||||
if (!showAddPicker) {
|
||||
setAddType(defaultAddType)
|
||||
updatePickerPosition()
|
||||
}
|
||||
setShowAddPicker((v) => !v)
|
||||
}, [showAddPicker, defaultAddType, updatePickerPosition])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
|
||||
const pickerEl = pickerRef.current
|
||||
const addRect = addCardRef.current.getBoundingClientRect()
|
||||
const pickerH = pickerEl.offsetHeight
|
||||
const vh = window.innerHeight
|
||||
const vw = window.innerWidth
|
||||
|
||||
let top = addRect.top - TRACK_GAP - pickerH
|
||||
if (top < 8) {
|
||||
top = addRect.bottom + TRACK_GAP
|
||||
if (top + pickerH > vh - 8) {
|
||||
top = vh - 8 - pickerH
|
||||
if (top < 8) top = 8
|
||||
}
|
||||
}
|
||||
|
||||
let right = vw - addRect.right
|
||||
const pickerRect = pickerEl.getBoundingClientRect()
|
||||
if (pickerRect.left < 8) {
|
||||
right = vw - ADD_PICKER_WIDTH - 8
|
||||
}
|
||||
|
||||
setPickerPos({ top, right })
|
||||
}, [showAddPicker])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
|
||||
setShowAddPicker(false)
|
||||
}
|
||||
}
|
||||
if (showAddPicker) {
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [showAddPicker])
|
||||
|
||||
const handleConfirmAdd = useCallback(() => {
|
||||
onAddClip(addType, addDuration)
|
||||
setShowAddPicker(false)
|
||||
}, [onAddClip, addType, addDuration])
|
||||
|
||||
return {
|
||||
showAddPicker,
|
||||
setShowAddPicker,
|
||||
pickerRef,
|
||||
addCardRef,
|
||||
pickerPos,
|
||||
availableTypes,
|
||||
addType,
|
||||
addDuration,
|
||||
setAddType,
|
||||
setAddDuration,
|
||||
handleTogglePicker,
|
||||
handleConfirmAdd,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
|
||||
interface ContextMenuState {
|
||||
x: number
|
||||
y: number
|
||||
clipId: string
|
||||
}
|
||||
|
||||
interface UseContextMenuOptions {
|
||||
onClipSplit?: (clipId: string, splitRatio: number) => void
|
||||
onClipResetTrim?: (clipId: string) => void
|
||||
onClipRemove?: (clipId: string) => void
|
||||
}
|
||||
|
||||
export function useContextMenu({
|
||||
onClipSplit,
|
||||
onClipResetTrim,
|
||||
onClipRemove,
|
||||
}: UseContextMenuOptions) {
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, clipId })
|
||||
}, [])
|
||||
|
||||
const handleContextSplit = useCallback(() => {
|
||||
if (!contextMenu) return
|
||||
onClipSplit?.(contextMenu.clipId, 0.5)
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipSplit])
|
||||
|
||||
const handleContextResetTrim = useCallback(() => {
|
||||
if (!contextMenu) return
|
||||
onClipResetTrim?.(contextMenu.clipId)
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipResetTrim])
|
||||
|
||||
const handleContextDelete = useCallback(() => {
|
||||
if (!contextMenu) return
|
||||
onClipRemove?.(contextMenu.clipId)
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipRemove])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||
setContextMenu(null)
|
||||
}
|
||||
}
|
||||
if (contextMenu) {
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [contextMenu])
|
||||
|
||||
return {
|
||||
contextMenu,
|
||||
setContextMenu,
|
||||
contextMenuRef,
|
||||
handleContextMenu,
|
||||
handleContextSplit,
|
||||
handleContextResetTrim,
|
||||
handleContextDelete,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import { createClipsFromAssets } from "@/api/template-editor"
|
||||
|
||||
const QUERY_KEY = "editPlanClips"
|
||||
|
||||
export function useClipImport(planId: string | undefined) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
||||
}
|
||||
|
||||
const importFromAssetsMutation = useMutation({
|
||||
mutationFn: (assetIds: string[]) => createClipsFromAssets(planId!, assetIds),
|
||||
onSuccess: (res) => {
|
||||
invalidate()
|
||||
message.success(`已导入 ${res.created_count} 个素材片段`)
|
||||
},
|
||||
onError: () => {
|
||||
message.error("导入素材失败")
|
||||
},
|
||||
})
|
||||
|
||||
const importFromAssets = useCallback(
|
||||
(assetIds: string[]) => {
|
||||
if (!planId || assetIds.length === 0) return
|
||||
importFromAssetsMutation.mutate(assetIds)
|
||||
},
|
||||
[planId, importFromAssetsMutation],
|
||||
)
|
||||
|
||||
return {
|
||||
importFromAssets,
|
||||
isImporting: importFromAssetsMutation.isPending,
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import type { ClipReorderItem } from "@/api/template-editor"
|
||||
import { reorderEditPlanClips } from "@/api/template-editor"
|
||||
|
||||
const QUERY_KEY = "editPlanClips"
|
||||
|
||||
export function useClipReorder(planId: string | undefined) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
||||
}
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (items: ClipReorderItem[]) => reorderEditPlanClips(planId!, items),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
},
|
||||
onError: () => {
|
||||
message.error("排序失败")
|
||||
invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
const reorderClips = useCallback(
|
||||
(items: ClipReorderItem[]) => {
|
||||
if (!planId || items.length === 0) return
|
||||
reorderMutation.mutate(items)
|
||||
},
|
||||
[planId, reorderMutation],
|
||||
)
|
||||
|
||||
return {
|
||||
reorderClips,
|
||||
isReordering: reorderMutation.isPending,
|
||||
}
|
||||
}
|
||||
Regular → Executable
+7
-56
@@ -1,19 +1,15 @@
|
||||
import { useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import type {
|
||||
CreateEditPlanClipRequest,
|
||||
UpdateEditPlanClipRequest,
|
||||
ClipReorderItem,
|
||||
} from "@/api/template-editor"
|
||||
import type { CreateEditPlanClipRequest, UpdateEditPlanClipRequest } from "@/api/template-editor"
|
||||
import {
|
||||
createEditPlanClip,
|
||||
updateEditPlanClip,
|
||||
deleteEditPlanClip,
|
||||
reorderEditPlanClips,
|
||||
batchDeleteEditPlanClips,
|
||||
createClipsFromAssets,
|
||||
} from "@/api/template-editor"
|
||||
import { useClipReorder } from "./useClipReorder"
|
||||
import { useClipImport } from "./useClipImport"
|
||||
|
||||
const QUERY_KEY = "editPlanClips"
|
||||
|
||||
@@ -24,10 +20,6 @@ interface UseEditPlanClipMutationsOptions {
|
||||
clipsLength: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑计划片段 CRUD Hook
|
||||
* 封装创建、更新、删除、批量删除、重排序、素材导入等操作
|
||||
*/
|
||||
export function useEditPlanClipMutations({
|
||||
planId,
|
||||
selectedClipId,
|
||||
@@ -40,7 +32,6 @@ export function useEditPlanClipMutations({
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
||||
}
|
||||
|
||||
/* ── 创建片段 ── */
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateEditPlanClipRequest) => createEditPlanClip(planId!, data),
|
||||
onSuccess: () => {
|
||||
@@ -61,7 +52,6 @@ export function useEditPlanClipMutations({
|
||||
[planId, clipsLength, createMutation],
|
||||
)
|
||||
|
||||
/* ── 更新片段 ── */
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ clipId, data }: { clipId: string; data: UpdateEditPlanClipRequest }) =>
|
||||
updateEditPlanClip(planId!, clipId, data),
|
||||
@@ -81,7 +71,6 @@ export function useEditPlanClipMutations({
|
||||
[planId, updateMutation],
|
||||
)
|
||||
|
||||
/* ── 删除片段 ── */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (clipId: string) => deleteEditPlanClip(planId!, clipId),
|
||||
onSuccess: () => {
|
||||
@@ -104,7 +93,6 @@ export function useEditPlanClipMutations({
|
||||
[planId, selectedClipId, setSelectedClipId, deleteMutation],
|
||||
)
|
||||
|
||||
/* ── 批量删除 ── */
|
||||
const batchDeleteMutation = useMutation({
|
||||
mutationFn: (clipIds: string[]) => batchDeleteEditPlanClips(planId!, clipIds),
|
||||
onSuccess: (res) => {
|
||||
@@ -127,45 +115,8 @@ export function useEditPlanClipMutations({
|
||||
[planId, selectedClipId, setSelectedClipId, batchDeleteMutation],
|
||||
)
|
||||
|
||||
/* ── 重排序 ── */
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (items: ClipReorderItem[]) => reorderEditPlanClips(planId!, items),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
},
|
||||
onError: () => {
|
||||
message.error("排序失败")
|
||||
invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
const reorderClips = useCallback(
|
||||
(items: ClipReorderItem[]) => {
|
||||
if (!planId || items.length === 0) return
|
||||
reorderMutation.mutate(items)
|
||||
},
|
||||
[planId, reorderMutation],
|
||||
)
|
||||
|
||||
/* ── 从素材批量导入 ── */
|
||||
const importFromAssetsMutation = useMutation({
|
||||
mutationFn: (assetIds: string[]) => createClipsFromAssets(planId!, assetIds),
|
||||
onSuccess: (res) => {
|
||||
invalidate()
|
||||
message.success(`已导入 ${res.created_count} 个素材片段`)
|
||||
},
|
||||
onError: () => {
|
||||
message.error("导入素材失败")
|
||||
},
|
||||
})
|
||||
|
||||
const importFromAssets = useCallback(
|
||||
(assetIds: string[]) => {
|
||||
if (!planId || assetIds.length === 0) return
|
||||
importFromAssetsMutation.mutate(assetIds)
|
||||
},
|
||||
[planId, importFromAssetsMutation],
|
||||
)
|
||||
const { reorderClips, isReordering } = useClipReorder(planId)
|
||||
const { importFromAssets, isImporting } = useClipImport(planId)
|
||||
|
||||
return {
|
||||
addClip,
|
||||
@@ -177,7 +128,7 @@ export function useEditPlanClipMutations({
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isReordering: reorderMutation.isPending,
|
||||
isImporting: importFromAssetsMutation.isPending,
|
||||
isReordering,
|
||||
isImporting,
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
+24
-149
@@ -1,12 +1,7 @@
|
||||
import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from "react"
|
||||
import { useState } from "react"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
import { DEFAULT_ADD_DURATION, ADD_PICKER_WIDTH, TRACK_GAP } from "../constants/timeline"
|
||||
|
||||
interface ContextMenuState {
|
||||
x: number
|
||||
y: number
|
||||
clipId: string
|
||||
}
|
||||
import { useContextMenu } from "./timeline-menus/useContextMenu"
|
||||
import { useAddPicker } from "./timeline-menus/useAddPicker"
|
||||
|
||||
/**
|
||||
* 时间线菜单 Hook
|
||||
@@ -20,150 +15,30 @@ export const useTimelineMenus = (
|
||||
onClipResetTrim?: (clipId: string) => void,
|
||||
onClipRemove?: (clipId: string) => void,
|
||||
) => {
|
||||
/* ── 右键菜单 ── */
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
/* ── 添加片段面板 ── */
|
||||
const [showAddPicker, setShowAddPicker] = useState(false)
|
||||
const pickerRef = useRef<HTMLDivElement>(null)
|
||||
const addCardRef = useRef<HTMLDivElement>(null)
|
||||
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
|
||||
|
||||
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
|
||||
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
|
||||
|
||||
/* ── 根据模式决定可选类型 ── */
|
||||
const availableTypes: ClipType[] = useMemo(
|
||||
() =>
|
||||
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"],
|
||||
[currentMode],
|
||||
)
|
||||
const {
|
||||
contextMenu,
|
||||
contextMenuRef,
|
||||
handleContextMenu,
|
||||
handleContextSplit,
|
||||
handleContextResetTrim,
|
||||
handleContextDelete,
|
||||
} = useContextMenu({ onClipSplit, onClipResetTrim, onClipRemove })
|
||||
|
||||
/* ── 默认添加类型:跟随模式 ── */
|
||||
const defaultAddType: ClipType = useMemo(() => {
|
||||
if (currentMode === "voice_over") return "voice"
|
||||
if (currentMode === "pip") return "pip"
|
||||
return "voice"
|
||||
}, [currentMode])
|
||||
|
||||
/* ── "+" 卡片:类型+时长选择状态 ── */
|
||||
const [addType, setAddType] = useState<ClipType>(defaultAddType)
|
||||
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
|
||||
|
||||
/* ── 模式切换时自动同步默认添加类型 ── */
|
||||
useEffect(() => {
|
||||
if (!availableTypes.includes(addType)) {
|
||||
setAddType(defaultAddType)
|
||||
}
|
||||
}, [currentMode, addType, availableTypes, defaultAddType])
|
||||
|
||||
/* ── 计算 picker 初始位置 ── */
|
||||
const updatePickerPosition = useCallback(() => {
|
||||
if (!addCardRef.current) return
|
||||
const rect = addCardRef.current.getBoundingClientRect()
|
||||
const vw = window.innerWidth
|
||||
const roughHeight = 180
|
||||
let top = rect.top - TRACK_GAP - roughHeight
|
||||
if (top < 8) top = 8
|
||||
let right = vw - rect.right
|
||||
if (rect.right - ADD_PICKER_WIDTH < 8) {
|
||||
right = vw - ADD_PICKER_WIDTH - 8
|
||||
}
|
||||
setPickerPos({ top, right })
|
||||
}, [])
|
||||
|
||||
const handleTogglePicker = useCallback(() => {
|
||||
if (!showAddPicker) {
|
||||
setAddType(defaultAddType)
|
||||
updatePickerPosition()
|
||||
}
|
||||
setShowAddPicker((v) => !v)
|
||||
}, [showAddPicker, defaultAddType, updatePickerPosition])
|
||||
|
||||
/* ── 渲染后精确边界校正 ── */
|
||||
useLayoutEffect(() => {
|
||||
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
|
||||
const pickerEl = pickerRef.current
|
||||
const addRect = addCardRef.current.getBoundingClientRect()
|
||||
const pickerH = pickerEl.offsetHeight
|
||||
const vh = window.innerHeight
|
||||
const vw = window.innerWidth
|
||||
|
||||
let top = addRect.top - TRACK_GAP - pickerH
|
||||
if (top < 8) {
|
||||
top = addRect.bottom + TRACK_GAP
|
||||
if (top + pickerH > vh - 8) {
|
||||
top = vh - 8 - pickerH
|
||||
if (top < 8) top = 8
|
||||
}
|
||||
}
|
||||
|
||||
let right = vw - addRect.right
|
||||
const pickerRect = pickerEl.getBoundingClientRect()
|
||||
if (pickerRect.left < 8) {
|
||||
right = vw - ADD_PICKER_WIDTH - 8
|
||||
}
|
||||
|
||||
setPickerPos({ top, right })
|
||||
}, [showAddPicker])
|
||||
|
||||
/* ── 点击外部关闭添加面板 ── */
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
|
||||
setShowAddPicker(false)
|
||||
}
|
||||
}
|
||||
if (showAddPicker) {
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [showAddPicker])
|
||||
|
||||
/* ── 点击外部关闭右键菜单 ── */
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||
setContextMenu(null)
|
||||
}
|
||||
}
|
||||
if (contextMenu) {
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [contextMenu])
|
||||
|
||||
/* ── 确认添加片段 ── */
|
||||
const handleConfirmAdd = useCallback(() => {
|
||||
onAddClip(addType, addDuration)
|
||||
setShowAddPicker(false)
|
||||
}, [onAddClip, addType, addDuration])
|
||||
|
||||
/* ── 右键菜单操作 ── */
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, clipId })
|
||||
}, [])
|
||||
|
||||
const handleContextSplit = useCallback(() => {
|
||||
if (!contextMenu) return
|
||||
onClipSplit?.(contextMenu.clipId, 0.5)
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipSplit])
|
||||
|
||||
const handleContextResetTrim = useCallback(() => {
|
||||
if (!contextMenu) return
|
||||
onClipResetTrim?.(contextMenu.clipId)
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipResetTrim])
|
||||
|
||||
const handleContextDelete = useCallback(() => {
|
||||
if (!contextMenu) return
|
||||
onClipRemove?.(contextMenu.clipId)
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipRemove])
|
||||
const {
|
||||
showAddPicker,
|
||||
pickerRef,
|
||||
addCardRef,
|
||||
pickerPos,
|
||||
availableTypes,
|
||||
addType,
|
||||
addDuration,
|
||||
setAddType,
|
||||
setAddDuration,
|
||||
handleTogglePicker,
|
||||
handleConfirmAdd,
|
||||
} = useAddPicker({ currentMode, onAddClip })
|
||||
|
||||
return {
|
||||
// 右键菜单
|
||||
|
||||
Regular → Executable
+11
-102
@@ -1,37 +1,21 @@
|
||||
/**
|
||||
* 智能剪辑右侧生成结果面板
|
||||
*/
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { PlayCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { formatDuration } from "@/api/voice-clone"
|
||||
import { ProgressIndicator } from "./result-panel/ProgressIndicator"
|
||||
import { ResultVideoCard } from "./result-panel/ResultVideoCard"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface GenerateResultPanelProps {
|
||||
/** 是否已生成完成 */
|
||||
generated: boolean
|
||||
/** 是否正在生成中 */
|
||||
generating: boolean
|
||||
/** 生成进度(0-100) */
|
||||
progress: number
|
||||
/** 生成错误信息 */
|
||||
generateError: string | null
|
||||
/** 生成的视频列表 */
|
||||
generatedVideos: GeneratedVideo[]
|
||||
/** 点击视频卡片预览回调 */
|
||||
onVideoPreview: (video: GeneratedVideo) => void
|
||||
/** 下载回调 */
|
||||
onDownload: () => void
|
||||
/** 分享回调 */
|
||||
onShare: () => void
|
||||
/** 前往成片库回调 */
|
||||
onGoToLibrary: () => void
|
||||
}
|
||||
|
||||
@@ -55,45 +39,8 @@ const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 生成中进度 */}
|
||||
{generating && (
|
||||
<div className="xx-result-progress">
|
||||
<div className="xx-progress-circle">
|
||||
<svg viewBox="0 0 80 80">
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="36"
|
||||
fill="none"
|
||||
stroke="var(--border-color)"
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="36"
|
||||
fill="none"
|
||||
stroke="var(--primary-color)"
|
||||
strokeWidth="6"
|
||||
strokeDasharray={`${Math.round(progress) * 2.26} 226`}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 40 40)"
|
||||
/>
|
||||
</svg>
|
||||
<span className="xx-progress-percent">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="xx-progress-text">
|
||||
<Text strong style={{ fontSize: 14, display: "block", marginBottom: 4 }}>
|
||||
正在生成视频
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
AI 正在处理素材,请稍候…
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generating && <ProgressIndicator progress={progress} />}
|
||||
|
||||
{/* 生成失败 */}
|
||||
{generateError && !generating && (
|
||||
<div className="xx-result-empty">
|
||||
<CloseCircleOutlined style={{ fontSize: 40, color: "#ff4d4f", marginBottom: 12 }} />
|
||||
@@ -106,7 +53,6 @@ const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!generated && !generating && !generateError && (
|
||||
<div className="xx-result-empty">
|
||||
<PlayCircleOutlined
|
||||
@@ -121,54 +67,17 @@ const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成结果卡片列表 */}
|
||||
{generated && generatedVideos.length > 0 && (
|
||||
<div className="xx-video-grid">
|
||||
{generatedVideos.map((video, idx) => (
|
||||
<div
|
||||
<ResultVideoCard
|
||||
key={video.id || idx}
|
||||
className="xx-video-card"
|
||||
onClick={() => onVideoPreview(video)}
|
||||
>
|
||||
<div className="xx-video-thumb">
|
||||
{video.thumbnail_url ? (
|
||||
<img src={video.thumbnail_url} alt="" />
|
||||
) : (
|
||||
<div className="xx-video-thumb-placeholder">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-video-play-overlay">
|
||||
<PlayCircleOutlined style={{ fontSize: 36, color: "#fff" }} />
|
||||
</div>
|
||||
{video.duration && (
|
||||
<span className="xx-video-duration">{formatDuration(video.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-video-info">
|
||||
<div className="xx-video-title">视频 {idx + 1}</div>
|
||||
<div className="xx-video-actions">
|
||||
<button
|
||||
className="xx-video-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDownload()
|
||||
}}
|
||||
>
|
||||
<DownloadOutlined />
|
||||
</button>
|
||||
<button
|
||||
className="xx-video-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onShare()
|
||||
}}
|
||||
>
|
||||
<ShareAltOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
video={video}
|
||||
index={idx}
|
||||
onPreview={onVideoPreview}
|
||||
onDownload={onDownload}
|
||||
onShare={onShare}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Regular → Executable
+16
-101
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Step 6 封面设置组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||
import { CoverModeSelector } from "./cover-settings/CoverModeSelector"
|
||||
import { FrameCoverPicker } from "./cover-settings/FrameCoverPicker"
|
||||
import { UploadCoverPicker } from "./cover-settings/UploadCoverPicker"
|
||||
|
||||
interface Step6CoverSettingsProps {
|
||||
coverSettings: CoverConfig
|
||||
@@ -28,7 +28,6 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
<div className="xx-form-section">
|
||||
<h3>🖼️ 选择封面</h3>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<div className="xx-cover-header">
|
||||
<span className="xx-cover-header-label">启用自定义封面</span>
|
||||
<label className="xx-switch">
|
||||
@@ -43,22 +42,14 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
|
||||
{coverSettings.enabled && (
|
||||
<>
|
||||
{/* 模式选择 */}
|
||||
<div className="xx-section-title">封面来源</div>
|
||||
<div className="xx-cover-mode-tabs">
|
||||
{(["auto", "frame", "upload"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`xx-cover-mode-tab${coverSettings.mode === m ? " active" : ""}`}
|
||||
onClick={() => setMode(m)}
|
||||
>
|
||||
<span className="xx-cover-mode-icon">{COVER_MODE_ICONS[m]}</span>
|
||||
<span className="xx-cover-mode-label">{COVER_MODE_LABELS[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<CoverModeSelector
|
||||
mode={coverSettings.mode}
|
||||
onModeChange={setMode}
|
||||
modeLabels={COVER_MODE_LABELS}
|
||||
modeIcons={COVER_MODE_ICONS}
|
||||
/>
|
||||
|
||||
{/* 智能封面 */}
|
||||
{coverSettings.mode === "auto" && (
|
||||
<div className="xx-cover-auto">
|
||||
<div className="xx-cover-auto-desc">
|
||||
@@ -71,95 +62,19 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 抽帧选封面 */}
|
||||
{coverSettings.mode === "frame" && (
|
||||
<div className="xx-cover-frame">
|
||||
<div className="xx-cover-frame-preview">
|
||||
<div className="xx-cover-frame-placeholder">
|
||||
<span className="xx-cover-frame-icon">🎞️</span>
|
||||
<span className="xx-cover-frame-time">
|
||||
{formatTime(coverSettings.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-slider">
|
||||
<div className="xx-cover-frame-slider-header">
|
||||
<span>拖动选择封面帧</span>
|
||||
<span className="xx-cover-frame-value">
|
||||
{formatTime(coverSettings.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={coverSettings.frame_time}
|
||||
onChange={(e) => setFrameTime(Number(e.target.value))}
|
||||
className="xx-cover-range"
|
||||
/>
|
||||
<div className="xx-cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-quick">
|
||||
<span className="xx-cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio
|
||||
return (
|
||||
<button
|
||||
key={ratio}
|
||||
className="xx-cover-quick-btn"
|
||||
onClick={() => setFrameTime(t)}
|
||||
>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<FrameCoverPicker
|
||||
frameTime={coverSettings.frame_time}
|
||||
totalDuration={totalDuration}
|
||||
formatTime={formatTime}
|
||||
onFrameTimeChange={setFrameTime}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 上传封面 */}
|
||||
{coverSettings.mode === "upload" && (
|
||||
<div className="xx-cover-upload">
|
||||
<div
|
||||
className="xx-cover-upload-area"
|
||||
onClick={() => {
|
||||
const input = document.getElementById("cover-upload-input")
|
||||
input?.click()
|
||||
}}
|
||||
>
|
||||
{coverSettings.upload_url ? (
|
||||
<div className="xx-cover-upload-preview">
|
||||
<img src={coverSettings.upload_url} alt="封面预览" />
|
||||
<div className="xx-cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-cover-upload-placeholder">
|
||||
<span style={{ fontSize: 32 }}>📤</span>
|
||||
<span className="xx-cover-upload-text">点击上传封面图片</span>
|
||||
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id="cover-upload-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
handleUpload(file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UploadCoverPicker uploadUrl={coverSettings.upload_url} onUpload={handleUpload} />
|
||||
)}
|
||||
|
||||
{/* 封面预览 */}
|
||||
<div className="xx-section-title">封面预览</div>
|
||||
<div className="xx-cover-preview-box">
|
||||
{coverSettings.upload_url ? (
|
||||
|
||||
Regular → Executable
+24
-130
@@ -2,19 +2,14 @@
|
||||
* Step 7 确认生成组件
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
LoadingOutlined,
|
||||
CheckCircleFilled,
|
||||
CloseCircleOutlined,
|
||||
MinusOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { useStep7Generate } from "../hooks/useStep7Generate"
|
||||
import SummaryCard from "./step7-confirm/SummaryCard"
|
||||
import GenerationStatus from "./step7-confirm/GenerationStatus"
|
||||
|
||||
interface Step7ConfirmGenerateProps {
|
||||
templates: EditingTemplate[]
|
||||
@@ -64,129 +59,28 @@ const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>✨ 确认生成</h3>
|
||||
<div className="xx-summary-card">
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">模板</span>
|
||||
<span className="xx-summary-value">{templateName}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">素材</span>
|
||||
<span className="xx-summary-value">{materialSummary}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">标题</span>
|
||||
<span className="xx-summary-value">{title || "未选择"}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">配音</span>
|
||||
<span className="xx-summary-value">{voiceName}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">封面</span>
|
||||
<span className="xx-summary-value">{coverSummary}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">生成数量</span>
|
||||
<span className="xx-summary-value">
|
||||
<div className="xx-count-stepper">
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount <= 1 || generating}
|
||||
onClick={handleDecrement}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-value">{generateCount}</span>
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount >= 10 || generating}
|
||||
onClick={handleIncrement}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-hint">条视频</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生成进度 / 结果反馈 */}
|
||||
{(generating || generated || generateError) && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-icon">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">
|
||||
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
|
||||
</div>
|
||||
<div className="xx-gen-progress-sub">预计还需 1-2 分钟,请稍候…</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-bar">
|
||||
<div
|
||||
className="xx-gen-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-gen-progress-tip">
|
||||
💡 生成过程中可以切换到其他页面操作,完成后会自动通知
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div className="xx-gen-success-card">
|
||||
<div className="xx-gen-success-icon">
|
||||
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
|
||||
</div>
|
||||
<div className="xx-gen-success-info">
|
||||
<div className="xx-gen-success-title">视频生成完成!</div>
|
||||
<div className="xx-gen-success-sub">
|
||||
共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={handleScrollToPreview}
|
||||
>
|
||||
查看结果
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
<div className="xx-gen-error-card">
|
||||
<div className="xx-gen-error-icon">
|
||||
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
</div>
|
||||
<div className="xx-gen-error-info">
|
||||
<div className="xx-gen-error-title">生成失败</div>
|
||||
<div className="xx-gen-error-msg">
|
||||
{typeof generateError === "string"
|
||||
? generateError
|
||||
: JSON.stringify(generateError)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
|
||||
🔄 重试
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={onDismissError}
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<SummaryCard
|
||||
templateName={templateName}
|
||||
materialSummary={materialSummary}
|
||||
title={title}
|
||||
voiceName={voiceName}
|
||||
coverSummary={coverSummary}
|
||||
generateCount={generateCount}
|
||||
generating={generating}
|
||||
onDecrement={handleDecrement}
|
||||
onIncrement={handleIncrement}
|
||||
/>
|
||||
<GenerationStatus
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
progress={progress}
|
||||
generatedVideos={generatedVideos}
|
||||
getGenerationPhase={getGenerationPhase}
|
||||
onScrollToPreview={handleScrollToPreview}
|
||||
onRetry={onRetry}
|
||||
onDismissError={onDismissError}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import React from "react"
|
||||
import type { CoverMode } from "../../../editing-planner/types"
|
||||
|
||||
interface CoverModeSelectorProps {
|
||||
mode: CoverMode
|
||||
onModeChange: (mode: CoverMode) => void
|
||||
modeLabels: Record<CoverMode, string>
|
||||
modeIcons: Record<CoverMode, string>
|
||||
}
|
||||
|
||||
export const CoverModeSelector: React.FC<CoverModeSelectorProps> = ({
|
||||
mode,
|
||||
onModeChange,
|
||||
modeLabels,
|
||||
modeIcons,
|
||||
}) => {
|
||||
const modes: CoverMode[] = ["auto", "frame", "upload"]
|
||||
return (
|
||||
<div className="xx-cover-mode-tabs">
|
||||
{modes.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`xx-cover-mode-tab${mode === m ? " active" : ""}`}
|
||||
onClick={() => onModeChange(m)}
|
||||
>
|
||||
<span className="xx-cover-mode-icon">{modeIcons[m]}</span>
|
||||
<span className="xx-cover-mode-label">{modeLabels[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from "react"
|
||||
|
||||
interface FrameCoverPickerProps {
|
||||
frameTime: number
|
||||
totalDuration: number
|
||||
formatTime: (seconds: number) => string
|
||||
onFrameTimeChange: (time: number) => void
|
||||
}
|
||||
|
||||
export const FrameCoverPicker: React.FC<FrameCoverPickerProps> = ({
|
||||
frameTime,
|
||||
totalDuration,
|
||||
formatTime,
|
||||
onFrameTimeChange,
|
||||
}) => {
|
||||
const quickRatios = [0, 0.25, 0.5, 0.75]
|
||||
|
||||
return (
|
||||
<div className="xx-cover-frame">
|
||||
<div className="xx-cover-frame-preview">
|
||||
<div className="xx-cover-frame-placeholder">
|
||||
<span className="xx-cover-frame-icon">🎞️</span>
|
||||
<span className="xx-cover-frame-time">{formatTime(frameTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-slider">
|
||||
<div className="xx-cover-frame-slider-header">
|
||||
<span>拖动选择封面帧</span>
|
||||
<span className="xx-cover-frame-value">{formatTime(frameTime)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={frameTime}
|
||||
onChange={(e) => onFrameTimeChange(Number(e.target.value))}
|
||||
className="xx-cover-range"
|
||||
/>
|
||||
<div className="xx-cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-quick">
|
||||
<span className="xx-cover-quick-label">快捷选帧:</span>
|
||||
{quickRatios.map((ratio) => {
|
||||
const t = totalDuration * ratio
|
||||
return (
|
||||
<button key={ratio} className="xx-cover-quick-btn" onClick={() => onFrameTimeChange(t)}>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import React from "react"
|
||||
|
||||
interface UploadCoverPickerProps {
|
||||
uploadUrl: string
|
||||
onUpload: (file: File) => void
|
||||
}
|
||||
|
||||
export const UploadCoverPicker: React.FC<UploadCoverPickerProps> = ({ uploadUrl, onUpload }) => {
|
||||
const handleClick = () => {
|
||||
const input = document.getElementById("cover-upload-input")
|
||||
input?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
onUpload(file)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-cover-upload">
|
||||
<div className="xx-cover-upload-area" onClick={handleClick}>
|
||||
{uploadUrl ? (
|
||||
<div className="xx-cover-upload-preview">
|
||||
<img src={uploadUrl} alt="封面预览" />
|
||||
<div className="xx-cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-cover-upload-placeholder">
|
||||
<span style={{ fontSize: 32 }}>📤</span>
|
||||
<span className="xx-cover-upload-text">点击上传封面图片</span>
|
||||
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id="cover-upload-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface ProgressIndicatorProps {
|
||||
progress: number
|
||||
}
|
||||
|
||||
export const ProgressIndicator: React.FC<ProgressIndicatorProps> = ({ progress }) => {
|
||||
return (
|
||||
<div className="xx-result-progress">
|
||||
<div className="xx-progress-circle">
|
||||
<svg viewBox="0 0 80 80">
|
||||
<circle cx="40" cy="40" r="36" fill="none" stroke="var(--border-color)" strokeWidth="6" />
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="36"
|
||||
fill="none"
|
||||
stroke="var(--primary-color)"
|
||||
strokeWidth="6"
|
||||
strokeDasharray={`${Math.round(progress) * 2.26} 226`}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 40 40)"
|
||||
/>
|
||||
</svg>
|
||||
<span className="xx-progress-percent">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="xx-progress-text">
|
||||
<Text strong style={{ fontSize: 14, display: "block", marginBottom: 4 }}>
|
||||
正在生成视频
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
AI 正在处理素材,请稍候…
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from "react"
|
||||
import { PlayCircleOutlined, DownloadOutlined, ShareAltOutlined } from "@ant-design/icons"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { formatDuration } from "@/api/voice-clone"
|
||||
|
||||
interface ResultVideoCardProps {
|
||||
video: GeneratedVideo
|
||||
index: number
|
||||
onPreview: (video: GeneratedVideo) => void
|
||||
onDownload: () => void
|
||||
onShare: () => void
|
||||
}
|
||||
|
||||
export const ResultVideoCard: React.FC<ResultVideoCardProps> = ({
|
||||
video,
|
||||
index,
|
||||
onPreview,
|
||||
onDownload,
|
||||
onShare,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-video-card" onClick={() => onPreview(video)}>
|
||||
<div className="xx-video-thumb">
|
||||
{video.thumbnail_url ? (
|
||||
<img src={video.thumbnail_url} alt="" />
|
||||
) : (
|
||||
<div className="xx-video-thumb-placeholder">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-video-play-overlay">
|
||||
<PlayCircleOutlined style={{ fontSize: 36, color: "#fff" }} />
|
||||
</div>
|
||||
{video.duration && (
|
||||
<span className="xx-video-duration">{formatDuration(video.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-video-info">
|
||||
<div className="xx-video-title">视频 {index + 1}</div>
|
||||
<div className="xx-video-actions">
|
||||
<button
|
||||
className="xx-video-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDownload()
|
||||
}}
|
||||
>
|
||||
<DownloadOutlined />
|
||||
</button>
|
||||
<button
|
||||
className="xx-video-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onShare()
|
||||
}}
|
||||
>
|
||||
<ShareAltOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from "react"
|
||||
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
|
||||
interface GenerationStatusProps {
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
progress: number
|
||||
generatedVideos: GeneratedVideo[]
|
||||
getGenerationPhase: (progress: number) => { icon: string; label: string }
|
||||
onScrollToPreview: () => void
|
||||
onRetry: () => void
|
||||
onDismissError: () => void
|
||||
}
|
||||
|
||||
const GenerationStatus: React.FC<GenerationStatusProps> = ({
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
getGenerationPhase,
|
||||
onScrollToPreview,
|
||||
onRetry,
|
||||
onDismissError,
|
||||
}) => {
|
||||
if (!generating && !generated && !generateError) return null
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-icon">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">
|
||||
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
|
||||
</div>
|
||||
<div className="xx-gen-progress-sub">预计还需 1-2 分钟,请稍候…</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-bar">
|
||||
<div
|
||||
className="xx-gen-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-gen-progress-tip">
|
||||
💡 生成过程中可以切换到其他页面操作,完成后会自动通知
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div className="xx-gen-success-card">
|
||||
<div className="xx-gen-success-icon">
|
||||
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
|
||||
</div>
|
||||
<div className="xx-gen-success-info">
|
||||
<div className="xx-gen-success-title">视频生成完成!</div>
|
||||
<div className="xx-gen-success-sub">
|
||||
共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={onScrollToPreview}
|
||||
>
|
||||
查看结果
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
<div className="xx-gen-error-card">
|
||||
<div className="xx-gen-error-icon">
|
||||
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
</div>
|
||||
<div className="xx-gen-error-info">
|
||||
<div className="xx-gen-error-title">生成失败</div>
|
||||
<div className="xx-gen-error-msg">
|
||||
{typeof generateError === "string" ? generateError : JSON.stringify(generateError)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
|
||||
🔄 重试
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={onDismissError}
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerationStatus
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from "react"
|
||||
import { MinusOutlined, PlusOutlined } from "@ant-design/icons"
|
||||
|
||||
interface SummaryCardProps {
|
||||
templateName: string
|
||||
materialSummary: string
|
||||
title: string
|
||||
voiceName: string
|
||||
coverSummary: string
|
||||
generateCount: number
|
||||
generating: boolean
|
||||
onDecrement: () => void
|
||||
onIncrement: () => void
|
||||
}
|
||||
|
||||
const SummaryCard: React.FC<SummaryCardProps> = ({
|
||||
templateName,
|
||||
materialSummary,
|
||||
title,
|
||||
voiceName,
|
||||
coverSummary,
|
||||
generateCount,
|
||||
generating,
|
||||
onDecrement,
|
||||
onIncrement,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-summary-card">
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">模板</span>
|
||||
<span className="xx-summary-value">{templateName}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">素材</span>
|
||||
<span className="xx-summary-value">{materialSummary}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">标题</span>
|
||||
<span className="xx-summary-value">{title || "未选择"}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">配音</span>
|
||||
<span className="xx-summary-value">{voiceName}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">封面</span>
|
||||
<span className="xx-summary-value">{coverSummary}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">生成数量</span>
|
||||
<span className="xx-summary-value">
|
||||
<div className="xx-count-stepper">
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount <= 1 || generating}
|
||||
onClick={onDecrement}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-value">{generateCount}</span>
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount >= 10 || generating}
|
||||
onClick={onIncrement}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-hint">条视频</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SummaryCard
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { UseGenerateVideoProps } from "./types"
|
||||
import { buildVoiceConfig } from "./voiceConfig"
|
||||
|
||||
/**
|
||||
* 构建 updateEditPlan 的 payload
|
||||
* 从 props 中提取需要的字段,组装成 API 所需的 config 结构
|
||||
*/
|
||||
export const buildEditPlanPayload = (props: UseGenerateVideoProps) => {
|
||||
const {
|
||||
titleSettings,
|
||||
selectedMaterials,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
coverSettings,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
} = props
|
||||
|
||||
const voiceConfig = buildVoiceConfig({
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
})
|
||||
|
||||
return {
|
||||
name: titleSettings.title.trim(),
|
||||
config: {
|
||||
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
|
||||
title_config: {
|
||||
ai_auto_select: titleSettings.aiAutoSelect,
|
||||
content: titleSettings.title,
|
||||
position: titleSettings.position,
|
||||
font_preset: titleSettings.font,
|
||||
font_color: titleSettings.color,
|
||||
font_size: titleSettings.size,
|
||||
},
|
||||
cover_config: coverSettings,
|
||||
...voiceConfig,
|
||||
ratio: videoRatio,
|
||||
style,
|
||||
duration,
|
||||
auto_subtitles: autoSubtitles,
|
||||
bgm,
|
||||
generate_count: generateCount,
|
||||
material_mode: materialMode,
|
||||
},
|
||||
total_duration: duration,
|
||||
status: "editing" as const,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成前置校验
|
||||
* 返回错误信息,通过则返回 null
|
||||
*/
|
||||
export const validateGenerateInputs = (props: UseGenerateVideoProps): string | null => {
|
||||
const { titleSettings, materialMode, selectedMaterials, voiceMode, selectedClonedVoice } = props
|
||||
|
||||
if (!titleSettings.title.trim()) {
|
||||
return "请先选择或输入标题"
|
||||
}
|
||||
if (materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
return "请至少选择一个素材"
|
||||
}
|
||||
if (voiceMode === "clone" && !selectedClonedVoice) {
|
||||
return "请先选择一个克隆音色"
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getAssets, getAssetLibraries } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
/**
|
||||
* 素材库加载 Hook
|
||||
* 管理素材库列表、当前选中库、素材列表加载
|
||||
*/
|
||||
export function useMaterialLibrary() {
|
||||
/* ── 素材库数据 API ── */
|
||||
const { data: libraries = [] } = useQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
})
|
||||
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
|
||||
|
||||
// 自动选中第一个视频库
|
||||
useEffect(() => {
|
||||
if (libraries.length > 0 && !selectedLibraryId) {
|
||||
setSelectedLibraryId(libraries[0].id)
|
||||
}
|
||||
}, [libraries, selectedLibraryId])
|
||||
|
||||
const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{
|
||||
items: AssetItem[]
|
||||
total: number
|
||||
}>({
|
||||
queryKey: ["generate-assets", selectedLibraryId],
|
||||
queryFn: () => getAssets(selectedLibraryId),
|
||||
enabled: !!selectedLibraryId,
|
||||
})
|
||||
|
||||
return {
|
||||
libraries,
|
||||
selectedLibraryId,
|
||||
setSelectedLibraryId,
|
||||
materials,
|
||||
materialsLoading,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import { message } from "antd"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { SMART_MATCH_REASONS } from "../../constants"
|
||||
|
||||
interface SmartMatchedResult {
|
||||
asset: AssetItem
|
||||
matchScore: number
|
||||
matchReason: string
|
||||
}
|
||||
|
||||
interface UseSmartMatchOptions {
|
||||
materials: { items: AssetItem[]; total: number }
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能素材匹配 Hook
|
||||
* 封装 AI 匹配、换一批、全选/清空等逻辑
|
||||
*/
|
||||
export function useSmartMatch({
|
||||
materials,
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
}: UseSmartMatchOptions) {
|
||||
const [smartMatchInput, setSmartMatchInput] = useState("")
|
||||
const [smartMatching, setSmartMatching] = useState(false)
|
||||
const [smartMatchedResults, setSmartMatchedResults] = useState<SmartMatchedResult[]>([])
|
||||
const [hasMatched, setHasMatched] = useState(false)
|
||||
|
||||
/* ── 智能素材匹配 ── */
|
||||
const handleSmartMatch = useCallback(async () => {
|
||||
if (!smartMatchInput.trim()) {
|
||||
message.warning("请先输入视频内容描述")
|
||||
return
|
||||
}
|
||||
if (materials.items.length === 0) {
|
||||
message.warning("当前视频库暂无素材")
|
||||
return
|
||||
}
|
||||
|
||||
setSmartMatching(true)
|
||||
setHasMatched(true)
|
||||
|
||||
// 模拟 AI 匹配延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// 从素材库中随机选取 5-8 个作为推荐结果
|
||||
const shuffled = [...materials.items].sort(() => Math.random() - 0.5)
|
||||
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4))
|
||||
const picked = shuffled.slice(0, count)
|
||||
|
||||
const results = picked.map((asset, idx) => ({
|
||||
asset,
|
||||
matchScore: Math.round(85 + Math.random() * 14), // 85-99 分
|
||||
matchReason:
|
||||
SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] +
|
||||
(Math.random() > 0.5 ? ",画面质感优秀" : ""),
|
||||
}))
|
||||
|
||||
// 按匹配度从高到低排序
|
||||
results.sort((a, b) => b.matchScore - a.matchScore)
|
||||
|
||||
setSmartMatchedResults(results)
|
||||
// 默认选中匹配度 >= 90 的素材
|
||||
const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id)
|
||||
onSmartSelectedIdsChange(
|
||||
defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id),
|
||||
)
|
||||
setSmartMatching(false)
|
||||
}, [smartMatchInput, materials.items, onSmartSelectedIdsChange])
|
||||
|
||||
const handleToggleSmartSelect = useCallback(
|
||||
(assetId: string) => {
|
||||
onSmartSelectedIdsChange(
|
||||
smartSelectedIds.includes(assetId)
|
||||
? smartSelectedIds.filter((id) => id !== assetId)
|
||||
: [...smartSelectedIds, assetId],
|
||||
)
|
||||
},
|
||||
[smartSelectedIds, onSmartSelectedIdsChange],
|
||||
)
|
||||
|
||||
const handleRefreshMatch = useCallback(async () => {
|
||||
if (materials.items.length <= 5) {
|
||||
message.info("视频库素材较少,无法换一批")
|
||||
return
|
||||
}
|
||||
setSmartMatching(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
|
||||
const remaining = materials.items.filter(
|
||||
(m) => !smartMatchedResults.some((r) => r.asset.id === m.id),
|
||||
)
|
||||
const shuffled = [...remaining].sort(() => Math.random() - 0.5)
|
||||
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3))
|
||||
const picked = shuffled.slice(0, count)
|
||||
|
||||
const results = picked.map((asset, idx) => ({
|
||||
asset,
|
||||
matchScore: Math.round(80 + Math.random() * 19),
|
||||
matchReason:
|
||||
SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] +
|
||||
(Math.random() > 0.5 ? ",节奏明快" : ""),
|
||||
}))
|
||||
results.sort((a, b) => b.matchScore - a.matchScore)
|
||||
|
||||
setSmartMatchedResults(results)
|
||||
onSmartSelectedIdsChange([])
|
||||
setSmartMatching(false)
|
||||
}, [materials.items, smartMatchedResults, onSmartSelectedIdsChange])
|
||||
|
||||
const handleSelectAllMatched = useCallback(() => {
|
||||
onSmartSelectedIdsChange(smartMatchedResults.map((r) => r.asset.id))
|
||||
}, [smartMatchedResults, onSmartSelectedIdsChange])
|
||||
|
||||
const handleClearSmartSelect = useCallback(() => {
|
||||
onSmartSelectedIdsChange([])
|
||||
}, [onSmartSelectedIdsChange])
|
||||
|
||||
/* ── 计算已选智能匹配素材的总时长 ── */
|
||||
const smartSelectedTotalDuration = useMemo(() => {
|
||||
return smartMatchedResults
|
||||
.filter((r) => smartSelectedIds.includes(r.asset.id))
|
||||
.reduce((sum, r) => sum + (r.asset.duration || 0), 0)
|
||||
}, [smartMatchedResults, smartSelectedIds])
|
||||
|
||||
return {
|
||||
smartMatchInput,
|
||||
setSmartMatchInput,
|
||||
smartMatching,
|
||||
smartMatchedResults,
|
||||
hasMatched,
|
||||
smartSelectedIds,
|
||||
handleSmartMatch,
|
||||
handleToggleSmartSelect,
|
||||
handleRefreshMatch,
|
||||
handleSelectAllMatched,
|
||||
handleClearSmartSelect,
|
||||
smartSelectedTotalDuration,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useCallback } from "react"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
interface UseVoiceModeSelectionOptions {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (voiceId: string) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
onSelectedClonedVoiceChange: (voiceId: string) => void
|
||||
addClone: (voice: VoiceClone) => void
|
||||
cloneModalOpen: boolean
|
||||
onCloneModalOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function useVoiceModeSelection({
|
||||
onSelectedVoiceChange,
|
||||
onVoiceModeChange,
|
||||
onSelectedClonedVoiceChange,
|
||||
addClone,
|
||||
onCloneModalOpenChange,
|
||||
}: UseVoiceModeSelectionOptions) {
|
||||
const handleSelectRecommendedVoice = useCallback(
|
||||
(voiceId: string) => {
|
||||
onVoiceModeChange("preset")
|
||||
onSelectedVoiceChange(voiceId)
|
||||
},
|
||||
[onVoiceModeChange, onSelectedVoiceChange],
|
||||
)
|
||||
|
||||
const handleSelectPresetVoice = useCallback(
|
||||
(voiceId: string) => {
|
||||
onVoiceModeChange("preset")
|
||||
onSelectedVoiceChange(voiceId)
|
||||
},
|
||||
[onVoiceModeChange, onSelectedVoiceChange],
|
||||
)
|
||||
|
||||
const handleSelectCloneVoice = useCallback(() => {
|
||||
onVoiceModeChange("clone")
|
||||
}, [onVoiceModeChange])
|
||||
|
||||
const handleSelectClonedVoice = useCallback(
|
||||
(voiceId: string) => {
|
||||
onSelectedClonedVoiceChange(voiceId)
|
||||
},
|
||||
[onSelectedClonedVoiceChange],
|
||||
)
|
||||
|
||||
const handleOpenCloneModal = useCallback(() => {
|
||||
onCloneModalOpenChange(true)
|
||||
}, [onCloneModalOpenChange])
|
||||
|
||||
const handleCloneSuccess = useCallback(
|
||||
(voice: VoiceClone) => {
|
||||
addClone(voice)
|
||||
onCloneModalOpenChange(false)
|
||||
},
|
||||
[addClone, onCloneModalOpenChange],
|
||||
)
|
||||
|
||||
return {
|
||||
handleSelectRecommendedVoice,
|
||||
handleSelectPresetVoice,
|
||||
handleSelectCloneVoice,
|
||||
handleSelectClonedVoice,
|
||||
handleOpenCloneModal,
|
||||
handleCloneSuccess,
|
||||
}
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
/**
|
||||
* GeneratePage 表单状态管理
|
||||
* 集中管理 7 步向导的所有共享状态、API 加载、URL 参数解析
|
||||
*/
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import type { GeneratedVideo, TitleConfig } from "@/api/template-editor"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { getEditPlan } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { getEditingTemplates } from "@/api/editing-planner"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { fetchPresetVoices } from "@/api/voices"
|
||||
import { DEFAULT_COVER_SETTINGS } from "../constants"
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
|
||||
aiAutoSelect: false,
|
||||
title: "",
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
size: 28,
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
color: "#ffffff",
|
||||
}
|
||||
|
||||
export interface GenerateFormState {
|
||||
/* 步骤 */
|
||||
currentStep: number
|
||||
setCurrentStep: (step: number | ((prev: number) => number)) => void
|
||||
|
||||
/* 模板 */
|
||||
selectedTemplate: string
|
||||
setSelectedTemplate: (id: string) => void
|
||||
userTemplates: EditingTemplate[]
|
||||
|
||||
/* 素材 */
|
||||
selectedMaterials: string[]
|
||||
setSelectedMaterials: (ids: string[]) => void
|
||||
materialMode: "manual" | "auto"
|
||||
setMaterialMode: (mode: "manual" | "auto") => void
|
||||
smartSelectedIds: string[]
|
||||
setSmartSelectedIds: (ids: string[]) => void
|
||||
|
||||
/* 标题 */
|
||||
titleSettings: TitleSettings
|
||||
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
|
||||
|
||||
/* 封面 */
|
||||
coverSettings: CoverConfig
|
||||
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
|
||||
|
||||
/* 配音 */
|
||||
selectedVoice: string
|
||||
setSelectedVoice: (id: string) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
setVoiceMode: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
setSelectedClonedVoice: (id: string) => void
|
||||
presetVoices: PresetVoiceItem[]
|
||||
|
||||
/* 克隆弹窗 */
|
||||
cloneModalOpen: boolean
|
||||
setCloneModalOpen: (open: boolean) => void
|
||||
|
||||
/* 生成数量 */
|
||||
generateCount: number
|
||||
setGenerateCount: (n: number) => void
|
||||
|
||||
/* 高级设置 */
|
||||
videoRatio: string
|
||||
duration: number
|
||||
style: string
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
|
||||
/* URL 参数 */
|
||||
editPlanId: string | null
|
||||
planConfigStr: string | null
|
||||
|
||||
/* 预览弹窗 */
|
||||
previewVideo: GeneratedVideo | null
|
||||
setPreviewVideo: (v: GeneratedVideo | null) => void
|
||||
previewModalOpen: boolean
|
||||
setPreviewModalOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const useGenerateFormState = (): GenerateFormState => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const editPlanId = searchParams.get("edit_plan_id")
|
||||
const planConfigStr = searchParams.get("plan_config")
|
||||
|
||||
/* ── 步骤状态 ── */
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
|
||||
/* ── 模板(从 API 加载) ── */
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("")
|
||||
const { data: userTemplates = [] } = useQuery({
|
||||
queryKey: ["generate-templates"],
|
||||
queryFn: () => getEditingTemplates(),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
/* 模板加载完成后自动选中第一个 */
|
||||
useEffect(() => {
|
||||
if (userTemplates.length > 0 && !selectedTemplate) {
|
||||
setSelectedTemplate(userTemplates[0].id)
|
||||
}
|
||||
}, [userTemplates, selectedTemplate])
|
||||
|
||||
/* ── 素材 ── */
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
|
||||
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
|
||||
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
|
||||
|
||||
/* ── 标题设置 ── */
|
||||
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
|
||||
|
||||
/* ── 封面设置 ── */
|
||||
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
|
||||
|
||||
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 / 封面 */
|
||||
useEffect(() => {
|
||||
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
|
||||
if (tpl?.title_config) {
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
aiAutoSelect: tpl.title_config!.ai_auto_select,
|
||||
title: tpl.title_config!.content || prev.title,
|
||||
position: tpl.title_config!.position || prev.position,
|
||||
font: tpl.title_config!.font_preset || prev.font,
|
||||
size: tpl.title_config!.font_size || prev.size,
|
||||
color: tpl.title_config!.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (tpl?.cover_config) {
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: tpl.cover_config!.enabled ?? prev.enabled,
|
||||
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
|
||||
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
|
||||
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
}
|
||||
}, [selectedTemplate, userTemplates])
|
||||
|
||||
/* ── 配音 ── */
|
||||
const [selectedVoice, setSelectedVoice] = useState("")
|
||||
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
|
||||
const [selectedClonedVoice, setSelectedClonedVoice] = useState("")
|
||||
|
||||
/* ── 预置音色 API ── */
|
||||
const { data: presetVoicesData } = useQuery({
|
||||
queryKey: ["preset-voices"],
|
||||
queryFn: fetchPresetVoices,
|
||||
})
|
||||
const presetVoices: PresetVoiceItem[] = useMemo(
|
||||
() => presetVoicesData?.items ?? [],
|
||||
[presetVoicesData],
|
||||
)
|
||||
|
||||
/* ── 克隆声音弹窗 ── */
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
||||
|
||||
/* ── 生成数量 ── */
|
||||
const [generateCount, setGenerateCount] = useState(1)
|
||||
|
||||
/* ── 高级设置(隐藏但保留) ── */
|
||||
const [videoRatio] = useState("16:9")
|
||||
const [duration] = useState(30)
|
||||
const [style] = useState("business")
|
||||
const [autoSubtitles] = useState(true)
|
||||
const [bgm] = useState(true)
|
||||
|
||||
/* ── 预览弹窗 ── */
|
||||
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
||||
|
||||
/** 解析 plan_config 并自动填充表单 */
|
||||
useEffect(() => {
|
||||
if (!planConfigStr) return
|
||||
try {
|
||||
const config = JSON.parse(planConfigStr) as {
|
||||
title_config?: {
|
||||
content?: string
|
||||
ai_auto_select?: boolean
|
||||
position?: string
|
||||
font_preset?: string
|
||||
font_size?: number
|
||||
font_color?: string
|
||||
}
|
||||
subtitle_config?: { enabled?: boolean }
|
||||
bgm_config?: { enabled?: boolean; music_id?: string }
|
||||
mode?: string
|
||||
total_duration?: number
|
||||
segments?: Array<{ media_asset_id?: string; material_type?: string }>
|
||||
}
|
||||
|
||||
if (config.title_config) {
|
||||
const tc = config.title_config as TitleConfig
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
title: tc.content || "",
|
||||
aiAutoSelect: tc.ai_auto_select || false,
|
||||
position: tc.position || prev.position,
|
||||
font: tc.font_preset || prev.font,
|
||||
size: tc.font_size || prev.size,
|
||||
color: tc.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (config.segments && config.segments.length > 0) {
|
||||
const assetIds = config.segments
|
||||
.map((s) => s.media_asset_id)
|
||||
.filter((id): id is string => !!id)
|
||||
if (assetIds.length > 0) {
|
||||
setSelectedMaterials(assetIds)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("解析 plan_config 失败:", err)
|
||||
}
|
||||
}, [planConfigStr])
|
||||
|
||||
/** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */
|
||||
useEffect(() => {
|
||||
if (!editPlanId || planConfigStr) return
|
||||
const loadPlanConfig = async () => {
|
||||
try {
|
||||
const plan = await getEditPlan(editPlanId)
|
||||
if (plan.name) setTitleSettings((prev) => ({ ...prev, title: plan.name }))
|
||||
const cfg = plan.config
|
||||
if (cfg?.title_config) {
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
aiAutoSelect: cfg.title_config!.ai_auto_select,
|
||||
title: cfg.title_config!.content || prev.title,
|
||||
position: cfg.title_config!.position || prev.position,
|
||||
font: cfg.title_config!.font_preset || prev.font,
|
||||
size: cfg.title_config!.font_size || prev.size,
|
||||
color: cfg.title_config!.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (cfg?.cover_config) {
|
||||
const cc = cfg.cover_config as CoverConfig
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: cc.enabled ?? prev.enabled,
|
||||
mode: cc.mode || prev.mode,
|
||||
frame_time: cc.frame_time ?? prev.frame_time,
|
||||
upload_url: cc.upload_url || prev.upload_url,
|
||||
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
}
|
||||
if (cfg?.asset_ids) {
|
||||
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("加载模板草稿配置失败:", err)
|
||||
}
|
||||
}
|
||||
loadPlanConfig()
|
||||
}, [editPlanId, planConfigStr])
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
selectedTemplate,
|
||||
setSelectedTemplate,
|
||||
userTemplates,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
materialMode,
|
||||
setMaterialMode,
|
||||
smartSelectedIds,
|
||||
setSmartSelectedIds,
|
||||
titleSettings,
|
||||
setTitleSettings,
|
||||
coverSettings,
|
||||
setCoverSettings,
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
voiceMode,
|
||||
setVoiceMode,
|
||||
selectedClonedVoice,
|
||||
setSelectedClonedVoice,
|
||||
presetVoices,
|
||||
cloneModalOpen,
|
||||
setCloneModalOpen,
|
||||
generateCount,
|
||||
setGenerateCount,
|
||||
videoRatio,
|
||||
duration,
|
||||
style,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
editPlanId,
|
||||
planConfigStr,
|
||||
previewVideo,
|
||||
setPreviewVideo,
|
||||
previewModalOpen,
|
||||
setPreviewModalOpen,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* GeneratePage 表单状态管理
|
||||
* 集中管理 7 步向导的所有共享状态、API 加载、URL 参数解析
|
||||
*/
|
||||
import { useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { DEFAULT_COVER_SETTINGS } from "../../constants"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import { useTemplateSelection } from "./useTemplateSelection"
|
||||
import { useTitleCoverSync } from "./useTitleCoverSync"
|
||||
import { useVoiceState } from "./useVoiceState"
|
||||
import { usePlanConfigLoader } from "./usePlanConfigLoader"
|
||||
|
||||
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
|
||||
aiAutoSelect: false,
|
||||
title: "",
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
size: 28,
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
color: "#ffffff",
|
||||
}
|
||||
|
||||
export interface GenerateFormState {
|
||||
/* 步骤 */
|
||||
currentStep: number
|
||||
setCurrentStep: (step: number | ((prev: number) => number)) => void
|
||||
|
||||
/* 模板 */
|
||||
selectedTemplate: string
|
||||
setSelectedTemplate: (id: string) => void
|
||||
userTemplates: EditingTemplate[]
|
||||
|
||||
/* 素材 */
|
||||
selectedMaterials: string[]
|
||||
setSelectedMaterials: (ids: string[]) => void
|
||||
materialMode: "manual" | "auto"
|
||||
setMaterialMode: (mode: "manual" | "auto") => void
|
||||
smartSelectedIds: string[]
|
||||
setSmartSelectedIds: (ids: string[]) => void
|
||||
|
||||
/* 标题 */
|
||||
titleSettings: TitleSettings
|
||||
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
|
||||
|
||||
/* 封面 */
|
||||
coverSettings: CoverConfig
|
||||
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
|
||||
|
||||
/* 配音 */
|
||||
selectedVoice: string
|
||||
setSelectedVoice: (id: string) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
setVoiceMode: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
setSelectedClonedVoice: (id: string) => void
|
||||
presetVoices: PresetVoiceItem[]
|
||||
|
||||
/* 克隆弹窗 */
|
||||
cloneModalOpen: boolean
|
||||
setCloneModalOpen: (open: boolean) => void
|
||||
|
||||
/* 生成数量 */
|
||||
generateCount: number
|
||||
setGenerateCount: (n: number) => void
|
||||
|
||||
/* 高级设置 */
|
||||
videoRatio: string
|
||||
duration: number
|
||||
style: string
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
|
||||
/* URL 参数 */
|
||||
editPlanId: string | null
|
||||
planConfigStr: string | null
|
||||
|
||||
/* 预览弹窗 */
|
||||
previewVideo: GeneratedVideo | null
|
||||
setPreviewVideo: (v: GeneratedVideo | null) => void
|
||||
previewModalOpen: boolean
|
||||
setPreviewModalOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const useGenerateFormState = (): GenerateFormState => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const editPlanId = searchParams.get("edit_plan_id")
|
||||
const planConfigStr = searchParams.get("plan_config")
|
||||
|
||||
/* ── 步骤状态 ── */
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
|
||||
/* ── 模板选择 ── */
|
||||
const { selectedTemplate, setSelectedTemplate, userTemplates } = useTemplateSelection()
|
||||
|
||||
/* ── 素材 ── */
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
|
||||
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
|
||||
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
|
||||
|
||||
/* ── 标题设置 ── */
|
||||
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
|
||||
|
||||
/* ── 封面设置 ── */
|
||||
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
|
||||
|
||||
/* ── 模板切换时同步标题/封面 ── */
|
||||
useTitleCoverSync({
|
||||
selectedTemplate,
|
||||
userTemplates,
|
||||
setTitleSettings,
|
||||
setCoverSettings,
|
||||
})
|
||||
|
||||
/* ── 配音状态 ── */
|
||||
const {
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
voiceMode,
|
||||
setVoiceMode,
|
||||
selectedClonedVoice,
|
||||
setSelectedClonedVoice,
|
||||
presetVoices,
|
||||
} = useVoiceState()
|
||||
|
||||
/* ── 克隆声音弹窗 ── */
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
||||
|
||||
/* ── 生成数量 ── */
|
||||
const [generateCount, setGenerateCount] = useState(1)
|
||||
|
||||
/* ── 高级设置(隐藏但保留) ── */
|
||||
const [videoRatio] = useState("16:9")
|
||||
const [duration] = useState(30)
|
||||
const [style] = useState("business")
|
||||
const [autoSubtitles] = useState(true)
|
||||
const [bgm] = useState(true)
|
||||
|
||||
/* ── 预览弹窗 ── */
|
||||
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
||||
|
||||
/* ── 从 URL / 编辑计划加载配置 ── */
|
||||
usePlanConfigLoader({
|
||||
editPlanId,
|
||||
planConfigStr,
|
||||
setTitleSettings,
|
||||
setCoverSettings,
|
||||
setSelectedMaterials,
|
||||
})
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
selectedTemplate,
|
||||
setSelectedTemplate,
|
||||
userTemplates,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
materialMode,
|
||||
setMaterialMode,
|
||||
smartSelectedIds,
|
||||
setSmartSelectedIds,
|
||||
titleSettings,
|
||||
setTitleSettings,
|
||||
coverSettings,
|
||||
setCoverSettings,
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
voiceMode,
|
||||
setVoiceMode,
|
||||
selectedClonedVoice,
|
||||
setSelectedClonedVoice,
|
||||
presetVoices,
|
||||
cloneModalOpen,
|
||||
setCloneModalOpen,
|
||||
generateCount,
|
||||
setGenerateCount,
|
||||
videoRatio,
|
||||
duration,
|
||||
style,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
editPlanId,
|
||||
planConfigStr,
|
||||
previewVideo,
|
||||
setPreviewVideo,
|
||||
previewModalOpen,
|
||||
setPreviewModalOpen,
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { useEffect } from "react"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import { getEditPlan } from "@/api/template-editor"
|
||||
|
||||
interface UsePlanConfigLoaderOptions {
|
||||
editPlanId: string | null
|
||||
planConfigStr: string | null
|
||||
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
|
||||
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
|
||||
setSelectedMaterials: (ids: string[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 参数或编辑计划 ID 加载表单配置
|
||||
*/
|
||||
export function usePlanConfigLoader({
|
||||
editPlanId,
|
||||
planConfigStr,
|
||||
setTitleSettings,
|
||||
setCoverSettings,
|
||||
setSelectedMaterials,
|
||||
}: UsePlanConfigLoaderOptions) {
|
||||
/** 解析 plan_config 并自动填充表单 */
|
||||
useEffect(() => {
|
||||
if (!planConfigStr) return
|
||||
try {
|
||||
const config = JSON.parse(planConfigStr) as {
|
||||
title_config?: {
|
||||
content?: string
|
||||
ai_auto_select?: boolean
|
||||
position?: string
|
||||
font_preset?: string
|
||||
font_size?: number
|
||||
font_color?: string
|
||||
}
|
||||
subtitle_config?: { enabled?: boolean }
|
||||
bgm_config?: { enabled?: boolean; music_id?: string }
|
||||
mode?: string
|
||||
total_duration?: number
|
||||
segments?: Array<{ media_asset_id?: string; material_type?: string }>
|
||||
}
|
||||
|
||||
if (config.title_config) {
|
||||
const tc = config.title_config as TitleConfig
|
||||
setTitleSettings((prev: TitleSettings) => ({
|
||||
...prev,
|
||||
title: tc.content || "",
|
||||
aiAutoSelect: tc.ai_auto_select || false,
|
||||
position: tc.position || prev.position,
|
||||
font: tc.font_preset || prev.font,
|
||||
size: tc.font_size || prev.size,
|
||||
color: tc.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (config.segments && config.segments.length > 0) {
|
||||
const assetIds = config.segments
|
||||
.map((s) => s.media_asset_id)
|
||||
.filter((id): id is string => !!id)
|
||||
if (assetIds.length > 0) {
|
||||
setSelectedMaterials(assetIds)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("解析 plan_config 失败:", err)
|
||||
}
|
||||
}, [planConfigStr, setTitleSettings, setSelectedMaterials])
|
||||
|
||||
/** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */
|
||||
useEffect(() => {
|
||||
if (!editPlanId || planConfigStr) return
|
||||
const loadPlanConfig = async () => {
|
||||
try {
|
||||
const plan = await getEditPlan(editPlanId)
|
||||
if (plan.name) setTitleSettings((prev: TitleSettings) => ({ ...prev, title: plan.name }))
|
||||
const cfg = plan.config
|
||||
if (cfg?.title_config) {
|
||||
setTitleSettings((prev: TitleSettings) => ({
|
||||
...prev,
|
||||
aiAutoSelect: cfg.title_config!.ai_auto_select,
|
||||
title: cfg.title_config!.content || prev.title,
|
||||
position: cfg.title_config!.position || prev.position,
|
||||
font: cfg.title_config!.font_preset || prev.font,
|
||||
size: cfg.title_config!.font_size || prev.size,
|
||||
color: cfg.title_config!.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (cfg?.cover_config) {
|
||||
const cc = cfg.cover_config as CoverConfig
|
||||
setCoverSettings((prev: CoverConfig) => ({
|
||||
...prev,
|
||||
enabled: cc.enabled ?? prev.enabled,
|
||||
mode: cc.mode || prev.mode,
|
||||
frame_time: cc.frame_time ?? prev.frame_time,
|
||||
upload_url: cc.upload_url || prev.upload_url,
|
||||
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
}
|
||||
if (cfg?.asset_ids) {
|
||||
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("加载模板草稿配置失败:", err)
|
||||
}
|
||||
}
|
||||
loadPlanConfig()
|
||||
}, [editPlanId, planConfigStr, setTitleSettings, setCoverSettings, setSelectedMaterials])
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getEditingTemplates } from "@/api/editing-planner"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
|
||||
export function useTemplateSelection() {
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("")
|
||||
const { data: userTemplates = [] } = useQuery<EditingTemplate[]>({
|
||||
queryKey: ["generate-templates"],
|
||||
queryFn: () => getEditingTemplates(),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
/* 模板加载完成后自动选中第一个 */
|
||||
useEffect(() => {
|
||||
if (userTemplates.length > 0 && !selectedTemplate) {
|
||||
setSelectedTemplate(userTemplates[0].id)
|
||||
}
|
||||
}, [userTemplates, selectedTemplate])
|
||||
|
||||
return { selectedTemplate, setSelectedTemplate, userTemplates }
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { useEffect } from "react"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
|
||||
interface UseTitleCoverSyncOptions {
|
||||
selectedTemplate: string
|
||||
userTemplates: EditingTemplate[]
|
||||
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
|
||||
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 当选中模板变化时,自动同步标题和封面配置
|
||||
*/
|
||||
export function useTitleCoverSync({
|
||||
selectedTemplate,
|
||||
userTemplates,
|
||||
setTitleSettings,
|
||||
setCoverSettings,
|
||||
}: UseTitleCoverSyncOptions) {
|
||||
useEffect(() => {
|
||||
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
|
||||
if (tpl?.title_config) {
|
||||
setTitleSettings((prev: TitleSettings) => ({
|
||||
...prev,
|
||||
aiAutoSelect: tpl.title_config!.ai_auto_select,
|
||||
title: tpl.title_config!.content || prev.title,
|
||||
position: tpl.title_config!.position || prev.position,
|
||||
font: tpl.title_config!.font_preset || prev.font,
|
||||
size: tpl.title_config!.font_size || prev.size,
|
||||
color: tpl.title_config!.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (tpl?.cover_config) {
|
||||
setCoverSettings((prev: CoverConfig) => ({
|
||||
...prev,
|
||||
enabled: tpl.cover_config!.enabled ?? prev.enabled,
|
||||
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
|
||||
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
|
||||
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
}
|
||||
}, [selectedTemplate, userTemplates, setTitleSettings, setCoverSettings])
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useState, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { fetchPresetVoices } from "@/api/voices"
|
||||
|
||||
export function useVoiceState() {
|
||||
const [selectedVoice, setSelectedVoice] = useState("")
|
||||
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
|
||||
const [selectedClonedVoice, setSelectedClonedVoice] = useState("")
|
||||
|
||||
/* 预置音色 API */
|
||||
const { data: presetVoicesData } = useQuery({
|
||||
queryKey: ["preset-voices"],
|
||||
queryFn: fetchPresetVoices,
|
||||
})
|
||||
const presetVoices: PresetVoiceItem[] = useMemo(
|
||||
() => presetVoicesData?.items ?? [],
|
||||
[presetVoicesData],
|
||||
)
|
||||
|
||||
return {
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
voiceMode,
|
||||
setVoiceMode,
|
||||
selectedClonedVoice,
|
||||
setSelectedClonedVoice,
|
||||
presetVoices,
|
||||
}
|
||||
}
|
||||
@@ -9,27 +9,11 @@ import { generateEditPlan, updateEditPlan, getEditPlan } from "@/api/template-ed
|
||||
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||
import { getGenerationPhase } from "./generate-video/phase"
|
||||
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
|
||||
import { buildVoiceConfig } from "./generate-video/voiceConfig"
|
||||
import { buildEditPlanPayload, validateGenerateInputs } from "./generate-video/buildPayload"
|
||||
import { extractBackendError, translateError } from "./generate-video/errorUtils"
|
||||
|
||||
export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const {
|
||||
titleSettings,
|
||||
selectedTemplate,
|
||||
selectedMaterials,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
coverSettings,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
} = props
|
||||
const { selectedTemplate } = props
|
||||
|
||||
/* ── 生成状态 ── */
|
||||
const [generating, setGenerating] = useState(false)
|
||||
@@ -58,16 +42,9 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const generate = useCallback(async () => {
|
||||
if (!titleSettings.title.trim()) {
|
||||
message.warning("请先选择或输入标题")
|
||||
return
|
||||
}
|
||||
if (materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
message.warning("请至少选择一个素材")
|
||||
return
|
||||
}
|
||||
if (voiceMode === "clone" && !selectedClonedVoice) {
|
||||
message.warning("请先选择一个克隆音色")
|
||||
const errorMsg = validateGenerateInputs(props)
|
||||
if (errorMsg) {
|
||||
message.warning(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,41 +55,13 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
const voiceConfig = buildVoiceConfig({
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
})
|
||||
const payload = buildEditPlanPayload(props)
|
||||
|
||||
// 获取或创建草稿
|
||||
await getEditPlan(selectedTemplate)
|
||||
|
||||
// 更新草稿内容 + 切换到 editing 状态
|
||||
await updateEditPlan(selectedTemplate, {
|
||||
name: titleSettings.title.trim(),
|
||||
config: {
|
||||
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
|
||||
title_config: {
|
||||
ai_auto_select: titleSettings.aiAutoSelect,
|
||||
content: titleSettings.title,
|
||||
position: titleSettings.position,
|
||||
font_preset: titleSettings.font,
|
||||
font_color: titleSettings.color,
|
||||
font_size: titleSettings.size,
|
||||
},
|
||||
cover_config: coverSettings,
|
||||
...voiceConfig,
|
||||
ratio: videoRatio,
|
||||
style,
|
||||
duration,
|
||||
auto_subtitles: autoSubtitles,
|
||||
bgm,
|
||||
generate_count: generateCount,
|
||||
material_mode: materialMode,
|
||||
},
|
||||
total_duration: duration,
|
||||
status: "editing",
|
||||
})
|
||||
await updateEditPlan(selectedTemplate, payload)
|
||||
|
||||
await generateEditPlan(selectedTemplate)
|
||||
startPolling()
|
||||
@@ -125,25 +74,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
}
|
||||
}, [
|
||||
titleSettings,
|
||||
selectedMaterials,
|
||||
selectedVoice,
|
||||
voiceMode,
|
||||
selectedClonedVoice,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
selectedTemplate,
|
||||
generateCount,
|
||||
materialMode,
|
||||
coverSettings,
|
||||
smartSelectedIds,
|
||||
clearTimer,
|
||||
startPolling,
|
||||
])
|
||||
}, [props, selectedTemplate, clearTimer, startPolling])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
const retry = useCallback(() => {
|
||||
|
||||
Regular → Executable
+23
-149
@@ -1,20 +1,11 @@
|
||||
/**
|
||||
* Step 2 素材选择 Hook
|
||||
* 封装素材库加载、手动选择、智能匹配等逻辑
|
||||
* 组合素材库加载 + 智能匹配两个子 Hook
|
||||
*/
|
||||
import { useState, useCallback, useEffect, useMemo } from "react"
|
||||
import { message } from "antd"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getAssets, getAssetLibraries } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { useCallback } from "react"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { SMART_MATCH_REASONS } from "../constants"
|
||||
|
||||
interface SmartMatchedResult {
|
||||
asset: AssetItem
|
||||
matchScore: number
|
||||
matchReason: string
|
||||
}
|
||||
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
|
||||
import { useSmartMatch } from "./step2-materials/useSmartMatch"
|
||||
|
||||
interface UseStep2MaterialsProps {
|
||||
materialMode: "manual" | "auto"
|
||||
@@ -33,34 +24,14 @@ export function useStep2Materials({
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
}: UseStep2MaterialsProps) {
|
||||
/* ── 素材库数据 API ── */
|
||||
const { data: libraries = [] } = useQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
|
||||
useMaterialLibrary()
|
||||
|
||||
const smartMatch = useSmartMatch({
|
||||
materials,
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
})
|
||||
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
|
||||
|
||||
// 自动选中第一个视频库
|
||||
useEffect(() => {
|
||||
if (libraries.length > 0 && !selectedLibraryId) {
|
||||
setSelectedLibraryId(libraries[0].id)
|
||||
}
|
||||
}, [libraries, selectedLibraryId])
|
||||
|
||||
const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{
|
||||
items: AssetItem[]
|
||||
total: number
|
||||
}>({
|
||||
queryKey: ["generate-assets", selectedLibraryId],
|
||||
queryFn: () => getAssets(selectedLibraryId),
|
||||
enabled: !!selectedLibraryId,
|
||||
})
|
||||
|
||||
/* ── 智能素材匹配状态 ── */
|
||||
const [smartMatchInput, setSmartMatchInput] = useState("")
|
||||
const [smartMatching, setSmartMatching] = useState(false)
|
||||
const [smartMatchedResults, setSmartMatchedResults] = useState<SmartMatchedResult[]>([])
|
||||
const [hasMatched, setHasMatched] = useState(false)
|
||||
|
||||
/* ── 手动选择素材 ── */
|
||||
const handleToggleMaterial = useCallback(
|
||||
@@ -74,103 +45,6 @@ export function useStep2Materials({
|
||||
[selectedMaterials, onSelectedMaterialsChange],
|
||||
)
|
||||
|
||||
/* ── 智能素材匹配 ── */
|
||||
const handleSmartMatch = useCallback(async () => {
|
||||
if (!smartMatchInput.trim()) {
|
||||
message.warning("请先输入视频内容描述")
|
||||
return
|
||||
}
|
||||
if (materials.items.length === 0) {
|
||||
message.warning("当前视频库暂无素材")
|
||||
return
|
||||
}
|
||||
|
||||
setSmartMatching(true)
|
||||
setHasMatched(true)
|
||||
|
||||
// 模拟 AI 匹配延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// 从素材库中随机选取 5-8 个作为推荐结果
|
||||
const shuffled = [...materials.items].sort(() => Math.random() - 0.5)
|
||||
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4))
|
||||
const picked = shuffled.slice(0, count)
|
||||
|
||||
const results = picked.map((asset, idx) => ({
|
||||
asset,
|
||||
matchScore: Math.round(85 + Math.random() * 14), // 85-99 分
|
||||
matchReason:
|
||||
SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] +
|
||||
(Math.random() > 0.5 ? ",画面质感优秀" : ""),
|
||||
}))
|
||||
|
||||
// 按匹配度从高到低排序
|
||||
results.sort((a, b) => b.matchScore - a.matchScore)
|
||||
|
||||
setSmartMatchedResults(results)
|
||||
// 默认选中匹配度 >= 90 的素材
|
||||
const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id)
|
||||
onSmartSelectedIdsChange(
|
||||
defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id),
|
||||
)
|
||||
setSmartMatching(false)
|
||||
}, [smartMatchInput, materials.items, onSmartSelectedIdsChange])
|
||||
|
||||
const handleToggleSmartSelect = useCallback(
|
||||
(assetId: string) => {
|
||||
onSmartSelectedIdsChange(
|
||||
smartSelectedIds.includes(assetId)
|
||||
? smartSelectedIds.filter((id) => id !== assetId)
|
||||
: [...smartSelectedIds, assetId],
|
||||
)
|
||||
},
|
||||
[smartSelectedIds, onSmartSelectedIdsChange],
|
||||
)
|
||||
|
||||
const handleRefreshMatch = useCallback(async () => {
|
||||
if (materials.items.length <= 5) {
|
||||
message.info("视频库素材较少,无法换一批")
|
||||
return
|
||||
}
|
||||
setSmartMatching(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
|
||||
const remaining = materials.items.filter(
|
||||
(m) => !smartMatchedResults.some((r) => r.asset.id === m.id),
|
||||
)
|
||||
const shuffled = [...remaining].sort(() => Math.random() - 0.5)
|
||||
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3))
|
||||
const picked = shuffled.slice(0, count)
|
||||
|
||||
const results = picked.map((asset, idx) => ({
|
||||
asset,
|
||||
matchScore: Math.round(80 + Math.random() * 19),
|
||||
matchReason:
|
||||
SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] +
|
||||
(Math.random() > 0.5 ? ",节奏明快" : ""),
|
||||
}))
|
||||
results.sort((a, b) => b.matchScore - a.matchScore)
|
||||
|
||||
setSmartMatchedResults(results)
|
||||
onSmartSelectedIdsChange([])
|
||||
setSmartMatching(false)
|
||||
}, [materials.items, smartMatchedResults, onSmartSelectedIdsChange])
|
||||
|
||||
const handleSelectAllMatched = useCallback(() => {
|
||||
onSmartSelectedIdsChange(smartMatchedResults.map((r) => r.asset.id))
|
||||
}, [smartMatchedResults, onSmartSelectedIdsChange])
|
||||
|
||||
const handleClearSmartSelect = useCallback(() => {
|
||||
onSmartSelectedIdsChange([])
|
||||
}, [onSmartSelectedIdsChange])
|
||||
|
||||
/* ── 计算已选智能匹配素材的总时长 ── */
|
||||
const smartSelectedTotalDuration = useMemo(() => {
|
||||
return smartMatchedResults
|
||||
.filter((r) => smartSelectedIds.includes(r.asset.id))
|
||||
.reduce((sum, r) => sum + (r.asset.duration || 0), 0)
|
||||
}, [smartMatchedResults, smartSelectedIds])
|
||||
|
||||
return {
|
||||
// 素材库
|
||||
libraries,
|
||||
@@ -185,18 +59,18 @@ export function useStep2Materials({
|
||||
selectedMaterials,
|
||||
handleToggleMaterial,
|
||||
// 智能匹配
|
||||
smartMatchInput,
|
||||
setSmartMatchInput,
|
||||
smartMatching,
|
||||
smartMatchedResults,
|
||||
hasMatched,
|
||||
smartSelectedIds,
|
||||
handleSmartMatch,
|
||||
handleToggleSmartSelect,
|
||||
handleRefreshMatch,
|
||||
handleSelectAllMatched,
|
||||
handleClearSmartSelect,
|
||||
smartSelectedTotalDuration,
|
||||
smartMatchInput: smartMatch.smartMatchInput,
|
||||
setSmartMatchInput: smartMatch.setSmartMatchInput,
|
||||
smartMatching: smartMatch.smartMatching,
|
||||
smartMatchedResults: smartMatch.smartMatchedResults,
|
||||
hasMatched: smartMatch.hasMatched,
|
||||
smartSelectedIds: smartMatch.smartSelectedIds,
|
||||
handleSmartMatch: smartMatch.handleSmartMatch,
|
||||
handleToggleSmartSelect: smartMatch.handleToggleSmartSelect,
|
||||
handleRefreshMatch: smartMatch.handleRefreshMatch,
|
||||
handleSelectAllMatched: smartMatch.handleSelectAllMatched,
|
||||
handleClearSmartSelect: smartMatch.handleClearSmartSelect,
|
||||
smartSelectedTotalDuration: smartMatch.smartSelectedTotalDuration,
|
||||
// utils
|
||||
formatDuration,
|
||||
}
|
||||
|
||||
Regular → Executable
+24
-43
@@ -2,15 +2,15 @@
|
||||
* Step 5 配音选择 Hook
|
||||
* 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑
|
||||
*/
|
||||
import { useCallback } from "react"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { message } from "antd"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { useVoiceAudio } from "./step5-voice/useVoiceAudio"
|
||||
import { useVoiceRecommend } from "./step5-voice/useVoiceRecommend"
|
||||
import { useTtsSynthesis } from "./step5-voice/useTtsSynthesis"
|
||||
import { useSaveToLibrary } from "./step5-voice/useSaveToLibrary"
|
||||
import { useVoiceModeSelection } from "./step5-voice/useVoiceModeSelection"
|
||||
|
||||
interface UseStep5VoiceProps {
|
||||
selectedVoice: string
|
||||
@@ -81,48 +81,29 @@ export function useStep5Voice({
|
||||
handleAddTagInModal,
|
||||
} = useSaveToLibrary(completedTtsJobId, resetTtsState)
|
||||
|
||||
/* ── 推荐音色选择 ── */
|
||||
const handleSelectRecommendedVoice = useCallback(
|
||||
(voiceId: string) => {
|
||||
onVoiceModeChange("preset")
|
||||
onSelectedVoiceChange(voiceId)
|
||||
},
|
||||
[onVoiceModeChange, onSelectedVoiceChange],
|
||||
)
|
||||
const {
|
||||
handleSelectRecommendedVoice,
|
||||
handleSelectPresetVoice,
|
||||
handleSelectCloneVoice,
|
||||
handleSelectClonedVoice,
|
||||
handleOpenCloneModal,
|
||||
handleCloneSuccess: rawCloneSuccess,
|
||||
} = useVoiceModeSelection({
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
voiceMode,
|
||||
onVoiceModeChange,
|
||||
selectedClonedVoice,
|
||||
onSelectedClonedVoiceChange,
|
||||
addClone,
|
||||
cloneModalOpen,
|
||||
onCloneModalOpenChange,
|
||||
})
|
||||
|
||||
/* ── 克隆成功回调 ── */
|
||||
const handleCloneSuccess = useCallback(
|
||||
(voice: VoiceClone) => {
|
||||
addClone(voice)
|
||||
onCloneModalOpenChange(false)
|
||||
message.success("音色克隆成功!")
|
||||
},
|
||||
[addClone, onCloneModalOpenChange],
|
||||
)
|
||||
|
||||
/* ── 预设音色选择操作 ── */
|
||||
const handleSelectPresetVoice = useCallback(
|
||||
(voiceId: string) => {
|
||||
onVoiceModeChange("preset")
|
||||
onSelectedVoiceChange(voiceId)
|
||||
},
|
||||
[onVoiceModeChange, onSelectedVoiceChange],
|
||||
)
|
||||
|
||||
const handleSelectCloneVoice = useCallback(() => {
|
||||
onVoiceModeChange("clone")
|
||||
}, [onVoiceModeChange])
|
||||
|
||||
const handleSelectClonedVoice = useCallback(
|
||||
(voiceId: string) => {
|
||||
onSelectedClonedVoiceChange(voiceId)
|
||||
},
|
||||
[onSelectedClonedVoiceChange],
|
||||
)
|
||||
|
||||
const handleOpenCloneModal = useCallback(() => {
|
||||
onCloneModalOpenChange(true)
|
||||
}, [onCloneModalOpenChange])
|
||||
const handleCloneSuccess = (voice: VoiceClone) => {
|
||||
rawCloneSuccess(voice)
|
||||
message.success("音色克隆成功!")
|
||||
}
|
||||
|
||||
return {
|
||||
// 数据
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react"
|
||||
import React, { useEffect } from "react"
|
||||
import {
|
||||
VideoCameraOutlined,
|
||||
PlayCircleOutlined,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { Button } from "@/components/ui"
|
||||
import type { ProductItem } from "../types"
|
||||
import { formatTime, formatSize } from "../utils"
|
||||
import { useVideoPlayer } from "../hooks/useVideoPlayer"
|
||||
|
||||
interface VideoPlayerProps {
|
||||
product: ProductItem
|
||||
@@ -27,52 +28,19 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
onShare,
|
||||
onViewDetail,
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(product.duration)
|
||||
const {
|
||||
videoRef,
|
||||
progressRef,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
progress,
|
||||
togglePlay,
|
||||
handleSeek,
|
||||
} = useVideoPlayer()
|
||||
|
||||
const hasVideo = !!product.videoUrl
|
||||
|
||||
/** 播放/暂停 */
|
||||
const handlePlayPause = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (isPlaying) {
|
||||
video.pause()
|
||||
} else {
|
||||
video.play().catch(() => {})
|
||||
}
|
||||
setIsPlaying(!isPlaying)
|
||||
}, [isPlaying])
|
||||
|
||||
/** 视频事件监听 */
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const onTime = () => setCurrentTime(video.currentTime)
|
||||
const onDur = () => setDuration(video.duration || product.duration)
|
||||
const onEnd = () => setIsPlaying(false)
|
||||
video.addEventListener("timeupdate", onTime)
|
||||
video.addEventListener("loadedmetadata", onDur)
|
||||
video.addEventListener("ended", onEnd)
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", onTime)
|
||||
video.removeEventListener("loadedmetadata", onDur)
|
||||
video.removeEventListener("ended", onEnd)
|
||||
}
|
||||
}, [product.duration])
|
||||
|
||||
/** 进度条点击 */
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
const newTime = percent * duration
|
||||
setCurrentTime(newTime)
|
||||
if (videoRef.current) videoRef.current.currentTime = newTime
|
||||
}
|
||||
const displayDuration = duration || product.duration
|
||||
|
||||
/** ESC 关闭 */
|
||||
useEffect(() => {
|
||||
@@ -83,8 +51,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
return () => window.removeEventListener("keydown", handleKey)
|
||||
}, [onClose])
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="xx-player-overlay" onClick={onClose}>
|
||||
<div className="xx-player-container" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -114,7 +80,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
)}
|
||||
|
||||
{/* 播放/暂停按钮 */}
|
||||
<button className="xx-player-play-btn" onClick={handlePlayPause}>
|
||||
<button className="xx-player-play-btn" onClick={togglePlay}>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
@@ -125,12 +91,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="xx-player-progress-wrap">
|
||||
<div ref={progressRef} className="xx-player-progress" onClick={handleProgressClick}>
|
||||
<div ref={progressRef} className="xx-player-progress" onClick={handleSeek}>
|
||||
<div className="xx-player-progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="xx-player-time">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
<span>{formatTime(displayDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { deleteProduct } from "@/api/products"
|
||||
|
||||
interface UseBatchDeleteOptions {
|
||||
selectedIds: Set<string>
|
||||
clearSelection: () => void
|
||||
}
|
||||
|
||||
export function useBatchDelete({ selectedIds, clearSelection }: UseBatchDeleteOptions) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
let successCount = 0
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await deleteProduct(id)
|
||||
successCount++
|
||||
} catch {
|
||||
// 忽略单个失败
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
clearSelection()
|
||||
message.success(`已批量删除 ${successCount}/${ids.length} 个视频`)
|
||||
}
|
||||
|
||||
return { handleBatchDelete }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchDownload, getBatchDownloadStatus } from "@/api/products"
|
||||
|
||||
interface UseBatchDownloadOptions {
|
||||
selectedIds: Set<string>
|
||||
clearSelection: () => void
|
||||
}
|
||||
|
||||
export function useBatchDownload({ selectedIds, clearSelection }: UseBatchDownloadOptions) {
|
||||
const [batchDownloading, setBatchDownloading] = useState(false)
|
||||
|
||||
const handleBatchDownload = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
if (ids.length === 0) return
|
||||
setBatchDownloading(true)
|
||||
try {
|
||||
const { job_id } = await batchDownload(ids)
|
||||
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`)
|
||||
|
||||
let attempts = 0
|
||||
const maxAttempts = 60
|
||||
const poll = async (): Promise<void> => {
|
||||
if (attempts >= maxAttempts) {
|
||||
message.warning("打包超时,请稍后在消息中心查看")
|
||||
return
|
||||
}
|
||||
attempts++
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
const status = await getBatchDownloadStatus(job_id)
|
||||
if (status.status === "completed" && status.download_url) {
|
||||
const a = document.createElement("a")
|
||||
a.href = status.download_url
|
||||
a.download = ""
|
||||
a.click()
|
||||
message.success(`已打包下载 ${ids.length} 个视频`)
|
||||
clearSelection()
|
||||
} else if (status.status === "failed") {
|
||||
message.error("批量下载失败,请重试")
|
||||
} else {
|
||||
await poll()
|
||||
}
|
||||
}
|
||||
await poll()
|
||||
} catch {
|
||||
message.error("发起批量下载失败")
|
||||
} finally {
|
||||
setBatchDownloading(false)
|
||||
}
|
||||
}, [selectedIds, clearSelection])
|
||||
|
||||
return { batchDownloading, handleBatchDownload }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { deleteProduct, updateReviewStatus, type ReviewStatus } from "@/api/products"
|
||||
|
||||
export function useProductMutations() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteProduct,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
message.success("已删除")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除失败")
|
||||
},
|
||||
})
|
||||
|
||||
const reviewMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
|
||||
updateReviewStatus(id, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
message.success("复核状态已更新")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("更新复核状态失败")
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
deleteMutation,
|
||||
reviewMutation,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isUpdatingReview: reviewMutation.isPending,
|
||||
}
|
||||
}
|
||||
Regular → Executable
+12
-104
@@ -1,17 +1,11 @@
|
||||
import { useState } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { message } from "antd"
|
||||
import {
|
||||
deleteProduct,
|
||||
getProductDownloadUrl,
|
||||
updateReviewStatus,
|
||||
batchDownload,
|
||||
getBatchDownloadStatus,
|
||||
type ReviewStatus,
|
||||
} from "@/api/products"
|
||||
import { getProductDownloadUrl } from "@/api/products"
|
||||
import type { ProductItem } from "../types"
|
||||
import { getNextReviewStatus } from "../utils"
|
||||
import { useBatchDownload } from "./product-actions/useBatchDownload"
|
||||
import { useBatchDelete } from "./product-actions/useBatchDelete"
|
||||
import { useProductMutations } from "./product-actions/useProductMutations"
|
||||
|
||||
interface UseProductActionsOptions {
|
||||
selectedIds: Set<string>
|
||||
@@ -26,35 +20,17 @@ export const useProductActions = ({
|
||||
products,
|
||||
setPlayingProduct,
|
||||
}: UseProductActionsOptions) => {
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
/* ── 删除 mutation ── */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteProduct,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
message.success("已删除")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除失败")
|
||||
},
|
||||
const { deleteMutation, reviewMutation, isDeleting, isUpdatingReview } = useProductMutations()
|
||||
|
||||
const { batchDownloading, handleBatchDownload } = useBatchDownload({
|
||||
selectedIds,
|
||||
clearSelection,
|
||||
})
|
||||
|
||||
/* ── 复核状态 mutation ── */
|
||||
const reviewMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
|
||||
updateReviewStatus(id, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
message.success("复核状态已更新")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("更新复核状态失败")
|
||||
},
|
||||
})
|
||||
const { handleBatchDelete } = useBatchDelete({ selectedIds, clearSelection })
|
||||
|
||||
/* 下载 — 调用真实 API 获取下载链接 */
|
||||
const handleDownload = async (product: ProductItem) => {
|
||||
if (product.status !== "completed") return
|
||||
try {
|
||||
@@ -69,7 +45,6 @@ export const useProductActions = ({
|
||||
}
|
||||
}
|
||||
|
||||
/* 分享 */
|
||||
const handleShare = (product: ProductItem) => {
|
||||
if (product.status !== "completed") return
|
||||
const link = `${window.location.origin}/share/${product.id}`
|
||||
@@ -79,109 +54,42 @@ export const useProductActions = ({
|
||||
)
|
||||
}
|
||||
|
||||
/* 查看详情 — 跳转到产品详情页 */
|
||||
const handleViewDetail = (product: ProductItem) => {
|
||||
setPlayingProduct(null)
|
||||
navigate(`/app/products/${product.id}`)
|
||||
}
|
||||
|
||||
/* 删除 */
|
||||
const handleDelete = (id: string) => {
|
||||
deleteMutation.mutate(id)
|
||||
}
|
||||
|
||||
/* 发布 — TODO: 后端发布 API 待实现 */
|
||||
const handlePublish = (_product: ProductItem) => {
|
||||
message.info("发布功能待后端 API 补齐")
|
||||
}
|
||||
|
||||
/* 切换复核状态 */
|
||||
const handleReviewStatusChange = (id: string) => {
|
||||
const current = products.find((p) => p.id === id)?.reviewStatus
|
||||
const nextStatus = getNextReviewStatus(current)
|
||||
reviewMutation.mutate({ id, status: nextStatus })
|
||||
}
|
||||
|
||||
/* 批量下载 */
|
||||
const [batchDownloading, setBatchDownloading] = useState(false)
|
||||
|
||||
const handleBatchDownload = async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
if (ids.length === 0) return
|
||||
setBatchDownloading(true)
|
||||
try {
|
||||
const { job_id } = await batchDownload(ids)
|
||||
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`)
|
||||
|
||||
let attempts = 0
|
||||
const maxAttempts = 60
|
||||
const poll = async (): Promise<void> => {
|
||||
if (attempts >= maxAttempts) {
|
||||
message.warning("打包超时,请稍后在消息中心查看")
|
||||
return
|
||||
}
|
||||
attempts++
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
const status = await getBatchDownloadStatus(job_id)
|
||||
if (status.status === "completed" && status.download_url) {
|
||||
const a = document.createElement("a")
|
||||
a.href = status.download_url
|
||||
a.download = ""
|
||||
a.click()
|
||||
message.success(`已打包下载 ${ids.length} 个视频`)
|
||||
clearSelection()
|
||||
} else if (status.status === "failed") {
|
||||
message.error("批量下载失败,请重试")
|
||||
} else {
|
||||
await poll()
|
||||
}
|
||||
}
|
||||
await poll()
|
||||
} catch {
|
||||
message.error("发起批量下载失败")
|
||||
} finally {
|
||||
setBatchDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 批量删除 */
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
let successCount = 0
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await deleteProduct(id)
|
||||
successCount++
|
||||
} catch {
|
||||
// 忽略单个失败
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
clearSelection()
|
||||
message.success(`已批量删除 ${successCount}/${ids.length} 个视频`)
|
||||
}
|
||||
|
||||
/* 批量发布 */
|
||||
const handleBatchPublish = () => {
|
||||
message.info("批量发布功能待后端 API 补齐")
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
return {
|
||||
// 单个操作
|
||||
handleDownload,
|
||||
handleShare,
|
||||
handleViewDetail,
|
||||
handleDelete,
|
||||
handlePublish,
|
||||
handleReviewStatusChange,
|
||||
// 批量操作
|
||||
handleBatchDownload,
|
||||
handleBatchDelete,
|
||||
handleBatchPublish,
|
||||
batchDownloading,
|
||||
// mutation 状态
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isUpdatingReview: reviewMutation.isPending,
|
||||
isDeleting,
|
||||
isUpdatingReview,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getProducts, type ProductItem as ApiProductItem } from "@/api/products"
|
||||
import { mapApiProduct } from "../../utils"
|
||||
import { useProductFiltering } from "./useProductFiltering"
|
||||
import { useBatchSelection } from "./useBatchSelection"
|
||||
|
||||
export type { Filters } from "./useProductFiltering"
|
||||
|
||||
export const useProductList = () => {
|
||||
/* ── 获取成品列表 ── */
|
||||
const {
|
||||
data: apiProducts = [],
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<ApiProductItem[], Error>({
|
||||
queryKey: ["products"],
|
||||
queryFn: () => getProducts(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
// 映射为前端类型,按创建时间倒序排列,防御非数组返回
|
||||
const products = useMemo(
|
||||
() =>
|
||||
(Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct).sort((a, b) => {
|
||||
if (!a.date || a.date === "—") return 1
|
||||
if (!b.date || b.date === "—") return -1
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
}),
|
||||
[apiProducts],
|
||||
)
|
||||
|
||||
/* 筛选 */
|
||||
const {
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterStatus,
|
||||
setFilterStatus,
|
||||
filterTime,
|
||||
setFilterTime,
|
||||
filterDuration,
|
||||
setFilterDuration,
|
||||
filterProject,
|
||||
setFilterProject,
|
||||
filterReviewStatus,
|
||||
setFilterReviewStatus,
|
||||
filteredProducts,
|
||||
projectOptions,
|
||||
} = useProductFiltering(products)
|
||||
|
||||
/* 批量选择 */
|
||||
const {
|
||||
selectedIds,
|
||||
batchMode,
|
||||
allSelected,
|
||||
handleSelectAll,
|
||||
handleToggleSelect,
|
||||
clearSelection,
|
||||
} = useBatchSelection(filteredProducts)
|
||||
|
||||
return {
|
||||
// 数据
|
||||
products,
|
||||
filteredProducts,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
// 筛选
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterStatus,
|
||||
setFilterStatus,
|
||||
filterTime,
|
||||
setFilterTime,
|
||||
filterDuration,
|
||||
setFilterDuration,
|
||||
filterProject,
|
||||
setFilterProject,
|
||||
filterReviewStatus,
|
||||
setFilterReviewStatus,
|
||||
projectOptions,
|
||||
// 批量选择
|
||||
selectedIds,
|
||||
batchMode,
|
||||
allSelected,
|
||||
handleSelectAll,
|
||||
handleToggleSelect,
|
||||
clearSelection,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import type { ProductItem } from "../../types"
|
||||
|
||||
export const useBatchSelection = (filteredProducts: ProductItem[]) => {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const batchMode = selectedIds.size > 0
|
||||
|
||||
const allSelected =
|
||||
filteredProducts.length > 0 && filteredProducts.every((p) => selectedIds.has(p.id))
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (allSelected) {
|
||||
// 仅取消选中当前可见的项,保留筛选外的选中状态
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
filteredProducts.forEach((p) => next.delete(p.id))
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
// 选中所有当前可见项
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
filteredProducts.forEach((p) => next.add(p.id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [allSelected, filteredProducts])
|
||||
|
||||
const handleToggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
batchMode,
|
||||
allSelected,
|
||||
handleSelectAll,
|
||||
handleToggleSelect,
|
||||
clearSelection,
|
||||
}
|
||||
}
|
||||
+3
-78
@@ -1,8 +1,5 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getProducts, type ProductItem as ApiProductItem } from "@/api/products"
|
||||
import type { ProductItem } from "../types"
|
||||
import { mapApiProduct } from "../utils"
|
||||
import type { ProductItem } from "../../types"
|
||||
|
||||
/** 筛选选项类型 */
|
||||
export interface Filters {
|
||||
@@ -27,32 +24,7 @@ const getProjectOptions = (products: ProductItem[]) =>
|
||||
label: name as string,
|
||||
}))
|
||||
|
||||
export const useProductList = () => {
|
||||
/* ── 获取成品列表 ── */
|
||||
const {
|
||||
data: apiProducts = [],
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<ApiProductItem[], Error>({
|
||||
queryKey: ["products"],
|
||||
queryFn: () => getProducts(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
// 映射为前端类型,按创建时间倒序排列,防御非数组返回
|
||||
const products = useMemo(() => {
|
||||
const list = (Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct)
|
||||
// 按创建时间倒序(最新的在最前面),无时间的排最后
|
||||
return list.sort((a, b) => {
|
||||
if (!a.date || a.date === "—") return 1
|
||||
if (!b.date || b.date === "—") return -1
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
})
|
||||
}, [apiProducts])
|
||||
|
||||
/* 筛选 */
|
||||
export const useProductFiltering = (products: ProductItem[]) => {
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all")
|
||||
const [filterTime, setFilterTime] = useState<string>("all")
|
||||
@@ -60,12 +32,6 @@ export const useProductList = () => {
|
||||
const [filterProject, setFilterProject] = useState<string>("all")
|
||||
const [filterReviewStatus, setFilterReviewStatus] = useState<string>("all")
|
||||
|
||||
/* 批量选择 */
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
/* 派生数据 */
|
||||
const batchMode = selectedIds.size > 0
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
let list = products
|
||||
|
||||
@@ -142,42 +108,7 @@ export const useProductList = () => {
|
||||
|
||||
const projectOptions = useMemo(() => getProjectOptions(products), [products])
|
||||
|
||||
/* 全选 */
|
||||
const allSelected =
|
||||
filteredProducts.length > 0 && filteredProducts.every((p) => selectedIds.has(p.id))
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filteredProducts.map((p) => p.id)))
|
||||
}
|
||||
}
|
||||
|
||||
/* 切换单个选择 */
|
||||
const handleToggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearSelection = () => setSelectedIds(new Set())
|
||||
|
||||
return {
|
||||
// 数据
|
||||
products,
|
||||
filteredProducts,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
// 筛选
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterStatus,
|
||||
@@ -190,13 +121,7 @@ export const useProductList = () => {
|
||||
setFilterProject,
|
||||
filterReviewStatus,
|
||||
setFilterReviewStatus,
|
||||
filteredProducts,
|
||||
projectOptions,
|
||||
// 批量选择
|
||||
selectedIds,
|
||||
batchMode,
|
||||
allSelected,
|
||||
handleSelectAll,
|
||||
handleToggleSelect,
|
||||
clearSelection,
|
||||
}
|
||||
}
|
||||
Regular → Executable
+5
-123
@@ -1,11 +1,8 @@
|
||||
import React from "react"
|
||||
import { Table, Tag, Button, Popconfirm, Tooltip } from "antd"
|
||||
import { RedoOutlined, InfoCircleOutlined, ClockCircleOutlined } from "@ant-design/icons"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import type { TaskItem, TaskStatus } from "@/api/tasks"
|
||||
import { STATUS_CONFIG, TYPE_LABELS } from "../constants"
|
||||
import { formatDuration, formatTime } from "../utils"
|
||||
import { Table } from "antd"
|
||||
import type { TaskItem } from "@/api/tasks"
|
||||
import { TaskErrorDetail } from "./TaskErrorDetail"
|
||||
import { useTaskTableColumns, TaskEmptyState } from "./task-table"
|
||||
|
||||
interface TaskTableProps {
|
||||
dataSource: TaskItem[]
|
||||
@@ -24,7 +21,6 @@ interface TaskTableProps {
|
||||
|
||||
/**
|
||||
* 任务列表表格
|
||||
* 含列定义、分页、展开行
|
||||
*/
|
||||
export const TaskTable: React.FC<TaskTableProps> = ({
|
||||
dataSource,
|
||||
@@ -40,116 +36,7 @@ export const TaskTable: React.FC<TaskTableProps> = ({
|
||||
onRetry,
|
||||
onViewDetail,
|
||||
}) => {
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<TaskItem> = [
|
||||
{
|
||||
title: "任务ID",
|
||||
dataIndex: "id",
|
||||
key: "id",
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (id: string) => (
|
||||
<Tooltip title={id}>
|
||||
<span className="task-id">{id.slice(0, 8)}...</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "task_type",
|
||||
key: "task_type",
|
||||
width: 100,
|
||||
render: (type: string) => {
|
||||
const config = TYPE_LABELS[type] || { label: type, color: "default" }
|
||||
return <Tag color={config.color}>{config.label}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 120,
|
||||
render: (status: TaskStatus, record: TaskItem) => {
|
||||
const config = STATUS_CONFIG[status] || {
|
||||
label: status,
|
||||
color: "default",
|
||||
icon: null,
|
||||
}
|
||||
return (
|
||||
<Tag color={config.color} icon={config.icon} className="task-status-tag">
|
||||
{config.label}
|
||||
{status === "running" && record.progress > 0 && (
|
||||
<span className="task-progress"> {record.progress}%</span>
|
||||
)}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "当前步骤",
|
||||
dataIndex: "current_step",
|
||||
key: "current_step",
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (step: string) => <span className="task-step">{step || "-"}</span>,
|
||||
},
|
||||
{
|
||||
title: "耗时",
|
||||
dataIndex: "duration_seconds",
|
||||
key: "duration_seconds",
|
||||
width: 100,
|
||||
render: (seconds: number) => <span className="task-duration">{formatDuration(seconds)}</span>,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
width: 120,
|
||||
render: (time: string) => <span className="task-time">{formatTime(time)}</span>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 100,
|
||||
fixed: "right",
|
||||
render: (_: unknown, record: TaskItem) => {
|
||||
if (record.status === "failed" && record.retryable) {
|
||||
return (
|
||||
<Popconfirm
|
||||
title="确认重试"
|
||||
description="确定要重试这个失败的任务吗?"
|
||||
onConfirm={() => onRetry(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<RedoOutlined />}
|
||||
loading={retryLoading}
|
||||
className="task-retry-btn"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
if (record.status === "failed") {
|
||||
return (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<InfoCircleOutlined />}
|
||||
onClick={() => onViewDetail(record)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return <span className="task-action-placeholder">-</span>
|
||||
},
|
||||
},
|
||||
]
|
||||
const columns = useTaskTableColumns({ retryLoading, onRetry, onViewDetail })
|
||||
|
||||
return (
|
||||
<Table
|
||||
@@ -178,12 +65,7 @@ export const TaskTable: React.FC<TaskTableProps> = ({
|
||||
scroll={{ x: 800 }}
|
||||
className="task-table"
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="task-empty">
|
||||
<ClockCircleOutlined />
|
||||
<p>暂无任务记录</p>
|
||||
</div>
|
||||
),
|
||||
emptyText: <TaskEmptyState />,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from "react"
|
||||
import { ClockCircleOutlined } from "@ant-design/icons"
|
||||
|
||||
export const TaskEmptyState: React.FC = () => (
|
||||
<div className="task-empty">
|
||||
<ClockCircleOutlined />
|
||||
<p>暂无任务记录</p>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useTaskTableColumns } from "./useTaskTableColumns"
|
||||
export { TaskEmptyState } from "./TaskEmptyState"
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Tag, Button, Popconfirm, Tooltip } from "antd"
|
||||
import { RedoOutlined, InfoCircleOutlined } from "@ant-design/icons"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import type { TaskItem, TaskStatus } from "@/api/tasks"
|
||||
import { STATUS_CONFIG, TYPE_LABELS } from "../../constants"
|
||||
import { formatDuration, formatTime } from "../../utils"
|
||||
|
||||
interface UseTaskTableColumnsOptions {
|
||||
retryLoading: boolean
|
||||
onRetry: (id: string) => void
|
||||
onViewDetail: (record: TaskItem) => void
|
||||
}
|
||||
|
||||
export function useTaskTableColumns({
|
||||
retryLoading,
|
||||
onRetry,
|
||||
onViewDetail,
|
||||
}: UseTaskTableColumnsOptions): ColumnsType<TaskItem> {
|
||||
return [
|
||||
{
|
||||
title: "任务ID",
|
||||
dataIndex: "id",
|
||||
key: "id",
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (id: string) => (
|
||||
<Tooltip title={id}>
|
||||
<span className="task-id">{id.slice(0, 8)}...</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "task_type",
|
||||
key: "task_type",
|
||||
width: 100,
|
||||
render: (type: string) => {
|
||||
const config = TYPE_LABELS[type] || { label: type, color: "default" }
|
||||
return <Tag color={config.color}>{config.label}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 120,
|
||||
render: (status: TaskStatus, record: TaskItem) => {
|
||||
const config = STATUS_CONFIG[status] || {
|
||||
label: status,
|
||||
color: "default",
|
||||
icon: null,
|
||||
}
|
||||
return (
|
||||
<Tag color={config.color} icon={config.icon} className="task-status-tag">
|
||||
{config.label}
|
||||
{status === "running" && record.progress > 0 && (
|
||||
<span className="task-progress"> {record.progress}%</span>
|
||||
)}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "当前步骤",
|
||||
dataIndex: "current_step",
|
||||
key: "current_step",
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (step: string) => <span className="task-step">{step || "-"}</span>,
|
||||
},
|
||||
{
|
||||
title: "耗时",
|
||||
dataIndex: "duration_seconds",
|
||||
key: "duration_seconds",
|
||||
width: 100,
|
||||
render: (seconds: number) => <span className="task-duration">{formatDuration(seconds)}</span>,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
width: 120,
|
||||
render: (time: string) => <span className="task-time">{formatTime(time)}</span>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 100,
|
||||
fixed: "right",
|
||||
render: (_: unknown, record: TaskItem) => {
|
||||
if (record.status === "failed" && record.retryable) {
|
||||
return (
|
||||
<Popconfirm
|
||||
title="确认重试"
|
||||
description="确定要重试这个失败的任务吗?"
|
||||
onConfirm={() => onRetry(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<RedoOutlined />}
|
||||
loading={retryLoading}
|
||||
className="task-retry-btn"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
if (record.status === "failed") {
|
||||
return (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<InfoCircleOutlined />}
|
||||
onClick={() => onViewDetail(record)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return <span className="task-action-placeholder">-</span>
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
Regular → Executable
+20
-136
@@ -1,25 +1,13 @@
|
||||
import React from "react"
|
||||
import { Button, Descriptions, Tooltip } from "antd"
|
||||
import { CopyOutlined, ThunderboltOutlined } from "@ant-design/icons"
|
||||
import type { TemplateItem, TemplateSegment } from "@/api/templates"
|
||||
import {
|
||||
gradientForCategory,
|
||||
getTypeColor,
|
||||
formatDuration,
|
||||
formatConfig,
|
||||
getMaterialTypeLabel,
|
||||
calcTotalSegmentDuration,
|
||||
} from "../../utils/templateLibrary"
|
||||
import { Descriptions } from "antd"
|
||||
import type { TemplateDetailModalProps } from "./template-detail-modal/types"
|
||||
import PreviewArea from "./template-detail-modal/PreviewArea"
|
||||
import SegmentList from "./template-detail-modal/SegmentList"
|
||||
import StyleConfig from "./template-detail-modal/StyleConfig"
|
||||
import DetailFooter from "./template-detail-modal/DetailFooter"
|
||||
import { getTypeColor, formatDuration } from "../../utils/templateLibrary"
|
||||
import { TEMPLATE_TYPES } from "../../constants/templateLibrary"
|
||||
|
||||
interface TemplateDetailModalProps {
|
||||
template: TemplateItem
|
||||
isFavorite: boolean
|
||||
onClose: () => void
|
||||
onToggleFavorite: (id: string) => void
|
||||
onUse: (template: TemplateItem) => void
|
||||
onCopy: (template: TemplateItem) => void
|
||||
}
|
||||
import { calcTotalSegmentDuration } from "../../utils/templateLibrary"
|
||||
|
||||
export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
template,
|
||||
@@ -31,6 +19,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
}) => {
|
||||
const segments = template.segments ?? []
|
||||
const totalSegmentDuration = calcTotalSegmentDuration(segments)
|
||||
const typeInfo = TEMPLATE_TYPES.find((t) => t.type === template.category)
|
||||
|
||||
return (
|
||||
<div className="xx-template-modal-overlay" onClick={onClose}>
|
||||
@@ -38,33 +27,8 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
className="xx-template-modal xx-template-modal-wide"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<button className="xx-template-modal-close" onClick={onClose} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
<PreviewArea template={template} onClose={onClose} />
|
||||
|
||||
{/* 预览区域 */}
|
||||
<div
|
||||
className="xx-template-modal-preview"
|
||||
style={{ background: gradientForCategory(template.category) }}
|
||||
>
|
||||
{template.thumbnail_url ? (
|
||||
<img
|
||||
src={template.thumbnail_url}
|
||||
alt={template.name}
|
||||
className="xx-template-modal-thumb-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-template-modal-preview-content">
|
||||
<span className="xx-template-preview-icon">
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon ?? "📋"}
|
||||
</span>
|
||||
<span className="xx-template-preview-title">{template.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="xx-template-modal-content">
|
||||
{/* 标题行 */}
|
||||
<div className="xx-template-modal-title-row">
|
||||
@@ -76,7 +40,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
background: `${getTypeColor(template.category)}18`,
|
||||
}}
|
||||
>
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon} {template.category}
|
||||
{typeInfo?.icon} {template.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -123,95 +87,15 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 素材规则(片段配置) */}
|
||||
{segments.length > 0 && (
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎬 素材规则</h4>
|
||||
<div className="xx-template-modal-clip-list">
|
||||
{segments
|
||||
.sort((a, b) => a.segment_order - b.segment_order)
|
||||
.map((seg: TemplateSegment, idx: number) => (
|
||||
<div key={seg.id ?? idx} className="xx-template-modal-clip-item">
|
||||
<span className="xx-template-modal-clip-order">#{seg.segment_order}</span>
|
||||
<span
|
||||
className="xx-template-modal-clip-badge"
|
||||
style={{
|
||||
color: seg.material_type ? getTypeColor(seg.material_type) : "#64748b",
|
||||
background: seg.material_type
|
||||
? `${getTypeColor(seg.material_type)}18`
|
||||
: "#f1f5f9",
|
||||
}}
|
||||
>
|
||||
{getMaterialTypeLabel(seg.material_type)}
|
||||
</span>
|
||||
<span className="xx-template-modal-clip-desc">
|
||||
{seg.description || `片段 ${seg.segment_order}`}
|
||||
</span>
|
||||
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}秒`}>
|
||||
<span className="xx-template-modal-clip-duration">
|
||||
{seg.duration_min}-{seg.duration_max}秒
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="xx-template-modal-total-duration">
|
||||
预估总时长:{formatDuration(Math.round(totalSegmentDuration))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 样式配置 */}
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎨 样式配置</h4>
|
||||
<div className="xx-template-modal-style-grid">
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">字幕样式</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.subtitle_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">标题样式</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.title_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">BGM 配置</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.bgm_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">视频比例</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{template.aspect_ratio ?? "16:9"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="xx-template-modal-stats">
|
||||
<span>已使用 {template.usage_count ?? 0} 次</span>
|
||||
<button
|
||||
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||
onClick={() => onToggleFavorite(template.id)}
|
||||
>
|
||||
{isFavorite ? "★ 已收藏" : "☆ 收藏"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="xx-template-modal-actions">
|
||||
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
|
||||
复制模板
|
||||
</Button>
|
||||
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onUse(template)}>
|
||||
使用此模板生成
|
||||
</Button>
|
||||
</div>
|
||||
<SegmentList segments={segments} totalDuration={totalSegmentDuration} />
|
||||
<StyleConfig template={template} />
|
||||
<DetailFooter
|
||||
template={template}
|
||||
isFavorite={isFavorite}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onUse={onUse}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
import React from "react"
|
||||
import { Button } from "antd"
|
||||
import { CopyOutlined, ThunderboltOutlined } from "@ant-design/icons"
|
||||
import type { TemplateItem } from "@/api/templates"
|
||||
|
||||
interface DetailFooterProps {
|
||||
template: TemplateItem
|
||||
isFavorite: boolean
|
||||
onToggleFavorite: (id: string) => void
|
||||
onUse: (template: TemplateItem) => void
|
||||
onCopy: (template: TemplateItem) => void
|
||||
}
|
||||
|
||||
/** 底部操作区:统计 + 收藏 + 按钮 */
|
||||
const DetailFooter: React.FC<DetailFooterProps> = ({
|
||||
template,
|
||||
isFavorite,
|
||||
onToggleFavorite,
|
||||
onUse,
|
||||
onCopy,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="xx-template-modal-stats">
|
||||
<span>已使用 {template.usage_count ?? 0} 次</span>
|
||||
<button
|
||||
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||
onClick={() => onToggleFavorite(template.id)}
|
||||
>
|
||||
{isFavorite ? "★ 已收藏" : "☆ 收藏"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="xx-template-modal-actions">
|
||||
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
|
||||
复制模板
|
||||
</Button>
|
||||
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onUse(template)}>
|
||||
使用此模板生成
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetailFooter
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
import React from "react"
|
||||
import type { TemplateItem } from "@/api/templates"
|
||||
import { gradientForCategory } from "../../../utils/templateLibrary"
|
||||
import { TEMPLATE_TYPES } from "../../../constants/templateLibrary"
|
||||
|
||||
interface PreviewAreaProps {
|
||||
template: TemplateItem
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** 预览区域 */
|
||||
const PreviewArea: React.FC<PreviewAreaProps> = ({ template, onClose }) => {
|
||||
const typeInfo = TEMPLATE_TYPES.find((t) => t.type === template.category)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="xx-template-modal-preview"
|
||||
style={{ background: gradientForCategory(template.category) }}
|
||||
>
|
||||
<button className="xx-template-modal-close" onClick={onClose} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
{template.thumbnail_url ? (
|
||||
<img
|
||||
src={template.thumbnail_url}
|
||||
alt={template.name}
|
||||
className="xx-template-modal-thumb-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-template-modal-preview-content">
|
||||
<span className="xx-template-preview-icon">{typeInfo?.icon ?? "📋"}</span>
|
||||
<span className="xx-template-preview-title">{template.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PreviewArea
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
import React from "react"
|
||||
import { Tooltip } from "antd"
|
||||
import type { TemplateSegment } from "@/api/templates"
|
||||
import { getMaterialTypeLabel, getTypeColor, formatDuration } from "../../../utils/templateLibrary"
|
||||
|
||||
interface SegmentListProps {
|
||||
segments: TemplateSegment[]
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
/** 素材规则 / 片段列表 */
|
||||
const SegmentList: React.FC<SegmentListProps> = ({ segments, totalDuration }) => {
|
||||
if (segments.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎬 素材规则</h4>
|
||||
<div className="xx-template-modal-clip-list">
|
||||
{segments
|
||||
.sort((a, b) => a.segment_order - b.segment_order)
|
||||
.map((seg, idx) => (
|
||||
<div key={seg.id ?? idx} className="xx-template-modal-clip-item">
|
||||
<span className="xx-template-modal-clip-order">#{seg.segment_order}</span>
|
||||
<span
|
||||
className="xx-template-modal-clip-badge"
|
||||
style={{
|
||||
color: seg.material_type ? getTypeColor(seg.material_type) : "#64748b",
|
||||
background: seg.material_type
|
||||
? `${getTypeColor(seg.material_type)}18`
|
||||
: "#f1f5f9",
|
||||
}}
|
||||
>
|
||||
{getMaterialTypeLabel(seg.material_type)}
|
||||
</span>
|
||||
<span className="xx-template-modal-clip-desc">
|
||||
{seg.description || `片段 ${seg.segment_order}`}
|
||||
</span>
|
||||
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}秒`}>
|
||||
<span className="xx-template-modal-clip-duration">
|
||||
{seg.duration_min}-{seg.duration_max}秒
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="xx-template-modal-total-duration">
|
||||
预估总时长:{formatDuration(Math.round(totalDuration))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SegmentList
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user