feat: 封面人物形象 + 素材匹配优化 + Windows 预览修复

- 新增 cover_avatar 积分类型和弹窗支持
- Modal 组件支持 maxHeight 属性
- 素材匹配增加 loading 状态(匹配中...)
- 修复 Windows 视频预览:统一 handlePreview、preload=metadata、修复 Rust UNC 路径
- 修复进度条倒退问题
- 更新运营脚本
- 新增 Windows 11 开发环境搭建文档
This commit is contained in:
小鱼开发
2026-05-26 15:40:21 +08:00
parent 993d6e0c78
commit 2b35a9ced0
13 changed files with 334 additions and 68 deletions
+192
View File
@@ -0,0 +1,192 @@
# Windows 11 开发环境搭建指南
> 适用场景:全新重装系统后的 Windows 11,国内网络环境。
---
## 前置说明
- **WebView2**Windows 11 自带,无需安装。
- **WSL2**Windows 11 默认支持,Docker Desktop 会自动启用。
- **全程使用 cmd + 官网 .exe 安装包**,不依赖 PowerShell 脚本。
---
## 一、基础工具安装(图形界面,双击下一步)
按顺序安装,装完一个再装下一个。
### 1. Git
- 下载:https://git-scm.com/download/win
- 安装:全默认,一路 Next。
### 2. Node.js 22 LTS
- 下载:https://nodejs.org/
- 安装:勾选 **"Automatically install necessary tools"**(会自动装 Python 2.7 等构建工具)。
### 3. Visual Studio Build Tools 2022
- 下载:https://aka.ms/vs/17/release/vs_BuildTools.exe
- 安装:只勾选 **"使用 C++ 的桌面开发"**(约 8GB),其他全取消。
### 4. Rust
- 下载:https://rustup.rs/ → 点击 `rustup-init.exe (64-bit)`
- 安装:选 **1) Proceed with default installation**(默认 MSVC 工具链)。
---
## 二、国内镜像配置(cmd 执行)
打开 **cmdWin+R → cmd**,逐行执行:
```cmd
:: ========== npm 镜像 ==========
npm config set registry https://registry.npmmirror.com
:: ========== Rust 镜像 ==========
mkdir "%USERPROFILE%\.cargo" 2>nul
echo [source.crates-io] > "%USERPROFILE%\.cargo\config.toml"
echo replace-with = 'ustc' >> "%USERPROFILE%\.cargo\config.toml"
echo [source.ustc] >> "%USERPROFILE%\.cargo\config.toml"
echo registry = "sparse+https://mirrors.ustc.edu.cn/crates.io-index/" >> "%USERPROFILE%\.cargo\config.toml"
setx RUSTUP_UPDATE_ROOT https://mirrors.ustc.edu.cn/rust-static/rustup
setx RUSTUP_DIST_SERVER https://mirrors.ustc.edu.cn/rust-static
:: ========== Python 镜像(预留,方案 B 用到) ==========
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
pip config set global.trusted-host pypi.tuna.tsinghua.edu.cn
echo 镜像配置完成,请关闭并重新打开 cmd
```
**执行完后,关闭 cmd,重新打开**,再执行验证:
```cmd
npm config get registry
cargo --version
```
---
## 三、方案 A:只跑前端(连测试环境后端)
### 1. 拉代码
```cmd
git clone <你的仓库地址>
cd meijiaka-zy\tauri-app
```
### 2. 装依赖
```cmd
npm ci
```
### 3. 启动
```cmd
npm run tauri dev
```
前端默认连接 `https://dev.tapi.meijiaka.cn/api/v1`,无需本地后端。
---
## 四、方案 B:前后端都本地跑
在方案 A 基础上继续。
### 1. Python 3.13
- 下载:https://www.python.org/ftp/python/3.13.0/python-3.13.0-amd64.exe
- 安装:**务必勾选 "Add python.exe to PATH"**,然后 Install Now。
### 2. 安装 uv
```cmd
pip install uv
```
### 3. Docker Desktop
- 下载:https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe
- 安装:默认,装完**重启电脑**。
- 重启后打开 Docker Desktop,等左下角状态变绿。
### 4. 后端启动
```cmd
cd meijiaka-zy\python-api
:: 安装依赖
uv pip install -e ".[dev]"
:: 复制环境变量
copy .env.example .env
:: 启动数据库
docker compose -f docker-compose.test.yml up -d db redis
:: 数据库迁移
alembic upgrade head
:: 启动 API(终端 1
make run
:: 或:uvicorn app.main:app --reload --port 8000
```
如果需要异步调度器(脚本/TTS/字幕生成等),另开终端:
```cmd
cd meijiaka-zy\python-api
make scheduler
:: 或:python -m app.scheduler.main
```
### 5. 前端启动(连本地后端)
```cmd
cd meijiaka-zy\tauri-app
npm run tauri dev
```
前端 Vite 开发服务器会代理 API 请求到 `localhost:8000`。如果代理异常,检查 `tauri-app/src/api/client.ts` 中的 `PYTHON_API_BASE_URL`
---
## 五、验证清单
全部装完后,在 cmd 里执行:
```cmd
git --version
node -v
npm -v
rustc --version
cargo --version
python --version
uv --version
docker --version
```
每个都要有版本号输出。
---
## 六、常见问题
| 现象 | 原因 | 解决 |
|------|------|------|
| `npm ci` 卡住 | 镜像没配好 | 检查 `npm config get registry` 是否为 `registry.npmmirror.com` |
| `cargo build` 卡住 | Rust 镜像没生效 | 关闭 cmd 重新打开,或检查 `%USERPROFILE%\.cargo\config.toml` |
| Tauri 编译报错 `link.exe not found` | VS Build Tools 没装 C++ 桌面开发 | 重装,确保勾选了该工作负载 |
| `tauri dev` 白屏 | 前端代理地址错误 | 检查 `client.ts` 里的 base URL |
| Docker 启动失败 | WSL2 未启用 | 控制面板 → 程序和功能 → 启用 Windows 功能 → 勾选 **适用于 Linux 的 Windows 子系统** |
| `python` 命令找不到 | 安装时没勾选 Add to PATH | 重装 Python,务必勾选 |
| `alembic` 命令找不到 | 没在虚拟环境里 | 确保在 `python-api` 目录下执行,`uv pip install` 已经装了 |
+2 -2
View File
@@ -108,8 +108,8 @@ END $$;
DO $$
DECLARE
v_mobile TEXT := '13800138000'; -- ← 修改:目标用户手机号
v_gift_points INT := 500; -- ← 修改:赠送积分数量
v_mobile TEXT := '13860199646'; -- ← 修改:目标用户手机号
v_gift_points INT := 5000; -- ← 修改:赠送积分数量
v_gift_days INT := 180; -- ← 修改:有效期(天)
v_reason TEXT := '运营活动赠送'; -- ← 修改:赠送原因(写入流水描述)
v_user_id UUID;
+1 -1
View File
@@ -4219,7 +4219,7 @@ dependencies = [
[[package]]
name = "tauri-app"
version = "1.6.1"
version = "1.6.2"
dependencies = [
"base64 0.22.1",
"chrono",
+11 -1
View File
@@ -29,6 +29,16 @@ pub fn escape_ffmpeg_path(path: &str) -> String {
path.replace("'", "'\\''")
}
/// 去掉 Windows UNC 前缀(\\?\),使 convertFileSrc 能正确生成 asset:// URL
fn normalize_path(path: &std::path::Path) -> String {
let s = path.to_string_lossy().to_string();
if s.starts_with(r"\\?\") {
std::path::PathBuf::from(&s[4..]).to_string_lossy().to_string()
} else {
s
}
}
/// 验证路径在允许的目录内,防止路径遍历攻击
/// 允许的目录:应用数据目录(app_local_data_dir
fn validate_safe_path(path: &str) -> Result<String, String> {
@@ -910,7 +920,7 @@ pub async fn transcode_for_preview(app: &AppHandle, input_path: &str) -> Result<
&& resolution_ok;
if can_skip {
return Ok(path_str);
return Ok(normalize_path(&std::path::Path::new(&path_str)));
}
}
}
+11 -1
View File
@@ -8,6 +8,7 @@ interface ModalProps {
children: React.ReactNode;
width?: string;
centerTitle?: boolean;
maxHeight?: string;
}
export default function Modal({
@@ -17,6 +18,7 @@ export default function Modal({
children,
width = '560px',
centerTitle = false,
maxHeight,
}: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
@@ -45,7 +47,15 @@ export default function Modal({
}
}}
>
<div className={`modal-container animate-slideUp ${!title ? 'modal-container--no-title' : ''}`} style={{ width: '90%', maxWidth: width, position: 'relative' }}>
<div
className={`modal-container animate-slideUp ${!title ? 'modal-container--no-title' : ''}`}
style={{
width: '90%',
maxWidth: width,
position: 'relative',
...(maxHeight ? { maxHeight } : {}),
}}
>
{title && (
<div
className="modal-header"
@@ -12,6 +12,7 @@ const SOURCE_TYPE_LABELS: Record<string, string> = {
compose: '压制成片',
subtitle_burn: '字幕烧录',
cover_design: '封面设计',
cover_avatar: '封面人物形象',
caption: '字幕生成',
};
@@ -40,7 +41,7 @@ export default function PricingModal({ open, onClose }: PricingModalProps) {
}, [open, rules.length]);
return (
<Modal open={open} onClose={onClose} width="600px">
<Modal open={open} onClose={onClose} width="600px" maxHeight="none">
{loading ? (
<div className="profile-pricing-loading">
...
@@ -307,6 +307,12 @@
flex: 1;
}
.recharge-modal-footer .btn.btn-ghost {
background: var(--bg-secondary);
border: 1px solid var(--border-light);
color: var(--text-secondary);
}
/* 订单信息 */
.recharge-order-info {
display: flex;
@@ -208,6 +208,7 @@ const SOURCE_TYPE_LABELS: Record<string, string> = {
compose: '压制成片',
subtitle_burn: '字幕烧录',
cover_design: '封面设计',
cover_avatar: '封面人物形象',
caption: '字幕生成',
};
@@ -217,6 +218,7 @@ const SOURCE_TYPE_OPTIONS = [
{ value: 'polish', label: '文案润色' },
{ value: 'title', label: '标题生成' },
{ value: 'tts', label: '配音合成' },
{ value: 'cover_avatar', label: '封面人物形象' },
{ value: 'voice_clone', label: '声音复刻' },
{ value: 'video', label: '视频生成' },
{ value: 'compose', label: '压制成片' },
@@ -635,7 +635,7 @@ export default function SubtitleBurning() {
onClick={() => setPreviewMode('result')}
disabled={!burnedVideoUrl}
>
{previewMode === 'result' ? '视频预览中...' : '视频预览'}
{previewMode === 'result' ? '视频预览中' : '视频预览'}
</button>
</div>
) : (
@@ -40,7 +40,7 @@ const GenerationControls: React.FC<GenerationControlsProps> = ({
style={{ flex: 1, marginTop: 0, flexShrink: 0 }}
onClick={onPreview}
>
{isPreviewing ? '视频预览中...' : '视频预览'}
{isPreviewing ? '视频预览中' : '视频预览'}
</button>
</div>
);
@@ -14,6 +14,7 @@ interface ShotTimelineProps {
userUploadedMaterials: Record<string, { path: string; duration: number }>;
selectedAvatarMaterial: AvatarMaterial | null;
isComposedPreview: boolean;
matchingShotIds?: Set<string>;
onSceneClick: (id: number, shot: ScriptShot) => void;
onSwitchMaterial: (shotId: string) => void;
onUploadMaterial: (shotId: string) => void;
@@ -33,6 +34,7 @@ const ShotTimeline: React.FC<ShotTimelineProps> = ({
userUploadedMaterials,
selectedAvatarMaterial,
isComposedPreview,
matchingShotIds,
onSceneClick,
onSwitchMaterial,
onUploadMaterial,
@@ -123,6 +125,7 @@ const ShotTimeline: React.FC<ShotTimelineProps> = ({
<button
className="btn btn-ghost btn-sm"
style={{ fontSize: '11px', padding: '2px 6px', color: 'var(--primary)' }}
disabled={matchingShotIds?.has(String(shot.id))}
onClick={(e) => {
e.stopPropagation();
const sid = String(shot.id);
@@ -133,7 +136,11 @@ const ShotTimeline: React.FC<ShotTimelineProps> = ({
}
}}
>
{materialMatchMap[String(shot.id)] ? '换一个' : '匹配素材'}
{matchingShotIds?.has(String(shot.id))
? '匹配中...'
: materialMatchMap[String(shot.id)]
? '换一个'
: '匹配素材'}
</button>
<button
className="btn btn-ghost btn-sm"
+94 -58
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { open } from '@tauri-apps/plugin-dialog';
import { exists } from '@tauri-apps/plugin-fs';
import { getPreviewVideoUrl } from '../../utils/videoPreview';
@@ -63,6 +63,15 @@ export default function VideoGeneration() {
const dubbingAudioDuration = useProjectStore(state => state.dubbingAudioDuration);
const [activeScene, setActiveScene] = useState<number>(1);
const activeSceneRef = useRef(activeScene);
useEffect(() => {
activeSceneRef.current = activeScene;
}, [activeScene]);
// 匹配请求进行中 shotId 集合,防止重复点击
const [matchingShotIds, setMatchingShotIds] = useState<Set<string>>(new Set());
// 匹配请求序号,用于丢弃过期的异步返回结果
const matchRequestIdRef = useRef(0);
// 控制右侧预览区显示完整视频还是镜头素材
const [isComposedPreview, setIsComposedPreview] = useState(false);
@@ -192,52 +201,10 @@ export default function VideoGeneration() {
}
}, [previewVideoUrl]);
// 点击卡片后自动预览对应素材/形象
useEffect(() => {
const activeShot = shots.find((s) => Number(s.id) === activeScene);
if (!activeShot) {
return;
}
setPreviewVideoUrl(null);
if (activeShot.type === 'empty_shot') {
const matched = materialMatchMap[String(activeShot.id)];
if (matched) {
setPreviewVideoUrl(matched.url);
}
} else if (selectedAvatarMaterial) {
setIsPreviewLoading(true);
(async () => {
try {
const url = await getPreviewVideoUrl(selectedAvatarMaterial.path);
setPreviewVideoUrl(url);
} catch (e) {
console.error('[VideoGeneration] 预览形象视频失败:', e);
setIsPreviewLoading(false);
}
})();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeScene]);
// 视频生成
const { isComposing, handleGenerate } = useVideoGeneration({
selectedAvatarMaterial,
materialMatchMap,
estimatedVideoPoints,
videoMultiplier,
checkBalance,
handleError,
onSuccess: () => {
setIsComposedPreview(true);
},
});
/**
* 预览视频(素材或人物形象)
*/
const handlePreview = async (urlOrPath: string) => {
const handlePreview = useCallback(async (urlOrPath: string) => {
setPreviewVideoUrl(null);
setIsPreviewLoading(true);
@@ -257,7 +224,52 @@ export default function VideoGeneration() {
toast.error('预览失败');
setIsPreviewLoading(false);
}
};
}, []);
// 点击卡片后自动预览对应素材/形象
useEffect(() => {
const activeShot = shots.find((s) => Number(s.id) === activeScene);
if (!activeShot) {
return;
}
setPreviewVideoUrl(null);
if (activeShot.type === 'empty_shot') {
const matched = materialMatchMap[String(activeShot.id)];
if (matched) {
handlePreview(matched.url);
} else {
setPreviewVideoUrl(null);
setIsPreviewLoading(false);
}
} else if (selectedAvatarMaterial) {
setIsPreviewLoading(true);
(async () => {
try {
const url = await getPreviewVideoUrl(selectedAvatarMaterial.path);
setPreviewVideoUrl(url);
} catch (e) {
console.error('[VideoGeneration] 预览形象视频失败:', e);
setIsPreviewLoading(false);
}
})();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeScene, handlePreview]);
// 视频生成
const { isComposing, handleGenerate } = useVideoGeneration({
selectedAvatarMaterial,
materialMatchMap,
estimatedVideoPoints,
videoMultiplier,
checkBalance,
handleError,
onSuccess: () => {
setIsComposedPreview(true);
},
});
/**
* 选择本地人物视频
@@ -324,18 +336,37 @@ export default function VideoGeneration() {
};
// 处理素材匹配(单个镜头触发匹配)
// 匹配成功后自动切换到对应镜头并预览,但只处理最新请求的结果,
// 避免用户快速连点多个镜头时,后返回的请求把画面切回之前的镜头。
const handleMatchMaterial = async (shotId: string) => {
const result = await matchSingleMaterial(shotId);
if (!result) {
return;
}
if (Number(shotId) === activeScene) {
// 当前镜头已激活,直接播放
setPreviewVideoUrl(result.url);
} else {
// 切换到该镜头,useEffect 会自动加载预览
setActiveScene(Number(shotId));
setIsComposedPreview(false);
if (matchingShotIds.has(shotId)) return;
setMatchingShotIds(prev => new Set(prev).add(shotId));
const requestId = ++matchRequestIdRef.current;
try {
const result = await matchSingleMaterial(shotId);
if (!result) {
return;
}
// 如果已经有更新的请求发出,丢弃当前过期结果
if (requestId !== matchRequestIdRef.current) {
return;
}
if (Number(shotId) === activeSceneRef.current) {
// 当前镜头已激活,直接预览(setActiveScene 值不变时不会触发 useEffect
handlePreview(result.url);
} else {
// 切换到对应镜头,useEffect 会自动加载预览
setActiveScene(Number(shotId));
setIsComposedPreview(false);
}
} finally {
setMatchingShotIds(prev => {
const next = new Set(prev);
next.delete(shotId);
return next;
});
}
};
@@ -368,6 +399,7 @@ export default function VideoGeneration() {
userUploadedMaterials={userUploadedMaterials}
selectedAvatarMaterial={selectedAvatarMaterial}
isComposedPreview={isComposedPreview}
matchingShotIds={matchingShotIds}
onSceneClick={handleSceneClick}
onSwitchMaterial={handleSwitchMaterial}
onUploadMaterial={handleUploadMaterial}
@@ -418,10 +450,14 @@ export default function VideoGeneration() {
autoPlay
muted
playsInline
preload="none"
preload="metadata"
onLoadStart={() => setIsPreviewLoading(true)}
onLoadedData={() => setIsPreviewLoading(false)}
onError={() => setIsPreviewLoading(false)}
onError={(e) => {
console.error('[VideoGeneration] 视频加载失败:', e);
toast.error('视频加载失败');
setIsPreviewLoading(false);
}}
/>
{isPreviewLoading && (
<div
+3 -1
View File
@@ -78,7 +78,9 @@ export const useProgressStore = create<ProgressState & ProgressActions>((set) =>
},
setProgress: (progress) => {
set({ progress: Math.min(100, Math.max(0, progress)) });
set((state) => ({
progress: Math.min(100, Math.max(state.progress ?? 0, progress)),
}));
},
success: (message = '生成完成', pointsConsumed) => {