Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb50442296 | |||
| 5bde975ea6 |
+22
-99
File diff suppressed because one or more lines are too long
@@ -1,290 +0,0 @@
|
||||
/**
|
||||
* CloneVoiceModal — 音色克隆弹窗
|
||||
*
|
||||
* 三步骤状态:input → uploading → success
|
||||
* 支持上传音频文件或直接录制(mock,无真实录音)
|
||||
*
|
||||
* V21 Design System — 零 antd 直接导入
|
||||
*/
|
||||
import React, { useState, useCallback, useRef } from "react";
|
||||
import { Modal, Button } from "@/components/ui";
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
|
||||
import type { VoiceClone } from "@/api/voiceClone";
|
||||
import { uploadAsset } from "@/api/assets";
|
||||
import "./clone-voice-modal.css";
|
||||
|
||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
||||
|
||||
type ModalStep = "input" | "uploading" | "success";
|
||||
|
||||
export interface CloneVoiceModalProps {
|
||||
/** 弹窗是否可见 */
|
||||
open: boolean;
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void;
|
||||
/** 克隆成功回调(返回新创建的音色) */
|
||||
onSuccess?: (voice: VoiceClone) => void;
|
||||
}
|
||||
|
||||
/* ── 默认音色名称计数器 ─────────────────────────────────── */
|
||||
|
||||
let cloneCounter = 1;
|
||||
|
||||
const getNextDefaultName = (): string => {
|
||||
const name = `我的声音 ${cloneCounter}`;
|
||||
cloneCounter += 1;
|
||||
return name;
|
||||
};
|
||||
|
||||
/* ── 组件 ───────────────────────────────────────────────── */
|
||||
|
||||
const CloneVoiceModal: React.FC<CloneVoiceModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [step, setStep] = useState<ModalStep>("input");
|
||||
const [voiceName, setVoiceName] = useState("");
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setStep("input");
|
||||
setVoiceName("");
|
||||
setSelectedFile(null);
|
||||
setIsRecording(false);
|
||||
setDragActive(false);
|
||||
}, []);
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const handleClose = useCallback(() => {
|
||||
resetState();
|
||||
onClose();
|
||||
}, [resetState, onClose]);
|
||||
|
||||
/** 上传区域点击 */
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
/** 文件选择 */
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
// 清除之前的录制状态
|
||||
setIsRecording(false);
|
||||
}
|
||||
// 清空 input 以允许重复选择同一文件
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
/** 拖拽事件 */
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||
if (ext === "mp3" || ext === "wav") {
|
||||
setSelectedFile(file);
|
||||
setIsRecording(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** 录制按钮(mock) */
|
||||
const handleRecord = () => {
|
||||
setIsRecording((prev) => !prev);
|
||||
if (!isRecording) {
|
||||
// 开始录制 — 清除已选文件
|
||||
setSelectedFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
/** 开始克隆 */
|
||||
const handleStartClone = async () => {
|
||||
const name = voiceName.trim() || getNextDefaultName();
|
||||
setStep("uploading");
|
||||
|
||||
try {
|
||||
// 先上传音频文件获取真实 URL
|
||||
let audioUrl: string;
|
||||
if (selectedFile) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", selectedFile);
|
||||
formData.append("kind", "voice");
|
||||
const uploadResult = await uploadAsset(formData);
|
||||
audioUrl = uploadResult.url;
|
||||
} else {
|
||||
// 录制功能暂未实现,提示用户上传
|
||||
setStep("input");
|
||||
return;
|
||||
}
|
||||
|
||||
// 提交克隆请求
|
||||
const result = await createVoiceClone({
|
||||
name,
|
||||
audio_url: audioUrl,
|
||||
});
|
||||
|
||||
setStep("success");
|
||||
|
||||
// 2秒后自动关闭
|
||||
setTimeout(() => {
|
||||
onSuccess?.(toVoiceClone(result));
|
||||
handleClose();
|
||||
}, 2000);
|
||||
} catch {
|
||||
setStep("input");
|
||||
}
|
||||
};
|
||||
|
||||
/** 弹窗打开时初始化默认名称 */
|
||||
const handleAfterOpenChange = (visible: boolean) => {
|
||||
if (visible) {
|
||||
setVoiceName(getNextDefaultName());
|
||||
}
|
||||
};
|
||||
|
||||
const canStart = selectedFile || isRecording;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
title="🎤 克隆新音色"
|
||||
width={520}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
afterOpenChange={handleAfterOpenChange}
|
||||
>
|
||||
{/* ── 输入步骤 ──────────────────────────────────── */}
|
||||
{step === "input" && (
|
||||
<div className="cvm-body">
|
||||
{/* 音色名称 */}
|
||||
<div className="cvm-field">
|
||||
<label className="cvm-label">音色名称</label>
|
||||
<input
|
||||
type="text"
|
||||
className="cvm-input"
|
||||
value={voiceName}
|
||||
onChange={(e) => setVoiceName(e.target.value)}
|
||||
placeholder="输入音色名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 上传区域 */}
|
||||
<div className="cvm-field">
|
||||
<label className="cvm-label">上传音频</label>
|
||||
<div
|
||||
className={`cvm-upload-zone${dragActive ? " cvm-upload-zone--active" : ""}`}
|
||||
onClick={handleUploadClick}
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="cvm-upload-icon">🎵</div>
|
||||
<p className="cvm-upload-title">
|
||||
{selectedFile ? selectedFile.name : "拖拽音频文件到此处"}
|
||||
</p>
|
||||
<p className="cvm-upload-hint">支持 MP3、WAV 格式</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".mp3,.wav,audio/mpeg,audio/wav"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 或分隔 */}
|
||||
<div className="cvm-divider">
|
||||
<div className="cvm-divider-line" />
|
||||
<span className="cvm-divider-text">或</span>
|
||||
<div className="cvm-divider-line" />
|
||||
</div>
|
||||
|
||||
{/* 录制区域 */}
|
||||
<div className="cvm-field">
|
||||
<label className="cvm-label">直接录制</label>
|
||||
<div className="cvm-record-area">
|
||||
<p className="cvm-record-hint">
|
||||
{isRecording
|
||||
? "录制中…再次点击停止"
|
||||
: "点击按钮开始录制你的声音"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={`cvm-record-btn${isRecording ? " cvm-record-btn--recording" : ""}`}
|
||||
onClick={handleRecord}
|
||||
>
|
||||
🎙️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示 */}
|
||||
<div className="cvm-tip">
|
||||
<span className="cvm-tip-icon">💡</span>
|
||||
<span>
|
||||
建议上传10秒~3分钟的清晰语音,环境安静、语速均匀效果最佳
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="cvm-footer">
|
||||
<Button buttonType="ghost" onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
disabled={!canStart}
|
||||
onClick={handleStartClone}
|
||||
>
|
||||
🎤 开始克隆
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 上传中步骤 ────────────────────────────────── */}
|
||||
{step === "uploading" && (
|
||||
<div className="cvm-uploading">
|
||||
<div className="cvm-uploading-spinner" />
|
||||
<p className="cvm-uploading-text">正在克隆你的音色…</p>
|
||||
<p className="cvm-uploading-sub">AI 正在分析你的声音特征,请稍候</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 成功步骤 ──────────────────────────────────── */}
|
||||
{step === "success" && (
|
||||
<div className="cvm-success">
|
||||
<div className="cvm-success-icon">✅</div>
|
||||
<h3 className="cvm-success-title">克隆已提交</h3>
|
||||
<p className="cvm-success-desc">
|
||||
音色正在生成中,完成后将出现在列表中
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloneVoiceModal;
|
||||
@@ -1,325 +0,0 @@
|
||||
/**
|
||||
* CloneVoiceModal — V21 Design System
|
||||
*
|
||||
* 音色克隆弹窗样式
|
||||
* 三步骤状态:input → uploading → success
|
||||
*/
|
||||
|
||||
/* ── 弹窗内容区 ─────────────────────────────────────────── */
|
||||
|
||||
.cvm-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* ── 表单区 ─────────────────────────────────────────────── */
|
||||
|
||||
.cvm-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cvm-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #475467);
|
||||
}
|
||||
|
||||
.cvm-input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--line, #e4e7ec);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface, #fff);
|
||||
color: var(--text-primary, #101828);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cvm-input:focus {
|
||||
border-color: var(--primary, #6366f1);
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
}
|
||||
|
||||
.cvm-input::placeholder {
|
||||
color: var(--muted, #98a2b3);
|
||||
}
|
||||
|
||||
/* ── 上传区域 ───────────────────────────────────────────── */
|
||||
|
||||
.cvm-upload-zone {
|
||||
border: 2px dashed var(--line, #e4e7ec);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 28px 20px;
|
||||
text-align: center;
|
||||
background: var(--bg-subtle, #f8fafc);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
|
||||
.cvm-upload-zone:hover {
|
||||
border-color: var(--primary, #6366f1);
|
||||
background: color-mix(in srgb, var(--primary-color) 4%, transparent);
|
||||
}
|
||||
|
||||
.cvm-upload-zone.cvm-upload-zone--active {
|
||||
border-color: var(--primary, #6366f1);
|
||||
background: color-mix(in srgb, var(--primary-color) 6%, transparent);
|
||||
}
|
||||
|
||||
.cvm-upload-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cvm-upload-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #101828);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.cvm-upload-hint {
|
||||
font-size: 13px;
|
||||
color: var(--muted, #98a2b3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 或分隔线 ───────────────────────────────────────────── */
|
||||
|
||||
.cvm-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.cvm-divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--line, #e4e7ec);
|
||||
}
|
||||
|
||||
.cvm-divider-text {
|
||||
font-size: 13px;
|
||||
color: var(--muted, #98a2b3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── 录制区域 ───────────────────────────────────────────── */
|
||||
|
||||
.cvm-record-area {
|
||||
border: 1px solid var(--line, #e4e7ec);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cvm-record-hint {
|
||||
font-size: 13px;
|
||||
color: var(--muted, #98a2b3);
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.cvm-record-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--error-color, #ef4444),
|
||||
var(--error-dark, #dc2626)
|
||||
);
|
||||
color: var(--text-inverse);
|
||||
box-shadow: 0 4px 14px
|
||||
color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent);
|
||||
transition:
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cvm-record-btn:hover {
|
||||
transform: scale(1.06);
|
||||
box-shadow: 0 6px 20px
|
||||
color-mix(in srgb, var(--error-color, #ef4444) 45%, transparent);
|
||||
}
|
||||
|
||||
.cvm-record-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.cvm-record-btn--recording {
|
||||
animation: cvm-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes cvm-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 4px 14px
|
||||
color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 4px 28px
|
||||
color-mix(in srgb, var(--error-color, #ef4444) 60%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 提示条 ─────────────────────────────────────────────── */
|
||||
|
||||
.cvm-tip {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: var(--warning-soft, #fef3c7);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--warning-color, #92400e);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cvm-tip-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── 底部按钮 ───────────────────────────────────────────── */
|
||||
|
||||
.cvm-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.cvm-footer .xx-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── 上传中状态 ─────────────────────────────────────────── */
|
||||
|
||||
.cvm-uploading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cvm-uploading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--line, #e4e7ec);
|
||||
border-top-color: var(--primary, #6366f1);
|
||||
border-radius: 50%;
|
||||
animation: cvm-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cvm-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.cvm-uploading-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #101828);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cvm-uploading-sub {
|
||||
font-size: 13px;
|
||||
color: var(--muted, #98a2b3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 成功状态 ───────────────────────────────────────────── */
|
||||
|
||||
.cvm-success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cvm-success-icon {
|
||||
font-size: 56px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cvm-success-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #101828);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cvm-success-desc {
|
||||
font-size: 14px;
|
||||
color: var(--muted, #98a2b3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 响应式 ─────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cvm-overlay {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.cvm-modal {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.cvm-upload-zone {
|
||||
padding: 20px 14px;
|
||||
}
|
||||
|
||||
.cvm-record-btn {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.cvm-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.cvm-record-btn {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.cvm-tip {
|
||||
font-size: 12px;
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
}
|
||||
@@ -532,3 +532,23 @@
|
||||
padding: 8px 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ── xx-card antd 子元素覆盖样式(从 Admin.css 迁移) ── */
|
||||
/* AdminComingSoon 等页面使用 <Card className="xx-card"> 时需要 */
|
||||
/* .xx-card 基础样式和 :hover 已在 global.css 中定义(V21 设计系统) */
|
||||
|
||||
.xx-card .ant-card-head {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.xx-card .ant-card-head-title {
|
||||
font-weight: 800;
|
||||
font-size: 17px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-card .ant-card-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ const Accounts: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
/** 绑定 mutation(mock) */
|
||||
/** 绑定 mutation */
|
||||
const bindMutation = useMutation({
|
||||
mutationFn: bindAccount,
|
||||
onSuccess: () => {
|
||||
@@ -165,7 +165,7 @@ const Accounts: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
/** 绑定新账号(mock:直接创建) */
|
||||
/** 绑定新账号 */
|
||||
const handleBind = (platformId: PlatformId) => {
|
||||
const platform = PLATFORMS.find((p) => p.id === platformId);
|
||||
if (!platform) return;
|
||||
|
||||
@@ -29,6 +29,7 @@ interface KpiItem {
|
||||
accent: string;
|
||||
}
|
||||
|
||||
// TODO: kpiData 当前使用硬编码 mock 数据,待后端提供 Dashboard 统计 API 后替换
|
||||
const kpiData: KpiItem[] = [
|
||||
{
|
||||
key: "projects",
|
||||
@@ -81,6 +82,7 @@ interface QuickEntry {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// TODO: quickEntries 描述中含硬编码计数(如 486个素材),待后端 API 后动态化
|
||||
const quickEntries: QuickEntry[] = [
|
||||
{
|
||||
id: "titles",
|
||||
@@ -135,6 +137,7 @@ const statusLabel: Record<TaskStatus, string> = {
|
||||
failed: "失败",
|
||||
};
|
||||
|
||||
// TODO: recentTasks 当前使用硬编码 mock 数据,待后端提供最近任务 API 后替换
|
||||
const recentTasks: RecentTask[] = [
|
||||
{
|
||||
id: "t-1",
|
||||
|
||||
@@ -55,13 +55,9 @@ interface TitleData {
|
||||
/* ============================================================
|
||||
* Mock 数据
|
||||
* ============================================================ */
|
||||
// TODO: 分类数据当前为前端硬编码 mock,待后端提供标题分类 API 后替换
|
||||
const MOCK_CATEGORIES: CategoryItem[] = [
|
||||
{ id: "cat-all", name: "全部标题", count: 15 },
|
||||
{ id: "cat-1", name: "美食探店", count: 4 },
|
||||
{ id: "cat-2", name: "科技数码", count: 3 },
|
||||
{ id: "cat-3", name: "生活日常", count: 4 },
|
||||
{ id: "cat-4", name: "美妆穿搭", count: 2 },
|
||||
{ id: "cat-5", name: "教育学习", count: 2 },
|
||||
{ id: "cat-all", name: "全部标题", count: 0 },
|
||||
];
|
||||
|
||||
/** 后端 TitleItem → 前端 TitleData 映射 */
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
CloseCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import PageHead from "@/components/layout/PageHead";
|
||||
import CloneVoiceModal from "@/components/modals/CloneVoiceModal";
|
||||
import CloneModal from "@/components/voice/CloneModal";
|
||||
import {
|
||||
getVoiceClones,
|
||||
deleteVoiceClone,
|
||||
@@ -356,7 +356,7 @@ const VoiceClone: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* 克隆音色弹窗 */}
|
||||
<CloneVoiceModal
|
||||
<CloneModal
|
||||
open={cloneModalOpen}
|
||||
onClose={() => setCloneModalOpen(false)}
|
||||
onSuccess={() => {
|
||||
|
||||
@@ -17,7 +17,6 @@ class WorkerSettings(BaseSettings):
|
||||
database_pool_recycle: int = 3600
|
||||
environment: str = "development"
|
||||
auto_create_schema: bool = False
|
||||
redis_url: str = "redis://redis:6379/0"
|
||||
|
||||
# 渲染引擎选择:legacy=旧VideoComposeService,unified=新UnifiedRenderService
|
||||
render_engine: str = "legacy"
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
#!/bin/sh
|
||||
# ============================================================
|
||||
# Production 部署脚本 - Registry 拉取方式
|
||||
# ============================================================
|
||||
# 原始位置:原内嵌在 .gitea/workflows/ci-cd.yml 的 deploy-production Job 中
|
||||
# 以 base64 编码存储在 DEPLOY_B64 变量中,通过 SSH 管道传送到服务器执行
|
||||
#
|
||||
# 功能:
|
||||
# 1. 登录 Gitea Registry
|
||||
# 2. Pull api / worker / web 三个镜像
|
||||
# 3. 备份旧前端静态资源(兼容 CDN 缓存,防止 404)
|
||||
# 4. 检查基础设施容器(PostgreSQL / Redis)
|
||||
# 5. 执行数据库 Migration
|
||||
# 6. 停止并重新启动三个业务容器
|
||||
# 7. 健康检查等待就绪
|
||||
# 8. 清理旧镜像
|
||||
#
|
||||
# 依赖的环境变量(由 CI 通过 SSH 传入):
|
||||
# IMAGE_TAG - 镜像版本标签(如 v0.1.127,对应 git tag)
|
||||
# REGISTRY_TOKEN - Gitea Registry 访问令牌
|
||||
# ============================================================
|
||||
|
||||
set -eu
|
||||
|
||||
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
|
||||
|
||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}"
|
||||
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}"
|
||||
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}"
|
||||
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
echo "ERROR: IMAGE_TAG is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -f "$ENV_FILE"
|
||||
mkdir -p "$GENERATED_DIR"
|
||||
mkdir -p "$LEGACY_ASSETS_DIR"
|
||||
|
||||
# ---- 登录 Registry ----
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
echo "Logging in to registry: $REGISTRY"
|
||||
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
|
||||
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
|
||||
echo "WARN: docker login failed, will try to pull anyway"
|
||||
}
|
||||
fi
|
||||
|
||||
# ---- Pull 三个镜像 ----
|
||||
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
echo "Pulling API image..."
|
||||
docker pull "$REGISTRY_API"
|
||||
echo "Pulling Worker image..."
|
||||
docker pull "$REGISTRY_WORKER"
|
||||
echo "Pulling Web image..."
|
||||
docker pull "$REGISTRY_WEB"
|
||||
|
||||
# ---- Re-tag 成本地镜像名 ----
|
||||
docker tag "$REGISTRY_API" "$LOCAL_API"
|
||||
docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER"
|
||||
docker tag "$REGISTRY_WEB" "$LOCAL_WEB"
|
||||
echo "All images pulled and tagged."
|
||||
|
||||
# ---- 备份旧前端 assets(生产环境访问量大,防止 CDN 缓存命中 404) ----
|
||||
echo "Backing up legacy assets from current web container..."
|
||||
if docker inspect xiaoxia-web-production >/dev/null 2>&1; then
|
||||
_tmpdir="/tmp/legacy-assets-$$"
|
||||
rm -rf "$_tmpdir"
|
||||
mkdir -p "$_tmpdir"
|
||||
docker cp xiaoxia-web-production:/usr/share/nginx/html/assets/. "$_tmpdir/" 2>/dev/null || true
|
||||
# 复制到 LEGACY_ASSETS_DIR(下一次部署时作为 fallback 挂载)
|
||||
if [ -d "$_tmpdir" ] && [ "$(ls -A "$_tmpdir" 2>/dev/null)" ]; then
|
||||
cp -an "$_tmpdir"/. "$LEGACY_ASSETS_DIR"/ 2>/dev/null || true
|
||||
echo "Legacy assets backed up: $(ls "$_tmpdir" | wc -l) files"
|
||||
fi
|
||||
rm -rf "$_tmpdir"
|
||||
else
|
||||
echo "No existing web container, skipping legacy assets backup"
|
||||
fi
|
||||
|
||||
# 清理超过 7 天的旧 assets 文件
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ]; then
|
||||
find "$LEGACY_ASSETS_DIR" -type f -mtime +7 -delete 2>/dev/null || true
|
||||
echo "Legacy assets cleanup done (retain 7 days)"
|
||||
fi
|
||||
|
||||
# ---- 检查基础设施容器状态 ----
|
||||
echo "Checking infrastructure containers..."
|
||||
for c in xiaoxia-postgres-production xiaoxia-redis-production; do
|
||||
if ! docker inspect "$c" >/dev/null 2>&1; then
|
||||
echo "ERROR: Required container not found: $c"
|
||||
exit 1
|
||||
fi
|
||||
state=$(docker inspect -f '{{.State.Status}}' "$c")
|
||||
if [ "$state" != "running" ]; then
|
||||
echo "ERROR: Container not running: $c ($state)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 创建生产网络 ----
|
||||
docker network create xiaoxia-net-production 2>/dev/null || true
|
||||
|
||||
# ---- 执行数据库 Migration ----
|
||||
echo "Running database migrations..."
|
||||
docker run --rm \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-e APP_ENV=production \
|
||||
"$LOCAL_API" sh -c "cd /app && alembic upgrade head"
|
||||
echo "Migrations completed."
|
||||
|
||||
# ---- 停止旧容器 ----
|
||||
echo "Stopping old containers..."
|
||||
docker rm -f xiaoxia-api-production 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-production 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-production 2>/dev/null || true
|
||||
|
||||
# ---- 启动参数(日志配置统一) ----
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
# ---- 启动 API 容器 ----
|
||||
echo "Starting API container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-api-production \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-p 127.0.0.1:8001:8000 \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_API"
|
||||
|
||||
# ---- 启动 Worker 容器 ----
|
||||
echo "Starting Worker container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-production \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WORKER"
|
||||
|
||||
# ---- 启动 Web 容器 ----
|
||||
# Legacy assets 挂载到 /usr/share/nginx/html/assets-legacy/assets/
|
||||
# nginx 配置中 assets location 有 fallback 规则
|
||||
LEGACY_VOLUME=""
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
echo "Web container: legacy assets mounted (fallback)"
|
||||
else
|
||||
echo "Web container: no legacy assets to mount"
|
||||
fi
|
||||
|
||||
echo "Starting Web container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-web-production \
|
||||
--network xiaoxia-net-production \
|
||||
-p 127.0.0.1:3002:80 \
|
||||
--restart unless-stopped \
|
||||
--cpus 0.5 \
|
||||
--memory 512m \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WEB"
|
||||
|
||||
# ---- 等待 API 就绪 ----
|
||||
echo "Waiting for API to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8001/health >/dev/null 2>&1; then
|
||||
echo "API is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/40)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if [ "$i" -ge 40 ]; then
|
||||
echo "ERROR: API did not become healthy within 120s"
|
||||
docker logs --tail 50 xiaoxia-api-production
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 等待 Web 就绪 ----
|
||||
echo "Waiting for Web to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 15 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:3002/ >/dev/null 2>&1; then
|
||||
echo "Web is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/15)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$i" -ge 15 ]; then
|
||||
echo "ERROR: Web did not become healthy within 30s"
|
||||
docker logs --tail 30 xiaoxia-web-production
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 清理旧镜像 ----
|
||||
echo "Cleaning up old images..."
|
||||
docker image prune -af --filter "until=168h" 2>/dev/null || true
|
||||
docker builder prune -af --filter "until=168h" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Production deployment complete ==="
|
||||
echo "API: http://127.0.0.1:8001"
|
||||
echo "Web: http://127.0.0.1:3002"
|
||||
echo "Version: $IMAGE_TAG"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production
|
||||
@@ -1,219 +0,0 @@
|
||||
#!/bin/sh
|
||||
# ============================================================
|
||||
# Staging 部署脚本 - Registry 拉取方式(替代 Watchtower)
|
||||
# ============================================================
|
||||
# 说明:原 Staging 使用 Watchtower 轮询 :staging 标签自动更新
|
||||
# 本脚本替代 Watchtower,由 CI 主动触发部署,优势:
|
||||
# - 部署时机精确可控,无需固定 sleep 等待
|
||||
# - 部署完成立即健康检查,失败可快速回滚
|
||||
# - 部署日志完整记录在 CI 中
|
||||
#
|
||||
# 功能:
|
||||
# 1. 登录 Gitea Registry
|
||||
# 2. Pull api / worker / web 三个镜像(用 commit SHA 作为 tag)
|
||||
# 3. 执行数据库 Migration
|
||||
# 4. 停止并重新启动三个业务容器
|
||||
# 5. 健康检查等待就绪
|
||||
#
|
||||
# 依赖的环境变量(由 CI 通过 SSH 传入):
|
||||
# IMAGE_TAG - 镜像标签(一般为 commit SHA)
|
||||
# REGISTRY_TOKEN - Gitea Registry 访问令牌
|
||||
# ============================================================
|
||||
|
||||
set -eu
|
||||
|
||||
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
|
||||
|
||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
|
||||
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
|
||||
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
echo "ERROR: IMAGE_TAG is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -f "$ENV_FILE"
|
||||
mkdir -p "$GENERATED_DIR"
|
||||
|
||||
# ---- 登录 Registry ----
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
echo "Logging in to registry: $REGISTRY"
|
||||
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
|
||||
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
|
||||
echo "WARN: docker login failed, will try to pull anyway"
|
||||
}
|
||||
fi
|
||||
|
||||
# ---- Pull 三个镜像 ----
|
||||
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
LOCAL_API="xiaoxia-saas-api:staging-${IMAGE_TAG}"
|
||||
LOCAL_WORKER="xiaoxia-saas-worker:staging-${IMAGE_TAG}"
|
||||
LOCAL_WEB="xiaoxia-saas-web:staging-${IMAGE_TAG}"
|
||||
|
||||
echo "Pulling API image..."
|
||||
docker pull "$REGISTRY_API"
|
||||
echo "Pulling Worker image..."
|
||||
docker pull "$REGISTRY_WORKER"
|
||||
echo "Pulling Web image..."
|
||||
docker pull "$REGISTRY_WEB"
|
||||
|
||||
# ---- Re-tag 成本地镜像名 ----
|
||||
docker tag "$REGISTRY_API" "$LOCAL_API"
|
||||
docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER"
|
||||
docker tag "$REGISTRY_WEB" "$LOCAL_WEB"
|
||||
echo "All images pulled and tagged."
|
||||
|
||||
# ---- 检查基础设施容器 ----
|
||||
echo "Checking infrastructure containers..."
|
||||
for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do
|
||||
if ! docker inspect "$c" >/dev/null 2>&1; then
|
||||
echo "ERROR: Required container not found: $c"
|
||||
exit 1
|
||||
fi
|
||||
state=$(docker inspect -f '{{.State.Status}}' "$c")
|
||||
if [ "$state" != "running" ]; then
|
||||
echo "ERROR: Container not running: $c ($state)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 创建 Staging 网络 ----
|
||||
docker network create xiaoxia-net-staging 2>/dev/null || true
|
||||
|
||||
# ---- 执行数据库 Migration ----
|
||||
echo "Running database migrations..."
|
||||
docker run --rm \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
"$LOCAL_API" sh -c "cd /app && alembic upgrade head"
|
||||
echo "Migrations completed."
|
||||
|
||||
# ---- 停止旧容器 ----
|
||||
echo "Stopping old containers..."
|
||||
docker rm -f xiaoxia-api-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-staging 2>/dev/null || true
|
||||
|
||||
# ---- 启动参数(日志配置统一) ----
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
# ---- 启动 API 容器 ----
|
||||
echo "Starting API container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-api-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:8000:8000 \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--cpus 1 \
|
||||
--memory 1g \
|
||||
--health-cmd "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_API"
|
||||
|
||||
# ---- 启动 Worker 容器 ----
|
||||
echo "Starting Worker container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--cpus 1 \
|
||||
--memory 1g \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WORKER"
|
||||
|
||||
# ---- 启动 Web 容器 ----
|
||||
echo "Starting Web container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-web-staging \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:3001:80 \
|
||||
--restart unless-stopped \
|
||||
--cpus 0.5 \
|
||||
--memory 256m \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WEB"
|
||||
|
||||
# ---- 等待 API 就绪 ----
|
||||
echo "Waiting for API to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 30 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
|
||||
echo "API is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$i" -ge 30 ]; then
|
||||
echo "ERROR: API did not become healthy within 60s"
|
||||
docker logs --tail 50 xiaoxia-api-staging
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 等待 Web 就绪 ----
|
||||
echo "Waiting for Web to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 15 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:3001/ >/dev/null 2>&1; then
|
||||
echo "Web is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/15)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$i" -ge 15 ]; then
|
||||
echo "ERROR: Web did not become healthy within 30s"
|
||||
docker logs --tail 30 xiaoxia-web-staging
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 清理旧镜像 ----
|
||||
echo "Cleaning up old images..."
|
||||
docker image prune -af --filter "until=72h" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Staging deployment complete ==="
|
||||
echo "API: http://127.0.0.1:8000"
|
||||
echo "Web: http://127.0.0.1:3001"
|
||||
echo "Version: $IMAGE_TAG"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
|
||||
Reference in New Issue
Block a user