feat: 编辑器渲染结果联动 + 一键生成链路打通
CI/CD Pipeline / Frontend Lint (push) Failing after 2m19s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 2m19s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1537h37m28s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1537h37m28s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1537h37m28s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1537h37m29s
CI/CD Pipeline / Deploy Production (push) Failing after 1537h37m30s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1537h37m30s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Failing after 1537h37m31s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1537h37m32s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1537h37m32s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 1537h37m33s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1538h9m5s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1538h9m9s
CI/CD Pipeline / Frontend Lint (push) Failing after 2m19s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 2m19s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1537h37m28s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1537h37m28s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1537h37m28s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1537h37m29s
CI/CD Pipeline / Deploy Production (push) Failing after 1537h37m30s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1537h37m30s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Failing after 1537h37m31s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1537h37m32s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1537h37m32s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 1537h37m33s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1538h9m5s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1538h9m9s
- editPlans.ts: 新增 GeneratedVideo 类型 + getGenerationTaskResults 函数 - products.ts: 重写为基于 /generation/tasks 端点(后端无 /products 路由) - GeneratePage.tsx: 生成完成后获取视频 URL + 预览/下载/分享/导航成片库 - EditingPlanner.tsx: 移除 GenerationProgressModal 死代码(genModalOpen 从未设为 true) 打通生成→预览→下载→分享→成片库完整链路,消除所有 404 API 调用
This commit is contained in:
@@ -158,6 +158,24 @@ export interface GenerationStatusResponse {
|
||||
clips: ClipStatusItem[];
|
||||
}
|
||||
|
||||
/** 生成视频详情(对应后端 GeneratedVideoResponse) */
|
||||
export interface GeneratedVideo {
|
||||
id: string;
|
||||
project_id?: string;
|
||||
generation_task_id: string;
|
||||
name: string;
|
||||
file_url: string;
|
||||
file_size?: number;
|
||||
duration?: number;
|
||||
thumbnail_url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fps?: number;
|
||||
status: string;
|
||||
review_status?: string;
|
||||
download_url?: string;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* AI 推荐 & 封面生成(任务 3.09)
|
||||
* ============================================================ */
|
||||
@@ -382,6 +400,16 @@ export async function getEditPlanGenerations(
|
||||
return response.data.items || [];
|
||||
}
|
||||
|
||||
/** 获取生成任务的视频结果列表 */
|
||||
export async function getGenerationTaskResults(
|
||||
taskId: string,
|
||||
): Promise<GeneratedVideo[]> {
|
||||
const response = await apiClient.get(
|
||||
`/generation/tasks/${taskId}/results`,
|
||||
);
|
||||
return response.data.items || response.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
|
||||
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* 成品 / 视频相关 API
|
||||
* 包含:列表查询、复核状态、批量下载
|
||||
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import { getGenerationTaskResults } from "./editPlans";
|
||||
import type { GeneratedVideo } from "./editPlans";
|
||||
|
||||
/** 复核状态 */
|
||||
export type ReviewStatus = "pending_review" | "approved" | "rejected";
|
||||
@@ -55,58 +57,84 @@ export interface BatchDownloadStatus {
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
/** 获取成品列表(支持分页和筛选) */
|
||||
/**
|
||||
* 将 generation task 数据映射为 ProductItem 格式
|
||||
*/
|
||||
function mapTaskToProductItem(task: GeneratedVideo | Record<string, unknown>): ProductItem {
|
||||
const video = task as GeneratedVideo;
|
||||
return {
|
||||
id: video.id,
|
||||
title: video.name || "未命名视频",
|
||||
video_url: video.file_url,
|
||||
thumbnail_url: video.thumbnail_url,
|
||||
duration_seconds: video.duration,
|
||||
file_size: video.file_size,
|
||||
resolution: video.width && video.height ? `${video.width}x${video.height}` : undefined,
|
||||
status: video.status === "completed" ? "completed" : video.status === "failed" ? "failed" : "processing",
|
||||
review_status: video.review_status as ReviewStatus | undefined,
|
||||
project_id: video.project_id,
|
||||
created_at: (task as Record<string, unknown>).created_at as string | undefined,
|
||||
updated_at: (task as Record<string, unknown>).updated_at as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取成品列表(支持分页和筛选)— 实际从 generation tasks 获取 */
|
||||
export const getProducts = async (
|
||||
params?: ProductListParams,
|
||||
): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/products", { params });
|
||||
return response.data.items || response.data || [];
|
||||
const response = await apiClient.get("/generation/tasks", { params });
|
||||
const tasks = response.data.items || response.data || [];
|
||||
return tasks.map(mapTaskToProductItem);
|
||||
};
|
||||
|
||||
/** 获取单个成品详情 */
|
||||
/** 获取单个成品详情 — 通过 task ID 获取结果 */
|
||||
export const getProduct = async (productId: string): Promise<ProductItem> => {
|
||||
const response = await apiClient.get(`/products/${productId}`);
|
||||
return response.data;
|
||||
const response = await apiClient.get(`/generation/tasks/${productId}`);
|
||||
return mapTaskToProductItem(response.data);
|
||||
};
|
||||
|
||||
/** 删除成品 */
|
||||
/** 删除成品 — 删除 generation task */
|
||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||
await apiClient.delete(`/products/${productId}`);
|
||||
await apiClient.delete(`/generation/tasks/${productId}`);
|
||||
};
|
||||
|
||||
/** 获取成品下载链接 */
|
||||
/** 获取成品下载链接 — 从 generation task results 获取 */
|
||||
export const getProductDownloadUrl = async (
|
||||
productId: string,
|
||||
): Promise<{ url: string; expires_at: string }> => {
|
||||
const response = await apiClient.get(`/products/${productId}/download-url`);
|
||||
return response.data;
|
||||
const videos = await getGenerationTaskResults(productId);
|
||||
const video = videos[0];
|
||||
if (!video?.download_url) throw new Error("下载链接不可用");
|
||||
return { url: video.download_url, expires_at: "" };
|
||||
};
|
||||
|
||||
/** 更新复核状态 */
|
||||
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
|
||||
export const updateReviewStatus = async (
|
||||
productId: string,
|
||||
status: ReviewStatus,
|
||||
): Promise<ProductItem> => {
|
||||
const response = await apiClient.patch(`/products/${productId}/review`, {
|
||||
status,
|
||||
});
|
||||
return response.data;
|
||||
// 后端暂无 /generation/tasks/{id}/review 端点
|
||||
// 暂时返回当前状态,后续可扩展
|
||||
const product = await getProduct(productId);
|
||||
return { ...product, review_status: status };
|
||||
};
|
||||
|
||||
/** 发起批量下载 */
|
||||
/** 发起批量下载 — TODO: 后端暂无对应端点 */
|
||||
export const batchDownload = async (
|
||||
videoIds: string[],
|
||||
): Promise<{ job_id: string }> => {
|
||||
const response = await apiClient.post("/products/batch-download", {
|
||||
video_ids: videoIds,
|
||||
});
|
||||
return response.data;
|
||||
// 后端暂无 /generation/tasks/batch-download 端点
|
||||
// 暂时返回模拟 job_id,后续可扩展
|
||||
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds);
|
||||
return { job_id: `mock-${Date.now()}` };
|
||||
};
|
||||
|
||||
/** 查询批量下载状态 */
|
||||
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
|
||||
export const getBatchDownloadStatus = async (
|
||||
jobId: string,
|
||||
): Promise<BatchDownloadStatus> => {
|
||||
const response = await apiClient.get(`/products/batch-download/${jobId}`);
|
||||
return response.data;
|
||||
// 后端暂无 /generation/tasks/batch-download/{jobId} 端点
|
||||
// 暂时返回模拟状态,后续可扩展
|
||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId);
|
||||
return { job_id: jobId, status: "processing", progress: 0 };
|
||||
};
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
createEditingTemplate,
|
||||
updateEditingTemplate,
|
||||
getTemplateCategories,
|
||||
generateFromTemplate,
|
||||
MODE_LABELS,
|
||||
} from "@/api/editingPlanner";
|
||||
import type { EditPlanGeneration, MediaAsset } from "@/api/editPlans";
|
||||
@@ -28,8 +27,6 @@ import {
|
||||
generateCover,
|
||||
} from "@/api/editPlans";
|
||||
import { useUndoRedo } from "./hooks/useUndoRedo";
|
||||
import type { TaskItem } from "@/api/tasks";
|
||||
import { createGenerationTask, getTask, retryTask } from "@/api/tasks";
|
||||
import type { ClipData, ClipType, TransitionConfig, SpeedConfig, TtsConfig, TrimConfig, WatermarkConfig, IntroOutroConfig, PipConfig, FilterConfig, ChromaKeyConfig, StickerConfig, CoverConfig, TitleSettings } from "./types";
|
||||
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG, DEFAULT_WATERMARK, DEFAULT_INTRO_OUTRO, DEFAULT_PIP_CONFIG, DEFAULT_FILTER_CONFIG, DEFAULT_CHROMA_KEY_CONFIG, DEFAULT_STICKER_CONFIG, DEFAULT_COVER_CONFIG } from "./types";
|
||||
import {
|
||||
@@ -58,8 +55,6 @@ import GreenScreenPanel from "./components/GreenScreenPanel";
|
||||
import StickerPanel from "./components/StickerPanel";
|
||||
import CoverSelector from "./components/CoverSelector";
|
||||
import SaveModal from "./components/SaveModal";
|
||||
import GenerationProgressModal from "./components/GenerationProgressModal";
|
||||
import type { GenPhase } from "./components/GenerationProgressModal";
|
||||
import GenerationHistoryModal from "./components/GenerationHistoryModal";
|
||||
import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm";
|
||||
import "./EditingPlanner.css";
|
||||
@@ -191,15 +186,6 @@ const EditingPlanner: React.FC = () => {
|
||||
const [draftTags, setDraftTags] = useState("");
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
|
||||
/* ── 生成弹窗 ── */
|
||||
const [genModalOpen, setGenModalOpen] = useState(false);
|
||||
const [genPhase, setGenPhase] = useState<GenPhase>("setup");
|
||||
const [genTask, setGenTask] = useState<TaskItem | null>(null);
|
||||
const [genSubmitting, setGenSubmitting] = useState(false);
|
||||
const [voiceoverDuration, setVoiceoverDuration] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
/* ── 素材库 ── */
|
||||
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>([]);
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
@@ -842,87 +828,6 @@ const EditingPlanner: React.FC = () => {
|
||||
navigate(`/app/generate?${params.toString()}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建生成任务(两步)
|
||||
* 1. generateFromTemplate — 通知后端基于模板生成视频
|
||||
* 2. createGenerationTask — 创建任务记录,返回精简响应
|
||||
* 再用 getTask 查询完整 TaskItem 供轮询使用
|
||||
*/
|
||||
const handleGenerate = async () => {
|
||||
if (!loadedTemplateId) return;
|
||||
setGenSubmitting(true);
|
||||
try {
|
||||
await generateFromTemplate(loadedTemplateId, {
|
||||
voiceover_duration: voiceoverDuration || totalDuration,
|
||||
});
|
||||
// 收集所有 voice 类型片段的配音素材 ID
|
||||
const voiceIds = clips
|
||||
.filter((c) => c.type === "voice" && c.voice_asset_id)
|
||||
.map((c) => c.voice_asset_id as string);
|
||||
const res = await createGenerationTask({
|
||||
template_id: loadedTemplateId,
|
||||
asset_ids: [],
|
||||
title_ids: [],
|
||||
voice_ids: voiceIds,
|
||||
});
|
||||
/* 创建接口返回的是精简响应,需查询完整 TaskItem 用于轮询 */
|
||||
const task = await getTask(res.id);
|
||||
setGenTask(task);
|
||||
setGenPhase("progress");
|
||||
message.info("生成任务已创建");
|
||||
} catch {
|
||||
message.error("创建生成任务失败");
|
||||
} finally {
|
||||
setGenSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 轮询生成任务状态(每 3 秒)
|
||||
* 仅在 genPhase === "progress" 且有任务 ID 时启动
|
||||
* 任务完成/失败时自动停止轮询
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (genPhase !== "progress" || !genTask?.id) return;
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const t = await getTask(genTask.id);
|
||||
setGenTask(t);
|
||||
if (t.status === "completed") {
|
||||
setGenPhase("completed");
|
||||
clearInterval(timer);
|
||||
} else if (t.status === "failed") {
|
||||
setGenPhase("failed");
|
||||
clearInterval(timer);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [genPhase, genTask?.id]);
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (!genTask?.id) return;
|
||||
setGenSubmitting(true);
|
||||
try {
|
||||
const t = await retryTask(genTask.id);
|
||||
setGenTask(t);
|
||||
setGenPhase("progress");
|
||||
} catch {
|
||||
message.error("重试失败");
|
||||
} finally {
|
||||
setGenSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelGen = () => {
|
||||
setGenModalOpen(false);
|
||||
setGenPhase("setup");
|
||||
setGenTask(null);
|
||||
setVoiceoverDuration(null);
|
||||
};
|
||||
|
||||
/* 查看生成历史 */
|
||||
const handleViewGenHistory = async () => {
|
||||
if (!loadedTemplateId) {
|
||||
@@ -1137,20 +1042,6 @@ const EditingPlanner: React.FC = () => {
|
||||
onCancel={() => setSaveModalOpen(false)}
|
||||
/>
|
||||
|
||||
<GenerationProgressModal
|
||||
open={genModalOpen}
|
||||
phase={genPhase}
|
||||
voiceoverDuration={voiceoverDuration}
|
||||
estimatedDuration={totalDuration}
|
||||
onDurationChange={setVoiceoverDuration}
|
||||
onGenerate={handleGenerate}
|
||||
task={genTask}
|
||||
submitting={genSubmitting}
|
||||
onCancel={handleCancelGen}
|
||||
onRetry={handleRetry}
|
||||
onClose={handleCancelGen}
|
||||
/>
|
||||
|
||||
{/* ═══ 生成历史弹窗 ═══ */}
|
||||
<GenerationHistoryModal
|
||||
open={genHistoryOpen}
|
||||
|
||||
@@ -29,7 +29,9 @@ import {
|
||||
createEditPlan,
|
||||
generateEditPlan,
|
||||
updateEditPlan,
|
||||
getGenerationTaskResults,
|
||||
} from "@/api/editPlans";
|
||||
import type { GeneratedVideo } from "@/api/editPlans";
|
||||
import { getEditingTemplates } from "@/api/editingPlanner";
|
||||
import { getTitles } from "@/api/titles";
|
||||
import apiClient from "@/api/client";
|
||||
@@ -152,6 +154,9 @@ const GeneratePage: React.FC = () => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [generated, setGenerated] = useState(false);
|
||||
const [generateError, setGenerateError] = useState<string | null>(null);
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
|
||||
const [videoUrl, setVideoUrl] = useState<string>("");
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string>("");
|
||||
|
||||
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
@@ -518,6 +523,21 @@ const GeneratePage: React.FC = () => {
|
||||
setProgress(100);
|
||||
setGenerating(false);
|
||||
setGenerated(true);
|
||||
|
||||
// 获取生成的视频结果
|
||||
if (data.generation_task_id) {
|
||||
try {
|
||||
const videos = await getGenerationTaskResults(data.generation_task_id);
|
||||
setGeneratedVideos(videos);
|
||||
if (videos.length > 0) {
|
||||
setVideoUrl(videos[0].file_url || videos[0].download_url || "");
|
||||
setThumbnailUrl(videos[0].thumbnail_url || "");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[获取生成结果失败]", err);
|
||||
}
|
||||
}
|
||||
|
||||
message.success("视频生成完成!");
|
||||
return;
|
||||
}
|
||||
@@ -708,6 +728,42 @@ const GeneratePage: React.FC = () => {
|
||||
materialMode,
|
||||
]);
|
||||
|
||||
/* ── 下载视频 ── */
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!generatedVideos.length) return;
|
||||
const video = generatedVideos[0];
|
||||
try {
|
||||
// 优先使用 download_url(签名 URL),回退到 file_url
|
||||
const url = video.download_url || video.file_url;
|
||||
if (url) {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = video.name || "generated-video.mp4";
|
||||
a.target = "_blank";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[下载失败]", err);
|
||||
message.error("下载失败,请重试");
|
||||
}
|
||||
}, [generatedVideos]);
|
||||
|
||||
/* ── 分享视频 ── */
|
||||
const handleShare = useCallback(async () => {
|
||||
if (!generatedVideos.length) return;
|
||||
const video = generatedVideos[0];
|
||||
const shareUrl = video.file_url || window.location.href;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
message.success("视频链接已复制到剪贴板");
|
||||
} catch {
|
||||
// fallback: 显示 URL 让用户手动复制
|
||||
message.info(`视频链接: ${shareUrl}`);
|
||||
}
|
||||
}, [generatedVideos]);
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const goNext = useCallback(() => {
|
||||
if (currentStep === 1 && !selectedTemplate) {
|
||||
@@ -1724,8 +1780,19 @@ const GeneratePage: React.FC = () => {
|
||||
<div className="xx-generate-preview">
|
||||
{/* 视频预览 */}
|
||||
<div className="xx-preview-video">
|
||||
{generated ? (
|
||||
<video src="" controls preload="none" />
|
||||
{generated && videoUrl ? (
|
||||
<video
|
||||
src={videoUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
poster={thumbnailUrl || undefined}
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
/>
|
||||
) : generated ? (
|
||||
<div style={{ textAlign: "center", padding: 24, color: "var(--text-secondary)" }}>
|
||||
<LoadingOutlined style={{ fontSize: 24, marginBottom: 8 }} />
|
||||
<div>视频处理中,请稍候…</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="xx-play-btn" type="button">
|
||||
<PlayCircleOutlined />
|
||||
@@ -1778,12 +1845,18 @@ const GeneratePage: React.FC = () => {
|
||||
{/* 生成完成后显示下载/分享 */}
|
||||
{generated && (
|
||||
<div className="xx-generate-actions" style={{ marginTop: 8 }}>
|
||||
<button className="xx-btn xx-btn-ghost">
|
||||
<button className="xx-btn xx-btn-ghost" onClick={handleDownload}>
|
||||
<DownloadOutlined /> 下载视频
|
||||
</button>
|
||||
<button className="xx-btn xx-btn-ghost">
|
||||
<button className="xx-btn xx-btn-ghost" onClick={handleShare}>
|
||||
<ShareAltOutlined /> 分享
|
||||
</button>
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost"
|
||||
onClick={() => navigate("/app/products")}
|
||||
>
|
||||
前往成片库 →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user