7 Commits

Author SHA1 Message Date
stringadmin ad4bca31b1 fix: address project review bugs 2026-06-12 17:25:30 +08:00
stringadmin f9e55578b3 Merge remote-tracking branch 'origin/main' into fix/ecommerce-ui-polish
# Conflicts:
#	src/features/ecommerce/EcommercePage.tsx
2026-06-12 16:04:09 +08:00
stringadmin 7fdaa38504 feat: 电商快捷工具接入真实API并增强预览交互
- 图片修改接入局部重绘API,改为左右对比布局
- 去水印接入真实API,带进度条
- A+详情页预览区增加生成中/失败状态与进度条
- 新增图片翻译页面(含语言选择器)
- 快捷功能栏改为一行五列均分布局,移除白框
- 预览弹窗与A+详情页结果增加保存本地按钮
2026-06-12 16:00:43 +08:00
stringadmin 6378ce7546 Merge pull request 'feat: 优化电商图片工作台快捷操作区与灵感货架视觉体验' (#10) from feat/ecommerce-imageworkbench-polish into main
Reviewed-on: #10
2026-06-12 07:57:53 +00:00
ludan 3cfcfe70d4 feat: 优化电商图片工作台快捷操作区与灵感货架视觉体验
本次修改聚焦于电商图片工作台(imageWorkbench)的视觉打磨,主要包含以下优化:

一、快捷操作区(quick action board)全面升级:
- 拓宽快捷操作面板宽度至 920px,增加内边距和间距,提升呼吸感
- 面板背景采用多层渐变叠加,模拟磨砂玻璃质感(glassmorphism)
- 按钮最小高度提升至 56px,引入分类专属色彩体系:
  · detail(精细优化):紫调 #7a5af8
  · edit(智能编辑):暖橙 #cc6b14
  · cutout(智能抠图):蓝色 #1073cc
  · watermark(水印去除):玫红 #c04468
- 每个按钮图标区采用 30px 圆角方块,带渐变背景和投影
- hover/focus 状态加入径向光晕效果(radial gradient),增强交互反馈

二、灵感实验室(inspiration lab)布局优化:
- 整体容器宽度改为自适应(2360px 上限),取消固定最大宽度限制
- 灵感行采用 grid 两栏布局(元信息 + 卡片带),列宽比例约 1:5
- 各行卡片宽度按类型差异化:
  · AI团队行:420-620px(较宽,展示文字信息)
  · 电商套图行:300-420px(适中)
  · 商品视频行:360-540px(偏宽)
- 行背景采用半透明渐变,边框颜色统一为品牌蓝调

三、紧凑型指令栏(compact composer)居中定位:
- 当历史面板折叠(is-history-collapsed)时,指令栏水平居中于可视画布
- 移动端适配:small 文本域尺寸、按钮和缩略图缩放

四、响应式适配:
- ≤900px:灵感行切换为单列布局,卡片统一宽度
- ≤640px:快捷面板改为 2 列网格,指令栏进一步紧凑

变更文件:
- src/styles/ecommerce-standalone.css (+382)
2026-06-12 15:56:33 +08:00
stringadmin 9fbf464dbd Merge pull request 'feat: add download/remove actions in product set preview and history detail compact composer' (#9) from feat/ecommerce-preview-actions-history-detail into main
Reviewed-on: #9
2026-06-12 06:23:22 +00:00
ludan e1a2e55792 feat: add download/remove actions in product set preview and history detail compact composer
- EcommercePage.tsx: Add DownloadOutlined icon and downloadResultAsset import; introduce ProductSetPreviewSelection interface (extends preview with nodeId/cardId/removable); enhance openProductSetPreview to accept nodeId and removable options; implement handleDownloadCanvasResult (triggers image download via downloadResultAsset), removeCanvasResult (filters canvas nodes and results by cardId), and removeSelectedProductSetPreview (removes card then closes preview); remove legacy canvas centering requestAnimationFrame block; add is-history-detail CSS class when viewing a history record; wire download/remove action buttons in product set preview modal footer; update canvas node result buttons to pass nodeId and removable options
- ecommerce-standalone.css (+382 lines):
  - Product set preview modal: enhanced layered shadows and frosted border; footer flex layout with label truncation; pill-shaped action buttons with gradient backgrounds, hover lift and glow transitions; danger variant for remove action in red tones; mobile ≤640px column layout with stretched full-width buttons
  - History detail mode (.is-history-detail): compact floating composer pill bar — fixed centered at workspace top with backdrop blur, pill-shaped container (border-radius 999px), single-line textarea, inline asset thumbnails, circular gradient send button, hidden option row; canvas nodes centered layout with max-width constraint; history item delete button repositioned absolute right with hover red state; responsive adjustments at ≤900px for full-width workspace
2026-06-12 14:21:25 +08:00
11 changed files with 2108 additions and 297 deletions
+1
View File
@@ -15,3 +15,4 @@ tmp/
*.swp
*.swo
coverage/
屏幕截图 *.png
+89 -76
View File
@@ -1,8 +1,5 @@
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"];
const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"];
type AbortSignalConstructorWithAny = typeof AbortSignal & {
any?: (signals: AbortSignal[]) => AbortSignal;
};
@@ -110,11 +107,45 @@ export interface ComplianceCheck {
allow_video_generation: boolean;
}
function findJsonSlice(raw: string): string {
const start = raw.search(/[\[{]/);
if (start < 0) return raw;
const stack: string[] = [];
let inString = false;
let escaped = false;
for (let index = start; index < raw.length; index += 1) {
const char = raw[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === "\"") {
inString = false;
}
continue;
}
if (char === "\"") {
inString = true;
} else if (char === "{" || char === "[") {
stack.push(char === "{" ? "}" : "]");
} else if (char === "}" || char === "]") {
if (stack.pop() !== char) break;
if (stack.length === 0) return raw.slice(start, index + 1);
}
}
return raw.slice(start);
}
function extractJson(text: string): unknown {
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
const raw = fenced ? fenced[1].trim() : text.trim();
const start = raw.search(/[[{]/);
const slice = start >= 0 ? raw.slice(start) : raw;
const slice = findJsonSlice(raw);
try {
return JSON.parse(slice);
} catch {
@@ -122,9 +153,16 @@ function extractJson(text: string): unknown {
}
}
type ChatContent =
| string
| Array<
| { type: "image_url"; image_url: { url: string } }
| { type: "text"; text: string }
>;
interface ChatMessage {
role: "system" | "user";
content: string;
content: ChatContent;
}
const MAX_RETRIES = 3;
@@ -171,43 +209,32 @@ async function chat(
userContent: string,
options?: { model?: string; signal?: AbortSignal },
): Promise<string> {
const candidateModels = options?.model ? [options.model] : TEXT_MODELS;
let lastError: Error | null = null;
return retryOnTransient(async () => {
const messages: ChatMessage[] = [
{ role: "system", content: systemPrompt },
{ role: "user", content: userContent },
];
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = combineAbortSignals(options?.signal, timeoutSignal);
const body: Record<string, unknown> = { messages, stream: false, temperature: 0.4 };
if (options?.model) body.model = options.model;
for (const model of candidateModels) {
try {
return await retryOnTransient(async () => {
const messages: ChatMessage[] = [
{ role: "system", content: systemPrompt },
{ role: "user", content: userContent },
];
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = combineAbortSignals(options?.signal, timeoutSignal);
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify({ model, messages, stream: false, temperature: 0.4 }),
signal: combinedSignal,
});
if (!res.ok) {
const errBody = await res.text().catch(() => "");
throw new Error(`AI 调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
}
const payload = await res.json();
const content: string =
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!content) throw new Error("模型未返回有效内容");
return content;
}, options?.signal);
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (options?.signal?.aborted) throw lastError;
// If user pinned a specific model, don't fall back to others
if (options?.model) throw lastError;
// Try next model in fallback chain
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify(body),
signal: combinedSignal,
});
if (!res.ok) {
const errBody = await res.text().catch(() => "");
throw new Error(`AI 调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
}
}
throw lastError ?? new Error("所有候选模型均不可用");
const payload = await res.json();
const content: string =
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!content) throw new Error("模型未返回有效内容");
return content;
}, options?.signal);
}
async function visionChat(
@@ -216,50 +243,36 @@ async function visionChat(
imageUrls: string[],
signal?: AbortSignal,
): Promise<string> {
const content = [
...imageUrls.map((url) => ({ type: "image_url", image_url: { url } })),
const content: ChatContent = [
...imageUrls.map((url) => ({ type: "image_url" as const, image_url: { url } })),
{ type: "text", text },
];
const messages = [
{ role: "system", content: systemPrompt },
{ role: "user", content },
];
] satisfies ChatMessage[];
let lastError: Error | null = null;
for (const model of VISION_MODELS) {
return retryOnTransient(async () => {
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = combineAbortSignals(signal, timeoutSignal);
try {
const out = await retryOnTransient(async () => {
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify({ model, messages, stream: false, temperature: 0.3 }),
signal: combinedSignal,
});
if (!res.ok) {
const errBody = await res.text().catch(() => "");
if (errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK");
throw new Error(`图片理解调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
}
const payload = await res.json();
const result: string =
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!result) throw new Error("图片理解未返回有效内容");
return result;
}, signal);
return out;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (signal?.aborted) throw lastError;
// Continue trying next vision model on transient failures, image format errors, or upstream errors
if (lastError.message === "IMAGE_FORMAT_FALLBACK") continue;
if (lastError.message.includes("图片理解调用失败")) continue;
if (isTransientError(lastError)) continue;
throw lastError;
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify({ messages, stream: false, temperature: 0.3 }),
signal: combinedSignal,
});
if (!res.ok) {
const errBody = await res.text().catch(() => "");
if (errBody.includes("image format")) throw new Error("图片格式不受支持,请更换图片后重试");
throw new Error(`图片理解调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
}
}
throw lastError ?? new Error("图片理解调用失败,所有模型均不可用");
const payload = await res.json();
const result: string =
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!result) throw new Error("图片理解未返回有效内容");
return result;
}, signal);
}
const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`;
+15 -14
View File
@@ -12,7 +12,7 @@ import type { WebGenerationPreviewTask } from "../types";
export interface ImageGenInput {
projectId?: string;
conversationId?: number;
model: string;
model?: string;
prompt: string;
ratio?: string;
quality?: string;
@@ -89,6 +89,8 @@ export interface ImageEditInput {
imageUrl: string;
function: string;
prompt?: string;
maskUrl?: string;
ratio?: string;
n?: number;
}
@@ -208,18 +210,18 @@ function getStoredSessionRole(): string {
}
function emitImageRouteDebug(label: string, payload: Record<string, unknown>): void {
// Only emit console logs for admin users — hides enterprise routing details
if (getStoredSessionRole() === "admin") {
const entry: ImageRouteDebugEntry = {
at: new Date().toISOString(),
label,
...payload,
};
try {
console.log(`${label} ${JSON.stringify(entry)}`);
} catch {
console.log(label, entry);
}
// Only emit route debug for admin users; provider routing is operational data.
if (getStoredSessionRole() !== "admin") return;
const entry: ImageRouteDebugEntry = {
at: new Date().toISOString(),
label,
...payload,
};
try {
console.log(`${label} ${JSON.stringify(entry)}`);
} catch {
console.log(label, entry);
}
if (typeof window === "undefined") return;
@@ -227,7 +229,6 @@ function emitImageRouteDebug(label: string, payload: Record<string, unknown>): v
const previousEntries = Array.isArray(debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__)
? debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__
: [];
const entry: ImageRouteDebugEntry = { at: new Date().toISOString(), label, ...payload };
debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__ = [...previousEntries.slice(-19), entry];
}
+4
View File
@@ -161,7 +161,11 @@ export function clearAllUserStorage(): void {
"omniai-web-profile-ui",
"omniai:more-recent-tools",
"omniai:generation-queue",
"omniai:generation-records.pending",
"omniai:ecommerce-video-workspace",
"omniai-canvas-saved-assets",
"omniai.clone-ai.",
"omniai.ecommerce.",
];
for (let i = window.localStorage.length - 1; i >= 0; i--) {
const key = window.localStorage.key(i);
-1
View File
@@ -50,7 +50,6 @@ export const webGenerationGateway = {
const result = await aiGenerationClient.createImageTask({
projectId: params?.projectId,
conversationId: params?.conversationId,
model: "gpt-image-2",
prompt,
ratio: params?.ratio || "16:9",
quality: params?.quality || "1K",
File diff suppressed because it is too large Load Diff
@@ -11,7 +11,14 @@ import {
SendOutlined,
StopOutlined,
} from "@ant-design/icons";
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks, saveVideoHistory } from "./ecommerceVideoService";
import {
runVideoPlan,
renderSceneImage,
renderScene,
buildSceneTasks,
saveVideoHistory,
buildComplianceFailureMessage,
} from "./ecommerceVideoService";
import {
PLAN_STEP_LABELS,
PLAN_STEPS_DISPLAY,
@@ -70,9 +77,11 @@ function buildInputFingerprint(input: {
durationSeconds: number;
resolution: string;
}): string {
const imageCount = input.productImageDataUrls.length;
const imageSignature = input.productImageDataUrls
.map((source) => `${source.length}:${hashString(source)}`)
.join("|");
return hashString([
String(imageCount),
imageSignature,
input.requirement.trim(),
input.platform,
input.aspectRatio,
@@ -81,6 +90,10 @@ function buildInputFingerprint(input: {
].join("::"));
}
function planAllowsVideoGeneration(plan: EcommerceVideoPlanResult | null): boolean {
return plan?.compliance.allow_video_generation !== false;
}
function mapResolutionToQuality(res: string): "720P" | "1080P" {
return res.includes("720") ? "720P" : "1080P";
}
@@ -163,6 +176,10 @@ export default function EcommerceVideoWorkspace({
useEffect(() => {
const delay = 600;
if (stage === "planned" && planResult && scenes.length > 0) {
if (!planAllowsVideoGeneration(planResult)) {
setError(buildComplianceFailureMessage(planResult.compliance));
return;
}
const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
return () => clearTimeout(timer);
}
@@ -468,6 +485,7 @@ export default function EcommerceVideoWorkspace({
let liveCompletedSteps: PlanStep[] = resume
? ALL_STEPS.filter((s) => stepCompletedFromProgress(s, resume))
: [];
let liveCurrentStep: PlanStep | null = null;
const persist = (stageNow: EcommerceVideoStage) => {
saveEcommerceVideoState({
inputFingerprint,
@@ -484,7 +502,10 @@ export default function EcommerceVideoWorkspace({
const result = await runVideoPlan(
productImageSources, requirement, buildConfig(),
{
onStepStart: (step) => setCurrentStep(step),
onStepStart: (step) => {
liveCurrentStep = step;
setCurrentStep(step);
},
onStepDone: (step) => {
liveCompletedSteps = [...liveCompletedSteps, step];
setCompletedSteps((prev) => [...prev, step]);
@@ -517,7 +538,7 @@ export default function EcommerceVideoWorkspace({
const message = err instanceof Error ? err.message : "策划失败";
setError(message);
// Mark the step that was in-progress as failed so user can resume
setFailedStep((prev) => prev || currentStep);
setFailedStep((prev) => prev || liveCurrentStep);
setStage("idle");
// Persist partial progress so the user can resume after a page switch
persist("idle");
@@ -526,8 +547,8 @@ export default function EcommerceVideoWorkspace({
const handlePlan = async () => {
if (!isAuthenticated) { onRequestLogin?.(); return; }
if (!productImageDataUrls.length && !requirement.trim()) {
setError("请先上传品图片或填写商品说明"); return;
if (!productImageDataUrls.length) {
setError("请先上传品图片"); return;
}
await runPlanFlow(null);
};
@@ -542,6 +563,10 @@ export default function EcommerceVideoWorkspace({
const handleGenerateImages = async () => {
if (!planResult || !scenes.length) return;
if (!planAllowsVideoGeneration(planResult)) {
setError(buildComplianceFailureMessage(planResult.compliance));
return;
}
setStage("imaging"); setError(null);
renderAbortRef.current = { current: false };
const ratio = aspectRatio.includes("9:16") || aspectRatio.includes("916") ? "9:16"
@@ -555,7 +580,11 @@ export default function EcommerceVideoWorkspace({
};
// Only redo scenes missing imageUrl — preserves successfully generated images on partial retry
const scenesToProcess = currentScenes.filter((s) => !s.imageUrl);
if (!scenesToProcess.length) { setStage("imaged"); return; }
if (!scenesToProcess.length) {
setStage("imaged");
saveEcommerceVideoState({ inputFingerprint, stage: "imaged", completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
return;
}
for (const scene of scenesToProcess) {
if (renderAbortRef.current.current) break;
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
@@ -597,6 +626,10 @@ export default function EcommerceVideoWorkspace({
const handleRenderVideos = async () => {
if (!scenes.length) return;
if (!planAllowsVideoGeneration(planResult)) {
setError(planResult ? buildComplianceFailureMessage(planResult.compliance) : "合规检查未通过,已停止生成。");
return;
}
if (!scenes.some((s) => s.imageUrl)) { setError("请先生成分镜图片"); return; }
setStage("rendering"); setError(null);
renderAbortRef.current = { current: false };
@@ -609,7 +642,12 @@ export default function EcommerceVideoWorkspace({
};
// Only render scenes that haven't completed yet — preserves successful videos on partial retry
const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed");
if (!scenesToProcess.length) { setStage(currentScenes.every((s) => s.status === "completed") ? "completed" : "partial_failed"); return; }
if (!scenesToProcess.length) {
const finalStage = currentScenes.every((s) => s.status === "completed") ? "completed" as const : "partial_failed" as const;
setStage(finalStage);
saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
return;
}
for (const scene of scenesToProcess) {
if (renderAbortRef.current.current) break;
if (!scene.imageUrl) continue;
@@ -11,6 +11,7 @@ import {
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { serverRequest } from "../../api/serverConnection";
import { waitForTask } from "../../api/taskSubscription";
import { ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import { ecommerceOssScopes } from "./ecommerceGenerationPersistence";
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
@@ -130,6 +131,18 @@ export interface PlanCallbacks {
const LOCAL_PREVIEW_MISSING_FILE_MESSAGE = "Please re-upload the product image before generating the short video.";
export function buildComplianceFailureMessage(compliance: NonNullable<EcommerceVideoPlanProgress["compliance"]>): string {
const issues = compliance.issues
.slice(0, 3)
.map((issue) => [issue.field, issue.problem, issue.suggestion].filter(Boolean).join(""))
.filter(Boolean)
.join("");
return issues
? `合规检查未通过,已停止生成。${issues}`
: "合规检查未通过,已停止生成。请修改商品说明或广告文案后重试。";
}
function readBlobAsDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -271,6 +284,10 @@ export async function runVideoPlan(
emit();
}
if (progress.compliance.allow_video_generation === false) {
throw new Error(buildComplianceFailureMessage(progress.compliance));
}
return {
imageUrls: progress.imageUrls!,
imageDescription: progress.imageDescription,
@@ -303,7 +320,6 @@ export async function renderSceneImage(
abortRef: { current: boolean },
): Promise<void> {
const { taskId } = await aiGenerationClient.createImageTask({
model: "gpt-image-2",
prompt: input.prompt,
ratio: input.aspectRatio,
quality: "2K",
@@ -315,7 +331,6 @@ export async function renderSceneImage(
const resultUrl = await waitForTask(taskId, {
abortRef,
kind: "image",
model: "gpt-image-2",
onProgress: (e) => callbacks.onSceneImageProgress(input.sceneId, e.progress),
});
@@ -351,7 +366,7 @@ export async function renderScene(
): Promise<void> {
const allReferenceUrls = [...input.productImageUrls, input.imageUrl];
const model = resolveVideoRequestModel({
model: input.model || "happyhorse-1.0",
model: input.model || ENTERPRISE_DEFAULT_VIDEO_MODEL,
referenceUrls: allReferenceUrls,
});
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -15,7 +15,11 @@ let flushTimer: ReturnType<typeof setTimeout> | null = null;
function getSessionId(): string | undefined {
try {
const raw = localStorage.getItem("omniai:session") || sessionStorage.getItem("omniai:session");
const raw =
localStorage.getItem("omniai-web-session") ||
sessionStorage.getItem("omniai-web-session") ||
localStorage.getItem("omniai:session") ||
sessionStorage.getItem("omniai:session");
if (!raw) return undefined;
const parsed = JSON.parse(raw);
return parsed?.user?.sessionId ?? undefined;
+10 -12
View File
@@ -3,7 +3,15 @@ import { compression } from "vite-plugin-compression2";
import { defineConfig } from "vite";
export default defineConfig(() => {
const devApiTarget = process.env.OMNIAI_DEV_API_TARGET;
const devApiTarget = process.env.OMNIAI_DEV_API_TARGET?.trim();
const apiProxy = devApiTarget
? {
"/api": {
target: devApiTarget,
changeOrigin: true,
},
}
: undefined;
return {
plugins: [
@@ -13,17 +21,7 @@ export default defineConfig(() => {
server: {
port: 5173,
host: "127.0.0.1",
proxy: devApiTarget ? {
"/api": {
target: devApiTarget,
changeOrigin: true,
},
} : {
"/api": {
target: "http://47.110.225.76:3601",
changeOrigin: true,
},
},
...(apiProxy ? { proxy: apiProxy } : {}),
},
preview: {
port: 4174,