Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d261652c7f | |||
| 5371b4a4d1 |
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 模板编辑器 API
|
||||
* 对接后端 /api/v1/templates 路由
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types"
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
/** 模板模式(后端枚举值) */
|
||||
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
|
||||
|
||||
/** 模式显示名称映射 */
|
||||
export const MODE_LABELS: Record<TemplateMode, string> = {
|
||||
pip: "混剪",
|
||||
voice_over: "人物口播",
|
||||
one_take: "一镜到底",
|
||||
voice_pip: "口播+混剪",
|
||||
}
|
||||
|
||||
/** 模式颜色映射 */
|
||||
export const MODE_COLORS: Record<TemplateMode, string> = {
|
||||
pip: "blue",
|
||||
voice_over: "green",
|
||||
one_take: "orange",
|
||||
voice_pip: "purple",
|
||||
}
|
||||
|
||||
/** 标题配置 */
|
||||
export interface TitleConfig {
|
||||
ai_auto_select: boolean
|
||||
content: string
|
||||
font_preset: string
|
||||
font_color: string
|
||||
font_size: number
|
||||
position: string
|
||||
}
|
||||
|
||||
/** 字幕配置 */
|
||||
export interface SubtitleConfig {
|
||||
enabled: boolean
|
||||
position: string
|
||||
font: string
|
||||
color: string
|
||||
size: number
|
||||
animation: string
|
||||
}
|
||||
|
||||
/** BGM 配置 */
|
||||
export interface BgmConfig {
|
||||
enabled: boolean
|
||||
music_id: string
|
||||
}
|
||||
|
||||
/** 模板片段 */
|
||||
export interface TemplateSegment {
|
||||
id?: string
|
||||
segment_order: number
|
||||
duration_min: number
|
||||
duration_max: number
|
||||
material_type: string | null
|
||||
}
|
||||
|
||||
/** 剪辑模板 */
|
||||
export interface EditingTemplate {
|
||||
id: string
|
||||
name: string
|
||||
mode: TemplateMode
|
||||
category: string
|
||||
tags: string[]
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
estimated_duration: number
|
||||
segments: TemplateSegment[]
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
watermark_config?: WatermarkConfig
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
/** 混剪配置 */
|
||||
pip_config?: PipConfig
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig
|
||||
/** 绿幕抠像配置 */
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
/** 贴纸配置 */
|
||||
sticker_config?: StickerConfig
|
||||
/** 封面配置 */
|
||||
cover_config?: CoverConfig
|
||||
is_active?: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 模板分类 */
|
||||
export interface TemplateCategory {
|
||||
id: string
|
||||
name: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
/** 创建/更新模板请求体 */
|
||||
export interface SaveTemplatePayload {
|
||||
name: string
|
||||
mode: TemplateMode
|
||||
category: string
|
||||
tags: string[]
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
estimated_duration: number
|
||||
segments: Omit<TemplateSegment, "id">[]
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
watermark_config?: WatermarkConfig
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
/** 混剪配置 */
|
||||
pip_config?: PipConfig
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig
|
||||
/** 绿幕抠像配置 */
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
/** 贴纸配置 */
|
||||
sticker_config?: StickerConfig
|
||||
/** 封面配置 */
|
||||
cover_config?: CoverConfig
|
||||
}
|
||||
|
||||
/** 使用模板生成请求体 */
|
||||
export interface GenerateFromTemplatePayload {
|
||||
voiceover_duration: number
|
||||
}
|
||||
|
||||
/** 验证警告详情 */
|
||||
export interface ValidationWarningDetails {
|
||||
/** 相关字段名 */
|
||||
field?: string
|
||||
/** 期望值 */
|
||||
expected?: string | number
|
||||
/** 实际值 */
|
||||
actual?: string | number
|
||||
/** 建议值 */
|
||||
suggested?: string | number
|
||||
}
|
||||
|
||||
/** 验证/生成响应 */
|
||||
export interface ValidateWarning {
|
||||
code: string
|
||||
message: string
|
||||
details?: ValidationWarningDetails
|
||||
}
|
||||
|
||||
/** 使用模板生成响应 */
|
||||
export interface GenerateFromTemplateResponse {
|
||||
template: EditingTemplate
|
||||
warnings: ValidateWarning[]
|
||||
}
|
||||
|
||||
/** 列表响应(带分页) */
|
||||
export interface ListTemplatesResponse {
|
||||
items: EditingTemplate[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 分类列表响应 */
|
||||
export interface ListCategoriesResponse {
|
||||
items: TemplateCategory[]
|
||||
}
|
||||
|
||||
// ============ API 函数 ============
|
||||
|
||||
/** 获取模板列表 */
|
||||
export const getEditingTemplates = async (params?: {
|
||||
category?: string
|
||||
tag?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}): Promise<EditingTemplate[]> => {
|
||||
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 50,
|
||||
},
|
||||
})
|
||||
let list = response.data.items
|
||||
if (params?.category) list = list.filter((t) => t.category === params.category)
|
||||
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!))
|
||||
return list
|
||||
}
|
||||
|
||||
/** 获取模板详情 */
|
||||
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建模板 */
|
||||
export const createEditingTemplate = async (
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.post<EditingTemplate>("/templates", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板 */
|
||||
export const updateEditingTemplate = async (
|
||||
id: string,
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除模板 */
|
||||
export const deleteEditingTemplate = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/templates/${id}`)
|
||||
}
|
||||
|
||||
/** 获取模板分类列表 */
|
||||
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
|
||||
const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list")
|
||||
return response.data.items
|
||||
}
|
||||
|
||||
/** 使用模板生成视频(调用 validate 端点) */
|
||||
export const generateFromTemplate = async (
|
||||
templateId: string,
|
||||
data: GenerateFromTemplatePayload,
|
||||
): Promise<GenerateFromTemplateResponse> => {
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/validate`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* 模板编辑器常量
|
||||
*/
|
||||
import type { TemplateMode } from "./types"
|
||||
|
||||
/** 模式显示名称映射 */
|
||||
export const MODE_LABELS: Record<TemplateMode, string> = {
|
||||
pip: "混剪",
|
||||
voice_over: "人物口播",
|
||||
one_take: "一镜到底",
|
||||
voice_pip: "口播+混剪",
|
||||
}
|
||||
|
||||
/** 模式颜色映射 */
|
||||
export const MODE_COLORS: Record<TemplateMode, string> = {
|
||||
pip: "blue",
|
||||
voice_over: "green",
|
||||
one_take: "orange",
|
||||
voice_pip: "purple",
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* 模板编辑器 API — 目录化入口
|
||||
* 保持与原 editing-planner.ts 相同导出,向后兼容
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
TemplateMode,
|
||||
TitleConfig,
|
||||
SubtitleConfig,
|
||||
BgmConfig,
|
||||
TemplateSegment,
|
||||
EditingTemplate,
|
||||
TemplateCategory,
|
||||
SaveTemplatePayload,
|
||||
GenerateFromTemplatePayload,
|
||||
ValidationWarningDetails,
|
||||
ValidateWarning,
|
||||
GenerateFromTemplateResponse,
|
||||
ListTemplatesResponse,
|
||||
ListCategoriesResponse,
|
||||
} from "./types"
|
||||
|
||||
// 常量
|
||||
export { MODE_LABELS, MODE_COLORS } from "./constants"
|
||||
|
||||
// API 函数
|
||||
export {
|
||||
getEditingTemplates,
|
||||
getEditingTemplate,
|
||||
createEditingTemplate,
|
||||
updateEditingTemplate,
|
||||
deleteEditingTemplate,
|
||||
getTemplateCategories,
|
||||
generateFromTemplate,
|
||||
} from "./templates"
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* 模板编辑器 API 函数
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
EditingTemplate,
|
||||
TemplateCategory,
|
||||
SaveTemplatePayload,
|
||||
GenerateFromTemplatePayload,
|
||||
GenerateFromTemplateResponse,
|
||||
ListTemplatesResponse,
|
||||
ListCategoriesResponse,
|
||||
} from "./types"
|
||||
|
||||
/** 获取模板列表 */
|
||||
export const getEditingTemplates = async (params?: {
|
||||
category?: string
|
||||
tag?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}): Promise<EditingTemplate[]> => {
|
||||
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 50,
|
||||
},
|
||||
})
|
||||
let list = response.data.items
|
||||
if (params?.category) list = list.filter((t) => t.category === params.category)
|
||||
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!))
|
||||
return list
|
||||
}
|
||||
|
||||
/** 获取模板详情 */
|
||||
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建模板 */
|
||||
export const createEditingTemplate = async (
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.post<EditingTemplate>("/templates", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板 */
|
||||
export const updateEditingTemplate = async (
|
||||
id: string,
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除模板 */
|
||||
export const deleteEditingTemplate = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/templates/${id}`)
|
||||
}
|
||||
|
||||
/** 获取模板分类列表 */
|
||||
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
|
||||
const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list")
|
||||
return response.data.items
|
||||
}
|
||||
|
||||
/** 使用模板生成视频(调用 validate 端点) */
|
||||
export const generateFromTemplate = async (
|
||||
templateId: string,
|
||||
data: GenerateFromTemplatePayload,
|
||||
): Promise<GenerateFromTemplateResponse> => {
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/validate`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* 模板编辑器类型定义
|
||||
*/
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types"
|
||||
|
||||
/** 模板模式(后端枚举值) */
|
||||
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
|
||||
|
||||
/** 标题配置 */
|
||||
export interface TitleConfig {
|
||||
ai_auto_select: boolean
|
||||
content: string
|
||||
font_preset: string
|
||||
font_color: string
|
||||
font_size: number
|
||||
position: string
|
||||
}
|
||||
|
||||
/** 字幕配置 */
|
||||
export interface SubtitleConfig {
|
||||
enabled: boolean
|
||||
position: string
|
||||
font: string
|
||||
color: string
|
||||
size: number
|
||||
animation: string
|
||||
}
|
||||
|
||||
/** BGM 配置 */
|
||||
export interface BgmConfig {
|
||||
enabled: boolean
|
||||
music_id: string
|
||||
}
|
||||
|
||||
/** 模板片段 */
|
||||
export interface TemplateSegment {
|
||||
id?: string
|
||||
segment_order: number
|
||||
duration_min: number
|
||||
duration_max: number
|
||||
material_type: string | null
|
||||
}
|
||||
|
||||
/** 剪辑模板 */
|
||||
export interface EditingTemplate {
|
||||
id: string
|
||||
name: string
|
||||
mode: TemplateMode
|
||||
category: string
|
||||
tags: string[]
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
estimated_duration: number
|
||||
segments: TemplateSegment[]
|
||||
watermark_config?: WatermarkConfig
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
pip_config?: PipConfig
|
||||
filter_config?: FilterConfig
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
sticker_config?: StickerConfig
|
||||
cover_config?: CoverConfig
|
||||
is_active?: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 模板分类 */
|
||||
export interface TemplateCategory {
|
||||
id: string
|
||||
name: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
/** 创建/更新模板请求体 */
|
||||
export interface SaveTemplatePayload {
|
||||
name: string
|
||||
mode: TemplateMode
|
||||
category: string
|
||||
tags: string[]
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
estimated_duration: number
|
||||
segments: Omit<TemplateSegment, "id">[]
|
||||
watermark_config?: WatermarkConfig
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
pip_config?: PipConfig
|
||||
filter_config?: FilterConfig
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
sticker_config?: StickerConfig
|
||||
cover_config?: CoverConfig
|
||||
}
|
||||
|
||||
/** 使用模板生成请求体 */
|
||||
export interface GenerateFromTemplatePayload {
|
||||
voiceover_duration: number
|
||||
}
|
||||
|
||||
/** 验证警告详情 */
|
||||
export interface ValidationWarningDetails {
|
||||
field?: string
|
||||
expected?: string | number
|
||||
actual?: string | number
|
||||
suggested?: string | number
|
||||
}
|
||||
|
||||
/** 验证警告 */
|
||||
export interface ValidateWarning {
|
||||
code: string
|
||||
message: string
|
||||
details?: ValidationWarningDetails
|
||||
}
|
||||
|
||||
/** 使用模板生成响应 */
|
||||
export interface GenerateFromTemplateResponse {
|
||||
template: EditingTemplate
|
||||
warnings: ValidateWarning[]
|
||||
}
|
||||
|
||||
/** 列表响应(带分页) */
|
||||
export interface ListTemplatesResponse {
|
||||
items: EditingTemplate[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 分类列表响应 */
|
||||
export interface ListCategoriesResponse {
|
||||
items: TemplateCategory[]
|
||||
}
|
||||
Regular → Executable
+37
-294
@@ -4,232 +4,36 @@
|
||||
* 展示克隆音色列表,卡片网格布局
|
||||
* 支持试听、使用、编辑名称、删除操作
|
||||
*/
|
||||
import React, { useState, useCallback } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Button, Tooltip } from "@/components/ui"
|
||||
import { Popconfirm } from "antd"
|
||||
import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
AudioOutlined,
|
||||
SoundOutlined,
|
||||
CalendarOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import {
|
||||
getVoiceClones,
|
||||
deleteVoiceClone,
|
||||
updateVoiceClone,
|
||||
formatDuration,
|
||||
type VoiceClone as VoiceCloneType,
|
||||
} from "@/api/voice-clone"
|
||||
import { VoiceCloneCard } from "./components/VoiceCloneCard"
|
||||
import { VoiceCloneEmpty, VoiceCloneSkeleton, ToastContainer } from "./components/States"
|
||||
import { EditNameDialog } from "./components/EditNameDialog"
|
||||
import { useVoiceCloneList } from "./hooks/useVoiceCloneList"
|
||||
import "./voice-clone.css"
|
||||
|
||||
/* ── 状态配置 ─────────────────────────────────────────── */
|
||||
|
||||
const STATUS_CONFIG: Record<VoiceCloneType["status"], { label: string; className: string }> = {
|
||||
ready: { label: "就绪", className: "vc-status-pill--ready" },
|
||||
processing: { label: "克隆中", className: "vc-status-pill--processing" },
|
||||
failed: { label: "失败", className: "vc-status-pill--failed" },
|
||||
}
|
||||
|
||||
/* ── Toast 系统 ─────────────────────────────────────────── */
|
||||
|
||||
interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
type: "success" | "error"
|
||||
}
|
||||
|
||||
let toastId = 0
|
||||
|
||||
/* ── 音色卡片组件 ───────────────────────────────────────── */
|
||||
|
||||
interface VoiceCloneCardProps {
|
||||
voice: VoiceCloneType
|
||||
onPlay: (voice: VoiceCloneType) => void
|
||||
onUse: (voice: VoiceCloneType) => void
|
||||
onEdit: (voice: VoiceCloneType) => void
|
||||
onDelete: (voice: VoiceCloneType) => void
|
||||
}
|
||||
|
||||
const VoiceCloneCard: React.FC<VoiceCloneCardProps> = ({
|
||||
voice,
|
||||
onPlay,
|
||||
onUse,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
const statusCfg = STATUS_CONFIG[voice.status]
|
||||
const isProcessing = voice.status === "processing"
|
||||
const createdDate = new Date(voice.created_at).toLocaleDateString("zh-CN")
|
||||
|
||||
return (
|
||||
<div className="vc-card">
|
||||
{/* 右上角操作 */}
|
||||
<div className="vc-card-actions">
|
||||
<Tooltip title="编辑名称">
|
||||
<button type="button" className="vc-card-action-btn" onClick={() => onEdit(voice)}>
|
||||
<EditOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Popconfirm
|
||||
title={`确定删除音色「${voice.name}」吗?`}
|
||||
onConfirm={() => onDelete(voice)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<button type="button" className="vc-card-action-btn vc-card-action-btn--danger">
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* 头部:头像 + 名称 + 状态 */}
|
||||
<div className="vc-card-header">
|
||||
<div className={`vc-card-avatar${isProcessing ? " vc-card-avatar--processing" : ""}`}>
|
||||
<AudioOutlined style={{ fontSize: 22 }} />
|
||||
</div>
|
||||
<div className="vc-card-info">
|
||||
<h4 className="vc-card-name">{voice.name}</h4>
|
||||
<span className={`vc-status-pill ${statusCfg.className}`}>
|
||||
<span className="vc-status-dot" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="vc-card-meta">
|
||||
<div className="vc-card-meta-row">
|
||||
<span className="vc-card-meta-icon">
|
||||
<SoundOutlined />
|
||||
</span>
|
||||
<span>时长:{formatDuration(voice.duration_seconds)}</span>
|
||||
</div>
|
||||
<div className="vc-card-meta-row">
|
||||
<span className="vc-card-meta-icon">
|
||||
<CalendarOutlined />
|
||||
</span>
|
||||
<span>创建于:{createdDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作区 */}
|
||||
<div className="vc-card-footer">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
disabled={isProcessing}
|
||||
onClick={() => onPlay(voice)}
|
||||
>
|
||||
▶ 试听
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
disabled={isProcessing}
|
||||
onClick={() => onUse(voice)}
|
||||
>
|
||||
使用此音色
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 主页面 ─────────────────────────────────────────────── */
|
||||
|
||||
const VoiceClone: React.FC = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
const [editingVoice, setEditingVoice] = useState<VoiceCloneType | null>(null)
|
||||
const [editName, setEditName] = useState("")
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
||||
|
||||
/** 显示 toast */
|
||||
const showToast = useCallback((message: string, type: Toast["type"]) => {
|
||||
const id = ++toastId
|
||||
setToasts((prev) => [...prev, { id, message, type }])
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, 3000)
|
||||
}, [])
|
||||
|
||||
/** 查询克隆音色列表 */
|
||||
const { data: voices = [], isLoading } = useQuery({
|
||||
queryKey: ["voiceClones"],
|
||||
queryFn: () => getVoiceClones(),
|
||||
})
|
||||
|
||||
/** 删除 mutation */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteVoiceClone,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voiceClones"] })
|
||||
showToast("音色已删除", "success")
|
||||
},
|
||||
onError: () => {
|
||||
showToast("删除失败", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 编辑 mutation */
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) => updateVoiceClone(id, { name }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voiceClones"] })
|
||||
setEditingVoice(null)
|
||||
showToast("名称已更新", "success")
|
||||
},
|
||||
onError: () => {
|
||||
showToast("更新失败", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 克隆新音色 — 打开弹窗 */
|
||||
const handleCloneNew = () => {
|
||||
setCloneModalOpen(true)
|
||||
}
|
||||
|
||||
/** 试听 */
|
||||
const handlePlay = (voice: VoiceCloneType) => {
|
||||
if (voice.sample_url) {
|
||||
const audio = new Audio(voice.sample_url)
|
||||
audio.play().catch(() => {
|
||||
showToast("播放失败", "error")
|
||||
})
|
||||
} else {
|
||||
showToast("暂无试听音频", "error")
|
||||
}
|
||||
}
|
||||
|
||||
/** 使用音色 — 跳转到生成页面 */
|
||||
const handleUse = (_voice: VoiceCloneType) => {
|
||||
showToast("已选择音色,跳转到生成页面", "success")
|
||||
}
|
||||
|
||||
/** 打开编辑弹窗 */
|
||||
const handleEdit = (voice: VoiceCloneType) => {
|
||||
setEditingVoice(voice)
|
||||
setEditName(voice.name)
|
||||
}
|
||||
|
||||
/** 确认编辑 */
|
||||
const handleEditConfirm = () => {
|
||||
if (!editingVoice || !editName.trim()) return
|
||||
updateMutation.mutate({ id: editingVoice.id, name: editName.trim() })
|
||||
}
|
||||
|
||||
/** 删除确认 — 使用 Popconfirm */
|
||||
const handleDelete = (voice: VoiceCloneType) => {
|
||||
deleteMutation.mutate(voice.id)
|
||||
}
|
||||
const {
|
||||
voices,
|
||||
isLoading,
|
||||
toasts,
|
||||
editingVoice,
|
||||
editName,
|
||||
setEditName,
|
||||
cloneModalOpen,
|
||||
updateLoading,
|
||||
handleCloneNew,
|
||||
handleCloseCloneModal,
|
||||
handleCloneSuccess,
|
||||
handlePlay,
|
||||
handleUse,
|
||||
handleEdit,
|
||||
handleCloseEdit,
|
||||
handleEditConfirm,
|
||||
handleDelete,
|
||||
} = useVoiceCloneList()
|
||||
|
||||
return (
|
||||
<div className="vc-page">
|
||||
@@ -244,24 +48,7 @@ const VoiceClone: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* 加载状态 — 骨架屏 */}
|
||||
{isLoading && (
|
||||
<div className="vc-grid">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="vc-card">
|
||||
<div className="vc-card-header">
|
||||
<div className="vc-skeleton-avatar" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="vc-skeleton-line vc-skeleton-line--title" />
|
||||
<div className="vc-skeleton-line vc-skeleton-line--short" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="vc-skeleton-line" />
|
||||
<div className="vc-skeleton-line vc-skeleton-line--short" />
|
||||
<div className="vc-skeleton-line vc-skeleton-line--footer" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <VoiceCloneSkeleton />}
|
||||
|
||||
{/* 卡片网格 */}
|
||||
{!isLoading && voices.length > 0 && (
|
||||
@@ -280,71 +67,27 @@ const VoiceClone: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!isLoading && voices.length === 0 && (
|
||||
<div className="vc-empty">
|
||||
<div className="vc-empty-icon">
|
||||
<AudioOutlined style={{ fontSize: 48 }} />
|
||||
</div>
|
||||
<h3 className="vc-empty-title">还没有克隆音色</h3>
|
||||
<p className="vc-empty-desc">上传你的声音,AI将克隆你的专属音色</p>
|
||||
<Button buttonType="primary" onClick={handleCloneNew}>
|
||||
立即克隆
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && voices.length === 0 && <VoiceCloneEmpty onClone={handleCloneNew} />}
|
||||
|
||||
{/* Toast 提示 */}
|
||||
{toasts.length > 0 && (
|
||||
<div className="vc-toast-container">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className={`vc-toast vc-toast--${t.type}`}>
|
||||
{t.type === "success" ? <CheckCircleOutlined /> : <CloseCircleOutlined />} {t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ToastContainer toasts={toasts} />
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
{editingVoice && (
|
||||
<div className="vc-edit-overlay" onClick={() => setEditingVoice(null)}>
|
||||
<div className="vc-edit-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="vc-edit-title">编辑音色名称</h3>
|
||||
<input
|
||||
type="text"
|
||||
className="vc-edit-input"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleEditConfirm()
|
||||
if (e.key === "Escape") setEditingVoice(null)
|
||||
}}
|
||||
autoFocus
|
||||
placeholder="输入音色名称"
|
||||
/>
|
||||
<div className="vc-edit-buttons">
|
||||
<Button buttonType="ghost" onClick={() => setEditingVoice(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={handleEditConfirm}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EditNameDialog
|
||||
name={editName}
|
||||
onNameChange={setEditName}
|
||||
onConfirm={handleEditConfirm}
|
||||
onCancel={handleCloseEdit}
|
||||
loading={updateLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 克隆音色弹窗 */}
|
||||
<CloneModal
|
||||
open={cloneModalOpen}
|
||||
onClose={() => setCloneModalOpen(false)}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voiceClones"] })
|
||||
showToast("音色克隆已提交", "success")
|
||||
}}
|
||||
onClose={handleCloseCloneModal}
|
||||
onSuccess={handleCloneSuccess}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
interface EditNameDialogProps {
|
||||
name: string
|
||||
onNameChange: (name: string) => void
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑音色名称弹窗
|
||||
*/
|
||||
export const EditNameDialog: React.FC<EditNameDialogProps> = ({
|
||||
name,
|
||||
onNameChange,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
loading,
|
||||
}) => {
|
||||
return (
|
||||
<div className="vc-edit-overlay" onClick={onCancel}>
|
||||
<div className="vc-edit-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="vc-edit-title">编辑音色名称</h3>
|
||||
<input
|
||||
type="text"
|
||||
className="vc-edit-input"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onConfirm()
|
||||
if (e.key === "Escape") onCancel()
|
||||
}}
|
||||
autoFocus
|
||||
placeholder="输入音色名称"
|
||||
/>
|
||||
<div className="vc-edit-buttons">
|
||||
<Button buttonType="ghost" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button buttonType="primary" onClick={onConfirm} disabled={loading}>
|
||||
{loading ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from "react"
|
||||
import { AudioOutlined, CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { Toast } from "../types"
|
||||
|
||||
interface VoiceCloneEmptyProps {
|
||||
onClone: () => void
|
||||
}
|
||||
|
||||
/** 空状态 */
|
||||
export const VoiceCloneEmpty: React.FC<VoiceCloneEmptyProps> = ({ onClone }) => (
|
||||
<div className="vc-empty">
|
||||
<div className="vc-empty-icon">
|
||||
<AudioOutlined style={{ fontSize: 48 }} />
|
||||
</div>
|
||||
<h3 className="vc-empty-title">还没有克隆音色</h3>
|
||||
<p className="vc-empty-desc">上传你的声音,AI将克隆你的专属音色</p>
|
||||
<Button buttonType="primary" onClick={onClone}>
|
||||
立即克隆
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
/** 骨架屏加载 */
|
||||
export const VoiceCloneSkeleton: React.FC = () => (
|
||||
<div className="vc-grid">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="vc-card">
|
||||
<div className="vc-card-header">
|
||||
<div className="vc-skeleton-avatar" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="vc-skeleton-line vc-skeleton-line--title" />
|
||||
<div className="vc-skeleton-line vc-skeleton-line--short" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="vc-skeleton-line" />
|
||||
<div className="vc-skeleton-line vc-skeleton-line--short" />
|
||||
<div className="vc-skeleton-line vc-skeleton-line--footer" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface ToastContainerProps {
|
||||
toasts: Toast[]
|
||||
}
|
||||
|
||||
/** Toast 提示容器 */
|
||||
export const ToastContainer: React.FC<ToastContainerProps> = ({ toasts }) => {
|
||||
if (toasts.length === 0) return null
|
||||
return (
|
||||
<div className="vc-toast-container">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className={`vc-toast vc-toast--${t.type}`}>
|
||||
{t.type === "success" ? <CheckCircleOutlined /> : <CloseCircleOutlined />} {t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from "react"
|
||||
import { Tooltip } from "@/components/ui"
|
||||
import { Popconfirm } from "antd"
|
||||
import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
AudioOutlined,
|
||||
SoundOutlined,
|
||||
CalendarOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import { formatDuration, type VoiceClone } from "@/api/voice-clone"
|
||||
import { STATUS_CONFIG } from "../types"
|
||||
|
||||
interface VoiceCloneCardProps {
|
||||
voice: VoiceClone
|
||||
onPlay: (voice: VoiceClone) => void
|
||||
onUse: (voice: VoiceClone) => void
|
||||
onEdit: (voice: VoiceClone) => void
|
||||
onDelete: (voice: VoiceClone) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 音色克隆卡片
|
||||
*/
|
||||
export const VoiceCloneCard: React.FC<VoiceCloneCardProps> = ({
|
||||
voice,
|
||||
onPlay,
|
||||
onUse,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
const statusCfg = STATUS_CONFIG[voice.status]
|
||||
const isProcessing = voice.status === "processing"
|
||||
const createdDate = new Date(voice.created_at).toLocaleDateString("zh-CN")
|
||||
|
||||
return (
|
||||
<div className="vc-card">
|
||||
{/* 右上角操作 */}
|
||||
<div className="vc-card-actions">
|
||||
<Tooltip title="编辑名称">
|
||||
<button type="button" className="vc-card-action-btn" onClick={() => onEdit(voice)}>
|
||||
<EditOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Popconfirm
|
||||
title={`确定删除音色「${voice.name}」吗?`}
|
||||
onConfirm={() => onDelete(voice)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<button type="button" className="vc-card-action-btn vc-card-action-btn--danger">
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* 头部:头像 + 名称 + 状态 */}
|
||||
<div className="vc-card-header">
|
||||
<div className={`vc-card-avatar${isProcessing ? " vc-card-avatar--processing" : ""}`}>
|
||||
<AudioOutlined style={{ fontSize: 22 }} />
|
||||
</div>
|
||||
<div className="vc-card-info">
|
||||
<h4 className="vc-card-name">{voice.name}</h4>
|
||||
<span className={`vc-status-pill ${statusCfg.className}`}>
|
||||
<span className="vc-status-dot" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="vc-card-meta">
|
||||
<div className="vc-card-meta-row">
|
||||
<span className="vc-card-meta-icon">
|
||||
<SoundOutlined />
|
||||
</span>
|
||||
<span>时长:{formatDuration(voice.duration_seconds)}</span>
|
||||
</div>
|
||||
<div className="vc-card-meta-row">
|
||||
<span className="vc-card-meta-icon">
|
||||
<CalendarOutlined />
|
||||
</span>
|
||||
<span>创建于:{createdDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作区 */}
|
||||
<div className="vc-card-footer">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
disabled={isProcessing}
|
||||
onClick={() => onPlay(voice)}
|
||||
>
|
||||
▶ 试听
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
disabled={isProcessing}
|
||||
onClick={() => onUse(voice)}
|
||||
>
|
||||
使用此音色
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useState, useCallback, useRef } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
getVoiceClones,
|
||||
deleteVoiceClone,
|
||||
updateVoiceClone,
|
||||
type VoiceClone,
|
||||
} from "@/api/voice-clone"
|
||||
import type { Toast } from "../types"
|
||||
|
||||
let toastId = 0
|
||||
|
||||
/**
|
||||
* 音色克隆列表业务 Hook
|
||||
* 封装列表查询、删除、编辑、试听、Toast 等所有业务逻辑
|
||||
*/
|
||||
export const useVoiceCloneList = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
const [editingVoice, setEditingVoice] = useState<VoiceClone | null>(null)
|
||||
const [editName, setEditName] = useState("")
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
/** 显示 toast */
|
||||
const showToast = useCallback((message: string, type: Toast["type"]) => {
|
||||
const id = ++toastId
|
||||
setToasts((prev) => [...prev, { id, message, type }])
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, 3000)
|
||||
}, [])
|
||||
|
||||
/** 查询克隆音色列表 */
|
||||
const { data: voices = [], isLoading } = useQuery({
|
||||
queryKey: ["voiceClones"],
|
||||
queryFn: () => getVoiceClones(),
|
||||
})
|
||||
|
||||
/** 删除 mutation */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteVoiceClone,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voiceClones"] })
|
||||
showToast("音色已删除", "success")
|
||||
},
|
||||
onError: () => {
|
||||
showToast("删除失败", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 编辑 mutation */
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) => updateVoiceClone(id, { name }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voiceClones"] })
|
||||
setEditingVoice(null)
|
||||
showToast("名称已更新", "success")
|
||||
},
|
||||
onError: () => {
|
||||
showToast("更新失败", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 克隆新音色 — 打开弹窗 */
|
||||
const handleCloneNew = useCallback(() => {
|
||||
setCloneModalOpen(true)
|
||||
}, [])
|
||||
|
||||
/** 关闭克隆弹窗 */
|
||||
const handleCloseCloneModal = useCallback(() => {
|
||||
setCloneModalOpen(false)
|
||||
}, [])
|
||||
|
||||
/** 克隆成功回调 */
|
||||
const handleCloneSuccess = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voiceClones"] })
|
||||
showToast("音色克隆已提交", "success")
|
||||
}, [queryClient, showToast])
|
||||
|
||||
/** 试听 */
|
||||
const handlePlay = useCallback(
|
||||
(voice: VoiceClone) => {
|
||||
if (voice.sample_url) {
|
||||
// 停止之前的播放
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
const audio = new Audio(voice.sample_url)
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {
|
||||
showToast("播放失败", "error")
|
||||
})
|
||||
} else {
|
||||
showToast("暂无试听音频", "error")
|
||||
}
|
||||
},
|
||||
[showToast],
|
||||
)
|
||||
|
||||
/** 使用音色 */
|
||||
const handleUse = useCallback(() => {
|
||||
showToast("已选择音色,跳转到生成页面", "success")
|
||||
}, [showToast])
|
||||
|
||||
/** 打开编辑弹窗 */
|
||||
const handleEdit = useCallback((voice: VoiceClone) => {
|
||||
setEditingVoice(voice)
|
||||
setEditName(voice.name)
|
||||
}, [])
|
||||
|
||||
/** 关闭编辑弹窗 */
|
||||
const handleCloseEdit = useCallback(() => {
|
||||
setEditingVoice(null)
|
||||
}, [])
|
||||
|
||||
/** 确认编辑 */
|
||||
const handleEditConfirm = useCallback(() => {
|
||||
if (!editingVoice || !editName.trim()) return
|
||||
updateMutation.mutate({ id: editingVoice.id, name: editName.trim() })
|
||||
}, [editingVoice, editName, updateMutation])
|
||||
|
||||
/** 删除确认 */
|
||||
const handleDelete = useCallback(
|
||||
(voice: VoiceClone) => {
|
||||
deleteMutation.mutate(voice.id)
|
||||
},
|
||||
[deleteMutation],
|
||||
)
|
||||
|
||||
return {
|
||||
// 数据
|
||||
voices,
|
||||
isLoading,
|
||||
// 状态
|
||||
toasts,
|
||||
editingVoice,
|
||||
editName,
|
||||
setEditName,
|
||||
cloneModalOpen,
|
||||
// 加载状态
|
||||
deleteLoading: deleteMutation.isPending,
|
||||
updateLoading: updateMutation.isPending,
|
||||
// 操作
|
||||
showToast,
|
||||
handleCloneNew,
|
||||
handleCloseCloneModal,
|
||||
handleCloneSuccess,
|
||||
handlePlay,
|
||||
handleUse,
|
||||
handleEdit,
|
||||
handleCloseEdit,
|
||||
handleEditConfirm,
|
||||
handleDelete,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
/** 状态配置 */
|
||||
export const STATUS_CONFIG: Record<VoiceClone["status"], { label: string; className: string }> = {
|
||||
ready: { label: "就绪", className: "vc-status-pill--ready" },
|
||||
processing: { label: "克隆中", className: "vc-status-pill--processing" },
|
||||
failed: { label: "失败", className: "vc-status-pill--failed" },
|
||||
}
|
||||
|
||||
/** Toast 类型 */
|
||||
export interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
type: "success" | "error"
|
||||
}
|
||||
Reference in New Issue
Block a user