Commit cda0db1d authored by yaoke.yk's avatar yaoke.yk

miniapp和Agent制作

parent c2bcc050
VITE_API_BASE_URL=http://localhost:8080
# 瑶光 AI 短剧小程序
这是 `doc/html` 对应的移动端快速制作版本,基于 UniApp + Vue 3 + TypeScript。它不是完整复刻 PC 管理台,而是把手机端最常用链路做轻。
## 两种制作模式
### PC 精修
适合严肃项目和需要人工把控的短剧制作:
```text
上传剧本 / 输入目标
-> 创建项目
-> Agent 生成剧本理解、角色、分镜
-> 回 PC 工作台精修
-> 在 PC 端生成视频和导出
```
请求特征:
```json
{
"runIntent": "auto",
"selectedSteps": ["script_understanding", "character_generation", "storyboard_generation"]
}
```
### 极速出片
适合先快速拿到一版样片:
```text
上传剧本 / 输入目标
-> 创建项目
-> Agent 自动跑剧本、角色、分镜、图片、视频、合成
-> 小程序查看结果
```
请求特征:
```json
{
"runIntent": "quick_video",
"selectedSteps": [
"script_understanding",
"character_generation",
"storyboard_generation",
"image_generation",
"video_generation",
"video_composition"
]
}
```
后端已经识别 `quick_video` 意图,并在 Agent 常规分镜链路后继续提交视频任务、等待任务完成、启动分集合成。小程序结果页会读取项目下的视频任务和分集合成任务,优先展示合成后的成片地址。
## 目录
```text
doc/miniapp
├─ src/api 后端接口封装
├─ src/components 小程序组件
├─ src/pages 页面
├─ src/stores Pinia 登录态
├─ src/types API 类型
└─ src/utils 常量和格式化
```
## 本地运行
```bash
cd doc/miniapp
npm install
npm run dev:h5
```
微信小程序构建:
```bash
npm run dev:mp-weixin
```
然后用微信开发者工具打开生成的 `dist/dev/mp-weixin`
## 后端地址
默认后端地址在 `src/utils/constants.ts`
```ts
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8080";
```
也可以复制 `.env.example``.env` 后修改:
```bash
VITE_API_BASE_URL=http://localhost:8080
```
如果用真机预览,`localhost` 指向手机本机,需要改成电脑局域网 IP 或正式域名,并在微信小程序后台配置 request/uploadFile 合法域名。
## 已接入接口
- `POST /auth/login`
- `GET /projects`
- `POST /projects`
- `GET /projects/{id}`
- `POST /projects/{projectId}/assets?assetType=script`
- `POST /agent/projects/{projectId}/runs`
- `GET /agent/projects/{projectId}/runs/latest`
- `POST /agent/runs/{runId}/pause`
- `POST /agent/runs/{runId}/resume`
- `GET /video-tasks/project/{projectId}`
- `GET /assembly/project/{projectId}/episode/{episodeId}`
## 后续建议
- 后端为 `quick_video` 返回最终 `finalVideoUrl``coverUrl``duration` 等字段后,替换结果页 `VideoPreview` 的空状态。
- PC 精修模式可在结果页展示“在电脑打开此项目”的二维码或短链。
- 视频生成长任务在小程序端建议继续用轮询,SSE/WebSocket 可作为后续增强。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>瑶光 AI 短剧</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
{
"name": "yaoai-miniapp",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev:h5": "uni",
"build:h5": "uni build",
"dev:mp-weixin": "uni -p mp-weixin",
"build:mp-weixin": "uni build -p mp-weixin",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-5000720260410001",
"@dcloudio/uni-components": "3.0.0-5000720260410001",
"@dcloudio/uni-h5": "3.0.0-5000720260410001",
"@dcloudio/uni-mp-weixin": "3.0.0-5000720260410001",
"pinia": "^2.3.1",
"vue": "^3.5.13"
},
"devDependencies": {
"@dcloudio/types": "3.4.28",
"@dcloudio/uni-cli-shared": "3.0.0-5000720260410001",
"@dcloudio/vite-plugin-uni": "3.0.0-5000720260410001",
"sass": "1.83.4",
"typescript": "^5.7.2",
"vite": "5.2.8",
"vue-tsc": "^2.2.0"
}
}
<script setup lang="ts">
import { onLaunch } from "@dcloudio/uni-app";
import { useAuthStore } from "@/stores/auth";
onLaunch(() => {
useAuthStore().hydrate();
});
</script>
<style lang="scss">
@use "./styles/app.scss";
</style>
import { request } from "@/api/client";
import type { AgentRun, AgentStartRequest } from "@/types/api";
export function startAgentRun(projectId: string | number, payload: AgentStartRequest) {
return request<AgentRun>(`/agent/projects/${projectId}/runs`, {
method: "POST",
data: payload
});
}
export function getLatestRun(projectId: string | number) {
return request<AgentRun | null>(`/agent/projects/${projectId}/runs/latest`);
}
export function getRun(runId: string | number) {
return request<AgentRun>(`/agent/runs/${runId}`);
}
export function pauseRun(runId: string | number) {
return request<AgentRun>(`/agent/runs/${runId}/pause`, {
method: "POST"
});
}
export function resumeRun(runId: string | number) {
return request<AgentRun>(`/agent/runs/${runId}/resume`, {
method: "POST"
});
}
import { request } from "@/api/client";
import type { LoginResponse } from "@/types/api";
export function login(email: string, password: string) {
return request<LoginResponse>("/auth/login", {
method: "POST",
data: {
email,
password
},
skipAuth: true
});
}
import { API_BASE_URL, AUTH_TOKEN_KEY } from "@/utils/constants";
import type { ApiEnvelope } from "@/types/api";
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
interface RequestOptions<TBody = unknown> {
method?: HttpMethod;
data?: TBody;
header?: Record<string, string>;
skipAuth?: boolean;
}
export class ApiError extends Error {
statusCode?: number;
code?: string;
constructor(message: string, statusCode?: number, code?: string) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.code = code;
}
}
function getToken() {
return uni.getStorageSync(AUTH_TOKEN_KEY) as string | undefined;
}
function buildHeaders(options?: RequestOptions) {
const token = getToken();
const headers: Record<string, string> = {
"content-type": "application/json",
...(options?.header || {})
};
if (token && !options?.skipAuth) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
export function request<TResponse, TBody = unknown>(path: string, options: RequestOptions<TBody> = {}) {
return new Promise<TResponse>((resolve, reject) => {
uni.request({
url: `${API_BASE_URL}${path}`,
method: options.method || "GET",
data: options.data as UniApp.RequestOptions["data"],
header: buildHeaders(options),
success(response) {
const statusCode = response.statusCode;
const body = response.data as ApiEnvelope<TResponse> | TResponse;
if (statusCode >= 200 && statusCode < 300) {
if (body && typeof body === "object" && "success" in body) {
const envelope = body as ApiEnvelope<TResponse>;
if (envelope.success === false) {
reject(new ApiError(envelope.error?.message || envelope.message || "请求失败", statusCode, envelope.error?.code));
return;
}
resolve((envelope.data ?? (body as TResponse)) as TResponse);
return;
}
resolve(body as TResponse);
return;
}
const envelope = body as ApiEnvelope<TResponse>;
reject(new ApiError(envelope?.error?.message || envelope?.message || `请求失败 ${statusCode}`, statusCode, envelope?.error?.code));
},
fail(error) {
reject(new ApiError(error.errMsg || "网络连接失败"));
}
});
});
}
export function uploadFile<TResponse>(path: string, filePath: string, formData?: Record<string, string | number | boolean>) {
return new Promise<TResponse>((resolve, reject) => {
const token = getToken();
uni.uploadFile({
url: `${API_BASE_URL}${path}`,
filePath,
name: "file",
formData,
header: token ? { Authorization: `Bearer ${token}` } : undefined,
success(response) {
const statusCode = response.statusCode;
let body: ApiEnvelope<TResponse> | TResponse | undefined;
try {
body = response.data ? (JSON.parse(response.data) as ApiEnvelope<TResponse> | TResponse) : undefined;
} catch {
body = response.data as TResponse;
}
if (statusCode >= 200 && statusCode < 300) {
if (body && typeof body === "object" && "success" in body) {
const envelope = body as ApiEnvelope<TResponse>;
if (envelope.success === false) {
reject(new ApiError(envelope.error?.message || envelope.message || "上传失败", statusCode, envelope.error?.code));
return;
}
resolve((envelope.data ?? body) as TResponse);
return;
}
resolve(body as TResponse);
return;
}
const envelope = body as ApiEnvelope<TResponse>;
reject(new ApiError(envelope?.error?.message || envelope?.message || `上传失败 ${statusCode}`, statusCode, envelope?.error?.code));
},
fail(error) {
reject(new ApiError(error.errMsg || "上传失败"));
}
});
});
}
import { request, uploadFile } from "@/api/client";
import type { AiTask, AssemblyTask, PageResult, ProjectDetail, ProjectSummary, UploadResult } from "@/types/api";
export interface CreateProjectPayload {
name: string;
description?: string;
type?: string;
aspectRatio?: string;
resolution?: string;
metadata?: Record<string, unknown>;
}
export async function listProjects() {
const result = await request<PageResult<ProjectSummary> | ProjectSummary[]>("/projects");
if (Array.isArray(result)) return result;
return result.records || result.list || result.content || [];
}
export function getProject(projectId: string | number) {
return request<ProjectDetail>(`/projects/${projectId}`);
}
export function createProject(payload: CreateProjectPayload) {
return request<ProjectDetail>("/projects", {
method: "POST",
data: payload
});
}
export function uploadScript(projectId: string | number, filePath: string) {
return uploadFile<UploadResult>(`/projects/${projectId}/assets?assetType=script`, filePath);
}
export function listVideoTasks(projectId: string | number) {
return request<AiTask[]>(`/projects/${projectId}/video-tasks`);
}
export function getAssemblyTask(projectId: string | number, episodeId: string | number) {
return request<AssemblyTask | null>(`/projects/${projectId}/episodes/${episodeId}/assembly`);
}
import { request } from "@/api/client";
import type { AiTask, AssemblyTask } from "@/types/api";
export function listVideoTasks(projectId: string | number) {
return request<AiTask[]>(`/projects/${projectId}/video-tasks`);
}
export function getAssemblyTask(projectId: string | number, episodeId: string | number) {
return request<AssemblyTask | null>(`/projects/${projectId}/episodes/${episodeId}/assembly`);
}
<template>
<view class="timeline">
<view v-for="(step, index) in normalizedSteps" :key="step.key" class="timeline__item">
<view class="timeline__mark" :class="`timeline__mark--${String(step.status).toLowerCase()}`">
<text>{{ index + 1 }}</text>
</view>
<view class="timeline__content">
<view class="timeline__head">
<text class="timeline__name">{{ step.name }}</text>
<text class="timeline__status">{{ statusText(step.status) }}</text>
</view>
<text v-if="step.output" class="timeline__output">{{ step.output }}</text>
<text v-else class="timeline__output muted">{{ stepHint(step.status) }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { AgentRunStatus, AgentStep } from "@/types/api";
import type { RunModeValue } from "@/utils/constants";
import { statusText } from "@/utils/format";
const props = defineProps<{
steps?: AgentStep[];
mode?: RunModeValue;
}>();
const pcRefineFallback = [
{ key: "script_understanding", name: "剧本理解", status: "PENDING" },
{ key: "character_generation", name: "角色设定", status: "PENDING" },
{ key: "storyboard_generation", name: "分镜生成", status: "PENDING" }
] as const;
const quickVideoFallback = [
...pcRefineFallback,
{ key: "image_generation", name: "分镜图生成", status: "PENDING" },
{ key: "video_generation", name: "视频生成", status: "PENDING" },
{ key: "video_composition", name: "合成成片", status: "PENDING" }
] as const;
const fallback = computed(() => (props.mode === "quick_video" ? quickVideoFallback : pcRefineFallback));
const normalizedSteps = computed(() => {
if (!props.steps?.length) return fallback.value.map((item) => ({ ...item, output: "" }));
return [...props.steps]
.sort((a, b) => (a.seq || 0) - (b.seq || 0))
.map((step, index) => ({
key: step.stepKey || String(step.id || index),
name: step.stepName || step.stepKey || `步骤 ${index + 1}`,
status: step.status || "PENDING",
output: step.output || step.scoreDetail || ""
}));
});
function stepHint(status?: AgentRunStatus) {
const normalizedStatus = String(status || "").toUpperCase();
if (normalizedStatus === "RUNNING") return "Agent 正在处理这一环节";
if (normalizedStatus === "DONE") return "已完成";
if (normalizedStatus === "FAILED") return "执行失败,请回到网页端查看详情";
return "等待开始";
}
</script>
<style scoped lang="scss">
.timeline {
padding: 8rpx 0;
}
.timeline__item {
display: flex;
gap: 18rpx;
padding: 18rpx 0;
}
.timeline__mark {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: #eef2ff;
color: #5b5cf6;
font-size: 22rpx;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.timeline__mark--running {
background: #d8faf3;
color: #0f766e;
}
.timeline__mark--done,
.timeline__mark--skipped {
background: #dcfce7;
color: #166534;
}
.timeline__mark--failed {
background: #fee2e2;
color: #991b1b;
}
.timeline__content {
flex: 1;
min-width: 0;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #eef1f6;
}
.timeline__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.timeline__name {
color: #182230;
font-size: 28rpx;
font-weight: 750;
}
.timeline__status {
color: #667085;
font-size: 23rpx;
}
.timeline__output {
display: block;
margin-top: 10rpx;
color: #475467;
font-size: 24rpx;
line-height: 1.5;
}
</style>
<template>
<view class="project-card panel" @tap="$emit('open')">
<view class="project-card__cover">
<text>{{ indexLabel }}</text>
</view>
<view class="project-card__body">
<view class="project-card__top">
<text class="project-card__title">{{ title }}</text>
<text class="status-pill" :class="statusClass(project.status)">{{ statusText(project.status) }}</text>
</view>
<text class="project-card__desc">{{ project.description || "等待 Agent 制作内容" }}</text>
<text class="project-card__time">{{ formatDateTime(project.updatedAt || project.createdAt) }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { ProjectSummary } from "@/types/api";
import { formatDateTime, statusClass, statusText } from "@/utils/format";
const props = defineProps<{
project: ProjectSummary;
index?: number;
}>();
defineEmits<{
(event: "open"): void;
}>();
const title = computed(() => props.project.name || props.project.title || `项目 ${props.project.id}`);
const indexLabel = computed(() => String((props.index || 0) + 1).padStart(2, "0"));
</script>
<style scoped lang="scss">
.project-card {
display: flex;
gap: 22rpx;
padding: 22rpx;
}
.project-card__cover {
width: 112rpx;
height: 112rpx;
border-radius: 14rpx;
background: linear-gradient(145deg, #20283a, #5b5cf6);
color: #ffffff;
font-size: 28rpx;
font-weight: 800;
display: flex;
align-items: flex-end;
justify-content: flex-start;
padding: 16rpx;
flex-shrink: 0;
}
.project-card__body {
min-width: 0;
flex: 1;
}
.project-card__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.project-card__title {
min-width: 0;
flex: 1;
color: #141923;
font-size: 29rpx;
font-weight: 750;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-card__desc {
display: -webkit-box;
margin-top: 12rpx;
color: #667085;
font-size: 25rpx;
line-height: 1.45;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.project-card__time {
display: block;
margin-top: 14rpx;
color: #98a2b3;
font-size: 22rpx;
}
</style>
<template>
<view class="upload panel" @tap="pickFile">
<view class="upload__icon">+</view>
<view class="upload__body">
<text class="upload__title">{{ fileName || "上传剧本文件" }}</text>
<text class="upload__desc">支持 doc、docx、txt、pdf、md,上传后可直接启动 Agent</text>
</view>
</view>
</template>
<script setup lang="ts">
import { SCRIPT_EXTENSIONS } from "@/utils/constants";
defineProps<{
fileName?: string;
}>();
const emit = defineEmits<{
(event: "picked", payload: { path: string; name: string }): void;
}>();
function pickFile() {
const chooseMessageFile = (uni as unknown as {
chooseMessageFile?: (options: Record<string, unknown>) => void;
}).chooseMessageFile;
const success = (result: { tempFiles?: Array<{ path: string; name?: string }> }) => {
const file = result.tempFiles?.[0];
if (!file) return;
emit("picked", {
path: file.path,
name: file.name || "剧本文件"
});
};
if (chooseMessageFile) {
chooseMessageFile({
count: 1,
type: "file",
extension: SCRIPT_EXTENSIONS,
success: success as (result: unknown) => void
});
return;
}
uni.chooseFile({
count: 1,
type: "all",
extension: SCRIPT_EXTENSIONS,
success: success as (result: UniApp.ChooseFileSuccessCallbackResult) => void
});
}
</script>
<style scoped lang="scss">
.upload {
display: flex;
align-items: center;
gap: 20rpx;
padding: 28rpx;
}
.upload__icon {
width: 74rpx;
height: 74rpx;
border-radius: 18rpx;
background: #eef2ff;
color: #5b5cf6;
font-size: 42rpx;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.upload__body {
min-width: 0;
flex: 1;
}
.upload__title {
display: block;
color: #141923;
font-size: 29rpx;
font-weight: 750;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload__desc {
display: block;
margin-top: 8rpx;
color: #667085;
font-size: 24rpx;
line-height: 1.45;
}
</style>
<template>
<view class="preview panel">
<video v-if="src" class="preview__video" :src="src" controls object-fit="contain" />
<view v-else class="preview__empty">
<text class="preview__title">视频生成完成后会出现在这里</text>
<text class="preview__desc">当前小程序先承接创建和进度查看,成片地址接入后可直接预览。</text>
</view>
</view>
</template>
<script setup lang="ts">
defineProps<{
src?: string;
}>();
</script>
<style scoped lang="scss">
.preview {
overflow: hidden;
background: #101522;
}
.preview__video,
.preview__empty {
width: 100%;
height: 720rpx;
}
.preview__empty {
padding: 44rpx;
display: flex;
flex-direction: column;
justify-content: flex-end;
background:
linear-gradient(180deg, rgba(16, 21, 34, 0.2), rgba(16, 21, 34, 0.96)),
radial-gradient(circle at 20% 16%, rgba(91, 92, 246, 0.44), transparent 36%),
#101522;
}
.preview__title {
color: #ffffff;
font-size: 32rpx;
font-weight: 800;
}
.preview__desc {
display: block;
margin-top: 14rpx;
color: rgba(255, 255, 255, 0.72);
font-size: 25rpx;
line-height: 1.55;
}
</style>
/// <reference types="@dcloudio/types" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}
import { createSSRApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
export function createApp() {
const app = createSSRApp(App);
app.use(createPinia());
return {
app
};
}
{
"name": "瑶光AI短剧",
"appid": "__UNI__YAOAI",
"description": "AI短剧快速生成助手",
"versionName": "0.1.0",
"versionCode": "100",
"transformPx": false,
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false,
"es6": true,
"postcss": true,
"minified": true
},
"usingComponents": true
},
"h5": {
"router": {
"mode": "hash"
}
}
}
{
"pages": [
{
"path": "pages/home/index",
"style": {
"navigationBarTitleText": "瑶光 AI 短剧",
"navigationBarBackgroundColor": "#f7f8fc",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/create/index",
"style": {
"navigationBarTitleText": "创建视频任务"
}
},
{
"path": "pages/projects/index",
"style": {
"navigationBarTitleText": "项目"
}
},
{
"path": "pages/project-progress/index",
"style": {
"navigationBarTitleText": "Agent 制作"
}
},
{
"path": "pages/result/index",
"style": {
"navigationBarTitleText": "视频结果"
}
},
{
"path": "pages/mine/index",
"style": {
"navigationBarTitleText": "我的"
}
},
{
"path": "pages/login/index",
"style": {
"navigationBarTitleText": "登录"
}
}
],
"tabBar": {
"color": "#667085",
"selectedColor": "#5b5cf6",
"backgroundColor": "#ffffff",
"borderStyle": "white",
"list": [
{
"pagePath": "pages/home/index",
"text": "制作"
},
{
"pagePath": "pages/projects/index",
"text": "项目"
},
{
"pagePath": "pages/mine/index",
"text": "我的"
}
]
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "瑶光 AI 短剧",
"navigationBarBackgroundColor": "#f7f8fc",
"backgroundColor": "#f7f8fc"
}
}
<template>
<view class="page create-page">
<text class="page-title">创建视频任务</text>
<text class="page-subtitle">选择制作模式后上传剧本,Agent 会按不同链路推进。</text>
<view class="mode-switch panel">
<button
v-for="mode in RUN_MODES"
:key="mode.value"
class="mode-switch__item"
:class="{ 'mode-switch__item--active': runMode === mode.value }"
@tap="runMode = mode.value"
>
<text>{{ mode.label }}</text>
</button>
</view>
<view class="mode-card panel">
<text class="mode-card__title">{{ selectedMode.title }}</text>
<text class="mode-card__desc">{{ selectedMode.description }}</text>
</view>
<view class="form">
<view>
<text class="field-label">项目名称</text>
<input v-model="projectName" class="field-input" placeholder="例如:都市逆袭第 1 集" />
</view>
<UploadPanel :file-name="fileName" @picked="handlePicked" />
<view>
<text class="field-label">制作目标</text>
<textarea v-model="goal" class="field-textarea" placeholder="描述你希望生成的视频风格、比例、集数、节奏或重点角色。" />
</view>
<view class="option-grid">
<view class="option panel">
<text class="option__label">风格</text>
<picker :range="styleLabels" :value="styleIndex" @change="styleIndex = Number($event.detail.value)">
<text class="option__value">{{ styleLabels[styleIndex] }}</text>
</picker>
</view>
<view class="option panel">
<text class="option__label">画幅</text>
<picker :range="ASPECT_RATIOS" :value="ratioIndex" @change="ratioIndex = Number($event.detail.value)">
<text class="option__value">{{ ASPECT_RATIOS[ratioIndex] }}</text>
</picker>
</view>
<view class="option panel">
<text class="option__label">清晰度</text>
<picker :range="RESOLUTIONS" :value="resolutionIndex" @change="resolutionIndex = Number($event.detail.value)">
<text class="option__value">{{ RESOLUTIONS[resolutionIndex] }}</text>
</picker>
</view>
</view>
<button class="primary-button" :loading="submitting" @tap="submit">{{ selectedMode.actionLabel }}</button>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import UploadPanel from "@/components/UploadPanel.vue";
import { createProject, uploadScript } from "@/api/projects";
import { startAgentRun } from "@/api/agent";
import { ASPECT_RATIOS, PROJECT_STYLES, RESOLUTIONS, RUN_MODES, type RunModeValue } from "@/utils/constants";
const projectName = ref("");
const goal = ref("");
const filePath = ref("");
const fileName = ref("");
const styleIndex = ref(0);
const ratioIndex = ref(0);
const resolutionIndex = ref(1);
const runMode = ref<RunModeValue>("pc_refine");
const submitting = ref(false);
const styleLabels = computed(() => PROJECT_STYLES.map((item) => item.label));
const selectedMode = computed(() => RUN_MODES.find((mode) => mode.value === runMode.value) || RUN_MODES[0]);
onLoad((query) => {
const idea = query?.idea ? decodeURIComponent(String(query.idea)) : "";
const mode = query?.mode ? String(query.mode) : "";
goal.value = idea;
projectName.value = idea ? idea.slice(0, 20) : "";
if (mode === "pc_refine" || mode === "quick_video") {
runMode.value = mode;
}
});
function handlePicked(file: { path: string; name: string }) {
filePath.value = file.path;
fileName.value = file.name;
if (!projectName.value) {
projectName.value = file.name.replace(/\.[^.]+$/, "");
}
}
async function submit() {
if (!projectName.value.trim()) {
uni.showToast({ title: "请输入项目名称", icon: "none" });
return;
}
if (!goal.value.trim()) {
uni.showToast({ title: "请输入制作目标", icon: "none" });
return;
}
submitting.value = true;
uni.showLoading({ title: runMode.value === "quick_video" ? "出片中" : "启动中" });
try {
const mode = selectedMode.value;
const project = await createProject({
name: projectName.value,
description: goal.value,
type: PROJECT_STYLES[styleIndex.value].value,
aspectRatio: ASPECT_RATIOS[ratioIndex.value],
resolution: RESOLUTIONS[resolutionIndex.value],
metadata: {
source: "miniapp",
runMode: mode.value,
runModeTitle: mode.title,
style: PROJECT_STYLES[styleIndex.value],
aspectRatio: ASPECT_RATIOS[ratioIndex.value],
resolution: RESOLUTIONS[resolutionIndex.value]
}
});
if (filePath.value) {
await uploadScript(project.id, filePath.value);
}
await startAgentRun(project.id, {
goal: goal.value,
runIntent: mode.runIntent,
selectedSteps: mode.selectedSteps,
contextOptions: {
useScriptAsset: Boolean(filePath.value),
useExistingOutline: true,
useExistingEpisodes: true,
useExistingAssets: true,
useExistingStoryboards: true
}
});
uni.navigateTo({ url: `/pages/project-progress/index?projectId=${project.id}` });
} catch (error) {
uni.showToast({ title: error instanceof Error ? error.message : "启动失败", icon: "none" });
} finally {
submitting.value = false;
uni.hideLoading();
}
}
</script>
<style scoped lang="scss">
.mode-switch {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8rpx;
margin-top: 30rpx;
padding: 8rpx;
background: #eef1f7;
box-shadow: none;
}
.mode-switch__item {
height: 76rpx;
border-radius: 12rpx;
color: #667085;
font-size: 27rpx;
font-weight: 760;
display: flex;
align-items: center;
justify-content: center;
}
.mode-switch__item--active {
background: #ffffff;
color: #111827;
box-shadow: 0 10rpx 24rpx rgba(17, 24, 39, 0.08);
}
.mode-card {
margin-top: 18rpx;
padding: 26rpx;
}
.mode-card__title {
display: block;
color: #141923;
font-size: 30rpx;
font-weight: 820;
}
.mode-card__desc {
display: block;
margin-top: 10rpx;
color: #667085;
font-size: 25rpx;
line-height: 1.55;
}
.form {
display: flex;
flex-direction: column;
gap: 28rpx;
margin-top: 28rpx;
}
.option-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16rpx;
}
.option {
padding: 22rpx;
}
.option__label {
display: block;
color: #667085;
font-size: 23rpx;
}
.option__value {
display: block;
margin-top: 14rpx;
color: #141923;
font-size: 28rpx;
font-weight: 760;
}
</style>
<template>
<view class="page home-page">
<view class="home-head">
<view>
<text class="page-title">今天生成哪条短剧?</text>
<text class="page-subtitle">手机端可以快速出片,也可以先让 Agent 制作素材后回 PC 精修。</text>
</view>
<button class="home-head__mine" @tap="goMine">我的</button>
</view>
<view class="quick panel">
<textarea v-model="idea" class="quick__textarea" placeholder="例如:把这个都市逆袭剧本做成 9:16 真人短剧,节奏紧凑,先生成前 10 个分镜。" />
<view class="quick__actions">
<button class="primary-button quick__primary" @tap="startQuickVideo">极速出片</button>
<button class="secondary-button quick__secondary" @tap="goCreate">上传剧本</button>
</view>
<button class="ghost-button quick__refine" @tap="goCreateForRefine">Agent 制作后去 PC 精修</button>
</view>
<view class="section-head">
<text>最近项目</text>
<button @tap="goProjects">全部</button>
</view>
<view v-if="loading" class="empty panel">
<text>正在读取项目...</text>
</view>
<view v-else-if="projects.length" class="project-list">
<ProjectCard v-for="(project, index) in projects" :key="project.id" :project="project" :index="index" @open="openProject(project.id)" />
</view>
<view v-else class="empty panel">
<text class="empty__title">还没有项目</text>
<text class="empty__desc">先创建一个任务,后面所有 Agent 进度都会在这里汇总。</text>
</view>
</view>
</template>
<script setup lang="ts">
import { onShow } from "@dcloudio/uni-app";
import { ref } from "vue";
import ProjectCard from "@/components/ProjectCard.vue";
import { createProject, listProjects } from "@/api/projects";
import { startAgentRun } from "@/api/agent";
import type { ProjectSummary } from "@/types/api";
import { useAuthStore } from "@/stores/auth";
import { RUN_MODES } from "@/utils/constants";
const auth = useAuthStore();
const idea = ref("");
const projects = ref<ProjectSummary[]>([]);
const loading = ref(false);
onShow(() => {
auth.hydrate();
if (!auth.isLoggedIn) {
uni.reLaunch({ url: "/pages/login/index" });
return;
}
fetchProjects();
});
async function fetchProjects() {
loading.value = true;
try {
const result = await listProjects();
projects.value = result.slice(0, 4);
} catch (error) {
uni.showToast({ title: error instanceof Error ? error.message : "项目读取失败", icon: "none" });
} finally {
loading.value = false;
}
}
function createUrl(mode: "pc_refine" | "quick_video") {
const encodedIdea = encodeURIComponent(idea.value);
return `/pages/create/index?idea=${encodedIdea}&mode=${mode}`;
}
function goCreate() {
uni.navigateTo({ url: createUrl("quick_video") });
}
function goCreateForRefine() {
uni.navigateTo({ url: createUrl("pc_refine") });
}
async function startQuickVideo() {
if (!idea.value.trim()) {
uni.showToast({ title: "先写一句创作目标", icon: "none" });
return;
}
const quickMode = RUN_MODES.find((mode) => mode.value === "quick_video") || RUN_MODES[1];
uni.showLoading({ title: "极速出片中" });
try {
const project = await createProject({
name: idea.value.slice(0, 20),
description: idea.value,
type: "miniapp_quick_video",
metadata: {
source: "miniapp",
runMode: quickMode.value,
runModeTitle: quickMode.title,
quickIdea: idea.value
}
});
await startAgentRun(project.id, {
goal: idea.value,
runIntent: quickMode.runIntent,
selectedSteps: quickMode.selectedSteps,
contextOptions: {
useScriptAsset: false,
useExistingOutline: true,
useExistingEpisodes: true,
useExistingAssets: true,
useExistingStoryboards: true
}
});
uni.navigateTo({ url: `/pages/project-progress/index?projectId=${project.id}` });
} catch (error) {
uni.showToast({ title: error instanceof Error ? error.message : "创建失败", icon: "none" });
} finally {
uni.hideLoading();
}
}
function openProject(projectId: string | number) {
uni.navigateTo({ url: `/pages/project-progress/index?projectId=${projectId}` });
}
function goProjects() {
uni.switchTab({ url: "/pages/projects/index" });
}
function goMine() {
uni.switchTab({ url: "/pages/mine/index" });
}
</script>
<style scoped lang="scss">
.home-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 22rpx;
}
.home-head__mine {
width: 104rpx;
height: 62rpx;
border-radius: 999rpx;
background: #ffffff;
color: #475467;
font-size: 24rpx;
font-weight: 700;
box-shadow: 0 10rpx 28rpx rgba(17, 24, 39, 0.06);
}
.quick {
margin-top: 32rpx;
padding: 26rpx;
}
.quick__textarea {
width: 100%;
min-height: 210rpx;
color: #182230;
font-size: 29rpx;
line-height: 1.55;
}
.quick__actions {
display: flex;
gap: 18rpx;
margin-top: 22rpx;
}
.quick__primary {
flex: 1.15;
}
.quick__secondary {
flex: 1;
}
.quick__refine {
margin-top: 8rpx;
}
.section-head {
margin-top: 38rpx;
margin-bottom: 18rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.section-head text {
color: #141923;
font-size: 31rpx;
font-weight: 800;
}
.section-head button {
color: #5b5cf6;
font-size: 25rpx;
font-weight: 700;
}
.project-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.empty {
padding: 38rpx;
}
.empty__title {
display: block;
color: #182230;
font-size: 29rpx;
font-weight: 760;
}
.empty__desc {
display: block;
margin-top: 10rpx;
color: #667085;
font-size: 25rpx;
line-height: 1.5;
}
</style>
<template>
<view class="page login-page">
<view class="login-brand">
<text class="login-brand__mark">AI</text>
<text class="page-title">瑶光 AI 短剧</text>
<text class="page-subtitle">登录后即可在手机上上传剧本、启动 Agent、查看制作进度。</text>
</view>
<view class="login-form panel">
<text class="field-label">账号</text>
<input
:value="email"
class="field-input"
placeholder="请输入邮箱"
type="text"
confirm-type="next"
cursor-spacing="24"
:adjust-position="true"
@input="handleEmailInput"
/>
<text class="field-label login-form__label">密码</text>
<input
:value="password"
class="field-input"
placeholder="请输入密码"
password
confirm-type="done"
cursor-spacing="24"
:adjust-position="true"
@input="handlePasswordInput"
@confirm="handleLogin"
/>
<button class="primary-button login-form__button" :loading="loading" @tap="handleLogin">登录</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const email = ref("");
const password = ref("");
const loading = ref(false);
function inputValue(event: unknown) {
const inputEvent = event as {
detail?: string | number | { value?: string | number };
target?: { value?: string | number };
};
if (typeof inputEvent.detail === "object" && inputEvent.detail) {
return String(inputEvent.detail.value || "");
}
return String(inputEvent.detail ?? inputEvent.target?.value ?? "");
}
function handleEmailInput(event: unknown) {
email.value = inputValue(event);
}
function handlePasswordInput(event: unknown) {
password.value = inputValue(event);
}
async function handleLogin() {
if (!email.value || !password.value) {
uni.showToast({ title: "请输入账号和密码", icon: "none" });
return;
}
loading.value = true;
try {
await auth.login(email.value, password.value);
uni.switchTab({ url: "/pages/home/index" });
} catch (error) {
uni.showToast({ title: error instanceof Error ? error.message : "登录失败", icon: "none" });
} finally {
loading.value = false;
}
}
</script>
<style scoped lang="scss">
.login-page {
padding-top: 96rpx;
}
.login-brand__mark {
width: 92rpx;
height: 92rpx;
margin-bottom: 28rpx;
border-radius: 24rpx;
background: #141923;
color: #ffffff;
font-size: 30rpx;
font-weight: 900;
display: flex;
align-items: center;
justify-content: center;
}
.login-form {
margin-top: 56rpx;
padding: 30rpx;
}
.login-form__label {
margin-top: 28rpx;
}
.login-form__button {
margin-top: 36rpx;
}
</style>
<template>
<view class="page mine-page">
<view class="profile panel">
<view class="profile__avatar">{{ avatarText }}</view>
<view class="profile__body">
<text class="profile__name">{{ auth.user?.displayName || auth.user?.username || "创作者" }}</text>
<text class="profile__email">{{ auth.user?.email || "未登录" }}</text>
</view>
</view>
<view class="setting panel">
<view class="setting__row">
<text>后端地址</text>
<text>{{ API_BASE_URL }}</text>
</view>
<view class="setting__row">
<text>当前端</text>
<text>微信小程序 / H5</text>
</view>
</view>
<button v-if="auth.isLoggedIn" class="secondary-button logout" @tap="auth.logout">退出登录</button>
<button v-else class="primary-button logout" @tap="goLogin">去登录</button>
</view>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { onShow } from "@dcloudio/uni-app";
import { useAuthStore } from "@/stores/auth";
import { API_BASE_URL } from "@/utils/constants";
const auth = useAuthStore();
const avatarText = computed(() => {
const name = auth.user?.displayName || auth.user?.username || auth.user?.email || "AI";
return name.slice(0, 2).toUpperCase();
});
onShow(() => {
auth.hydrate();
});
function goLogin() {
uni.reLaunch({ url: "/pages/login/index" });
}
</script>
<style scoped lang="scss">
.profile {
display: flex;
align-items: center;
gap: 22rpx;
padding: 30rpx;
}
.profile__avatar {
width: 104rpx;
height: 104rpx;
border-radius: 28rpx;
background: #141923;
color: #ffffff;
font-size: 28rpx;
font-weight: 900;
display: flex;
align-items: center;
justify-content: center;
}
.profile__name {
display: block;
color: #141923;
font-size: 32rpx;
font-weight: 820;
}
.profile__email {
display: block;
margin-top: 8rpx;
color: #667085;
font-size: 25rpx;
}
.setting {
margin-top: 24rpx;
padding: 8rpx 28rpx;
}
.setting__row {
display: flex;
justify-content: space-between;
gap: 24rpx;
padding: 26rpx 0;
border-bottom: 1rpx solid #eef1f6;
color: #475467;
font-size: 26rpx;
}
.setting__row:last-child {
border-bottom: 0;
}
.setting__row text:last-child {
color: #182230;
text-align: right;
}
.logout {
margin-top: 28rpx;
}
</style>
<template>
<view class="page progress-page">
<view class="progress-head panel">
<view class="progress-head__top">
<view>
<text class="progress-head__label">{{ modeTitle }}</text>
<text class="progress-head__title">{{ project?.name || project?.title || "视频项目" }}</text>
</view>
<text class="status-pill" :class="statusClass(run?.status)">{{ statusText(run?.status) }}</text>
</view>
<progress class="progress-head__bar" :percent="percent" activeColor="#5b5cf6" backgroundColor="#e8ecf5" stroke-width="8" />
<text class="progress-head__goal">{{ run?.goal || project?.description || "等待 Agent 返回任务目标" }}</text>
</view>
<view class="timeline-panel panel">
<ProgressTimeline :steps="run?.steps" :mode="runMode" />
</view>
<view class="actions">
<button v-if="run?.status === 'RUNNING'" class="secondary-button" @tap="pause">暂停</button>
<button v-else-if="run?.status === 'PAUSED'" class="secondary-button" @tap="resume">继续</button>
<button class="primary-button" :class="{ disabled: run?.status !== 'DONE' }" @tap="goResult">{{ resultButtonText }}</button>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onUnmounted, ref } from "vue";
import { onLoad, onPullDownRefresh } from "@dcloudio/uni-app";
import ProgressTimeline from "@/components/ProgressTimeline.vue";
import { getLatestRun, pauseRun, resumeRun } from "@/api/agent";
import { getProject } from "@/api/projects";
import type { AgentRun, ProjectDetail } from "@/types/api";
import type { RunModeValue } from "@/utils/constants";
import { statusClass, statusText, stepProgress } from "@/utils/format";
const projectId = ref<string | number>("");
const project = ref<ProjectDetail | null>(null);
const run = ref<AgentRun | null>(null);
let timer: ReturnType<typeof setInterval> | undefined;
const projectMetadata = computed(() => (project.value?.metadata || {}) as { runMode?: RunModeValue; runModeTitle?: string });
const runMode = computed<RunModeValue>(() => {
if (projectMetadata.value.runMode === "quick_video") return "quick_video";
return "pc_refine";
});
const modeTitle = computed(() => projectMetadata.value.runModeTitle || (runMode.value === "quick_video" ? "极速出片" : "Agent 制作,PC 精修"));
const resultButtonText = computed(() => (runMode.value === "quick_video" ? "查看视频结果" : "回 PC 精修"));
const percent = computed(() => {
if (!run.value?.steps?.length) return run.value?.status === "DONE" ? 100 : 0;
return stepProgress(run.value.steps.map((step) => step.status));
});
onLoad((query) => {
projectId.value = String(query?.projectId || "");
if (!projectId.value) {
uni.showToast({ title: "缺少项目 ID", icon: "none" });
return;
}
refresh();
timer = setInterval(refreshRun, 4000);
});
onPullDownRefresh(async () => {
await refresh();
uni.stopPullDownRefresh();
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
async function refresh() {
await Promise.all([refreshProject(), refreshRun()]);
}
async function refreshProject() {
try {
project.value = await getProject(projectId.value);
} catch {
project.value = null;
}
}
async function refreshRun() {
if (!projectId.value) return;
try {
run.value = await getLatestRun(projectId.value);
} catch (error) {
uni.showToast({ title: error instanceof Error ? error.message : "进度读取失败", icon: "none" });
}
}
async function pause() {
if (!run.value?.id) return;
run.value = await pauseRun(run.value.id);
}
async function resume() {
if (!run.value?.id) return;
run.value = await resumeRun(run.value.id);
}
function goResult() {
if (run.value?.status !== "DONE") {
uni.showToast({ title: runMode.value === "quick_video" ? "出片完成后可查看结果" : "Agent 完成后可回 PC 精修", icon: "none" });
return;
}
uni.navigateTo({ url: `/pages/result/index?projectId=${projectId.value}` });
}
</script>
<style scoped lang="scss">
.progress-head {
padding: 28rpx;
}
.progress-head__top {
display: flex;
justify-content: space-between;
gap: 20rpx;
}
.progress-head__label {
display: block;
color: #5b5cf6;
font-size: 24rpx;
font-weight: 800;
}
.progress-head__title {
display: block;
margin-top: 8rpx;
color: #141923;
font-size: 34rpx;
font-weight: 820;
}
.progress-head__bar {
margin-top: 28rpx;
}
.progress-head__goal {
display: block;
margin-top: 20rpx;
color: #667085;
font-size: 25rpx;
line-height: 1.55;
}
.timeline-panel {
margin-top: 22rpx;
padding: 10rpx 28rpx;
}
.actions {
display: grid;
grid-template-columns: 1fr 1.5fr;
gap: 18rpx;
margin-top: 26rpx;
padding-bottom: 40rpx;
}
.actions .primary-button:only-child {
grid-column: 1 / -1;
}
.disabled {
opacity: 0.58;
}
</style>
<template>
<view class="page projects-page">
<view class="projects-head">
<view>
<text class="page-title">项目</text>
<text class="page-subtitle">所有从小程序或网页创建的视频项目都会在这里汇总。</text>
</view>
<button class="projects-head__add" @tap="goCreate">+</button>
</view>
<view v-if="loading" class="empty panel">
<text>正在加载...</text>
</view>
<view v-else-if="projects.length" class="project-list">
<ProjectCard v-for="(project, index) in projects" :key="project.id" :project="project" :index="index" @open="openProject(project.id)" />
</view>
<view v-else class="empty panel">
<text class="empty__title">暂无项目</text>
<text class="empty__desc">创建第一个视频任务后,进度和结果会自动出现在这里。</text>
<button class="primary-button empty__button" @tap="goCreate">创建任务</button>
</view>
</view>
</template>
<script setup lang="ts">
import { onShow } from "@dcloudio/uni-app";
import { ref } from "vue";
import ProjectCard from "@/components/ProjectCard.vue";
import { listProjects } from "@/api/projects";
import type { ProjectSummary } from "@/types/api";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const projects = ref<ProjectSummary[]>([]);
const loading = ref(false);
onShow(() => {
auth.hydrate();
if (!auth.isLoggedIn) {
uni.reLaunch({ url: "/pages/login/index" });
return;
}
fetchProjects();
});
async function fetchProjects() {
loading.value = true;
try {
projects.value = await listProjects();
} catch (error) {
uni.showToast({ title: error instanceof Error ? error.message : "项目读取失败", icon: "none" });
} finally {
loading.value = false;
}
}
function openProject(projectId: string | number) {
uni.navigateTo({ url: `/pages/project-progress/index?projectId=${projectId}` });
}
function goCreate() {
uni.navigateTo({ url: "/pages/create/index" });
}
</script>
<style scoped lang="scss">
.projects-head {
display: flex;
justify-content: space-between;
gap: 24rpx;
}
.projects-head__add {
width: 76rpx;
height: 76rpx;
border-radius: 22rpx;
background: #141923;
color: #ffffff;
font-size: 40rpx;
display: flex;
align-items: center;
justify-content: center;
}
.project-list {
display: flex;
flex-direction: column;
gap: 18rpx;
margin-top: 32rpx;
}
.empty {
margin-top: 32rpx;
padding: 38rpx;
}
.empty__title {
display: block;
color: #182230;
font-size: 30rpx;
font-weight: 780;
}
.empty__desc {
display: block;
margin-top: 10rpx;
color: #667085;
font-size: 25rpx;
line-height: 1.5;
}
.empty__button {
margin-top: 28rpx;
}
</style>
<template>
<view class="page result-page">
<text class="page-title">视频结果</text>
<text class="page-subtitle">{{ project?.name || project?.title || "视频项目" }}</text>
<VideoPreview :src="videoUrl" class="result-preview" />
<view class="result-info panel">
<text class="result-info__title">制作摘要</text>
<view class="result-info__row">
<text>当前状态</text>
<text>{{ statusText(run?.status) }}</text>
</view>
<view class="result-info__row">
<text>Agent 步骤</text>
<text>{{ run?.steps?.length || 0 }}</text>
</view>
<view class="result-info__row">
<text>视频任务</text>
<text>{{ succeededVideoCount }}/{{ videoTasks.length }} 完成</text>
</view>
<view v-if="assemblyStatus" class="result-info__row">
<text>合成状态</text>
<text>{{ assemblyStatus }}</text>
</view>
<view class="result-info__row">
<text>制作目标</text>
<text>{{ run?.goal || project?.description || "暂无" }}</text>
</view>
</view>
<button class="secondary-button result-back" @tap="backToProgress">回到进度</button>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import VideoPreview from "@/components/VideoPreview.vue";
import { getLatestRun } from "@/api/agent";
import { getProject } from "@/api/projects";
import { getAssemblyTask, listVideoTasks } from "@/api/video";
import type { AgentRun, AiTask, AssemblyTask, ProjectDetail } from "@/types/api";
import { statusText } from "@/utils/format";
const projectId = ref("");
const project = ref<ProjectDetail | null>(null);
const run = ref<AgentRun | null>(null);
const videoUrl = ref("");
const videoTasks = ref<AiTask[]>([]);
const assemblies = ref<AssemblyTask[]>([]);
const succeededVideoCount = computed(() => videoTasks.value.filter((task) => task.status === "succeeded").length);
const assemblyStatus = computed(() => {
if (!assemblies.value.length) return "";
const succeeded = assemblies.value.filter((task) => task.status === "succeeded").length;
return `${succeeded}/${assemblies.value.length} 完成`;
});
onLoad(async (query) => {
projectId.value = String(query?.projectId || "");
if (!projectId.value) return;
await loadResult();
});
async function loadResult() {
try {
const [projectResult, runResult, taskResult] = await Promise.all([
getProject(projectId.value),
getLatestRun(projectId.value),
listVideoTasks(projectId.value)
]);
project.value = projectResult;
run.value = runResult;
videoTasks.value = taskResult || [];
await loadAssemblies();
videoUrl.value = pickBestVideoUrl();
} catch (error) {
uni.showToast({ title: error instanceof Error ? error.message : "结果读取失败", icon: "none" });
}
}
async function loadAssemblies() {
const episodeIds = Array.from(
new Set(videoTasks.value.map((task) => task.episodeId).filter((episodeId): episodeId is string | number => Boolean(episodeId)))
);
const results = await Promise.all(episodeIds.map((episodeId) => getAssemblyTask(projectId.value, episodeId).catch(() => null)));
assemblies.value = results.filter((task): task is AssemblyTask => Boolean(task));
}
function pickBestVideoUrl() {
const assembly = assemblies.value.find((task) => task.status === "succeeded" && task.downloadUrl);
if (assembly?.downloadUrl) return assembly.downloadUrl;
const shot = videoTasks.value.find((task) => task.status === "succeeded" && task.resultVideoUrl);
return shot?.resultVideoUrl || "";
}
function backToProgress() {
uni.navigateTo({ url: `/pages/project-progress/index?projectId=${projectId.value}` });
}
</script>
<style scoped lang="scss">
.result-preview {
margin-top: 28rpx;
}
.result-info {
margin-top: 24rpx;
padding: 28rpx;
}
.result-info__title {
display: block;
margin-bottom: 16rpx;
color: #141923;
font-size: 30rpx;
font-weight: 800;
}
.result-info__row {
display: flex;
justify-content: space-between;
gap: 28rpx;
padding: 18rpx 0;
border-top: 1rpx solid #eef1f6;
color: #475467;
font-size: 25rpx;
line-height: 1.45;
}
.result-info__row text:last-child {
flex: 1;
color: #182230;
text-align: right;
}
.result-back {
margin-top: 26rpx;
}
</style>
import { defineStore } from "pinia";
import { login as loginApi } from "@/api/auth";
import { AUTH_TOKEN_KEY, AUTH_USER_KEY } from "@/utils/constants";
import type { UserProfile } from "@/types/api";
interface AuthState {
token: string;
user: UserProfile | null;
}
export const useAuthStore = defineStore("auth", {
state: (): AuthState => ({
token: "",
user: null
}),
getters: {
isLoggedIn: (state) => Boolean(state.token)
},
actions: {
hydrate() {
this.token = uni.getStorageSync(AUTH_TOKEN_KEY) || "";
const storedUser = uni.getStorageSync(AUTH_USER_KEY);
this.user = storedUser ? JSON.parse(storedUser) : null;
},
async login(email: string, password: string) {
const result = await loginApi(email, password);
this.token = result.token;
this.user = result.user || { email };
uni.setStorageSync(AUTH_TOKEN_KEY, result.token);
uni.setStorageSync(AUTH_USER_KEY, JSON.stringify(this.user));
},
logout() {
this.token = "";
this.user = null;
uni.removeStorageSync(AUTH_TOKEN_KEY);
uni.removeStorageSync(AUTH_USER_KEY);
uni.reLaunch({ url: "/pages/login/index" });
}
}
});
page {
min-height: 100%;
background: #f7f8fc;
color: #141923;
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
}
/* #ifdef H5 */
body {
margin: 0;
background: #edf1f7;
}
#app {
min-height: 100vh;
max-width: 430px;
margin: 0 auto;
background: #f7f8fc;
box-shadow: 0 0 60rpx rgba(17, 24, 39, 0.08);
}
/* #endif */
view,
text,
button,
input,
textarea,
scroll-view {
box-sizing: border-box;
}
button {
margin: 0;
padding: 0;
line-height: 1;
border-radius: 0;
background: transparent;
}
button::after {
border: 0;
}
.page {
min-height: 100vh;
padding: 28rpx;
}
.page-title {
display: block;
font-size: 42rpx;
font-weight: 800;
color: #111827;
letter-spacing: 0;
}
.page-subtitle {
display: block;
margin-top: 10rpx;
font-size: 26rpx;
line-height: 1.6;
color: #667085;
}
.panel {
border: 1rpx solid #e7eaf3;
border-radius: 16rpx;
background: #ffffff;
box-shadow: 0 16rpx 40rpx rgba(17, 24, 39, 0.05);
}
.primary-button {
height: 92rpx;
border-radius: 16rpx;
background: #5b5cf6;
color: #ffffff;
font-size: 30rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.secondary-button {
height: 84rpx;
border: 1rpx solid #d9deea;
border-radius: 16rpx;
color: #252b37;
background: #ffffff;
font-size: 28rpx;
font-weight: 650;
display: flex;
align-items: center;
justify-content: center;
}
.ghost-button {
height: 76rpx;
color: #5b5cf6;
font-size: 27rpx;
font-weight: 650;
display: flex;
align-items: center;
justify-content: center;
}
.field-label {
display: block;
margin-bottom: 14rpx;
color: #303846;
font-size: 26rpx;
font-weight: 700;
}
.field-input,
.field-textarea {
width: 100%;
border: 1rpx solid #dfe4ef;
border-radius: 14rpx;
background: #ffffff;
color: #141923;
font-size: 28rpx;
}
.field-input {
height: 88rpx;
min-height: 88rpx;
padding: 0 24rpx;
display: block;
position: relative;
z-index: 1;
line-height: 88rpx;
}
.field-textarea {
min-height: 190rpx;
padding: 22rpx 24rpx;
line-height: 1.55;
}
.muted {
color: #667085;
}
.status-pill {
height: 42rpx;
padding: 0 16rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 700;
display: inline-flex;
align-items: center;
}
.status-running {
color: #115e59;
background: #d8faf3;
}
.status-done {
color: #245b1e;
background: #dcfce7;
}
.status-failed {
color: #991b1b;
background: #fee2e2;
}
.status-paused,
.status-pending {
color: #5b3b00;
background: #fff3c4;
}
export interface ApiEnvelope<T> {
success?: boolean;
data?: T;
error?: {
code?: string;
message?: string;
};
message?: string;
}
export interface UserProfile {
id?: string | number;
username?: string;
email?: string;
displayName?: string;
avatarUrl?: string;
roles?: string[];
}
export interface LoginResponse {
token: string;
tokenType?: string;
user?: UserProfile;
}
export interface ProjectSummary {
id: string | number;
name?: string;
title?: string;
description?: string;
status?: string;
type?: string;
createdAt?: string;
updatedAt?: string;
coverUrl?: string;
}
export interface ProjectDetail extends ProjectSummary {
scriptAssetId?: string | number;
metadata?: Record<string, unknown>;
}
export interface PageResult<T> {
records?: T[];
list?: T[];
content?: T[];
total?: number;
}
export type AgentRunStatus = "PENDING" | "RUNNING" | "PAUSED" | "DONE" | "FAILED" | string;
export interface AgentContextOptions {
useScriptAsset?: boolean;
useExistingOutline?: boolean;
useExistingEpisodes?: boolean;
useExistingAssets?: boolean;
useExistingStoryboards?: boolean;
}
export interface AgentStartRequest {
goal: string;
runIntent?: "auto" | "script_understanding" | "continue_existing" | "quick_video" | string;
forceRegenerate?: boolean;
selectedSteps?: string[];
contextOptions?: AgentContextOptions;
idempotencyKey?: string;
}
export interface AgentStep {
id?: string | number;
stepKey?: string;
stepName?: string;
agentType?: "DECISION" | "EXECUTOR" | "SUPERVISION" | string;
status?: AgentRunStatus;
score?: number;
scoreDetail?: string;
output?: string;
durationMs?: number;
startedAt?: string;
doneAt?: string;
seq?: number;
}
export interface AgentRun {
id: string | number;
projectId?: string | number;
goal?: string;
status?: AgentRunStatus;
createdAt?: string;
steps?: AgentStep[];
}
export interface UploadResult {
id?: string | number;
assetId?: string | number;
url?: string;
name?: string;
}
export interface AiTask {
id: string | number;
projectId?: string | number;
episodeId?: string | number;
storyboardId?: string | number;
taskType?: string;
status?: string;
resultVideoUrl?: string;
errorMessage?: string;
prompt?: string;
createdAt?: string;
updatedAt?: string;
}
export interface AssemblyTask {
id: string | number;
episodeId?: string | number;
status?: string;
shotCount?: number;
downloadUrl?: string;
errorMessage?: string;
createdAt?: string;
updatedAt?: string;
}
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8080";
export const AUTH_TOKEN_KEY = "yaoai-auth-token";
export const AUTH_USER_KEY = "yaoai-auth-user";
export const SCRIPT_EXTENSIONS = ["doc", "docx", "txt", "pdf", "md"];
export const PROJECT_STYLES = [
{ label: "真人短剧", value: "live_action_drama" },
{ label: "国漫风", value: "guoman" },
{ label: "写实电影感", value: "cinematic_realism" },
{ label: "3D动画", value: "three_d_animation" }
];
export const ASPECT_RATIOS = ["9:16", "16:9", "1:1"];
export const RESOLUTIONS = ["720p", "1080p"];
export const PC_REFINE_AGENT_STEPS = [
"script_understanding",
"character_generation",
"storyboard_generation"
];
export const QUICK_VIDEO_AGENT_STEPS = [
"script_understanding",
"character_generation",
"storyboard_generation",
"image_generation",
"video_generation",
"video_composition"
];
export const DEFAULT_AGENT_STEPS = PC_REFINE_AGENT_STEPS;
export type RunModeValue = "pc_refine" | "quick_video";
export const RUN_MODES: Array<{
value: RunModeValue;
label: string;
title: string;
description: string;
runIntent: "auto" | "quick_video";
selectedSteps: string[];
actionLabel: string;
}> = [
{
value: "pc_refine",
label: "PC 精修",
title: "Agent 制作,PC 精修",
description: "生成剧本理解、角色和分镜,完成后回到 PC 工作台继续改图、改分镜、生成视频。",
runIntent: "auto",
selectedSteps: PC_REFINE_AGENT_STEPS,
actionLabel: "创建 Agent 制作任务"
},
{
value: "quick_video",
label: "极速出片",
title: "一键全自动出片",
description: "让 Agent 尝试自动跑完图片、视频和合成,适合先快速拿到一版样片。",
runIntent: "quick_video",
selectedSteps: QUICK_VIDEO_AGENT_STEPS,
actionLabel: "开始极速出片"
}
];
import type { AgentRunStatus } from "@/types/api";
export function formatDateTime(value?: string) {
if (!value) return "刚刚";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const pad = (input: number) => String(input).padStart(2, "0");
return `${date.getMonth() + 1}/${date.getDate()} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
export function statusText(status?: AgentRunStatus) {
const normalizedStatus = String(status || "").toUpperCase();
switch (normalizedStatus) {
case "PENDING":
return "排队中";
case "RUNNING":
return "制作中";
case "PAUSED":
return "已暂停";
case "DONE":
return "已完成";
case "FAILED":
return "失败";
case "DRAFT":
return "草稿";
case "CREATED":
return "已创建";
default:
return status || "未开始";
}
}
export function statusClass(status?: AgentRunStatus) {
const normalizedStatus = String(status || "").toUpperCase();
switch (normalizedStatus) {
case "RUNNING":
return "status-running";
case "DONE":
return "status-done";
case "FAILED":
return "status-failed";
case "PAUSED":
return "status-paused";
default:
return "status-pending";
}
}
export function stepProgress(statuses: Array<AgentRunStatus | undefined>) {
if (!statuses.length) return 0;
const done = statuses.filter((status) => status === "DONE" || status === "SKIPPED").length;
return Math.round((done / statuses.length) * 100);
}
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"types": ["@dcloudio/types"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}
import { defineConfig } from "vite";
import uniPlugin from "@dcloudio/vite-plugin-uni";
const uni = "default" in uniPlugin ? uniPlugin.default : uniPlugin;
export default defineConfig({
plugins: [uni()]
});
......@@ -10,7 +10,9 @@ import com.yaoai.domain.entity.GraphRun;
import com.yaoai.domain.entity.GraphStep;
import com.yaoai.domain.entity.Outline;
import com.yaoai.domain.entity.Storyboard;
import com.yaoai.domain.mapper.AiTaskMapper;
import com.yaoai.domain.mapper.AgentRunPlanMapper;
import com.yaoai.domain.mapper.AssemblyTaskMapper;
import com.yaoai.domain.mapper.CharacterMapper;
import com.yaoai.domain.mapper.EpisodeMapper;
import com.yaoai.domain.mapper.GraphRunMapper;
......@@ -18,9 +20,11 @@ import com.yaoai.domain.mapper.GraphStepMapper;
import com.yaoai.domain.mapper.OutlineMapper;
import com.yaoai.domain.mapper.SceneMapper;
import com.yaoai.domain.mapper.StoryboardMapper;
import com.yaoai.pipeline.service.AssemblyPipelineService;
import com.yaoai.pipeline.service.AssetGenPipelineService;
import com.yaoai.pipeline.service.OutlinePipelineService;
import com.yaoai.pipeline.service.StoryboardPipelineService;
import com.yaoai.pipeline.service.VideoTaskPipelineService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
......@@ -44,6 +48,10 @@ class AgentRunServiceImplTest {
@Mock
private GraphStepMapper graphStepMapper;
@Mock
private AiTaskMapper aiTaskMapper;
@Mock
private AssemblyTaskMapper assemblyTaskMapper;
@Mock
private AgentRunPlanMapper agentRunPlanMapper;
@Mock
private OutlineMapper outlineMapper;
......@@ -67,12 +75,57 @@ class AgentRunServiceImplTest {
private AssetGenPipelineService assetGenPipeline;
@Mock
private StoryboardPipelineService storyboardPipeline;
@Mock
private VideoTaskPipelineService videoTaskPipeline;
@Mock
private AssemblyPipelineService assemblyPipeline;
@Test
void quickVideoPlan_shouldAppendVideoPipelineStepsAfterStoryboardGeneration() {
AgentRunServiceImpl service = new AgentRunServiceImpl(
graphRunMapper,
graphStepMapper,
aiTaskMapper,
assemblyTaskMapper,
agentRunPlanMapper,
outlineMapper,
episodeMapper,
characterMapper,
sceneMapper,
storyboardMapper,
sseManager,
llmService,
new ObjectMapper(),
scriptUnderstandingService,
outlinePipeline,
assetGenPipeline,
storyboardPipeline,
videoTaskPipeline,
assemblyPipeline
);
AgentRunCommand command = runCommand("goal");
command.setRunIntent("quick_video");
List<?> steps = ReflectionTestUtils.invokeMethod(service, "planSteps", false, command);
List<String> stepKeys = steps.stream()
.map(step -> {
Object key = ReflectionTestUtils.invokeMethod(step, "key");
return String.valueOf(key);
})
.toList();
assertTrue(stepKeys.indexOf("image_generation") > stepKeys.indexOf("supervision_2"));
assertTrue(stepKeys.indexOf("video_generation") > stepKeys.indexOf("image_generation"));
assertTrue(stepKeys.indexOf("video_composition") > stepKeys.indexOf("video_generation"));
}
@Test
void storyboardGen_shouldPopulateMissingVideoPromptsAfterGeneratingStoryboards() {
AgentRunServiceImpl service = new AgentRunServiceImpl(
graphRunMapper,
graphStepMapper,
aiTaskMapper,
assemblyTaskMapper,
agentRunPlanMapper,
outlineMapper,
episodeMapper,
......@@ -85,7 +138,9 @@ class AgentRunServiceImplTest {
scriptUnderstandingService,
outlinePipeline,
assetGenPipeline,
storyboardPipeline
storyboardPipeline,
videoTaskPipeline,
assemblyPipeline
);
Long runId = 88L;
......
......@@ -3,6 +3,7 @@ package com.yaoai.pipeline.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import com.yaoai.billing.dto.BillingChargeRequest;
import com.yaoai.billing.service.BillingService;
import com.yaoai.common.context.UserContext;
import com.yaoai.domain.entity.AiTask;
import com.yaoai.domain.entity.AssemblyTask;
import com.yaoai.domain.mapper.AiTaskMapper;
......@@ -50,6 +51,7 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
@Override
public AssemblyTask startAssembly(Long projectId, Long episodeId, Long tenantId) {
Long userId = resolveUserId();
AssemblyTask task = new AssemblyTask();
task.setTenantId(tenantId);
task.setProjectId(projectId);
......@@ -59,7 +61,14 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
assemblyTaskMapper.insert(task);
Long taskId = task.getId();
CompletableFuture.runAsync(() -> doAssemble(taskId, episodeId), assemblyExecutor);
CompletableFuture.runAsync(() -> {
UserContext.set(userId);
try {
doAssemble(taskId, episodeId, userId);
} finally {
UserContext.clear();
}
}, assemblyExecutor);
return task;
}
......@@ -74,7 +83,7 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
return tosService.publicUrl(task.getResultTosKey());
}
private void doAssemble(Long assemblyTaskId, Long episodeId) {
private void doAssemble(Long assemblyTaskId, Long episodeId, Long userId) {
AssemblyTask task = assemblyTaskMapper.selectById(assemblyTaskId);
task.setStatus("running");
assemblyTaskMapper.updateById(task);
......@@ -113,7 +122,7 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
assemblyTaskMapper.updateById(task);
billingService.charge(BillingChargeRequest.builder()
.tenantId(task.getTenantId())
.userId(StpUtil.getLoginIdAsLong())
.userId(userId)
.projectId(task.getProjectId())
.operation("assembly")
.modality("COMPOSE")
......@@ -138,4 +147,12 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
task.setErrorMessage(msg);
assemblyTaskMapper.updateById(task);
}
private Long resolveUserId() {
Long userId = UserContext.get();
if (userId != null) {
return userId;
}
return StpUtil.getLoginIdAsLong();
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment