chore: save work before switching to dev
This commit is contained in:
2
.env
2
.env
@@ -1,5 +1,5 @@
|
|||||||
# port 端口号
|
# port 端口号
|
||||||
VITE_PORT = 8888
|
VITE_PORT = 8080
|
||||||
|
|
||||||
# open 运行 npm run dev 时自动打开浏览器
|
# open 运行 npm run dev 时自动打开浏览器
|
||||||
VITE_OPEN = true
|
VITE_OPEN = true
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import request from '/@/utils/request';
|
import request, { type RequestOptions } from '/@/utils/request';
|
||||||
|
|
||||||
export interface CreationListParams {
|
export interface CreationListParams {
|
||||||
pageNum: number;
|
pageNum: number;
|
||||||
@@ -61,28 +61,32 @@ export interface DownloadToFileParams {
|
|||||||
fileURL: string;
|
fileURL: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCreationList(params: CreationListParams) {
|
// requestOptions 用来声明“这个接口的错误提示由谁负责”。
|
||||||
|
export function getCreationList(params: CreationListParams, requestOptions?: RequestOptions) {
|
||||||
return request({
|
return request({
|
||||||
url: '/black-deacon/creation/info/list',
|
url: '/black-deacon/creation/info/list',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params,
|
params,
|
||||||
|
requestOptions,
|
||||||
}) as Promise<CreationListResponse>;
|
}) as Promise<CreationListResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCreation(data: CreationSubmitParams) {
|
export function createCreation(data: CreationSubmitParams, requestOptions?: RequestOptions) {
|
||||||
return request({
|
return request({
|
||||||
url: '/black-deacon/creation/info/creation',
|
url: '/black-deacon/creation/info/creation',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data,
|
data,
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
|
requestOptions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadToFile(data: DownloadToFileParams) {
|
export function downloadToFile(data: DownloadToFileParams, requestOptions?: RequestOptions) {
|
||||||
return request({
|
return request({
|
||||||
url: '/oss/file/downloadToBrowser',
|
url: '/oss/file/downloadToBrowser',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data,
|
data,
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
requestOptions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import request from '/@/utils/request';
|
import request, { type RequestOptions } from '/@/utils/request';
|
||||||
|
|
||||||
export interface LiveAccountParams {
|
export interface LiveAccountParams {
|
||||||
pageNum: number;
|
pageNum: number;
|
||||||
@@ -47,42 +47,47 @@ export interface LiveAccountDetailResponse {
|
|||||||
data: LiveAccount;
|
data: LiveAccount;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLiveAccountList(params: LiveAccountParams): Promise<LiveAccountListResponse> {
|
export function getLiveAccountList(params: LiveAccountParams, requestOptions?: RequestOptions): Promise<LiveAccountListResponse> {
|
||||||
return request({
|
return request({
|
||||||
url: '/erp/live/account/controller/listLiveAccounts',
|
url: '/erp/live/account/controller/listLiveAccounts',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params,
|
params,
|
||||||
|
requestOptions,
|
||||||
}) as Promise<LiveAccountListResponse>;
|
}) as Promise<LiveAccountListResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLiveAccountDetail(params: { id: string }): Promise<LiveAccountDetailResponse> {
|
export function getLiveAccountDetail(params: { id: string }, requestOptions?: RequestOptions): Promise<LiveAccountDetailResponse> {
|
||||||
return request({
|
return request({
|
||||||
url: '/erp/live/account/controller/getLiveAccount',
|
url: '/erp/live/account/controller/getLiveAccount',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params,
|
params,
|
||||||
|
requestOptions,
|
||||||
}) as Promise<LiveAccountDetailResponse>;
|
}) as Promise<LiveAccountDetailResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLiveAccount(data: LiveAccountSaveParams) {
|
export function createLiveAccount(data: LiveAccountSaveParams, requestOptions?: RequestOptions) {
|
||||||
return request({
|
return request({
|
||||||
url: '/erp/live/account/controller/createLiveAccount',
|
url: '/erp/live/account/controller/createLiveAccount',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data,
|
data,
|
||||||
|
requestOptions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateLiveAccount(data: LiveAccountSaveParams) {
|
export function updateLiveAccount(data: LiveAccountSaveParams, requestOptions?: RequestOptions) {
|
||||||
return request({
|
return request({
|
||||||
url: '/erp/live/account/controller/updateLiveAccount',
|
url: '/erp/live/account/controller/updateLiveAccount',
|
||||||
method: 'put',
|
method: 'put',
|
||||||
data,
|
data,
|
||||||
|
requestOptions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteLiveAccount(params: { id: string }) {
|
export function deleteLiveAccount(params: { id: string }, requestOptions?: RequestOptions) {
|
||||||
return request({
|
return request({
|
||||||
url: '/erp/live/account/controller/deleteLiveAccount',
|
url: '/erp/live/account/controller/deleteLiveAccount',
|
||||||
method: 'delete',
|
method: 'delete',
|
||||||
params,
|
params,
|
||||||
|
requestOptions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import request from '/@/utils/request';
|
import request, { type RequestOptions } from '/@/utils/request';
|
||||||
|
|
||||||
export interface ScheduleListParams {
|
export interface ScheduleListParams {
|
||||||
pageNum: number;
|
pageNum: number;
|
||||||
@@ -73,42 +73,47 @@ export interface ScheduleDetailResponse {
|
|||||||
data: ScheduleDetail;
|
data: ScheduleDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getScheduleList(params: ScheduleListParams): Promise<ScheduleListResponse> {
|
export function getScheduleList(params: ScheduleListParams, requestOptions?: RequestOptions): Promise<ScheduleListResponse> {
|
||||||
return request({
|
return request({
|
||||||
url: '/erp/schedule/controller/listSchedules',
|
url: '/erp/schedule/controller/listSchedules',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params,
|
params,
|
||||||
|
requestOptions,
|
||||||
}) as Promise<ScheduleListResponse>;
|
}) as Promise<ScheduleListResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getScheduleDetail(params: { id: string }): Promise<ScheduleDetailResponse> {
|
export function getScheduleDetail(params: { id: string }, requestOptions?: RequestOptions): Promise<ScheduleDetailResponse> {
|
||||||
return request({
|
return request({
|
||||||
url: '/erp/schedule/controller/getSchedule',
|
url: '/erp/schedule/controller/getSchedule',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params,
|
params,
|
||||||
|
requestOptions,
|
||||||
}) as Promise<ScheduleDetailResponse>;
|
}) as Promise<ScheduleDetailResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSchedule(data: ScheduleSaveParams) {
|
export function createSchedule(data: ScheduleSaveParams, requestOptions?: RequestOptions) {
|
||||||
return request({
|
return request({
|
||||||
url: '/erp/schedule/controller/createSchedule',
|
url: '/erp/schedule/controller/createSchedule',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data,
|
data,
|
||||||
|
requestOptions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateSchedule(data: ScheduleSaveParams) {
|
export function updateSchedule(data: ScheduleSaveParams, requestOptions?: RequestOptions) {
|
||||||
return request({
|
return request({
|
||||||
url: '/erp/schedule/controller/updateSchedule',
|
url: '/erp/schedule/controller/updateSchedule',
|
||||||
method: 'put',
|
method: 'put',
|
||||||
data,
|
data,
|
||||||
|
requestOptions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteSchedule(params: { id: string }) {
|
export function deleteSchedule(params: { id: string }, requestOptions?: RequestOptions) {
|
||||||
return request({
|
return request({
|
||||||
url: '/erp/schedule/controller/deleteSchedule',
|
url: '/erp/schedule/controller/deleteSchedule',
|
||||||
method: 'delete',
|
method: 'delete',
|
||||||
params,
|
params,
|
||||||
|
requestOptions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,8 +178,8 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
// 清除缓存/token等
|
// 手动退出登录也只清理登录态缓存,保留主题、语言等本地配置。
|
||||||
Session.clear();
|
Session.clearAuth();
|
||||||
// 显式回到登录页,避免保留之前受保护页面的重定向参数
|
// 显式回到登录页,避免保留之前受保护页面的重定向参数
|
||||||
await router.replace('/login');
|
await router.replace('/login');
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
} else {
|
} else {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
next(`/login?redirect=${to.path}¶ms=${JSON.stringify(to.query ? to.query : to.params)}`);
|
next(`/login?redirect=${to.path}¶ms=${JSON.stringify(to.query ? to.query : to.params)}`);
|
||||||
Session.clear();
|
// 进入受保护页面但本地已没有 token 时,只清理登录态缓存即可。
|
||||||
|
Session.clearAuth();
|
||||||
NProgress.done();
|
NProgress.done();
|
||||||
} else if (token && to.path === '/login') {
|
} else if (token && to.path === '/login') {
|
||||||
next('/home');
|
next('/home');
|
||||||
|
|||||||
@@ -1,35 +1,92 @@
|
|||||||
import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import type { MessageHandler } from 'element-plus';
|
||||||
import { Session } from '/@/utils/storage';
|
import { Session } from '/@/utils/storage';
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
import { getChangedFields } from '/@/utils/diffUtils';
|
import { getChangedFields } from '/@/utils/diffUtils';
|
||||||
import { handleModuleNotEnabled } from '/@/utils/assetSubscribe';
|
import { handleModuleNotEnabled } from '/@/utils/assetSubscribe';
|
||||||
|
|
||||||
// 标记是否正在处理 token 过期,避免重复弹窗
|
/**
|
||||||
let isHandlingTokenExpired = false;
|
* 控制一次请求的错误提示归属:
|
||||||
|
* - global: 交给 request.ts 统一弹错,适合绝大多数接口
|
||||||
|
* - page: 页面自己在 catch 中决定提示文案,避免与全局重复
|
||||||
|
* - silent: 完全静默,适合轮询、后台刷新等不希望打扰用户的请求
|
||||||
|
*/
|
||||||
|
export interface RequestOptions {
|
||||||
|
errorMode?: 'global' | 'page' | 'silent';
|
||||||
|
}
|
||||||
|
|
||||||
// 错误消息防抖:防止短时间内显示多个错误消息
|
declare module 'axios' {
|
||||||
let lastErrorTime = 0;
|
interface AxiosRequestConfig {
|
||||||
const ERROR_MESSAGE_INTERVAL = 2000; // 2秒内只显示一个错误
|
requestOptions?: RequestOptions;
|
||||||
|
|
||||||
const showErrorMessage = (message: string) => {
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
// 2秒内只显示一个错误消息(不管内容是否相同)
|
|
||||||
if (now - lastErrorTime < ERROR_MESSAGE_INTERVAL) {
|
|
||||||
return; // 跳过
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lastErrorTime = now;
|
interface InternalAxiosRequestConfig {
|
||||||
ElMessage.error(message);
|
requestOptions?: RequestOptions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记是否正在处理 token 过期,避免出现多个登录过期弹窗。
|
||||||
|
let isHandlingTokenExpired = false;
|
||||||
|
|
||||||
|
// 始终只保留一个错误消息实例,新的错误会先关闭旧的提示。
|
||||||
|
let activeErrorMessage: MessageHandler | null = null;
|
||||||
|
|
||||||
|
// 同类错误提示做一个短时间节流,避免接口连发时刷屏。
|
||||||
|
let lastErrorTime = 0;
|
||||||
|
const ERROR_MESSAGE_INTERVAL = 2000;
|
||||||
|
|
||||||
|
const getErrorMode = (config?: InternalAxiosRequestConfig) => config?.requestOptions?.errorMode ?? 'global';
|
||||||
|
const shouldShowGlobalError = (config?: InternalAxiosRequestConfig) => getErrorMode(config) === 'global';
|
||||||
|
|
||||||
|
const closeActiveErrorMessage = () => {
|
||||||
|
activeErrorMessage?.close();
|
||||||
|
activeErrorMessage = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================
|
/**
|
||||||
// Axios 实例配置
|
* token 过期只接受明确的后端信号:
|
||||||
// 地址配置见 .env.development 文件
|
* - HTTP 401
|
||||||
// ============================================================
|
* - 业务 code = 401
|
||||||
|
* - 已知的固定错误文案
|
||||||
|
* 不再使用模糊的 includes('token'),避免把普通业务错误误判成登录过期。
|
||||||
|
*/
|
||||||
|
const isTokenExpiredError = (httpStatus?: number, code?: number, message?: string) => {
|
||||||
|
const normalizedMessage = String(message || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const tokenExpiredMessages = ['token is invalid', 'token 解析失败', 'decrypt error', 'jwt expired', 'invalid token'];
|
||||||
|
|
||||||
|
return httpStatus === 401 || code === 401 || tokenExpiredMessages.includes(normalizedMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局错误提示统一从这里走:
|
||||||
|
* 1. 先判断当前请求是否允许全局弹错
|
||||||
|
* 2. 再做节流,防止短时间重复提示
|
||||||
|
* 3. 最后保证页面上同一时刻只有一个错误弹窗
|
||||||
|
*/
|
||||||
|
const showErrorMessage = (message: string, config?: InternalAxiosRequestConfig) => {
|
||||||
|
if (!shouldShowGlobalError(config)) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastErrorTime < ERROR_MESSAGE_INTERVAL) return;
|
||||||
|
|
||||||
|
lastErrorTime = now;
|
||||||
|
closeActiveErrorMessage();
|
||||||
|
|
||||||
|
let currentMessage: MessageHandler | null = null;
|
||||||
|
currentMessage = ElMessage.error({
|
||||||
|
message,
|
||||||
|
onClose: () => {
|
||||||
|
if (activeErrorMessage === currentMessage) {
|
||||||
|
activeErrorMessage = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
activeErrorMessage = currentMessage;
|
||||||
|
};
|
||||||
|
|
||||||
// 统一服务实例(端口8000)- 全部模块共用
|
|
||||||
const service: AxiosInstance = axios.create({
|
const service: AxiosInstance = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_URL,
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
timeout: 50000,
|
timeout: 50000,
|
||||||
@@ -41,23 +98,21 @@ const service: AxiosInstance = axios.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// token 过期处理函数
|
/**
|
||||||
|
* 登录过期时优先关闭普通错误消息,再弹出唯一的登录过期确认框。
|
||||||
|
* 这样用户不会先看到一个普通报错,再叠一个登录过期弹窗。
|
||||||
|
*/
|
||||||
const handleTokenExpired = () => {
|
const handleTokenExpired = () => {
|
||||||
if (isHandlingTokenExpired) return;
|
if (isHandlingTokenExpired) return;
|
||||||
|
|
||||||
isHandlingTokenExpired = true;
|
isHandlingTokenExpired = true;
|
||||||
|
closeActiveErrorMessage();
|
||||||
|
|
||||||
ElMessageBox.alert('登录状态已过期,请重新登录', '提示', {
|
ElMessageBox.alert('登录状态已过期,请重新登录', '提示', {
|
||||||
confirmButtonText: '确定',
|
confirmButtonText: '确定',
|
||||||
showClose: false,
|
showClose: false,
|
||||||
closeOnClickModal: false,
|
closeOnClickModal: false,
|
||||||
closeOnPressEscape: false,
|
closeOnPressEscape: false,
|
||||||
beforeClose: (action, _instance, done) => {
|
|
||||||
if (action === 'confirm') {
|
|
||||||
done();
|
|
||||||
performLogout();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
performLogout();
|
performLogout();
|
||||||
@@ -67,60 +122,47 @@ const handleTokenExpired = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 执行退出登录操作
|
/**
|
||||||
|
* 统一退出动作:
|
||||||
|
* - 只清理登录态相关缓存
|
||||||
|
* - 重置 token 过期处理标记
|
||||||
|
* - 最后回到登录页
|
||||||
|
*/
|
||||||
const performLogout = () => {
|
const performLogout = () => {
|
||||||
Session.clear();
|
Session.clearAuth();
|
||||||
localStorage.clear();
|
|
||||||
isHandlingTokenExpired = false;
|
isHandlingTokenExpired = false;
|
||||||
// Hash 路由统一回登录页,避免跳到错误地址
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/#/login';
|
window.location.href = '/#/login';
|
||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 请求拦截器
|
|
||||||
const requestInterceptor = (config: InternalAxiosRequestConfig) => {
|
const requestInterceptor = (config: InternalAxiosRequestConfig) => {
|
||||||
// 检查 token 是否有效
|
|
||||||
const token = Session.get('token');
|
const token = Session.get('token');
|
||||||
if (token) {
|
if (token) {
|
||||||
// 可以在这里添加 token 有效性检查(如果需要)
|
|
||||||
config.headers!['Authorization'] = `Bearer ${token}`;
|
config.headers!['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT 请求最小化传参处理
|
// PUT 请求只传变更字段,避免把未修改的数据整包提交给后端。
|
||||||
// 如果请求数据中包含 _originalData,则自动计算差异,只传递修改过的字段
|
|
||||||
if (config.method?.toLowerCase() === 'put' && config.data && typeof config.data === 'object') {
|
if (config.method?.toLowerCase() === 'put' && config.data && typeof config.data === 'object') {
|
||||||
const { _originalData, ...currentData } = config.data;
|
const { _originalData, ...currentData } = config.data;
|
||||||
|
|
||||||
if (_originalData && typeof _originalData === 'object') {
|
if (_originalData && typeof _originalData === 'object') {
|
||||||
// 获取 id 字段(必须保留)
|
|
||||||
const idField = currentData.id || currentData.Id || currentData.ID;
|
const idField = currentData.id || currentData.Id || currentData.ID;
|
||||||
|
|
||||||
// 计算差异
|
|
||||||
const changedFields = getChangedFields(_originalData, currentData, {
|
const changedFields = getChangedFields(_originalData, currentData, {
|
||||||
exclude: ['_originalData', 'id', 'Id', 'ID'],
|
exclude: ['_originalData', 'id', 'Id', 'ID'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 如果有变化,只传递 id + 变化的字段
|
config.data = Object.keys(changedFields).length > 0 ? { id: idField, ...changedFields } : { id: idField };
|
||||||
if (Object.keys(changedFields).length > 0) {
|
|
||||||
config.data = { id: idField, ...changedFields };
|
|
||||||
} else {
|
|
||||||
// 没有变化,只传递 id
|
|
||||||
config.data = { id: idField };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestErrorHandler = (error: any) => {
|
const requestErrorHandler = (error: any) => Promise.reject(error);
|
||||||
return Promise.reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 响应拦截器
|
|
||||||
const responseInterceptor = (response: AxiosResponse) => {
|
const responseInterceptor = (response: AxiosResponse) => {
|
||||||
// 文件流响应直接返回
|
// 文件流直接返回原始响应,调用方需要自行处理 Blob。
|
||||||
if (
|
if (
|
||||||
response.config.responseType === 'blob' ||
|
response.config.responseType === 'blob' ||
|
||||||
response.headers['content-type']?.includes('application/zip') ||
|
response.headers['content-type']?.includes('application/zip') ||
|
||||||
@@ -133,75 +175,58 @@ const responseInterceptor = (response: AxiosResponse) => {
|
|||||||
const httpStatus = response.status;
|
const httpStatus = response.status;
|
||||||
const code = res?.code;
|
const code = res?.code;
|
||||||
const message = res?.message;
|
const message = res?.message;
|
||||||
|
const config = response.config;
|
||||||
|
|
||||||
// 检查 token 相关错误
|
if (isTokenExpiredError(httpStatus, code, message)) {
|
||||||
if (
|
|
||||||
httpStatus === 401 ||
|
|
||||||
code === 401 ||
|
|
||||||
message?.includes('token') ||
|
|
||||||
message === 'token is invalid' ||
|
|
||||||
message === 'token 解析失败' ||
|
|
||||||
message?.includes('decrypt error')
|
|
||||||
) {
|
|
||||||
handleTokenExpired();
|
handleTokenExpired();
|
||||||
return Promise.reject(new Error('登录状态已过期'));
|
return Promise.reject(new Error('登录状态已过期'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理模块未开通错误 (403)
|
|
||||||
// 跳过资产SKU查询接口,避免弹窗内部请求触发循环
|
|
||||||
const requestUrl = response.config.url || '';
|
const requestUrl = response.config.url || '';
|
||||||
if (code === 402 && !requestUrl.includes('/assets/asset/sku/')) {
|
if (code === 402 && !requestUrl.includes('/assets/asset/sku/')) {
|
||||||
// 获取当前路由路径
|
|
||||||
const currentPath = window.location.hash.replace('#', '') || window.location.pathname;
|
const currentPath = window.location.hash.replace('#', '') || window.location.pathname;
|
||||||
handleModuleNotEnabled(currentPath);
|
handleModuleNotEnabled(currentPath);
|
||||||
// 直接返回,不再显示错误消息
|
|
||||||
return Promise.reject(new Error('模块未开通'));
|
return Promise.reject(new Error('模块未开通'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 业务逻辑错误处理(排除403,因为上面已处理)
|
// 业务失败默认走全局提示;如果页面声明自己处理,这里只抛错不弹窗。
|
||||||
if (code !== undefined && code !== 0 && code !== 200 && code !== 403) {
|
if (code !== undefined && code !== 0 && code !== 200 && code !== 403) {
|
||||||
const errorMsg = message || `请求失败(${code})`;
|
const errorMsg = message || `请求失败(${code})`;
|
||||||
showErrorMessage(errorMsg);
|
showErrorMessage(errorMsg, config);
|
||||||
return Promise.reject(new Error(errorMsg));
|
return Promise.reject(new Error(errorMsg));
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 响应错误拦截器
|
|
||||||
const responseErrorHandler = (error: any) => {
|
const responseErrorHandler = (error: any) => {
|
||||||
|
const config = error.config as InternalAxiosRequestConfig | undefined;
|
||||||
|
const httpStatus = error.response?.status;
|
||||||
|
const responseMessage = error.response?.data?.message;
|
||||||
|
|
||||||
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
|
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
|
||||||
showErrorMessage('请求超时,请检查网络连接');
|
showErrorMessage('请求超时,请检查网络连接', config);
|
||||||
return Promise.reject(new Error('请求超时'));
|
return Promise.reject(new Error('请求超时'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!error.response) {
|
if (!error.response) {
|
||||||
if (error.message === 'Network Error') {
|
|
||||||
// ElMessage.error('网络连接错误,请检查网络设置');
|
|
||||||
} else {
|
|
||||||
// ElMessage.error('网络异常,请检查连接');
|
|
||||||
}
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const httpStatus = error.response.status;
|
if (isTokenExpiredError(httpStatus, error.response?.data?.code, responseMessage)) {
|
||||||
// 优先使用返回数据中的 message 字段
|
handleTokenExpired();
|
||||||
const responseMessage = error.response.data?.message;
|
return Promise.reject(new Error('登录状态已过期'));
|
||||||
|
}
|
||||||
|
|
||||||
// 处理 HTTP 错误状态
|
|
||||||
const requestUrl = error.response.config?.url || '';
|
const requestUrl = error.response.config?.url || '';
|
||||||
|
|
||||||
switch (httpStatus) {
|
switch (httpStatus) {
|
||||||
case 401:
|
|
||||||
handleTokenExpired();
|
|
||||||
break;
|
|
||||||
case 402:
|
case 402:
|
||||||
// 模块未开通处理,跳过SKU相关接口避免循环
|
|
||||||
if (!requestUrl.includes('/assets/asset/sku/') && !requestUrl.includes('getAssetAndSku')) {
|
if (!requestUrl.includes('/assets/asset/sku/') && !requestUrl.includes('getAssetAndSku')) {
|
||||||
// 检查是否刚从开通页面返回(5秒内不再跳转)
|
|
||||||
const lastSubscribeTime = sessionStorage.getItem('lastSubscribeTime');
|
const lastSubscribeTime = sessionStorage.getItem('lastSubscribeTime');
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (lastSubscribeTime && now - parseInt(lastSubscribeTime) < 5000) {
|
if (lastSubscribeTime && now - parseInt(lastSubscribeTime) < 5000) {
|
||||||
showErrorMessage(responseMessage || '服务开通中,请稍后刷新页面');
|
showErrorMessage(responseMessage || '服务开通中,请稍后刷新页面', config);
|
||||||
return Promise.reject(new Error('模块开通中'));
|
return Promise.reject(new Error('模块开通中'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,40 +234,38 @@ const responseErrorHandler = (error: any) => {
|
|||||||
handleModuleNotEnabled(currentPath);
|
handleModuleNotEnabled(currentPath);
|
||||||
return Promise.reject(new Error('模块未开通'));
|
return Promise.reject(new Error('模块未开通'));
|
||||||
}
|
}
|
||||||
showErrorMessage(responseMessage || '服务未开通');
|
showErrorMessage(responseMessage || '服务未开通', config);
|
||||||
break;
|
break;
|
||||||
case 403:
|
case 403:
|
||||||
showErrorMessage(responseMessage || '没有权限访问该资源');
|
showErrorMessage(responseMessage || '没有权限访问该资源', config);
|
||||||
break;
|
break;
|
||||||
case 404:
|
case 404:
|
||||||
showErrorMessage(responseMessage || '请求的资源不存在');
|
showErrorMessage(responseMessage || '请求的资源不存在', config);
|
||||||
break;
|
break;
|
||||||
case 429:
|
case 429:
|
||||||
showErrorMessage(responseMessage || '请求过于频繁,请稍后再试');
|
// 429 是限流,不等于登录过期,这里只保留频率提示。
|
||||||
handleTokenExpired();
|
showErrorMessage(responseMessage || '请求过于频繁,请稍后再试', config);
|
||||||
break;
|
break;
|
||||||
case 500:
|
case 500:
|
||||||
showErrorMessage(responseMessage || '服务器内部错误');
|
showErrorMessage(responseMessage || '服务器内部错误', config);
|
||||||
break;
|
break;
|
||||||
case 502:
|
case 502:
|
||||||
showErrorMessage(responseMessage || '网关错误');
|
showErrorMessage(responseMessage || '网关错误', config);
|
||||||
break;
|
break;
|
||||||
case 503:
|
case 503:
|
||||||
showErrorMessage(responseMessage || '服务不可用');
|
showErrorMessage(responseMessage || '服务不可用', config);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
if (httpStatus >= 400) {
|
if (httpStatus >= 400) {
|
||||||
showErrorMessage(responseMessage || `请求失败(${httpStatus})`);
|
showErrorMessage(responseMessage || `请求失败(${httpStatus})`, config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 为实例添加拦截器
|
|
||||||
service.interceptors.request.use(requestInterceptor, requestErrorHandler);
|
service.interceptors.request.use(requestInterceptor, requestErrorHandler);
|
||||||
service.interceptors.response.use(responseInterceptor, responseErrorHandler);
|
service.interceptors.response.use(responseInterceptor, responseErrorHandler);
|
||||||
|
|
||||||
// 导出
|
|
||||||
export default service;
|
export default service;
|
||||||
export { showErrorMessage };
|
export { closeActiveErrorMessage, showErrorMessage };
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这些 key 属于登录态或用户会话上下文。
|
||||||
|
* 退出登录时只清理这部分数据,避免误删主题、语言、布局等本地个性化配置。
|
||||||
|
*/
|
||||||
|
const SESSION_AUTH_KEYS = ['token', 'userInfo', 'userMenu', 'permissions'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* window.localStorage 浏览器永久缓存
|
* window.localStorage 浏览器永久缓存
|
||||||
* @method set 设置永久缓存
|
* @method set 设置永久缓存
|
||||||
@@ -33,6 +39,7 @@ export const Local = {
|
|||||||
* @method get 获取临时缓存
|
* @method get 获取临时缓存
|
||||||
* @method remove 移除临时缓存
|
* @method remove 移除临时缓存
|
||||||
* @method clear 移除全部临时缓存
|
* @method clear 移除全部临时缓存
|
||||||
|
* @method clearAuth 移除登录态相关缓存
|
||||||
*/
|
*/
|
||||||
export const Session = {
|
export const Session = {
|
||||||
// 设置临时缓存
|
// 设置临时缓存
|
||||||
@@ -56,4 +63,10 @@ export const Session = {
|
|||||||
Cookies.remove('token');
|
Cookies.remove('token');
|
||||||
window.sessionStorage.clear();
|
window.sessionStorage.clear();
|
||||||
},
|
},
|
||||||
|
// 只清理登录态相关缓存,保留非登录相关的页面状态与本地配置
|
||||||
|
clearAuth() {
|
||||||
|
SESSION_AUTH_KEYS.forEach((key) => this.remove(key));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { SESSION_AUTH_KEYS };
|
||||||
|
|||||||
@@ -1,20 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="creation-page" :class="{ 'is-submitting': submitLoading }">
|
<div class="creation-page">
|
||||||
<div v-if="submitLoading" class="creation-loading-mask">
|
|
||||||
<div class="creation-loading-card">
|
|
||||||
<div class="loading-orbit">
|
|
||||||
<span class="loading-ring ring-outer"></span>
|
|
||||||
<span class="loading-ring ring-inner"></span>
|
|
||||||
<span class="loading-core"></span>
|
|
||||||
</div>
|
|
||||||
<div class="loading-title">正在创作中</div>
|
|
||||||
<div class="loading-desc">内容生成较慢,请稍候,创作完成后会自动刷新结果</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="panel left" v-loading="treeLoading">
|
<div class="panel left" v-loading="treeLoading">
|
||||||
<div class="title">工作空间</div>
|
<div class="title">工作空间</div>
|
||||||
<div class="tree-wrap">
|
<div class="tree-wrap">
|
||||||
<el-empty v-if="treeNodes.length === 0 && !treeLoading" description="暂无作品数据" />
|
<el-empty v-if="!treeLoading && treeNodes.length === 0" description="暂无作品数据" />
|
||||||
<el-tree
|
<el-tree
|
||||||
v-else
|
v-else
|
||||||
:data="treeNodes"
|
:data="treeNodes"
|
||||||
@@ -27,235 +16,185 @@
|
|||||||
>
|
>
|
||||||
<template #default="{ data }">
|
<template #default="{ data }">
|
||||||
<div class="tree-node">
|
<div class="tree-node">
|
||||||
<div class="tree-node-main">
|
<span class="ellipsis">{{ data.label }}</span>
|
||||||
<el-icon v-if="data.nodeType === 'date'"><ele-Calendar /></el-icon>
|
|
||||||
<el-icon v-else-if="data.nodeType === 'contentType'"><ele-Collection /></el-icon>
|
|
||||||
<el-icon v-else-if="data.nodeType === 'theme'"><ele-CollectionTag /></el-icon>
|
|
||||||
<el-icon v-else-if="data.nodeType === 'title'"><ele-FolderOpened /></el-icon>
|
|
||||||
<el-icon v-else-if="data.nodeType === 'html'"><ele-Document /></el-icon>
|
|
||||||
<el-icon v-else><ele-Picture /></el-icon>
|
|
||||||
<span class="ellipsis">{{ data.label }}</span>
|
|
||||||
</div>
|
|
||||||
<el-button
|
<el-button
|
||||||
v-if="data.nodeType === 'html' || data.nodeType === 'image'"
|
v-if="data.nodeType === 'html' || data.nodeType === 'image'"
|
||||||
type="primary"
|
type="primary"
|
||||||
link
|
link
|
||||||
class="tree-download"
|
class="tree-download"
|
||||||
@click.stop="downloadNode(data)"
|
@click.stop="downloadNode(data)"
|
||||||
><el-icon><ele-Download /></el-icon
|
>下载</el-button
|
||||||
></el-button>
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-tree>
|
</el-tree>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel center">
|
<div class="editor-shell">
|
||||||
<div class="title">内容创建参数配置</div>
|
<div class="panel top">
|
||||||
<el-form ref="formRef" :model="formData" :rules="rules" label-position="top" class="compact-form">
|
<div>
|
||||||
<div class="form-grid">
|
<div class="title">作品创作工作流</div>
|
||||||
<el-form-item label="1. 创作模式" prop="mode" class="span-1"
|
<div class="sub">右侧可编辑属性,底部同时展示原生 JSON 和后端 DSL。</div>
|
||||||
><el-select v-model="formData.mode"><el-option v-for="item in modeOptions" :key="item" :label="item" :value="item" /></el-select
|
|
||||||
></el-form-item>
|
|
||||||
<el-form-item label="2. 内容类型" prop="content_type" class="span-1"
|
|
||||||
><el-select v-model="formData.content_type"
|
|
||||||
><el-option v-for="item in contentTypeOptions" :key="item" :label="item" :value="item" /></el-select
|
|
||||||
></el-form-item>
|
|
||||||
<el-form-item label="3. 主题(系列名)" prop="theme" class="span-1"
|
|
||||||
><el-input v-model="formData.theme" placeholder="例如:春季通勤穿搭、小个子显高技巧"
|
|
||||||
/></el-form-item>
|
|
||||||
<el-form-item label="4. 标题(具体标题)" prop="title" class="span-1"
|
|
||||||
><el-input v-model="formData.title" placeholder="例如:通勤穿搭技巧、5个显高穿搭法则"
|
|
||||||
/></el-form-item>
|
|
||||||
<el-form-item label="5. 内容风格" prop="style" class="span-1"
|
|
||||||
><el-select v-model="formData.style"><el-option v-for="item in styleOptions" :key="item" :label="item" :value="item" /></el-select
|
|
||||||
></el-form-item>
|
|
||||||
<el-form-item label="6. 生成条数" prop="count" class="span-1"
|
|
||||||
><el-input-number v-model="formData.count" :min="1" :max="3" controls-position="right" class="w100"
|
|
||||||
/></el-form-item>
|
|
||||||
<el-form-item v-if="showImageConfig" label="7. 每条配图数量" prop="image_per_post" class="span-1"
|
|
||||||
><el-input-number v-model="formData.image_per_post" :min="1" :max="3" controls-position="right" class="w100"
|
|
||||||
/></el-form-item>
|
|
||||||
<el-form-item v-if="showImageConfig" label="8. 图片比例" prop="image_ratio" class="span-1"
|
|
||||||
><el-select v-model="formData.image_ratio"
|
|
||||||
><el-option v-for="item in imageRatioOptions" :key="item" :label="item" :value="item" /></el-select
|
|
||||||
></el-form-item>
|
|
||||||
<el-form-item :label="showImageConfig ? '9. 描述' : '7. 描述'" prop="description" class="span-2 description-item"
|
|
||||||
><el-input v-model="formData.description" type="textarea" :rows="4" placeholder="请输入内容补充描述、重点要求或限制条件"
|
|
||||||
/></el-form-item>
|
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" class="submit-btn" :loading="submitLoading" @click="handleSubmit">告诉我你的选择,我马上开始创作!</el-button>
|
<div class="actions">
|
||||||
</el-form>
|
<el-button @click="resetFlow">重置示例</el-button>
|
||||||
</div>
|
<el-button @click="showDsl = !showDsl">{{ showDsl ? '收起 DSL' : '展开 DSL' }}</el-button>
|
||||||
<div class="panel right" v-loading="previewLoading">
|
<el-button type="primary" @click="syncDsl">同步 DSL</el-button>
|
||||||
<div class="title preview-title">预览区域</div>
|
|
||||||
<div class="preview-main">
|
|
||||||
<el-empty v-if="!selectedPreview" description="请选择预览节点" />
|
|
||||||
<iframe v-else-if="selectedPreview.nodeType === 'html'" :src="selectedPreview.url" class="iframe" frameborder="0"></iframe>
|
|
||||||
<div v-else class="img-wrap">
|
|
||||||
<el-image :src="selectedPreview.url" :preview-src-list="[selectedPreview.url]" fit="contain" preview-teleported class="img" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="main">
|
||||||
|
<div class="panel canvas-panel">
|
||||||
|
<div class="meta">
|
||||||
|
<span>工作流画布</span><span>节点 {{ flowDsl.nodes.length }} / 连线 {{ flowDsl.edges.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="canvas-wrap"><div ref="logicFlowRef" class="logicflow-canvas"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel side">
|
||||||
|
<div class="title-sm">当前选中元素</div>
|
||||||
|
<el-empty v-if="!selectedElement" description="请先点击一个节点或连线" :image-size="84" />
|
||||||
|
<div v-else class="form-wrap">
|
||||||
|
<div>ID:{{ selectedElement.id }}</div>
|
||||||
|
<div>分类:{{ selectedElement.kind }}</div>
|
||||||
|
<div>业务类型:{{ formState.nodeCode || '-' }}</div>
|
||||||
|
<el-form label-position="top" class="prop-form">
|
||||||
|
<el-form-item v-if="selectedElement.kind === 'node'" label="节点名称"><el-input v-model="formState.text" /></el-form-item>
|
||||||
|
<el-form-item v-if="selectedElement.kind === 'node'" label="业务类型"><el-input v-model="formState.nodeCode" /></el-form-item>
|
||||||
|
<el-form-item v-if="selectedElement.kind === 'edge' || formState.nodeCode === 'theme-input'" label="字段"
|
||||||
|
><el-input v-model="formState.field"
|
||||||
|
/></el-form-item>
|
||||||
|
<el-form-item v-if="formState.nodeCode === 'copywriting-agent'" label="模型"><el-input v-model="formState.model" /></el-form-item>
|
||||||
|
<el-form-item v-if="formState.nodeCode === 'copywriting-agent'" label="Temperature"
|
||||||
|
><el-input-number v-model="formState.temperature" :min="0" :max="2" :step="0.1" class="w100"
|
||||||
|
/></el-form-item>
|
||||||
|
<el-form-item v-if="formState.nodeCode === 'image-agent'" label="图片比例"><el-input v-model="formState.imageRatio" /></el-form-item>
|
||||||
|
<el-form-item v-if="formState.nodeCode === 'publish-output'" label="发布渠道"><el-input v-model="formState.channel" /></el-form-item>
|
||||||
|
<el-button type="primary" class="w100" @click="applySelected">应用到当前元素</el-button>
|
||||||
|
</el-form>
|
||||||
|
<pre class="json-box">{{ pretty(selectedElement.properties) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-show="showDsl" class="panel dsl">
|
||||||
|
<div class="title-sm">LogicFlow 原生 JSON</div>
|
||||||
|
<pre class="json-box">{{ pretty(flowDsl) }}</pre>
|
||||||
|
<div class="title-sm">发给后端的业务 DSL JSON</div>
|
||||||
|
<pre class="json-box">{{ pretty(workflowDsl) }}</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import {
|
import LogicFlow from '@logicflow/core';
|
||||||
createCreation,
|
import { Control, DndPanel, SelectionSelect } from '@logicflow/extension';
|
||||||
downloadToFile,
|
import '@logicflow/core/dist/index.css';
|
||||||
getCreationList,
|
import '@logicflow/extension/lib/style/index.css';
|
||||||
type CreationListParams,
|
import { downloadToFile, getCreationList, type CreationListParams, type CreationTreeItem } from '/@/api/digitalHuman/creation';
|
||||||
type CreationSubmitParams,
|
|
||||||
type CreationTreeItem,
|
|
||||||
} from '/@/api/digitalHuman/creation';
|
|
||||||
|
|
||||||
type NodeType = 'date' | 'contentType' | 'theme' | 'title' | 'html' | 'image';
|
type NodeType = 'date' | 'contentType' | 'theme' | 'title' | 'html' | 'image';
|
||||||
|
type Item = Record<string, any>;
|
||||||
interface TreeNode {
|
interface TreeNode {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
nodeType: NodeType;
|
nodeType: NodeType;
|
||||||
children?: TreeNode[];
|
children?: TreeNode[];
|
||||||
createdDate?: string;
|
|
||||||
contentType?: string;
|
|
||||||
theme?: string;
|
|
||||||
creationTitle?: string;
|
|
||||||
fileUrl?: string;
|
fileUrl?: string;
|
||||||
}
|
}
|
||||||
interface PreviewState {
|
interface SelectedState {
|
||||||
url: string;
|
id: string;
|
||||||
nodeType: 'html' | 'image';
|
type: string;
|
||||||
|
kind: 'node' | 'edge';
|
||||||
|
properties: Item;
|
||||||
|
text?: string;
|
||||||
}
|
}
|
||||||
const formRef = ref<FormInstance>();
|
|
||||||
const treeLoading = ref(false);
|
const treeLoading = ref(false);
|
||||||
const submitLoading = ref(false);
|
|
||||||
const previewLoading = ref(false);
|
|
||||||
const imgAddressPrefix = ref('');
|
|
||||||
const treeNodes = ref<TreeNode[]>([]);
|
const treeNodes = ref<TreeNode[]>([]);
|
||||||
const selectedPreview = ref<PreviewState | null>(null);
|
const imgAddressPrefix = ref('');
|
||||||
const apiBaseUrl = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '');
|
const selectedElement = ref<SelectedState | null>(null);
|
||||||
|
const flowDsl = ref<{ nodes: Item[]; edges: Item[] }>({ nodes: [], edges: [] });
|
||||||
|
const logicFlowRef = ref<HTMLDivElement | null>(null);
|
||||||
|
const logicFlowInstance = ref<LogicFlow | null>(null);
|
||||||
|
const showDsl = ref(false);
|
||||||
|
const formState = reactive({ text: '', nodeCode: '', field: '', model: '', temperature: 0.7, imageRatio: '3:4', channel: '' });
|
||||||
const treeProps = { children: 'children', label: 'label' };
|
const treeProps = { children: 'children', label: 'label' };
|
||||||
const queryParams = reactive<CreationListParams>({ keyword: '', pageNum: 1, pageSize: 10 });
|
const queryParams: CreationListParams = { keyword: '', pageNum: 1, pageSize: 10 };
|
||||||
const formData = reactive<CreationSubmitParams>({
|
const apiBaseUrl = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '');
|
||||||
mode: '混合模式(文案 + 图片)',
|
const nodePalette = [
|
||||||
content_type: '穿搭分享',
|
{ type: 'rect', text: '主题输入', label: '主题输入', properties: { nodeCode: 'theme-input', field: 'theme' } },
|
||||||
theme: '',
|
{ type: 'rect', text: '文案生成', label: '文案生成', properties: { nodeCode: 'copywriting-agent', model: 'gpt-4o-mini', temperature: 0.7 } },
|
||||||
title: '',
|
{ type: 'rect', text: '图片生成', label: '图片生成', properties: { nodeCode: 'image-agent', imageRatio: '3:4' } },
|
||||||
description: '',
|
{ type: 'rect', text: '发布输出', label: '发布输出', properties: { nodeCode: 'publish-output', channel: 'xiaohongshu' } },
|
||||||
style: '生活分享 — 亲切自然,像朋友聊天',
|
|
||||||
count: 1,
|
|
||||||
image_per_post: 1,
|
|
||||||
image_ratio: '3:4 — 小红书',
|
|
||||||
});
|
|
||||||
const showImageConfig = computed(() => formData.mode === '混合模式(文案 + 图片)' || formData.mode === '纯图片模式');
|
|
||||||
const modeOptions = ['混合模式(文案 + 图片)', '纯文案模式', '纯图片模式'];
|
|
||||||
const contentTypeOptions = ['穿搭分享', '好物推荐', '美妆护肤', '探店分享', '旅行日常', '美食分享'];
|
|
||||||
const styleOptions = [
|
|
||||||
'生活分享 — 亲切自然,像朋友聊天',
|
|
||||||
'专业测评 — 深度分析,数据支撑',
|
|
||||||
'种草推荐 — 强调亮点,感染力强',
|
|
||||||
'干货教学 — 条理清晰,步骤明确',
|
|
||||||
];
|
];
|
||||||
const imageRatioOptions = ['3:4 — 小红书', '1:1 — 方图', '16:9 — 横版'];
|
const defaultGraphData = {
|
||||||
watch(
|
nodes: [
|
||||||
() => formData.mode,
|
{ id: 'theme-node', type: 'rect', x: 160, y: 170, text: '主题输入', properties: { nodeCode: 'theme-input', field: 'theme' } },
|
||||||
() => {
|
{
|
||||||
if (!showImageConfig.value) {
|
id: 'copy-node',
|
||||||
formData.image_per_post = 1;
|
type: 'rect',
|
||||||
formData.image_ratio = '3:4 — 小红书';
|
x: 420,
|
||||||
}
|
y: 170,
|
||||||
},
|
text: '文案生成',
|
||||||
{ immediate: true }
|
properties: { nodeCode: 'copywriting-agent', model: 'gpt-4o-mini', temperature: 0.7 },
|
||||||
);
|
|
||||||
const rules: FormRules = {
|
|
||||||
mode: [{ required: true, message: '请选择创作模式', trigger: 'change' }],
|
|
||||||
content_type: [{ required: true, message: '请选择内容类型', trigger: 'change' }],
|
|
||||||
theme: [{ required: true, message: '请输入主题', trigger: 'blur' }],
|
|
||||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
|
||||||
style: [{ required: true, message: '请选择内容风格', trigger: 'change' }],
|
|
||||||
count: [{ required: true, message: '请输入生成条数', trigger: 'change' }],
|
|
||||||
image_per_post: [{
|
|
||||||
required: true,
|
|
||||||
message: '请输入配图数量',
|
|
||||||
trigger: 'change',
|
|
||||||
validator: (rule, value, callback) => {
|
|
||||||
void rule;
|
|
||||||
if (!showImageConfig.value) return callback();
|
|
||||||
if (!value) return callback(new Error('请输入配图数量'));
|
|
||||||
callback();
|
|
||||||
},
|
},
|
||||||
}],
|
{ id: 'image-node', type: 'rect', x: 680, y: 170, text: '图片生成', properties: { nodeCode: 'image-agent', imageRatio: '3:4' } },
|
||||||
image_ratio: [{
|
{ id: 'publish-node', type: 'rect', x: 940, y: 170, text: '发布输出', properties: { nodeCode: 'publish-output', channel: 'xiaohongshu' } },
|
||||||
required: true,
|
],
|
||||||
message: '请选择图片比例',
|
edges: [
|
||||||
trigger: 'change',
|
{ id: 'edge-1', type: 'polyline', sourceNodeId: 'theme-node', targetNodeId: 'copy-node', text: '主题变量', properties: { field: 'theme' } },
|
||||||
validator: (rule, value, callback) => {
|
{ id: 'edge-2', type: 'polyline', sourceNodeId: 'copy-node', targetNodeId: 'image-node', text: '文案结果', properties: { field: 'copywriting' } },
|
||||||
void rule;
|
{ id: 'edge-3', type: 'polyline', sourceNodeId: 'image-node', targetNodeId: 'publish-node', text: '图文结果', properties: { field: 'assets' } },
|
||||||
if (!showImageConfig.value) return callback();
|
],
|
||||||
if (!value) return callback(new Error('请选择图片比例'));
|
|
||||||
callback();
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
const joinUrl = (base: string, path: string) => `${base.replace(/\/$/, '')}${path.startsWith('/') ? path : `/${path}`}`;
|
|
||||||
const buildAssetUrl = (path?: string) => {
|
|
||||||
if (!path) return '';
|
|
||||||
if (/^https?:\/\//i.test(path)) return path;
|
|
||||||
const prefix = imgAddressPrefix.value || '';
|
|
||||||
if (/^https?:\/\//i.test(prefix)) return joinUrl(prefix, path);
|
|
||||||
if (prefix) return joinUrl(joinUrl(apiBaseUrl, prefix), path);
|
|
||||||
return joinUrl(apiBaseUrl, path);
|
|
||||||
};
|
};
|
||||||
|
const workflowDsl = computed(() => ({
|
||||||
|
version: '1.0.0',
|
||||||
|
startNodeId: flowDsl.value.nodes[0]?.id || '',
|
||||||
|
nodes: flowDsl.value.nodes.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
nodeCode: n.properties?.nodeCode || 'unknown',
|
||||||
|
name: typeof n.text === 'string' ? n.text : n.text?.value || '',
|
||||||
|
config: { ...n.properties },
|
||||||
|
})),
|
||||||
|
edges: flowDsl.value.edges.map((e) => ({ id: e.id, from: e.sourceNodeId, to: e.targetNodeId, mapping: { ...e.properties } })),
|
||||||
|
}));
|
||||||
|
const pretty = (v: unknown) => JSON.stringify(v, null, 2);
|
||||||
|
const joinUrl = (b: string, p: string) => `${b.replace(/\/$/, '')}${p.startsWith('/') ? p : `/${p}`}`;
|
||||||
|
const buildAssetUrl = (p?: string) =>
|
||||||
|
!p
|
||||||
|
? ''
|
||||||
|
: /^https?:\/\//i.test(p)
|
||||||
|
? p
|
||||||
|
: /^https?:\/\//i.test(imgAddressPrefix.value || '')
|
||||||
|
? joinUrl(imgAddressPrefix.value, p)
|
||||||
|
: imgAddressPrefix.value
|
||||||
|
? joinUrl(joinUrl(apiBaseUrl, imgAddressPrefix.value), p)
|
||||||
|
: joinUrl(apiBaseUrl, p);
|
||||||
const buildTreeNodes = (tree: CreationTreeItem[]): TreeNode[] =>
|
const buildTreeNodes = (tree: CreationTreeItem[]): TreeNode[] =>
|
||||||
tree.map((dateGroup, dIndex) => ({
|
tree.map((d, di) => ({
|
||||||
id: `date-${dIndex}`,
|
id: `date-${di}`,
|
||||||
label: dateGroup.createdDate,
|
label: d.createdDate,
|
||||||
nodeType: 'date' as const,
|
nodeType: 'date',
|
||||||
children: (dateGroup.contentTypes || []).map((contentTypeGroup, cIndex) => ({
|
children: (d.contentTypes || []).map((c, ci) => ({
|
||||||
id: `content-type-${dIndex}-${cIndex}`,
|
id: `content-${di}-${ci}`,
|
||||||
label: contentTypeGroup.contentType,
|
label: c.contentType,
|
||||||
nodeType: 'contentType' as const,
|
nodeType: 'contentType',
|
||||||
createdDate: dateGroup.createdDate,
|
children: (c.themes || []).map((t, ti) => ({
|
||||||
contentType: contentTypeGroup.contentType,
|
id: `theme-${di}-${ci}-${ti}`,
|
||||||
children: (contentTypeGroup.themes || []).map((themeGroup, tIndex) => ({
|
label: t.theme,
|
||||||
id: `theme-${dIndex}-${cIndex}-${tIndex}`,
|
nodeType: 'theme',
|
||||||
label: themeGroup.theme,
|
children: (t.titles || []).map((title, i) => ({
|
||||||
nodeType: 'theme' as const,
|
id: `title-${di}-${ci}-${ti}-${i}`,
|
||||||
createdDate: dateGroup.createdDate,
|
label: title.title || `作品${i + 1}`,
|
||||||
contentType: contentTypeGroup.contentType,
|
nodeType: 'title',
|
||||||
theme: themeGroup.theme,
|
|
||||||
children: (themeGroup.titles || []).map((titleItem, i) => ({
|
|
||||||
id: `title-${dIndex}-${cIndex}-${tIndex}-${i}`,
|
|
||||||
label: titleItem.title || `作品${i + 1}`,
|
|
||||||
nodeType: 'title' as const,
|
|
||||||
createdDate: dateGroup.createdDate,
|
|
||||||
contentType: contentTypeGroup.contentType,
|
|
||||||
theme: themeGroup.theme,
|
|
||||||
creationTitle: titleItem.title || `作品${i + 1}`,
|
|
||||||
children: [
|
children: [
|
||||||
...(titleItem.htmlFileUrl
|
...(title.htmlFileUrl
|
||||||
? [
|
? [{ id: `html-${di}-${ci}-${ti}-${i}`, label: 'HTML', nodeType: 'html' as const, fileUrl: title.htmlFileUrl }]
|
||||||
{
|
|
||||||
id: `html-${dIndex}-${cIndex}-${tIndex}-${i}`,
|
|
||||||
label: 'HTML',
|
|
||||||
nodeType: 'html' as const,
|
|
||||||
createdDate: dateGroup.createdDate,
|
|
||||||
contentType: contentTypeGroup.contentType,
|
|
||||||
theme: themeGroup.theme,
|
|
||||||
creationTitle: titleItem.title || `作品${i + 1}`,
|
|
||||||
fileUrl: titleItem.htmlFileUrl,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
: []),
|
||||||
...(titleItem.imageUrls || []).map((img, imgIndex) => ({
|
...(title.imageUrls || []).map((img, ii) => ({
|
||||||
id: `img-${dIndex}-${cIndex}-${tIndex}-${i}-${imgIndex}`,
|
id: `img-${di}-${ci}-${ti}-${i}-${ii}`,
|
||||||
label: img.name || `图片 ${imgIndex + 1}`,
|
label: img.name || `图片 ${ii + 1}`,
|
||||||
nodeType: 'image' as const,
|
nodeType: 'image' as const,
|
||||||
createdDate: dateGroup.createdDate,
|
|
||||||
contentType: contentTypeGroup.contentType,
|
|
||||||
theme: themeGroup.theme,
|
|
||||||
creationTitle: titleItem.title || `作品${i + 1}`,
|
|
||||||
fileUrl: img.url,
|
fileUrl: img.url,
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
@@ -263,195 +202,185 @@ const buildTreeNodes = (tree: CreationTreeItem[]): TreeNode[] =>
|
|||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
const handleNodeClick = (data: TreeNode) => {
|
|
||||||
if (data.contentType) formData.content_type = data.contentType;
|
|
||||||
if (data.theme) formData.theme = data.theme;
|
|
||||||
if (data.nodeType === 'title') {
|
|
||||||
formData.title = data.creationTitle || data.label || formData.title;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.nodeType !== 'html' && data.nodeType !== 'image') return;
|
|
||||||
const url = buildAssetUrl(data.fileUrl);
|
|
||||||
if (!url) return ElMessage.warning('当前节点没有可预览地址');
|
|
||||||
selectedPreview.value = { url, nodeType: data.nodeType };
|
|
||||||
formData.title = data.creationTitle || formData.title;
|
|
||||||
};
|
|
||||||
const downloadNode = async (data: TreeNode) => {
|
|
||||||
if (data.nodeType !== 'html' && data.nodeType !== 'image') return;
|
|
||||||
if (!data.fileUrl) return ElMessage.warning('当前节点没有可下载地址');
|
|
||||||
try {
|
|
||||||
const response = await downloadToFile({ fileURL: data.fileUrl });
|
|
||||||
const blob = response instanceof Blob ? response : response?.data;
|
|
||||||
if (!(blob instanceof Blob)) throw new Error('无效的下载数据');
|
|
||||||
const fileName = decodeURIComponent(data.fileUrl.split('/').pop() || `${data.label}.${data.nodeType === 'html' ? 'html' : 'png'}`);
|
|
||||||
const objectUrl = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = objectUrl;
|
|
||||||
link.download = fileName;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(objectUrl);
|
|
||||||
ElMessage.success('下载成功');
|
|
||||||
} catch {
|
|
||||||
ElMessage.error('下载失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const findFirstPreviewNode = (nodes: TreeNode[]): TreeNode | null => {
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.nodeType === 'html' || node.nodeType === 'image') return node;
|
|
||||||
if (node.children?.length) {
|
|
||||||
const matched = findFirstPreviewNode(node.children);
|
|
||||||
if (matched) return matched;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
const getList = async () => {
|
const getList = async () => {
|
||||||
treeLoading.value = true;
|
treeLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await getCreationList({ ...queryParams, keyword: queryParams.keyword || undefined });
|
// 这里改成 page,表示列表加载失败的文案由当前页面自己决定。
|
||||||
|
const res = await getCreationList({ ...queryParams, keyword: queryParams.keyword || undefined }, { errorMode: 'page' });
|
||||||
imgAddressPrefix.value = res.data?.imgAddressPrefix || '';
|
imgAddressPrefix.value = res.data?.imgAddressPrefix || '';
|
||||||
treeNodes.value = buildTreeNodes(res.data?.Tree || []);
|
treeNodes.value = buildTreeNodes(res.data?.Tree || []);
|
||||||
selectedPreview.value = null;
|
|
||||||
await nextTick();
|
|
||||||
const firstLeaf = findFirstPreviewNode(treeNodes.value);
|
|
||||||
if (firstLeaf) handleNodeClick(firstLeaf);
|
|
||||||
} catch {
|
} catch {
|
||||||
treeNodes.value = [];
|
treeNodes.value = [];
|
||||||
imgAddressPrefix.value = '';
|
imgAddressPrefix.value = '';
|
||||||
selectedPreview.value = null;
|
// 既然这个请求声明由页面自己处理错误,这里保留页面可读性更强的业务文案。
|
||||||
ElMessage.error('获取作品创作列表失败');
|
ElMessage.error('获取作品创作列表失败');
|
||||||
} finally {
|
} finally {
|
||||||
treeLoading.value = false;
|
treeLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleSubmit = async () => {
|
const handleNodeClick = (d: TreeNode) => {
|
||||||
if (!formRef.value || submitLoading.value) return;
|
if (d.nodeType !== 'html' && d.nodeType !== 'image') return;
|
||||||
|
const url = buildAssetUrl(d.fileUrl);
|
||||||
|
if (!url) return ElMessage.warning('当前节点没有可用预览地址');
|
||||||
|
window.open(url, '_blank');
|
||||||
|
};
|
||||||
|
const downloadNode = async (d: TreeNode) => {
|
||||||
|
if (d.nodeType !== 'html' && d.nodeType !== 'image') return;
|
||||||
|
if (!d.fileUrl) return ElMessage.warning('当前节点没有可下载地址');
|
||||||
try {
|
try {
|
||||||
await formRef.value.validate();
|
// 下载失败时希望展示更贴近页面语义的提示,因此改为 page 模式。
|
||||||
submitLoading.value = true;
|
const r = await downloadToFile({ fileURL: d.fileUrl }, { errorMode: 'page' });
|
||||||
selectedPreview.value = null;
|
const blob = r instanceof Blob ? r : r?.data;
|
||||||
await createCreation({
|
if (!(blob instanceof Blob)) throw new Error('invalid blob');
|
||||||
...formData,
|
const name = decodeURIComponent(d.fileUrl.split('/').pop() || `${d.label}.${d.nodeType === 'html' ? 'html' : 'png'}`);
|
||||||
count: Number(formData.count),
|
const u = URL.createObjectURL(blob);
|
||||||
image_per_post: Number(formData.image_per_post),
|
const a = document.createElement('a');
|
||||||
description: formData.description?.trim() || undefined,
|
a.href = u;
|
||||||
});
|
a.download = name;
|
||||||
ElMessage.success('创作任务已提交');
|
document.body.appendChild(a);
|
||||||
await getList();
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(u);
|
||||||
|
ElMessage.success('下载成功');
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('提交创作任务失败');
|
// 下载接口已经声明由页面自己处理错误,所以这里只会出现一条下载失败提示。
|
||||||
} finally {
|
ElMessage.error('下载失败');
|
||||||
submitLoading.value = false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
onMounted(getList);
|
const syncDsl = () => {
|
||||||
|
const lf = logicFlowInstance.value;
|
||||||
|
if (!lf) return;
|
||||||
|
const data = lf.getGraphData() as { nodes?: Item[]; edges?: Item[] };
|
||||||
|
flowDsl.value = { nodes: data.nodes || [], edges: data.edges || [] };
|
||||||
|
};
|
||||||
|
watch(
|
||||||
|
selectedElement,
|
||||||
|
(e) => {
|
||||||
|
formState.text = String(e?.text || '');
|
||||||
|
formState.nodeCode = String(e?.properties?.nodeCode || '');
|
||||||
|
formState.field = String(e?.properties?.field || '');
|
||||||
|
formState.model = String(e?.properties?.model || '');
|
||||||
|
formState.temperature = Number(e?.properties?.temperature ?? 0.7);
|
||||||
|
formState.imageRatio = String(e?.properties?.imageRatio || '3:4');
|
||||||
|
formState.channel = String(e?.properties?.channel || '');
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
const applySelected = () => {
|
||||||
|
const lf = logicFlowInstance.value,
|
||||||
|
cur = selectedElement.value;
|
||||||
|
if (!lf || !cur) return;
|
||||||
|
const p: Item = { ...cur.properties, nodeCode: formState.nodeCode };
|
||||||
|
formState.field ? (p.field = formState.field) : delete p.field;
|
||||||
|
formState.model ? (p.model = formState.model) : delete p.model;
|
||||||
|
formState.nodeCode === 'copywriting-agent' ? (p.temperature = formState.temperature) : delete p.temperature;
|
||||||
|
formState.imageRatio ? (p.imageRatio = formState.imageRatio) : delete p.imageRatio;
|
||||||
|
formState.channel ? (p.channel = formState.channel) : delete p.channel;
|
||||||
|
lf.setProperties(cur.id, p);
|
||||||
|
if (formState.text) lf.updateText(cur.id, formState.text);
|
||||||
|
const g = lf.getGraphData() as { nodes: Item[]; edges: Item[] };
|
||||||
|
const n = g.nodes.find((x) => x.id === cur.id),
|
||||||
|
e = g.edges.find((x) => x.id === cur.id);
|
||||||
|
selectedElement.value = n
|
||||||
|
? { id: n.id, type: n.type, kind: 'node', properties: n.properties || {}, text: typeof n.text === 'string' ? n.text : n.text?.value }
|
||||||
|
: e
|
||||||
|
? { id: e.id, type: e.type, kind: 'edge', properties: e.properties || {}, text: typeof e.text === 'string' ? e.text : e.text?.value }
|
||||||
|
: null;
|
||||||
|
syncDsl();
|
||||||
|
ElMessage.success('已更新当前元素配置');
|
||||||
|
};
|
||||||
|
const setupDndPanel = () => {
|
||||||
|
const lf = logicFlowInstance.value as LogicFlow & {
|
||||||
|
extension: { dndPanel?: { setPatternItems: (items: Array<Record<string, unknown>>) => void } };
|
||||||
|
};
|
||||||
|
lf.extension.dndPanel?.setPatternItems(nodePalette);
|
||||||
|
};
|
||||||
|
const bindEvents = () => {
|
||||||
|
const lf = logicFlowInstance.value;
|
||||||
|
if (!lf) return;
|
||||||
|
lf.on('node:click', ({ data }: { data: any }) => {
|
||||||
|
selectedElement.value = {
|
||||||
|
id: data.id,
|
||||||
|
type: data.type,
|
||||||
|
kind: 'node',
|
||||||
|
properties: data.properties || {},
|
||||||
|
text: typeof data.text === 'string' ? data.text : data.text?.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
lf.on('edge:click', ({ data }: { data: any }) => {
|
||||||
|
selectedElement.value = {
|
||||||
|
id: data.id,
|
||||||
|
type: data.type,
|
||||||
|
kind: 'edge',
|
||||||
|
properties: data.properties || {},
|
||||||
|
text: typeof data.text === 'string' ? data.text : data.text?.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
lf.on('blank:click', () => {
|
||||||
|
selectedElement.value = null;
|
||||||
|
});
|
||||||
|
['history:change', 'node:add', 'edge:add', 'node:delete', 'edge:delete'].forEach((n) => lf.on(n, syncDsl));
|
||||||
|
};
|
||||||
|
const initLogicFlow = () => {
|
||||||
|
if (!logicFlowRef.value) return;
|
||||||
|
LogicFlow.use(Control);
|
||||||
|
LogicFlow.use(DndPanel);
|
||||||
|
LogicFlow.use(SelectionSelect);
|
||||||
|
const lf = new LogicFlow({
|
||||||
|
container: logicFlowRef.value,
|
||||||
|
grid: { size: 16, visible: true, type: 'dot', config: { color: '#d7e0ef', thickness: 1 } },
|
||||||
|
background: { backgroundColor: '#fbfcfe' },
|
||||||
|
keyboard: { enabled: true },
|
||||||
|
adjustEdge: true,
|
||||||
|
edgeType: 'polyline',
|
||||||
|
style: {
|
||||||
|
rect: { width: 120, height: 54, radius: 10, stroke: '#334155', strokeWidth: 1.4, fill: '#fff' },
|
||||||
|
polyline: { stroke: '#475569', strokeWidth: 1.4 },
|
||||||
|
edgeText: { fill: '#64748b', fontSize: 12, textWidth: 120, background: { fill: '#fff' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logicFlowInstance.value = lf;
|
||||||
|
lf.render(defaultGraphData);
|
||||||
|
lf.fitView(60, 80);
|
||||||
|
setupDndPanel();
|
||||||
|
bindEvents();
|
||||||
|
syncDsl();
|
||||||
|
};
|
||||||
|
const resetFlow = () => {
|
||||||
|
const lf = logicFlowInstance.value;
|
||||||
|
if (!lf) return;
|
||||||
|
lf.render(defaultGraphData);
|
||||||
|
lf.fitView(60, 80);
|
||||||
|
selectedElement.value = null;
|
||||||
|
syncDsl();
|
||||||
|
ElMessage.success('示例流程已重置');
|
||||||
|
};
|
||||||
|
onMounted(async () => {
|
||||||
|
await getList();
|
||||||
|
await nextTick();
|
||||||
|
initLogicFlow();
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
logicFlowInstance.value?.destroy();
|
||||||
|
logicFlowInstance.value = null;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.creation-page {
|
.creation-page {
|
||||||
height: calc(100vh - 100px);
|
height: calc(100vh - 100px);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 292px minmax(470px, 1fr) minmax(500px, 1.02fr);
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
background: #f6f8fb;
|
background: #f6f8fb;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.creation-page.is-submitting {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.creation-loading-mask {
|
|
||||||
position: absolute;
|
|
||||||
inset: 14px;
|
|
||||||
z-index: 20;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(246, 248, 251, 0.78);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
.creation-loading-card {
|
|
||||||
width: min(420px, calc(100% - 40px));
|
|
||||||
padding: 36px 28px;
|
|
||||||
border-radius: 20px;
|
|
||||||
background: rgba(255, 255, 255, 0.96);
|
|
||||||
box-shadow: 0 18px 48px rgba(64, 102, 255, 0.18);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.loading-orbit {
|
|
||||||
position: relative;
|
|
||||||
width: 108px;
|
|
||||||
height: 108px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.loading-ring {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
border-style: solid;
|
|
||||||
animation: orbit-rotate 1.8s linear infinite;
|
|
||||||
}
|
|
||||||
.ring-outer {
|
|
||||||
border-width: 4px;
|
|
||||||
border-color: #5b8cff transparent #8fb3ff transparent;
|
|
||||||
}
|
|
||||||
.ring-inner {
|
|
||||||
inset: 15px;
|
|
||||||
border-width: 4px;
|
|
||||||
border-color: transparent #7c9dff transparent #d2deff;
|
|
||||||
animation-direction: reverse;
|
|
||||||
animation-duration: 1.2s;
|
|
||||||
}
|
|
||||||
.loading-core {
|
|
||||||
position: absolute;
|
|
||||||
inset: 34px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #5b8cff 0%, #7a5cff 100%);
|
|
||||||
box-shadow: 0 0 0 10px rgba(91, 140, 255, 0.12);
|
|
||||||
animation: core-pulse 1.6s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.loading-title {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1f2d3d;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.loading-desc {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: #5f6b7a;
|
|
||||||
}
|
|
||||||
@keyframes orbit-rotate {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes core-pulse {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: scale(0.92);
|
|
||||||
box-shadow: 0 0 0 10px rgba(91, 140, 255, 0.12);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1);
|
|
||||||
box-shadow: 0 0 0 18px rgba(91, 140, 255, 0.2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.panel {
|
.panel {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 10px;
|
border-radius: 14px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.05);
|
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.05);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -459,15 +388,21 @@ onMounted(getList);
|
|||||||
.title {
|
.title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #303133;
|
color: #1f2937;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
.preview-title {
|
.title-sm {
|
||||||
margin-bottom: 0;
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
.tree-wrap,
|
.sub {
|
||||||
.center,
|
font-size: 13px;
|
||||||
.preview-main {
|
line-height: 1.7;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.tree-wrap {
|
||||||
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
.tree-node {
|
.tree-node {
|
||||||
@@ -475,107 +410,113 @@ onMounted(getList);
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.tree-node-main {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.tree-download {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 2px;
|
|
||||||
}
|
}
|
||||||
.ellipsis {
|
.ellipsis {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.compact-form {
|
.editor-shell {
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.form-grid {
|
.top {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.main {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: minmax(0, 1fr) 340px;
|
||||||
gap: 0 12px;
|
gap: 14px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
.span-1 {
|
.meta {
|
||||||
grid-column: span 1;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
.span-2 {
|
.canvas-wrap {
|
||||||
grid-column: span 2;
|
flex: 1;
|
||||||
|
min-height: 560px;
|
||||||
|
border: 1px solid #e8eef7;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(180deg, #fcfdff 0%, #f8fbff 100%);
|
||||||
}
|
}
|
||||||
.description-item {
|
.logicflow-canvas {
|
||||||
margin-bottom: 8px;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 560px;
|
||||||
|
}
|
||||||
|
.form-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.prop-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.w100 {
|
.w100 {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.submit-btn {
|
.json-box {
|
||||||
width: 100%;
|
margin: 0;
|
||||||
height: 40px;
|
padding: 12px;
|
||||||
margin-top: auto;
|
border-radius: 12px;
|
||||||
border-radius: 8px;
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.preview-main {
|
:deep(.lf-dndpanel) {
|
||||||
flex: 1;
|
top: 14px;
|
||||||
min-height: 0;
|
left: 14px;
|
||||||
background: #f8fafc;
|
|
||||||
border: 1px solid #edf1f7;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
}
|
||||||
.iframe {
|
:deep(.lf-control) {
|
||||||
width: 100%;
|
right: 14px;
|
||||||
height: 100%;
|
top: 14px;
|
||||||
min-height: 520px;
|
left: auto;
|
||||||
border: 1px solid #ebeef5;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fff;
|
|
||||||
}
|
}
|
||||||
.img-wrap {
|
:deep(.lf-node-selected .lf-basic-shape) {
|
||||||
height: 100%;
|
stroke: #2563eb !important;
|
||||||
min-height: 520px;
|
stroke-width: 1.8 !important;
|
||||||
border: 1px solid #ebeef5;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #fff;
|
|
||||||
}
|
}
|
||||||
.img {
|
:deep(.lf-edge-selected path) {
|
||||||
width: 100%;
|
stroke: #2563eb !important;
|
||||||
height: 100%;
|
|
||||||
min-height: 480px;
|
|
||||||
}
|
}
|
||||||
:deep(.el-form-item) {
|
@media (max-width: 1400px) {
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
:deep(.el-form-item__label) {
|
|
||||||
padding-bottom: 4px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #606266;
|
|
||||||
}
|
|
||||||
:deep(.el-input__wrapper),
|
|
||||||
:deep(.el-select__wrapper),
|
|
||||||
:deep(.el-textarea__inner),
|
|
||||||
:deep(.el-input-number) {
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
:deep(.el-select),
|
|
||||||
:deep(.el-input),
|
|
||||||
:deep(.el-input-number),
|
|
||||||
:deep(.el-textarea) {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
@media (max-width: 1800px) {
|
|
||||||
.creation-page {
|
.creation-page {
|
||||||
grid-template-columns: 280px minmax(430px, 1fr) minmax(460px, 0.98fr);
|
grid-template-columns: 260px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.main {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.creation-page {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.top {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ export default defineComponent({
|
|||||||
const { isTagsViewCurrenFull } = storeToRefs(storesTagsViewRoutes);
|
const { isTagsViewCurrenFull } = storeToRefs(storesTagsViewRoutes);
|
||||||
const onSetAuth = () => {
|
const onSetAuth = () => {
|
||||||
// https://gitee.com/lyt-top/vue-next-admin/issues/I5C3JS
|
// https://gitee.com/lyt-top/vue-next-admin/issues/I5C3JS
|
||||||
// 清除缓存/token等
|
// 401 页面回登录时只清理登录态相关缓存,保留本地个性化配置。
|
||||||
Session.clear();
|
Session.clearAuth();
|
||||||
// 使用 reload 时,不需要调用 resetRoute() 重置路由
|
// 使用 reload 时,不需要调用 resetRoute() 重置路由
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ const openDialog = async (row?: { id?: string }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
const res = await getLiveAccountDetail({ id: String(row.id) });
|
// 详情加载失败时由当前弹窗给出更易懂的业务提示。
|
||||||
|
const res = await getLiveAccountDetail({ id: String(row.id) }, { errorMode: 'page' });
|
||||||
if (res?.data) {
|
if (res?.data) {
|
||||||
fillForm(res.data);
|
fillForm(res.data);
|
||||||
}
|
}
|
||||||
@@ -129,11 +130,12 @@ const handleSubmit = async () => {
|
|||||||
remark: formData.remark,
|
remark: formData.remark,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 提交失败提示交给当前弹窗自己处理,避免和 request.ts 的统一报错重复。
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
await updateLiveAccount(payload);
|
await updateLiveAccount(payload, { errorMode: 'page' });
|
||||||
ElMessage.success('修改成功');
|
ElMessage.success('修改成功');
|
||||||
} else {
|
} else {
|
||||||
await createLiveAccount(payload);
|
await createLiveAccount(payload, { errorMode: 'page' });
|
||||||
ElMessage.success('新增成功');
|
ElMessage.success('新增成功');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,13 +131,17 @@ const tableData = reactive({
|
|||||||
const getList = async () => {
|
const getList = async () => {
|
||||||
try {
|
try {
|
||||||
tableData.loading = true;
|
tableData.loading = true;
|
||||||
const res = await getLiveAccountList({
|
// 列表失败文案由当前页面决定,避免和全局请求报错同时出现。
|
||||||
...tableData.param,
|
const res = await getLiveAccountList(
|
||||||
platform: searchForm.platform || undefined,
|
{
|
||||||
accountName: searchForm.accountName || undefined,
|
...tableData.param,
|
||||||
accountId: searchForm.accountId || undefined,
|
platform: searchForm.platform || undefined,
|
||||||
status: searchForm.status,
|
accountName: searchForm.accountName || undefined,
|
||||||
});
|
accountId: searchForm.accountId || undefined,
|
||||||
|
status: searchForm.status,
|
||||||
|
},
|
||||||
|
{ errorMode: 'page' }
|
||||||
|
);
|
||||||
if (res && res.data) {
|
if (res && res.data) {
|
||||||
tableData.data = (res.data.list || []).map((item: any) => ({
|
tableData.data = (res.data.list || []).map((item: any) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -191,7 +195,7 @@ const handleDelete = async (row: LiveAccountItem) => {
|
|||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
});
|
});
|
||||||
await deleteLiveAccount({ id: row.id });
|
await deleteLiveAccount({ id: row.id }, { errorMode: 'page' });
|
||||||
ElMessage.success('删除成功');
|
ElMessage.success('删除成功');
|
||||||
getList();
|
getList();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -153,7 +153,8 @@ const openDialog = async (row?: { id?: string }) => {
|
|||||||
await loadOptions();
|
await loadOptions();
|
||||||
|
|
||||||
if (row?.id) {
|
if (row?.id) {
|
||||||
const res = await getScheduleDetail({ id: String(row.id) });
|
// 详情请求失败时,这个弹窗希望给出更明确的页面语义提示。
|
||||||
|
const res = await getScheduleDetail({ id: String(row.id) }, { errorMode: 'page' });
|
||||||
const detail = res?.data;
|
const detail = res?.data;
|
||||||
if (detail) {
|
if (detail) {
|
||||||
formData.id = String(detail.id);
|
formData.id = String(detail.id);
|
||||||
@@ -195,11 +196,12 @@ const handleSubmit = async () => {
|
|||||||
remark: formData.remark,
|
remark: formData.remark,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 提交失败文案由弹窗自己控制,避免接口层和弹窗层重复报错。
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
await updateSchedule(payload);
|
await updateSchedule(payload, { errorMode: 'page' });
|
||||||
ElMessage.success('修改排班成功');
|
ElMessage.success('修改排班成功');
|
||||||
} else {
|
} else {
|
||||||
await createSchedule(payload);
|
await createSchedule(payload, { errorMode: 'page' });
|
||||||
ElMessage.success('新增排班成功');
|
ElMessage.success('新增排班成功');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,12 +144,16 @@ const getStatusTagType = (status: number): 'success' | 'info' | 'warning' => {
|
|||||||
const getList = async () => {
|
const getList = async () => {
|
||||||
try {
|
try {
|
||||||
tableData.loading = true;
|
tableData.loading = true;
|
||||||
const res = await getScheduleList({
|
// 列表失败文案由当前页面决定,避免和 request.ts 的全局错误提示重复。
|
||||||
...tableData.param,
|
const res = await getScheduleList(
|
||||||
anchorName: searchForm.anchorName || undefined,
|
{
|
||||||
accountName: searchForm.accountName || undefined,
|
...tableData.param,
|
||||||
status: searchForm.status,
|
anchorName: searchForm.anchorName || undefined,
|
||||||
} as any);
|
accountName: searchForm.accountName || undefined,
|
||||||
|
status: searchForm.status,
|
||||||
|
} as any,
|
||||||
|
{ errorMode: 'page' }
|
||||||
|
);
|
||||||
const scheduleData = res?.data;
|
const scheduleData = res?.data;
|
||||||
if (scheduleData) {
|
if (scheduleData) {
|
||||||
tableData.data = (scheduleData.list || []).map((item: any) => ({
|
tableData.data = (scheduleData.list || []).map((item: any) => ({
|
||||||
@@ -207,7 +211,7 @@ const handleDelete = async (row: ScheduleItem) => {
|
|||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
});
|
});
|
||||||
await deleteSchedule({ id: row.id });
|
await deleteSchedule({ id: row.id }, { errorMode: 'page' });
|
||||||
ElMessage.success('删除成功');
|
ElMessage.success('删除成功');
|
||||||
getList();
|
getList();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user