Compare commits
5 Commits
1baef251ab
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ae927a851 | |||
| f137ae591e | |||
| d516886fc9 | |||
| d628dfdd72 | |||
| 24e517dfec |
2
.env
2
.env
@@ -1,5 +1,5 @@
|
||||
# port 端口号
|
||||
VITE_PORT = 8888
|
||||
VITE_PORT = 8080
|
||||
|
||||
# open 运行 npm run dev 时自动打开浏览器
|
||||
VITE_OPEN = true
|
||||
|
||||
@@ -11,6 +11,6 @@ RUN npm install --registry=https://registry.npmmirror.com
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8888
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import request from '/@/utils/request';
|
||||
import request, { type RequestOptions } from '/@/utils/request';
|
||||
|
||||
export interface CreationListParams {
|
||||
pageNum: number;
|
||||
@@ -61,28 +61,32 @@ export interface DownloadToFileParams {
|
||||
fileURL: string;
|
||||
}
|
||||
|
||||
export function getCreationList(params: CreationListParams) {
|
||||
// requestOptions 用来声明“这个接口的错误提示由谁负责”。
|
||||
export function getCreationList(params: CreationListParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/black-deacon/creation/info/list',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<CreationListResponse>;
|
||||
}
|
||||
|
||||
export function createCreation(data: CreationSubmitParams) {
|
||||
export function createCreation(data: CreationSubmitParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/black-deacon/creation/info/creation',
|
||||
method: 'post',
|
||||
data,
|
||||
timeout: 0,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadToFile(data: DownloadToFileParams) {
|
||||
export function downloadToFile(data: DownloadToFileParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/oss/file/downloadToBrowser',
|
||||
method: 'post',
|
||||
data,
|
||||
responseType: 'blob',
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import request from '/@/utils/request';
|
||||
import request, { type RequestOptions } from '/@/utils/request';
|
||||
|
||||
export interface LiveAccountParams {
|
||||
pageNum: number;
|
||||
@@ -47,42 +47,47 @@ export interface LiveAccountDetailResponse {
|
||||
data: LiveAccount;
|
||||
}
|
||||
|
||||
export function getLiveAccountList(params: LiveAccountParams): Promise<LiveAccountListResponse> {
|
||||
export function getLiveAccountList(params: LiveAccountParams, requestOptions?: RequestOptions): Promise<LiveAccountListResponse> {
|
||||
return request({
|
||||
url: '/erp/live/account/controller/listLiveAccounts',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<LiveAccountListResponse>;
|
||||
}
|
||||
|
||||
export function getLiveAccountDetail(params: { id: string }): Promise<LiveAccountDetailResponse> {
|
||||
export function getLiveAccountDetail(params: { id: string }, requestOptions?: RequestOptions): Promise<LiveAccountDetailResponse> {
|
||||
return request({
|
||||
url: '/erp/live/account/controller/getLiveAccount',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<LiveAccountDetailResponse>;
|
||||
}
|
||||
|
||||
export function createLiveAccount(data: LiveAccountSaveParams) {
|
||||
export function createLiveAccount(data: LiveAccountSaveParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/erp/live/account/controller/createLiveAccount',
|
||||
method: 'post',
|
||||
data,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateLiveAccount(data: LiveAccountSaveParams) {
|
||||
export function updateLiveAccount(data: LiveAccountSaveParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/erp/live/account/controller/updateLiveAccount',
|
||||
method: 'put',
|
||||
data,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteLiveAccount(params: { id: string }) {
|
||||
export function deleteLiveAccount(params: { id: string }, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/erp/live/account/controller/deleteLiveAccount',
|
||||
method: 'delete',
|
||||
params,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import request from '/@/utils/request';
|
||||
import request, { type RequestOptions } from '/@/utils/request';
|
||||
|
||||
export interface ScheduleListParams {
|
||||
pageNum: number;
|
||||
@@ -73,42 +73,47 @@ export interface ScheduleDetailResponse {
|
||||
data: ScheduleDetail;
|
||||
}
|
||||
|
||||
export function getScheduleList(params: ScheduleListParams): Promise<ScheduleListResponse> {
|
||||
export function getScheduleList(params: ScheduleListParams, requestOptions?: RequestOptions): Promise<ScheduleListResponse> {
|
||||
return request({
|
||||
url: '/erp/schedule/controller/listSchedules',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<ScheduleListResponse>;
|
||||
}
|
||||
|
||||
export function getScheduleDetail(params: { id: string }): Promise<ScheduleDetailResponse> {
|
||||
export function getScheduleDetail(params: { id: string }, requestOptions?: RequestOptions): Promise<ScheduleDetailResponse> {
|
||||
return request({
|
||||
url: '/erp/schedule/controller/getSchedule',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<ScheduleDetailResponse>;
|
||||
}
|
||||
|
||||
export function createSchedule(data: ScheduleSaveParams) {
|
||||
export function createSchedule(data: ScheduleSaveParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/erp/schedule/controller/createSchedule',
|
||||
method: 'post',
|
||||
data,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateSchedule(data: ScheduleSaveParams) {
|
||||
export function updateSchedule(data: ScheduleSaveParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/erp/schedule/controller/updateSchedule',
|
||||
method: 'put',
|
||||
data,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSchedule(params: { id: string }) {
|
||||
export function deleteSchedule(params: { id: string }, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/erp/schedule/controller/deleteSchedule',
|
||||
method: 'delete',
|
||||
params,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -178,8 +178,8 @@ export default defineComponent({
|
||||
},
|
||||
})
|
||||
.then(async () => {
|
||||
// 清除缓存/token等
|
||||
Session.clear();
|
||||
// 手动退出登录也只清理登录态缓存,保留主题、语言等本地配置。
|
||||
Session.clearAuth();
|
||||
// 显式回到登录页,避免保留之前受保护页面的重定向参数
|
||||
await router.replace('/login');
|
||||
})
|
||||
|
||||
@@ -95,7 +95,8 @@ router.beforeEach(async (to, from, next) => {
|
||||
} else {
|
||||
if (!token) {
|
||||
next(`/login?redirect=${to.path}¶ms=${JSON.stringify(to.query ? to.query : to.params)}`);
|
||||
Session.clear();
|
||||
// 进入受保护页面但本地已没有 token 时,只清理登录态缓存即可。
|
||||
Session.clearAuth();
|
||||
NProgress.done();
|
||||
} else if (token && to.path === '/login') {
|
||||
next('/home');
|
||||
|
||||
@@ -1,35 +1,92 @@
|
||||
import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import type { MessageHandler } from 'element-plus';
|
||||
import { Session } from '/@/utils/storage';
|
||||
import qs from 'qs';
|
||||
import { getChangedFields } from '/@/utils/diffUtils';
|
||||
import { handleModuleNotEnabled } from '/@/utils/assetSubscribe';
|
||||
|
||||
// 标记是否正在处理 token 过期,避免重复弹窗
|
||||
let isHandlingTokenExpired = false;
|
||||
/**
|
||||
* 控制一次请求的错误提示归属:
|
||||
* - global: 交给 request.ts 统一弹错,适合绝大多数接口
|
||||
* - page: 页面自己在 catch 中决定提示文案,避免与全局重复
|
||||
* - silent: 完全静默,适合轮询、后台刷新等不希望打扰用户的请求
|
||||
*/
|
||||
export interface RequestOptions {
|
||||
errorMode?: 'global' | 'page' | 'silent';
|
||||
}
|
||||
|
||||
// 错误消息防抖:防止短时间内显示多个错误消息
|
||||
let lastErrorTime = 0;
|
||||
const ERROR_MESSAGE_INTERVAL = 2000; // 2秒内只显示一个错误
|
||||
|
||||
const showErrorMessage = (message: string) => {
|
||||
const now = Date.now();
|
||||
|
||||
// 2秒内只显示一个错误消息(不管内容是否相同)
|
||||
if (now - lastErrorTime < ERROR_MESSAGE_INTERVAL) {
|
||||
return; // 跳过
|
||||
declare module 'axios' {
|
||||
interface AxiosRequestConfig {
|
||||
requestOptions?: RequestOptions;
|
||||
}
|
||||
|
||||
lastErrorTime = now;
|
||||
ElMessage.error(message);
|
||||
interface InternalAxiosRequestConfig {
|
||||
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 实例配置
|
||||
// 地址配置见 .env.development 文件
|
||||
// ============================================================
|
||||
/**
|
||||
* token 过期只接受明确的后端信号:
|
||||
* - 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({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
timeout: 50000,
|
||||
@@ -41,23 +98,21 @@ const service: AxiosInstance = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// token 过期处理函数
|
||||
/**
|
||||
* 登录过期时优先关闭普通错误消息,再弹出唯一的登录过期确认框。
|
||||
* 这样用户不会先看到一个普通报错,再叠一个登录过期弹窗。
|
||||
*/
|
||||
const handleTokenExpired = () => {
|
||||
if (isHandlingTokenExpired) return;
|
||||
|
||||
isHandlingTokenExpired = true;
|
||||
closeActiveErrorMessage();
|
||||
|
||||
ElMessageBox.alert('登录状态已过期,请重新登录', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
showClose: false,
|
||||
closeOnClickModal: false,
|
||||
closeOnPressEscape: false,
|
||||
beforeClose: (action, _instance, done) => {
|
||||
if (action === 'confirm') {
|
||||
done();
|
||||
performLogout();
|
||||
}
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
performLogout();
|
||||
@@ -67,60 +122,47 @@ const handleTokenExpired = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// 执行退出登录操作
|
||||
/**
|
||||
* 统一退出动作:
|
||||
* - 只清理登录态相关缓存
|
||||
* - 重置 token 过期处理标记
|
||||
* - 最后回到登录页
|
||||
*/
|
||||
const performLogout = () => {
|
||||
Session.clear();
|
||||
localStorage.clear();
|
||||
Session.clearAuth();
|
||||
isHandlingTokenExpired = false;
|
||||
// Hash 路由统一回登录页,避免跳到错误地址
|
||||
setTimeout(() => {
|
||||
window.location.href = '/#/login';
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 请求拦截器
|
||||
const requestInterceptor = (config: InternalAxiosRequestConfig) => {
|
||||
// 检查 token 是否有效
|
||||
const token = Session.get('token');
|
||||
if (token) {
|
||||
// 可以在这里添加 token 有效性检查(如果需要)
|
||||
config.headers!['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// PUT 请求最小化传参处理
|
||||
// 如果请求数据中包含 _originalData,则自动计算差异,只传递修改过的字段
|
||||
// PUT 请求只传变更字段,避免把未修改的数据整包提交给后端。
|
||||
if (config.method?.toLowerCase() === 'put' && config.data && typeof config.data === 'object') {
|
||||
const { _originalData, ...currentData } = config.data;
|
||||
|
||||
if (_originalData && typeof _originalData === 'object') {
|
||||
// 获取 id 字段(必须保留)
|
||||
const idField = currentData.id || currentData.Id || currentData.ID;
|
||||
|
||||
// 计算差异
|
||||
const changedFields = getChangedFields(_originalData, currentData, {
|
||||
exclude: ['_originalData', 'id', 'Id', 'ID'],
|
||||
});
|
||||
|
||||
// 如果有变化,只传递 id + 变化的字段
|
||||
if (Object.keys(changedFields).length > 0) {
|
||||
config.data = { id: idField, ...changedFields };
|
||||
} else {
|
||||
// 没有变化,只传递 id
|
||||
config.data = { id: idField };
|
||||
}
|
||||
config.data = Object.keys(changedFields).length > 0 ? { id: idField, ...changedFields } : { id: idField };
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
const requestErrorHandler = (error: any) => {
|
||||
return Promise.reject(error);
|
||||
};
|
||||
const requestErrorHandler = (error: any) => Promise.reject(error);
|
||||
|
||||
// 响应拦截器
|
||||
const responseInterceptor = (response: AxiosResponse) => {
|
||||
// 文件流响应直接返回
|
||||
// 文件流直接返回原始响应,调用方需要自行处理 Blob。
|
||||
if (
|
||||
response.config.responseType === 'blob' ||
|
||||
response.headers['content-type']?.includes('application/zip') ||
|
||||
@@ -133,75 +175,58 @@ const responseInterceptor = (response: AxiosResponse) => {
|
||||
const httpStatus = response.status;
|
||||
const code = res?.code;
|
||||
const message = res?.message;
|
||||
const config = response.config;
|
||||
|
||||
// 检查 token 相关错误
|
||||
if (
|
||||
httpStatus === 401 ||
|
||||
code === 401 ||
|
||||
message?.includes('token') ||
|
||||
message === 'token is invalid' ||
|
||||
message === 'token 解析失败' ||
|
||||
message?.includes('decrypt error')
|
||||
) {
|
||||
if (isTokenExpiredError(httpStatus, code, message)) {
|
||||
handleTokenExpired();
|
||||
return Promise.reject(new Error('登录状态已过期'));
|
||||
}
|
||||
|
||||
// 处理模块未开通错误 (403)
|
||||
// 跳过资产SKU查询接口,避免弹窗内部请求触发循环
|
||||
const requestUrl = response.config.url || '';
|
||||
if (code === 402 && !requestUrl.includes('/assets/asset/sku/')) {
|
||||
// 获取当前路由路径
|
||||
const currentPath = window.location.hash.replace('#', '') || window.location.pathname;
|
||||
handleModuleNotEnabled(currentPath);
|
||||
// 直接返回,不再显示错误消息
|
||||
return Promise.reject(new Error('模块未开通'));
|
||||
}
|
||||
|
||||
// 业务逻辑错误处理(排除403,因为上面已处理)
|
||||
// 业务失败默认走全局提示;如果页面声明自己处理,这里只抛错不弹窗。
|
||||
if (code !== undefined && code !== 0 && code !== 200 && code !== 403) {
|
||||
const errorMsg = message || `请求失败(${code})`;
|
||||
showErrorMessage(errorMsg);
|
||||
showErrorMessage(errorMsg, config);
|
||||
return Promise.reject(new Error(errorMsg));
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
// 响应错误拦截器
|
||||
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')) {
|
||||
showErrorMessage('请求超时,请检查网络连接');
|
||||
showErrorMessage('请求超时,请检查网络连接', config);
|
||||
return Promise.reject(new Error('请求超时'));
|
||||
}
|
||||
|
||||
if (!error.response) {
|
||||
if (error.message === 'Network Error') {
|
||||
// ElMessage.error('网络连接错误,请检查网络设置');
|
||||
} else {
|
||||
// ElMessage.error('网络异常,请检查连接');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const httpStatus = error.response.status;
|
||||
// 优先使用返回数据中的 message 字段
|
||||
const responseMessage = error.response.data?.message;
|
||||
if (isTokenExpiredError(httpStatus, error.response?.data?.code, responseMessage)) {
|
||||
handleTokenExpired();
|
||||
return Promise.reject(new Error('登录状态已过期'));
|
||||
}
|
||||
|
||||
// 处理 HTTP 错误状态
|
||||
const requestUrl = error.response.config?.url || '';
|
||||
|
||||
switch (httpStatus) {
|
||||
case 401:
|
||||
handleTokenExpired();
|
||||
break;
|
||||
case 402:
|
||||
// 模块未开通处理,跳过SKU相关接口避免循环
|
||||
if (!requestUrl.includes('/assets/asset/sku/') && !requestUrl.includes('getAssetAndSku')) {
|
||||
// 检查是否刚从开通页面返回(5秒内不再跳转)
|
||||
const lastSubscribeTime = sessionStorage.getItem('lastSubscribeTime');
|
||||
const now = Date.now();
|
||||
if (lastSubscribeTime && now - parseInt(lastSubscribeTime) < 5000) {
|
||||
showErrorMessage(responseMessage || '服务开通中,请稍后刷新页面');
|
||||
showErrorMessage(responseMessage || '服务开通中,请稍后刷新页面', config);
|
||||
return Promise.reject(new Error('模块开通中'));
|
||||
}
|
||||
|
||||
@@ -209,40 +234,38 @@ const responseErrorHandler = (error: any) => {
|
||||
handleModuleNotEnabled(currentPath);
|
||||
return Promise.reject(new Error('模块未开通'));
|
||||
}
|
||||
showErrorMessage(responseMessage || '服务未开通');
|
||||
showErrorMessage(responseMessage || '服务未开通', config);
|
||||
break;
|
||||
case 403:
|
||||
showErrorMessage(responseMessage || '没有权限访问该资源');
|
||||
showErrorMessage(responseMessage || '没有权限访问该资源', config);
|
||||
break;
|
||||
case 404:
|
||||
showErrorMessage(responseMessage || '请求的资源不存在');
|
||||
showErrorMessage(responseMessage || '请求的资源不存在', config);
|
||||
break;
|
||||
case 429:
|
||||
showErrorMessage(responseMessage || '请求过于频繁,请稍后再试');
|
||||
handleTokenExpired();
|
||||
// 429 是限流,不等于登录过期,这里只保留频率提示。
|
||||
showErrorMessage(responseMessage || '请求过于频繁,请稍后再试', config);
|
||||
break;
|
||||
case 500:
|
||||
showErrorMessage(responseMessage || '服务器内部错误');
|
||||
showErrorMessage(responseMessage || '服务器内部错误', config);
|
||||
break;
|
||||
case 502:
|
||||
showErrorMessage(responseMessage || '网关错误');
|
||||
showErrorMessage(responseMessage || '网关错误', config);
|
||||
break;
|
||||
case 503:
|
||||
showErrorMessage(responseMessage || '服务不可用');
|
||||
showErrorMessage(responseMessage || '服务不可用', config);
|
||||
break;
|
||||
default:
|
||||
if (httpStatus >= 400) {
|
||||
showErrorMessage(responseMessage || `请求失败(${httpStatus})`);
|
||||
showErrorMessage(responseMessage || `请求失败(${httpStatus})`, config);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
};
|
||||
|
||||
// 为实例添加拦截器
|
||||
service.interceptors.request.use(requestInterceptor, requestErrorHandler);
|
||||
service.interceptors.response.use(responseInterceptor, responseErrorHandler);
|
||||
|
||||
// 导出
|
||||
export default service;
|
||||
export { showErrorMessage };
|
||||
export { closeActiveErrorMessage, showErrorMessage };
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
/**
|
||||
* 这些 key 属于登录态或用户会话上下文。
|
||||
* 退出登录时只清理这部分数据,避免误删主题、语言、布局等本地个性化配置。
|
||||
*/
|
||||
const SESSION_AUTH_KEYS = ['token', 'userInfo', 'userMenu', 'permissions'];
|
||||
|
||||
/**
|
||||
* window.localStorage 浏览器永久缓存
|
||||
* @method set 设置永久缓存
|
||||
@@ -33,6 +39,7 @@ export const Local = {
|
||||
* @method get 获取临时缓存
|
||||
* @method remove 移除临时缓存
|
||||
* @method clear 移除全部临时缓存
|
||||
* @method clearAuth 移除登录态相关缓存
|
||||
*/
|
||||
export const Session = {
|
||||
// 设置临时缓存
|
||||
@@ -56,4 +63,10 @@ export const Session = {
|
||||
Cookies.remove('token');
|
||||
window.sessionStorage.clear();
|
||||
},
|
||||
// 只清理登录态相关缓存,保留非登录相关的页面状态与本地配置
|
||||
clearAuth() {
|
||||
SESSION_AUTH_KEYS.forEach((key) => this.remove(key));
|
||||
},
|
||||
};
|
||||
|
||||
export { SESSION_AUTH_KEYS };
|
||||
|
||||
@@ -50,40 +50,88 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel center">
|
||||
<div class="title">内容创建参数配置</div>
|
||||
<div class="form-header">
|
||||
<div class="title">内容创建参数配置</div>
|
||||
<el-badge :value="taskBadgeCount" :hidden="taskBadgeCount === 0" class="task-badge">
|
||||
<el-button circle class="task-trigger" @click="taskDialogVisible = true">
|
||||
<el-icon><ele-Bell /></el-icon>
|
||||
</el-button>
|
||||
</el-badge>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-position="top" class="compact-form">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="1. 创作模式" prop="mode" class="span-1"
|
||||
><el-select v-model="formData.mode"><el-option v-for="item in modeOptions" :key="item" :label="item" :value="item" /></el-select
|
||||
<el-form-item prop="mode" class="span-1"
|
||||
><el-select v-model="formData.mode" placeholder="创作模式"
|
||||
><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-form-item prop="content_type" class="span-1"
|
||||
><el-select v-model="formData.content_type" placeholder="内容类型" filterable allow-create default-first-option
|
||||
><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 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 prop="title" class="span-1"
|
||||
><el-input v-model="formData.title" placeholder="标题(具体标题),例如:通勤穿搭技巧"
|
||||
/></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 prop="style" class="span-1"
|
||||
><el-select v-model="formData.style" placeholder="内容风格" filterable allow-create default-first-option
|
||||
><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-form-item prop="count" class="span-1">
|
||||
<div class="number-field">
|
||||
<span class="number-label">生成条数</span>
|
||||
<el-input-number v-model="formData.count" :min="1" :max="5" controls-position="right" class="w100" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="showImageConfig" prop="image_per_post" class="span-1">
|
||||
<div class="number-field">
|
||||
<span class="number-label">每条配图数量</span>
|
||||
<el-input-number v-model="formData.image_per_post" :min="1" :max="3" controls-position="right" class="w100" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="showImageConfig" prop="image_ratio" class="span-1"
|
||||
><el-select v-model="formData.image_ratio" placeholder="图片比例"
|
||||
><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>
|
||||
<el-form-item prop="description" class="span-2 description-item">
|
||||
<div class="chat-input-box">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 8 }"
|
||||
resize="none"
|
||||
placeholder=" 说点什么"
|
||||
class="chat-textarea"
|
||||
/>
|
||||
<div v-if="descriptionFiles.length" class="chat-file-list">
|
||||
<el-tag v-for="file in descriptionFiles" :key="file.uid" closable type="info" effect="plain" @close="removeDescriptionFile(file.uid)">
|
||||
{{ file.name }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="chat-toolbar">
|
||||
<div class="chat-actions">
|
||||
<el-upload
|
||||
v-model:file-list="descriptionFiles"
|
||||
class="chat-upload"
|
||||
multiple
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt"
|
||||
>
|
||||
<el-button text class="toolbar-btn">
|
||||
<el-icon><ele-Paperclip /></el-icon>
|
||||
<span>上传图片/文件</span>
|
||||
</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
<el-button circle type="primary" class="chat-send-btn" :loading="submitLoading" @click="handleSubmit">
|
||||
<el-icon><ele-Top /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-button type="primary" class="submit-btn" :loading="submitLoading" @click="handleSubmit">告诉我你的选择,我马上开始创作!</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="panel right" v-loading="previewLoading">
|
||||
@@ -96,12 +144,44 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog v-model="taskDialogVisible" title="创作任务" width="680px" append-to-body class="task-dialog">
|
||||
<div class="task-list">
|
||||
<el-empty v-if="creationTasks.length === 0" description="暂无创作任务" />
|
||||
<div v-for="task in pagedCreationTasks" :key="task.id" class="task-item">
|
||||
<div class="task-item-header">
|
||||
<div class="task-name">{{ task.title }}</div>
|
||||
<el-tag :type="getTaskTagType(task.status)" effect="light">{{ getTaskStatusText(task.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="task-summary">{{ task.summary }}</div>
|
||||
<div class="task-time">
|
||||
创建:{{ task.createdAt }}<span v-if="task.updatedAt"> | 更新:{{ task.updatedAt }}</span>
|
||||
</div>
|
||||
<div v-if="task.error" class="task-error">{{ task.error }}</div>
|
||||
<div class="task-actions-row">
|
||||
<el-button v-if="task.status === 'running'" type="warning" link @click="pauseTask(task)">暂停</el-button>
|
||||
<el-button v-if="task.status === 'paused'" type="primary" link :loading="submitLoading" @click="continueTask(task)">继续</el-button>
|
||||
<el-button v-if="task.status === 'failed'" type="primary" link :loading="submitLoading" @click="retryTask(task)">重新执行</el-button>
|
||||
<el-button type="danger" link @click="deleteTask(task.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="creationTasks.length > taskPageSize" class="task-pagination" :style="{ marginTop: '20px' }">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:current-page="taskPage"
|
||||
:page-size="taskPageSize"
|
||||
:total="creationTasks.length"
|
||||
@current-change="handleTaskPageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage, type FormInstance, type FormRules, type UploadUserFile } from 'element-plus';
|
||||
import {
|
||||
createCreation,
|
||||
downloadToFile,
|
||||
@@ -110,6 +190,7 @@ import {
|
||||
type CreationSubmitParams,
|
||||
type CreationTreeItem,
|
||||
} from '/@/api/digitalHuman/creation';
|
||||
import { uploadFile } from '/@/api/knowledge/document';
|
||||
|
||||
type NodeType = 'date' | 'contentType' | 'theme' | 'title' | 'html' | 'image';
|
||||
interface TreeNode {
|
||||
@@ -128,12 +209,79 @@ interface PreviewState {
|
||||
nodeType: 'html' | 'image';
|
||||
}
|
||||
const formRef = ref<FormInstance>();
|
||||
type CreationTaskStatus = 'running' | 'success' | 'failed' | 'paused';
|
||||
interface CreationTask {
|
||||
id: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
status: CreationTaskStatus;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
error?: string;
|
||||
params: CreationSubmitParams;
|
||||
}
|
||||
const treeLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const previewLoading = ref(false);
|
||||
const imgAddressPrefix = ref('');
|
||||
const treeNodes = ref<TreeNode[]>([]);
|
||||
const selectedPreview = ref<PreviewState | null>(null);
|
||||
const descriptionFiles = ref<UploadUserFile[]>([]);
|
||||
const taskDialogVisible = ref(false);
|
||||
const taskPage = ref(1);
|
||||
const taskPageSize = ref(3);
|
||||
const mockCreationParams: CreationSubmitParams = {
|
||||
mode: '混合模式(文案 + 图片)',
|
||||
content_type: '穿搭分享',
|
||||
theme: '春季通勤穿搭',
|
||||
title: '通勤穿搭技巧',
|
||||
description: '模拟任务描述',
|
||||
style: '生活分享 — 亲切自然,像朋友聊天',
|
||||
count: 1,
|
||||
image_per_post: 1,
|
||||
image_ratio: '3:4 — 小红书',
|
||||
};
|
||||
const creationTasks = ref<CreationTask[]>([
|
||||
{
|
||||
id: 3,
|
||||
title: '小个子显高穿搭法则',
|
||||
summary: '穿搭分享 / 小个子显高技巧 / 5个显高穿搭法则',
|
||||
status: 'running',
|
||||
createdAt: '2026-04-27 10:20:18',
|
||||
updatedAt: '2026-04-27 10:21:03',
|
||||
params: { ...mockCreationParams, theme: '小个子显高技巧', title: '5个显高穿搭法则' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '春季通勤穿搭技巧',
|
||||
summary: '穿搭分享 / 春季通勤穿搭 / 通勤穿搭技巧',
|
||||
status: 'success',
|
||||
createdAt: '2026-04-27 09:45:12',
|
||||
updatedAt: '2026-04-27 09:48:36',
|
||||
params: { ...mockCreationParams },
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: '周末约会氛围感穿搭',
|
||||
summary: '穿搭分享 / 约会穿搭 / 周末约会氛围感穿搭',
|
||||
status: 'failed',
|
||||
createdAt: '2026-04-27 09:12:05',
|
||||
updatedAt: '2026-04-27 09:13:21',
|
||||
error: '素材上传失败,请重新执行任务',
|
||||
params: { ...mockCreationParams, theme: '约会穿搭', title: '周末约会氛围感穿搭' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '氛围感穿搭',
|
||||
summary: '穿搭分享 / 约会穿搭 / 氛围感穿搭',
|
||||
status: 'failed',
|
||||
createdAt: '2026-04-27 09:12:05',
|
||||
updatedAt: '2026-04-27 09:13:21',
|
||||
error: '素材上传失败,请重新执行任务',
|
||||
params: { ...mockCreationParams, theme: '约会穿搭', title: '周末约会氛围感穿搭' },
|
||||
},
|
||||
]);
|
||||
const taskIdSeed = ref(3);
|
||||
const apiBaseUrl = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '');
|
||||
const treeProps = { children: 'children', label: 'label' };
|
||||
const queryParams = reactive<CreationListParams>({ keyword: '', pageNum: 1, pageSize: 10 });
|
||||
@@ -158,6 +306,30 @@ const styleOptions = [
|
||||
'干货教学 — 条理清晰,步骤明确',
|
||||
];
|
||||
const imageRatioOptions = ['3:4 — 小红书', '1:1 — 方图', '16:9 — 横版'];
|
||||
const taskBadgeCount = computed(() => creationTasks.value.filter((task) => task.status !== 'success').length);
|
||||
const pagedCreationTasks = computed(() => {
|
||||
const start = (taskPage.value - 1) * taskPageSize.value;
|
||||
return creationTasks.value.slice(start, start + taskPageSize.value);
|
||||
});
|
||||
watch(
|
||||
() => creationTasks.value.length,
|
||||
(total) => {
|
||||
const maxPage = Math.max(1, Math.ceil(total / taskPageSize.value));
|
||||
if (taskPage.value > maxPage) taskPage.value = maxPage;
|
||||
}
|
||||
);
|
||||
const handleTaskPageChange = (page: number) => {
|
||||
taskPage.value = page;
|
||||
};
|
||||
const formatTaskTime = () => {
|
||||
const date = new Date();
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`;
|
||||
};
|
||||
const getTaskStatusText = (status: CreationTaskStatus) => ({ running: '执行中', success: '已完成', failed: '失败', paused: '已暂停' })[status];
|
||||
const getTaskTagType = (status: CreationTaskStatus) =>
|
||||
({ running: 'warning', success: 'success', failed: 'danger', paused: 'info' })[status] as 'warning' | 'success' | 'danger' | 'info';
|
||||
const buildTaskSummary = (params: CreationSubmitParams) =>
|
||||
`${params.content_type} / ${params.theme || '未填写主题'} / ${params.title || '未填写标题'}`;
|
||||
watch(
|
||||
() => formData.mode,
|
||||
() => {
|
||||
@@ -175,28 +347,32 @@ const rules: FormRules = {
|
||||
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();
|
||||
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();
|
||||
},
|
||||
},
|
||||
}],
|
||||
image_ratio: [{
|
||||
required: true,
|
||||
message: '请选择图片比例',
|
||||
trigger: 'change',
|
||||
validator: (rule, value, callback) => {
|
||||
void rule;
|
||||
if (!showImageConfig.value) return callback();
|
||||
if (!value) return callback(new Error('请选择图片比例'));
|
||||
callback();
|
||||
],
|
||||
image_ratio: [
|
||||
{
|
||||
required: true,
|
||||
message: '请选择图片比例',
|
||||
trigger: 'change',
|
||||
validator: (rule, value, callback) => {
|
||||
void rule;
|
||||
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) => {
|
||||
@@ -297,16 +473,6 @@ const downloadNode = async (data: TreeNode) => {
|
||||
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 () => {
|
||||
treeLoading.value = true;
|
||||
try {
|
||||
@@ -314,9 +480,6 @@ const getList = async () => {
|
||||
imgAddressPrefix.value = res.data?.imgAddressPrefix || '';
|
||||
treeNodes.value = buildTreeNodes(res.data?.Tree || []);
|
||||
selectedPreview.value = null;
|
||||
await nextTick();
|
||||
const firstLeaf = findFirstPreviewNode(treeNodes.value);
|
||||
if (firstLeaf) handleNodeClick(firstLeaf);
|
||||
} catch {
|
||||
treeNodes.value = [];
|
||||
imgAddressPrefix.value = '';
|
||||
@@ -326,24 +489,91 @@ const getList = async () => {
|
||||
treeLoading.value = false;
|
||||
}
|
||||
};
|
||||
const removeDescriptionFile = (uid?: number) => {
|
||||
descriptionFiles.value = descriptionFiles.value.filter((file) => file.uid !== uid);
|
||||
};
|
||||
const extractUploadUrl = (res: unknown) => {
|
||||
const data = (res as { data?: Record<string, string> })?.data;
|
||||
const root = res as Record<string, string>;
|
||||
return data?.url || data?.fileUrl || data?.filePath || data?.path || root?.url || root?.fileUrl || root?.filePath || root?.path || '';
|
||||
};
|
||||
const buildDescription = async () => {
|
||||
const description = formData.description?.trim() || '';
|
||||
const rawFiles = descriptionFiles.value.map((item) => item.raw).filter(Boolean) as File[];
|
||||
if (rawFiles.length === 0) return description || undefined;
|
||||
const uploadedFiles = await Promise.all(
|
||||
rawFiles.map(async (file) => {
|
||||
const res = await uploadFile(file);
|
||||
return {
|
||||
name: file.name,
|
||||
url: extractUploadUrl(res),
|
||||
};
|
||||
})
|
||||
);
|
||||
const attachmentText = uploadedFiles.map((file, index) => `${index + 1}. ${file.name}${file.url ? `:${file.url}` : ''}`).join('\n');
|
||||
return [description, `参考附件:\n${attachmentText}`].filter(Boolean).join('\n\n');
|
||||
};
|
||||
const runCreationTask = async (task: CreationTask) => {
|
||||
try {
|
||||
submitLoading.value = true;
|
||||
selectedPreview.value = null;
|
||||
task.status = 'running';
|
||||
task.error = undefined;
|
||||
task.updatedAt = formatTaskTime();
|
||||
await createCreation(task.params);
|
||||
task.status = 'success';
|
||||
task.updatedAt = formatTaskTime();
|
||||
ElMessage.success('创作任务已提交');
|
||||
await getList();
|
||||
} catch (error) {
|
||||
task.status = 'failed';
|
||||
task.updatedAt = formatTaskTime();
|
||||
task.error = error instanceof Error ? error.message : '任务执行失败,请稍后重试';
|
||||
ElMessage.error('提交创作任务失败');
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
};
|
||||
const retryTask = async (task: CreationTask) => {
|
||||
if (submitLoading.value) return;
|
||||
await runCreationTask(task);
|
||||
};
|
||||
const deleteTask = (taskId: number) => {
|
||||
creationTasks.value = creationTasks.value.filter((task) => task.id !== taskId);
|
||||
};
|
||||
const pauseTask = (task: CreationTask) => {
|
||||
if (task.status !== 'running') return;
|
||||
task.status = 'paused';
|
||||
task.updatedAt = formatTaskTime();
|
||||
};
|
||||
const continueTask = async (task: CreationTask) => {
|
||||
if (task.status !== 'paused' || submitLoading.value) return;
|
||||
await runCreationTask(task);
|
||||
};
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value || submitLoading.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
submitLoading.value = true;
|
||||
selectedPreview.value = null;
|
||||
await createCreation({
|
||||
const description = await buildDescription();
|
||||
const params: CreationSubmitParams = {
|
||||
...formData,
|
||||
count: Number(formData.count),
|
||||
image_per_post: Number(formData.image_per_post),
|
||||
description: formData.description?.trim() || undefined,
|
||||
});
|
||||
ElMessage.success('创作任务已提交');
|
||||
await getList();
|
||||
description,
|
||||
};
|
||||
const task: CreationTask = {
|
||||
id: ++taskIdSeed.value,
|
||||
title: params.title || `创作任务 ${taskIdSeed.value}`,
|
||||
summary: buildTaskSummary(params),
|
||||
status: 'running',
|
||||
createdAt: formatTaskTime(),
|
||||
params,
|
||||
};
|
||||
creationTasks.value.unshift(task);
|
||||
taskDialogVisible.value = true;
|
||||
await runCreationTask(task);
|
||||
} catch {
|
||||
ElMessage.error('提交创作任务失败');
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
};
|
||||
onMounted(getList);
|
||||
@@ -462,9 +692,80 @@ onMounted(getList);
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.preview-title {
|
||||
.form-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.form-header .title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.task-trigger {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-color: #e6ebf3;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.task-trigger:hover {
|
||||
color: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.task-item {
|
||||
padding: 14px;
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 12px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
.task-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.task-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1f2d3d;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.task-summary,
|
||||
.task-time {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #7b8794;
|
||||
}
|
||||
.task-error {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: #fff2f0;
|
||||
color: #f56c6c;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.task-actions-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.preview-title {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.tree-wrap,
|
||||
.center,
|
||||
.preview-main {
|
||||
@@ -501,7 +802,7 @@ onMounted(getList);
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 12px;
|
||||
gap: 24px 12px;
|
||||
}
|
||||
.span-1 {
|
||||
grid-column: span 1;
|
||||
@@ -510,17 +811,91 @@ onMounted(getList);
|
||||
grid-column: span 2;
|
||||
}
|
||||
.description-item {
|
||||
margin-bottom: 8px;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.number-field {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.number-label {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
transform: translateY(-50%);
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
pointer-events: none;
|
||||
}
|
||||
.number-field :deep(.el-input-number .el-input__inner) {
|
||||
padding-left: 92px;
|
||||
text-align: left;
|
||||
}
|
||||
.chat-input-box {
|
||||
width: 100%;
|
||||
padding: 14px 14px 10px;
|
||||
border-radius: 22px;
|
||||
background: #fff;
|
||||
border: 1px solid #e9edf3;
|
||||
box-shadow: 0 10px 24px rgba(31, 45, 61, 0.08);
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
.chat-input-box:focus-within {
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
box-shadow: 0 12px 28px rgba(64, 158, 255, 0.14);
|
||||
}
|
||||
.chat-textarea {
|
||||
font-size: 14px;
|
||||
}
|
||||
.chat-file-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 4px 2px;
|
||||
}
|
||||
.chat-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.chat-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-upload {
|
||||
display: inline-flex;
|
||||
}
|
||||
.toolbar-btn {
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: 15px;
|
||||
color: #303133;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.toolbar-btn:hover {
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
.toolbar-btn :deep(.el-icon) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.chat-send-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.w100 {
|
||||
width: 100%;
|
||||
}
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin-top: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.preview-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -553,19 +928,49 @@ onMounted(getList);
|
||||
height: 100%;
|
||||
min-height: 480px;
|
||||
}
|
||||
:deep(.chat-input-box .el-textarea__inner) {
|
||||
padding: 0 2px;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
line-height: 1.6;
|
||||
}
|
||||
:deep(.chat-input-box .el-textarea__inner::placeholder) {
|
||||
color: #a8abb2;
|
||||
}
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
:deep(.el-form-item__error) {
|
||||
padding-top: 4px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
:deep(.el-form-item__label) {
|
||||
padding-bottom: 4px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
display: none;
|
||||
}
|
||||
:deep(.el-input__wrapper),
|
||||
:deep(.el-select__wrapper),
|
||||
:deep(.el-textarea__inner),
|
||||
:deep(.el-input-number) {
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 0 1px #e8edf5 inset;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
:deep(.el-input__wrapper:hover),
|
||||
:deep(.el-select__wrapper:hover),
|
||||
:deep(.el-textarea__inner:hover),
|
||||
:deep(.el-input-number:hover) {
|
||||
box-shadow: 0 0 0 1px #d6e2f2 inset;
|
||||
}
|
||||
:deep(.el-input__wrapper.is-focus),
|
||||
:deep(.el-select__wrapper.is-focused),
|
||||
:deep(.el-textarea__inner:focus) {
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary-light-5) inset;
|
||||
}
|
||||
:deep(.el-input__wrapper),
|
||||
:deep(.el-select__wrapper) {
|
||||
min-height: 40px;
|
||||
}
|
||||
:deep(.el-select),
|
||||
:deep(.el-input),
|
||||
|
||||
@@ -36,8 +36,8 @@ export default defineComponent({
|
||||
const { isTagsViewCurrenFull } = storeToRefs(storesTagsViewRoutes);
|
||||
const onSetAuth = () => {
|
||||
// https://gitee.com/lyt-top/vue-next-admin/issues/I5C3JS
|
||||
// 清除缓存/token等
|
||||
Session.clear();
|
||||
// 401 页面回登录时只清理登录态相关缓存,保留本地个性化配置。
|
||||
Session.clearAuth();
|
||||
// 使用 reload 时,不需要调用 resetRoute() 重置路由
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
@@ -101,7 +101,8 @@ const openDialog = async (row?: { id?: string }) => {
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
const res = await getLiveAccountDetail({ id: String(row.id) });
|
||||
// 详情加载失败时由当前弹窗给出更易懂的业务提示。
|
||||
const res = await getLiveAccountDetail({ id: String(row.id) }, { errorMode: 'page' });
|
||||
if (res?.data) {
|
||||
fillForm(res.data);
|
||||
}
|
||||
@@ -129,11 +130,12 @@ const handleSubmit = async () => {
|
||||
remark: formData.remark,
|
||||
};
|
||||
|
||||
// 提交失败提示交给当前弹窗自己处理,避免和 request.ts 的统一报错重复。
|
||||
if (isEdit.value) {
|
||||
await updateLiveAccount(payload);
|
||||
await updateLiveAccount(payload, { errorMode: 'page' });
|
||||
ElMessage.success('修改成功');
|
||||
} else {
|
||||
await createLiveAccount(payload);
|
||||
await createLiveAccount(payload, { errorMode: 'page' });
|
||||
ElMessage.success('新增成功');
|
||||
}
|
||||
|
||||
|
||||
@@ -131,13 +131,17 @@ const tableData = reactive({
|
||||
const getList = async () => {
|
||||
try {
|
||||
tableData.loading = true;
|
||||
const res = await getLiveAccountList({
|
||||
...tableData.param,
|
||||
platform: searchForm.platform || undefined,
|
||||
accountName: searchForm.accountName || undefined,
|
||||
accountId: searchForm.accountId || undefined,
|
||||
status: searchForm.status,
|
||||
});
|
||||
// 列表失败文案由当前页面决定,避免和全局请求报错同时出现。
|
||||
const res = await getLiveAccountList(
|
||||
{
|
||||
...tableData.param,
|
||||
platform: searchForm.platform || undefined,
|
||||
accountName: searchForm.accountName || undefined,
|
||||
accountId: searchForm.accountId || undefined,
|
||||
status: searchForm.status,
|
||||
},
|
||||
{ errorMode: 'page' }
|
||||
);
|
||||
if (res && res.data) {
|
||||
tableData.data = (res.data.list || []).map((item: any) => ({
|
||||
...item,
|
||||
@@ -191,7 +195,7 @@ const handleDelete = async (row: LiveAccountItem) => {
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
await deleteLiveAccount({ id: row.id });
|
||||
await deleteLiveAccount({ id: row.id }, { errorMode: 'page' });
|
||||
ElMessage.success('删除成功');
|
||||
getList();
|
||||
} catch (error) {
|
||||
|
||||
@@ -153,7 +153,8 @@ const openDialog = async (row?: { id?: string }) => {
|
||||
await loadOptions();
|
||||
|
||||
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;
|
||||
if (detail) {
|
||||
formData.id = String(detail.id);
|
||||
@@ -195,11 +196,12 @@ const handleSubmit = async () => {
|
||||
remark: formData.remark,
|
||||
};
|
||||
|
||||
// 提交失败文案由弹窗自己控制,避免接口层和弹窗层重复报错。
|
||||
if (isEdit.value) {
|
||||
await updateSchedule(payload);
|
||||
await updateSchedule(payload, { errorMode: 'page' });
|
||||
ElMessage.success('修改排班成功');
|
||||
} else {
|
||||
await createSchedule(payload);
|
||||
await createSchedule(payload, { errorMode: 'page' });
|
||||
ElMessage.success('新增排班成功');
|
||||
}
|
||||
|
||||
|
||||
@@ -144,12 +144,16 @@ const getStatusTagType = (status: number): 'success' | 'info' | 'warning' => {
|
||||
const getList = async () => {
|
||||
try {
|
||||
tableData.loading = true;
|
||||
const res = await getScheduleList({
|
||||
...tableData.param,
|
||||
anchorName: searchForm.anchorName || undefined,
|
||||
accountName: searchForm.accountName || undefined,
|
||||
status: searchForm.status,
|
||||
} as any);
|
||||
// 列表失败文案由当前页面决定,避免和 request.ts 的全局错误提示重复。
|
||||
const res = await getScheduleList(
|
||||
{
|
||||
...tableData.param,
|
||||
anchorName: searchForm.anchorName || undefined,
|
||||
accountName: searchForm.accountName || undefined,
|
||||
status: searchForm.status,
|
||||
} as any,
|
||||
{ errorMode: 'page' }
|
||||
);
|
||||
const scheduleData = res?.data;
|
||||
if (scheduleData) {
|
||||
tableData.data = (scheduleData.list || []).map((item: any) => ({
|
||||
@@ -207,7 +211,7 @@ const handleDelete = async (row: ScheduleItem) => {
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
await deleteSchedule({ id: row.id });
|
||||
await deleteSchedule({ id: row.id }, { errorMode: 'page' });
|
||||
ElMessage.success('删除成功');
|
||||
getList();
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user