Merge branch 'dev' of https://gitee.com/red-future---jilin-g/admin-ui into dev
This commit is contained in:
@@ -5,7 +5,7 @@ export interface AssetQueryParams {
|
||||
name?: string;
|
||||
type?: string;
|
||||
status?: number;
|
||||
page?: number;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export interface SkuQueryParams {
|
||||
keyword?: string;
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
page?: number;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
@@ -119,6 +119,15 @@ export function listAssetSkus(params: SkuQueryParams) {
|
||||
});
|
||||
}
|
||||
|
||||
// 根据assetId获取资产和SKU信息(用于套餐开通弹窗)
|
||||
export function getAssetAndSku(params: { assetId: string }) {
|
||||
return newService({
|
||||
url: '/assets/asset/getAssetAndSku',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 创建 SKU
|
||||
export function createAssetSku(data: CreateSkuParams) {
|
||||
return newService({
|
||||
@@ -137,8 +146,8 @@ export function getAssetSku(id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// 修改 SKU
|
||||
export function updateAssetSku(data: CreateSkuParams & { id: string }) {
|
||||
// 修改 SKU(支持部分更新,只传递修改过的字段)
|
||||
export function updateAssetSku(data: Partial<CreateSkuParams> & { id: string }) {
|
||||
return newService({
|
||||
url: '/assets/asset/sku/updateAssetSku',
|
||||
method: 'put',
|
||||
@@ -154,3 +163,84 @@ export function deleteAssetSku(id: string) {
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
// 获取规格单位选项
|
||||
export function getSpecsUnitOptions(assetType: string) {
|
||||
return newService({
|
||||
url: '/assets/enum/getSpecsUnit',
|
||||
method: 'get',
|
||||
params: { assetType },
|
||||
});
|
||||
}
|
||||
|
||||
// 获取库存表单字段
|
||||
export function getStockFormFields(assetSkuId: string) {
|
||||
return newService({
|
||||
url: '/assets/stock/manage/getStockFormFields',
|
||||
method: 'get',
|
||||
params: { assetSkuId },
|
||||
});
|
||||
}
|
||||
|
||||
// 库存操作
|
||||
export interface StockOperationParams {
|
||||
assetSkuId: string;
|
||||
stock?: number;
|
||||
batchNo?: string;
|
||||
productionDate?: string;
|
||||
expiryDate?: string;
|
||||
expiryWarningDate?: string;
|
||||
[key: string]: any; // 支持动态字段
|
||||
}
|
||||
|
||||
export function stockOperation(data: StockOperationParams) {
|
||||
return newService({
|
||||
url: '/assets/stock/manage/stockOperation',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 操作日志查询参数
|
||||
export interface LogQueryParams {
|
||||
collection_id: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
// 操作日志信息
|
||||
export interface OperationLogInfo {
|
||||
id: string;
|
||||
service_name: string;
|
||||
collection: string;
|
||||
collection_id: string[];
|
||||
operation: string;
|
||||
creator: string;
|
||||
createdAt: string;
|
||||
data: { FieldName: string; FieldValue: any }[] | null;
|
||||
ip_address: string;
|
||||
}
|
||||
|
||||
// 查询操作日志
|
||||
export function listLogs(params: LogQueryParams) {
|
||||
return newService({
|
||||
url: '/assets/log/listLogs',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 订阅/开通资产服务参数
|
||||
export interface SubscribeAssetParams {
|
||||
skuId: string;
|
||||
assetId?: string;
|
||||
}
|
||||
|
||||
// 订阅/开通资产服务
|
||||
export function subscribeAsset(data: SubscribeAssetParams) {
|
||||
return newService({
|
||||
url: '/assets/asset/subscribe',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function getCategory(id: string) {
|
||||
// 获取属性类型选项
|
||||
export function getCategoryAttrTypeOptions() {
|
||||
return newService({
|
||||
url: '/assets/category/getCategoryAttrTypeOptions',
|
||||
url: '/assets/enum/getCategoryAttrType',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function getRoleList(query:Object) {
|
||||
|
||||
export function getRoleParams() {
|
||||
return request({
|
||||
url: '/api/v1/system/role/getParams',
|
||||
url: '/api/v1/system/role/getParamsInfo',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import request from '/@/utils/request';
|
||||
|
||||
export function getUserList(query:Object) {
|
||||
return request({
|
||||
url: '/api/v1/system/user/list',
|
||||
url: '/api/v1/system/user/getList',
|
||||
method: 'get',
|
||||
params:query
|
||||
})
|
||||
@@ -17,7 +17,7 @@ export function getDeptTree() {
|
||||
|
||||
export function getParams() {
|
||||
return request({
|
||||
url: '/api/v1/system/user/params',
|
||||
url: '/api/v1/system/user/paramsInfo',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="layout-logo" v-if="setShowLogo" @click="onThemeConfigChange">
|
||||
<div class="layout-logo" v-if="setShowLogo" @click="onLogoClick">
|
||||
<img :src="logoMini" class="layout-logo-medium-img" />
|
||||
<span>{{ themeConfig.globalTitle }}</span>
|
||||
</div>
|
||||
<div class="layout-logo-size" v-else @click="onThemeConfigChange">
|
||||
<div class="layout-logo-size" v-else @click="onLogoClick">
|
||||
<img :src="logoMini" class="layout-logo-size-img" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -11,6 +11,7 @@
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useThemeConfig } from '/@/stores/themeConfig';
|
||||
|
||||
import logoMini from '/@/assets/logo-mini.svg';
|
||||
@@ -18,6 +19,7 @@ import logoMini from '/@/assets/logo-mini.svg';
|
||||
export default defineComponent({
|
||||
name: 'layoutLogo',
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
const storesThemeConfig = useThemeConfig();
|
||||
const { themeConfig } = storeToRefs(storesThemeConfig);
|
||||
// 设置 logo 的显示。classic 经典布局默认显示 logo
|
||||
@@ -25,16 +27,15 @@ export default defineComponent({
|
||||
let { isCollapse, layout } = themeConfig.value;
|
||||
return !isCollapse || layout === 'classic' || document.body.clientWidth < 1000;
|
||||
});
|
||||
// logo 点击实现菜单展开/收起
|
||||
const onThemeConfigChange = () => {
|
||||
if (themeConfig.value.layout === 'transverse') return false;
|
||||
themeConfig.value.isCollapse = !themeConfig.value.isCollapse;
|
||||
// logo 点击跳转首页
|
||||
const onLogoClick = () => {
|
||||
router.push('/home');
|
||||
};
|
||||
return {
|
||||
logoMini,
|
||||
setShowLogo,
|
||||
themeConfig,
|
||||
onThemeConfigChange,
|
||||
onLogoClick,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ export const dynamicRoutes: Array<RouteRecordRaw> = [
|
||||
meta: {
|
||||
title: 'message.router.home',
|
||||
isLink: '',
|
||||
isHide: false,
|
||||
isHide: true,
|
||||
isKeepAlive: true,
|
||||
isAffix: true,
|
||||
isIframe: false,
|
||||
|
||||
@@ -9,11 +9,26 @@
|
||||
.el-button--default i.iconfont,
|
||||
.el-button--default i.fa {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
// 非圆形按钮的图标右边距(圆形按钮不需要)
|
||||
.el-button:not(.is-circle) i.el-icon + span,
|
||||
.el-button:not(.is-circle) i.iconfont,
|
||||
.el-button:not(.is-circle) i.fa,
|
||||
.el-button--default:not(.is-circle) i.iconfont,
|
||||
.el-button--default:not(.is-circle) i.fa {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.el-button:not(.is-circle) > i.el-icon:first-child:not(:last-child),
|
||||
.el-button:not(.is-circle) > i.iconfont:first-child:not(:last-child),
|
||||
.el-button:not(.is-circle) > i.fa:first-child:not(:last-child) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
.el-button--small i.iconfont,
|
||||
.el-button--small i.fa {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
.el-button--small:not(.is-circle) > i.iconfont:first-child:not(:last-child),
|
||||
.el-button--small:not(.is-circle) > i.fa:first-child:not(:last-child) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
|
||||
65
src/utils/assetSubscribe.ts
Normal file
65
src/utils/assetSubscribe.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// 开通页面地址(public/web/subscribe.html)
|
||||
const SUBSCRIBE_PAGE_URL = '/web/subscribe.html';
|
||||
|
||||
// 路由路径与 assetId 的映射关系
|
||||
const ROUTE_ASSET_MAP: Record<string, { assetId: string; serviceName: string }> = {
|
||||
// CID广告业务(聚合广告)
|
||||
'/cidService': { assetId: '696f423705e496ba4ccbe665', serviceName: '聚合广告' },
|
||||
|
||||
// AI客服业务
|
||||
'/customerService': { assetId: '696f421205e496ba4ccbe662', serviceName: 'AI客服' },
|
||||
|
||||
// 聚合电商业务(资产管理)
|
||||
'/assets': { assetId: '696b4acd1be1c8b76c4b4c15', serviceName: '资产管理' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据路由路径获取对应的 assetId 和服务名称
|
||||
*/
|
||||
export function getAssetInfoByRoute(routePath: string): { assetId: string; serviceName: string } | null {
|
||||
// 精确匹配
|
||||
if (ROUTE_ASSET_MAP[routePath]) {
|
||||
return ROUTE_ASSET_MAP[routePath];
|
||||
}
|
||||
|
||||
// 前缀匹配
|
||||
for (const [prefix, info] of Object.entries(ROUTE_ASSET_MAP)) {
|
||||
if (routePath.startsWith(prefix)) {
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到外部开通页面
|
||||
* @param assetId 资产ID
|
||||
*/
|
||||
export function redirectToSubscribePage(assetId: string) {
|
||||
// 当前页面地址作为返回地址
|
||||
const returnUrl = encodeURIComponent(window.location.href);
|
||||
// 构建跳转URL
|
||||
const url = `${SUBSCRIBE_PAGE_URL}?assetId=${assetId}&returnUrl=${returnUrl}`;
|
||||
console.log('[redirectToSubscribePage] 跳转到开通页面:', url);
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 402 错误码(模块未开通)
|
||||
*/
|
||||
export function handleModuleNotEnabled(routePath: string): boolean {
|
||||
console.log('[模块未开通] 当前路由路径:', routePath);
|
||||
const assetInfo = getAssetInfoByRoute(routePath);
|
||||
console.log('[模块未开通] 匹配到的资产信息:', assetInfo);
|
||||
|
||||
if (assetInfo) {
|
||||
redirectToSubscribePage(assetInfo.assetId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果没有匹配到路由,尝试使用默认的资产管理
|
||||
console.warn('[模块未开通] 未匹配到路由,使用默认资产管理');
|
||||
redirectToSubscribePage('696b4acd1be1c8b76c4b4c15');
|
||||
return true;
|
||||
}
|
||||
152
src/utils/diffUtils.ts
Normal file
152
src/utils/diffUtils.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 差异比较工具函数
|
||||
* 用于编辑时最小化传参,只传递修改过的字段
|
||||
*/
|
||||
|
||||
/**
|
||||
* 深度比较两个值是否相等
|
||||
* @param val1 值1
|
||||
* @param val2 值2
|
||||
* @returns 是否相等
|
||||
*/
|
||||
export function isEqual(val1: any, val2: any): boolean {
|
||||
// 处理 null 和 undefined
|
||||
if (val1 === val2) return true;
|
||||
if (val1 == null || val2 == null) return val1 == val2;
|
||||
|
||||
// 处理基本类型
|
||||
if (typeof val1 !== 'object' || typeof val2 !== 'object') {
|
||||
return val1 === val2;
|
||||
}
|
||||
|
||||
// 处理数组
|
||||
if (Array.isArray(val1) && Array.isArray(val2)) {
|
||||
if (val1.length !== val2.length) return false;
|
||||
return val1.every((item, index) => isEqual(item, val2[index]));
|
||||
}
|
||||
|
||||
// 处理对象
|
||||
if (Array.isArray(val1) !== Array.isArray(val2)) return false;
|
||||
|
||||
const keys1 = Object.keys(val1);
|
||||
const keys2 = Object.keys(val2);
|
||||
if (keys1.length !== keys2.length) return false;
|
||||
|
||||
return keys1.every((key) => isEqual(val1[key], val2[key]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个对象,返回差异部分
|
||||
* @param original 原始数据
|
||||
* @param current 当前数据
|
||||
* @param options 配置选项
|
||||
* @returns 差异数据对象
|
||||
*/
|
||||
export function getChangedFields<T extends Record<string, any>>(
|
||||
original: T,
|
||||
current: T,
|
||||
options?: {
|
||||
/** 需要包含的字段(即使没有变化也会包含) */
|
||||
alwaysInclude?: string[];
|
||||
/** 需要排除的字段(即使有变化也不会包含) */
|
||||
exclude?: string[];
|
||||
/** 字段值转换器,用于提交前转换值 */
|
||||
transformers?: Record<string, (value: any) => any>;
|
||||
}
|
||||
): Partial<T> {
|
||||
const { alwaysInclude = [], exclude = [], transformers = {} } = options || {};
|
||||
const changed: Partial<T> = {};
|
||||
|
||||
// 遍历当前数据的所有字段
|
||||
const allKeys = new Set([...Object.keys(original), ...Object.keys(current)]);
|
||||
|
||||
allKeys.forEach((key) => {
|
||||
// 排除指定字段
|
||||
if (exclude.includes(key)) return;
|
||||
|
||||
const originalValue = original[key];
|
||||
const currentValue = current[key];
|
||||
|
||||
// 检查是否需要始终包含
|
||||
if (alwaysInclude.includes(key)) {
|
||||
const value = transformers[key] ? transformers[key](currentValue) : currentValue;
|
||||
(changed as any)[key] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
// 比较值是否变化
|
||||
if (!isEqual(originalValue, currentValue)) {
|
||||
const value = transformers[key] ? transformers[key](currentValue) : currentValue;
|
||||
(changed as any)[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个用于保存和比较表单数据的工具
|
||||
* @returns 工具对象
|
||||
*/
|
||||
export function createFormDiff<T extends Record<string, any>>() {
|
||||
let originalData: T | null = null;
|
||||
|
||||
return {
|
||||
/**
|
||||
* 保存原始数据
|
||||
* @param data 原始数据
|
||||
*/
|
||||
saveOriginal(data: T) {
|
||||
// 深拷贝保存原始数据
|
||||
originalData = JSON.parse(JSON.stringify(data));
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取原始数据
|
||||
* @returns 原始数据
|
||||
*/
|
||||
getOriginal(): T | null {
|
||||
return originalData;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取变化的字段
|
||||
* @param current 当前数据
|
||||
* @param options 配置选项
|
||||
* @returns 差异数据对象
|
||||
*/
|
||||
getChanges(
|
||||
current: T,
|
||||
options?: {
|
||||
alwaysInclude?: string[];
|
||||
exclude?: string[];
|
||||
transformers?: Record<string, (value: any) => any>;
|
||||
}
|
||||
): Partial<T> {
|
||||
if (!originalData) {
|
||||
// 如果没有原始数据,返回所有当前数据
|
||||
return { ...current };
|
||||
}
|
||||
return getChangedFields(originalData, current, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查是否有变化
|
||||
* @param current 当前数据
|
||||
* @param exclude 排除的字段
|
||||
* @returns 是否有变化
|
||||
*/
|
||||
hasChanges(current: T, exclude?: string[]): boolean {
|
||||
if (!originalData) return true;
|
||||
const changes = getChangedFields(originalData, current, { exclude });
|
||||
return Object.keys(changes).length > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* 重置原始数据
|
||||
*/
|
||||
reset() {
|
||||
originalData = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,11 +2,34 @@ import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from
|
||||
import { ElMessage, ElMessageBox } 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;
|
||||
|
||||
// 配置新建第一个 axios 实例(原来的主服务)
|
||||
// 错误消息防抖:防止短时间内显示多个错误消息
|
||||
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; // 跳过
|
||||
}
|
||||
|
||||
lastErrorTime = now;
|
||||
ElMessage.error(message);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Axios 实例配置
|
||||
// 地址配置见 .env.development 文件
|
||||
// ============================================================
|
||||
|
||||
// 主服务实例(端口8808)- 系统管理、用户认证、权限、模块开通等
|
||||
const service: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
timeout: 50000,
|
||||
@@ -18,9 +41,9 @@ const service: AxiosInstance = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// 配置新建第二个 axios 实例(新功能服务)
|
||||
// 新功能服务实例(端口8000)- 资产管理、分类、SKU、订单等新模块
|
||||
const newService: AxiosInstance = axios.create({
|
||||
baseURL: 'http://localhost:8000/',
|
||||
baseURL: import.meta.env.VITE_NEW_API_URL,
|
||||
timeout: 50000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
paramsSerializer: {
|
||||
@@ -75,6 +98,33 @@ const requestInterceptor = (config: InternalAxiosRequestConfig) => {
|
||||
// 可以在这里添加 token 有效性检查(如果需要)
|
||||
config.headers!['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// PUT 请求最小化传参处理
|
||||
// 如果请求数据中包含 _originalData,则自动计算差异,只传递修改过的字段
|
||||
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 };
|
||||
}
|
||||
|
||||
console.log('[最小化传参] 原始字段数:', Object.keys(currentData).length, '-> 传递字段数:', Object.keys(config.data).length);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
@@ -111,10 +161,22 @@ const responseInterceptor = (response: AxiosResponse) => {
|
||||
return Promise.reject(new Error('登录状态已过期'));
|
||||
}
|
||||
|
||||
// 业务逻辑错误处理
|
||||
if (code !== undefined && code !== 0 && code !== 200) {
|
||||
// 处理模块未开通错误 (403)
|
||||
// 跳过资产SKU查询接口,避免弹窗内部请求触发循环
|
||||
const requestUrl = response.config.url || '';
|
||||
if (code === 402 && !requestUrl.includes('/assets/asset/sku/')) {
|
||||
// 获取当前路由路径
|
||||
const currentPath = window.location.hash.replace('#', '') || window.location.pathname;
|
||||
console.log('[request.ts] 检测到403错误,当前路径:', currentPath);
|
||||
handleModuleNotEnabled(currentPath);
|
||||
// 直接返回,不再显示错误消息
|
||||
return Promise.reject(new Error('模块未开通'));
|
||||
}
|
||||
|
||||
// 业务逻辑错误处理(排除403,因为上面已处理)
|
||||
if (code !== undefined && code !== 0 && code !== 200 && code !== 403) {
|
||||
const errorMsg = message || `请求失败(${code})`;
|
||||
ElMessage.error(errorMsg);
|
||||
showErrorMessage(errorMsg);
|
||||
return Promise.reject(new Error(errorMsg));
|
||||
}
|
||||
|
||||
@@ -126,7 +188,7 @@ const responseErrorHandler = (error: any) => {
|
||||
console.error('API请求错误:', error);
|
||||
|
||||
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
|
||||
ElMessage.error('请求超时,请检查网络连接');
|
||||
showErrorMessage('请求超时,请检查网络连接');
|
||||
return Promise.reject(new Error('请求超时'));
|
||||
}
|
||||
|
||||
@@ -140,31 +202,56 @@ const responseErrorHandler = (error: any) => {
|
||||
}
|
||||
|
||||
const httpStatus = error.response.status;
|
||||
const message = error.response.data?.message || error.response.statusText;
|
||||
// 优先使用返回数据中的 message 字段
|
||||
const responseMessage = error.response.data?.message;
|
||||
|
||||
// 处理 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) {
|
||||
console.log('[responseErrorHandler] 刚完成开通,跳过402处理');
|
||||
showErrorMessage(responseMessage || '服务开通中,请稍后刷新页面');
|
||||
return Promise.reject(new Error('模块开通中'));
|
||||
}
|
||||
|
||||
const currentPath = window.location.hash.replace('#', '') || window.location.pathname;
|
||||
console.log('[responseErrorHandler] 检测到HTTP 402错误,当前路径:', currentPath);
|
||||
handleModuleNotEnabled(currentPath);
|
||||
return Promise.reject(new Error('模块未开通'));
|
||||
}
|
||||
showErrorMessage(responseMessage || '服务未开通');
|
||||
break;
|
||||
case 403:
|
||||
// ElMessage.error('没有权限访问该资源');
|
||||
showErrorMessage(responseMessage || '没有权限访问该资源');
|
||||
break;
|
||||
case 404:
|
||||
// ElMessage.error('请求的资源不存在');
|
||||
showErrorMessage(responseMessage || '请求的资源不存在');
|
||||
break;
|
||||
case 429:
|
||||
showErrorMessage(responseMessage || '请求过于频繁,请稍后再试');
|
||||
handleTokenExpired();
|
||||
break;
|
||||
case 500:
|
||||
// ElMessage.error('服务器内部错误');
|
||||
showErrorMessage(responseMessage || '服务器内部错误');
|
||||
break;
|
||||
case 502:
|
||||
// ElMessage.error('网关错误');
|
||||
showErrorMessage(responseMessage || '网关错误');
|
||||
break;
|
||||
case 503:
|
||||
// ElMessage.error('服务不可用');
|
||||
showErrorMessage(responseMessage || '服务不可用');
|
||||
break;
|
||||
default:
|
||||
if (httpStatus >= 400) {
|
||||
ElMessage.error(message || `请求失败(${httpStatus})`);
|
||||
showErrorMessage(responseMessage || `请求失败(${httpStatus})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,4 +267,4 @@ newService.interceptors.response.use(responseInterceptor, responseErrorHandler);
|
||||
|
||||
// 导出
|
||||
export default service;
|
||||
export { newService };
|
||||
export { newService, showErrorMessage };
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<el-cascader
|
||||
v-model="ruleForm.categoryId"
|
||||
:options="categoryOptions"
|
||||
:props="{ checkStrictly: true, emitPath: false, value: 'id', label: 'name', children: 'children' }"
|
||||
:props="categoryProps"
|
||||
placeholder="请选择资产分类"
|
||||
clearable
|
||||
class="w100"
|
||||
@@ -62,6 +62,22 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="库存类型">
|
||||
<el-radio-group v-model="ruleForm.unlimitedStock" :disabled="isEdit">
|
||||
<el-radio :value="false">有限库存</el-radio>
|
||||
<el-radio :value="true">无限库存</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8" v-if="tenantId == 1">
|
||||
<el-form-item label="存储模式">
|
||||
<el-radio-group v-model="ruleForm.stockMode" :disabled="isEdit">
|
||||
<el-radio :value="1">明细模式</el-radio>
|
||||
<el-radio :value="2">批次模式</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 分类属性值选择 -->
|
||||
@@ -445,6 +461,8 @@ import type { FormInstance, FormRules } from 'element-plus';
|
||||
import { Plus, Delete } from '@element-plus/icons-vue';
|
||||
import { getAsset, createAsset, updateAsset, uploadAssetImage } from '/@/api/assets/asset';
|
||||
import { getCategoryTree, getCategory } from '/@/api/assets/category';
|
||||
import { createFormDiff } from '/@/utils/diffUtils';
|
||||
import { Session } from '/@/utils/storage';
|
||||
import Editor from '/@/components/editor/index.vue';
|
||||
import type { UploadFile, UploadUserFile, UploadRequestOptions } from 'element-plus';
|
||||
|
||||
@@ -469,6 +487,7 @@ interface CategoryAttr {
|
||||
type: string;
|
||||
options?: { label: string; value: string }[];
|
||||
required?: boolean;
|
||||
dictType?: string;
|
||||
}
|
||||
|
||||
interface KeyValuePair {
|
||||
@@ -481,9 +500,12 @@ interface RuleForm {
|
||||
name: string;
|
||||
type: string;
|
||||
categoryId: string;
|
||||
categoryPath: string;
|
||||
description: string;
|
||||
onlineTime: string;
|
||||
offlineTime: string;
|
||||
unlimitedStock: boolean;
|
||||
stockMode: number;
|
||||
physicalAssetConfig: {
|
||||
shipping: {
|
||||
deliveryMethod: string;
|
||||
@@ -535,6 +557,16 @@ const submitLoading = ref(false);
|
||||
const formLoading = ref(false);
|
||||
const categoryOptions = ref<any[]>([]);
|
||||
const categoryAttrs = ref<CategoryAttr[]>([]);
|
||||
|
||||
// 分类选择器配置 - 只能选择最下级节点(isLeafNode: true)
|
||||
const categoryProps = {
|
||||
checkStrictly: true,
|
||||
emitPath: false,
|
||||
value: 'id',
|
||||
label: 'name',
|
||||
children: 'children',
|
||||
disabled: (data: any) => !data.isLeafNode,
|
||||
};
|
||||
const isTimeSlotLimitReached = computed(() => ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.timeSlots.length >= MAX_TIME_SLOTS);
|
||||
|
||||
// 获取属性的key
|
||||
@@ -554,6 +586,13 @@ const dialogVisible = ref(false);
|
||||
const dialogImageUrl = ref('');
|
||||
// 图片拼接
|
||||
const fileAddressPrefix = ref('');
|
||||
// 使用通用工具函数保存原始数据,用于最小化传参
|
||||
const assetFormDiff = createFormDiff<Record<string, any>>();
|
||||
|
||||
// 获取租户ID
|
||||
const tenantId = ref(Session.get('userInfo')?.tenantId || '');
|
||||
console.log(tenantId.value,'租户id');
|
||||
|
||||
|
||||
const formatImageUrl = (url?: string) => {
|
||||
if (!url) return '';
|
||||
@@ -581,9 +620,12 @@ const getInitialForm = (): RuleForm => ({
|
||||
name: '',
|
||||
type: 'physical',
|
||||
categoryId: '',
|
||||
categoryPath: '',
|
||||
description: '',
|
||||
onlineTime: '',
|
||||
offlineTime: '',
|
||||
unlimitedStock: false,
|
||||
stockMode: 1,
|
||||
physicalAssetConfig: {
|
||||
shipping: {
|
||||
deliveryMethod: 'express',
|
||||
@@ -842,15 +884,38 @@ const fetchCategories = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// 分类变更时获取分类属性
|
||||
// 递归查找分类节点
|
||||
const findCategoryNode = (nodes: any[], id: string): any => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node;
|
||||
if (node.children?.length) {
|
||||
const found = findCategoryNode(node.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 分类变更时获取分类属性和path
|
||||
const onCategoryChange = (categoryId: string) => {
|
||||
categoryAttrs.value = [];
|
||||
ruleForm.metadata = {};
|
||||
ruleForm.categoryPath = '';
|
||||
if (!categoryId) return;
|
||||
|
||||
// 从分类树中查找选中节点,获取path
|
||||
const node = findCategoryNode(categoryOptions.value, categoryId);
|
||||
if (node?.path !== undefined) {
|
||||
ruleForm.categoryPath = node.path;
|
||||
}
|
||||
|
||||
getCategory(categoryId)
|
||||
.then((res: any) => {
|
||||
const data = res.data;
|
||||
// 如果接口返回了path,优先使用接口返回的
|
||||
if (data?.path !== undefined) {
|
||||
ruleForm.categoryPath = data.path;
|
||||
}
|
||||
if (data?.attrs && Array.isArray(data.attrs)) {
|
||||
categoryAttrs.value = data.attrs;
|
||||
// 初始化属性值,确保 boolean 类型默认为 false
|
||||
@@ -885,9 +950,12 @@ const openDialog = (row?: any, edit?: boolean) => {
|
||||
ruleForm.name = data.name || '';
|
||||
ruleForm.type = data.type || 'physical';
|
||||
ruleForm.categoryId = data.categoryId || '';
|
||||
ruleForm.categoryPath = data.categoryPath || '';
|
||||
ruleForm.description = data.description || '';
|
||||
ruleForm.onlineTime = data.onlineTime || '';
|
||||
ruleForm.offlineTime = data.offlineTime || '';
|
||||
ruleForm.unlimitedStock = data.unlimitedStock || false;
|
||||
ruleForm.stockMode = data.stockMode || 1;
|
||||
|
||||
// 主图预览 (支持 imageUrl 和 fileURL)
|
||||
const mainImg = data.imageUrl || data.fileURL;
|
||||
@@ -1018,7 +1086,18 @@ const openDialog = (row?: any, edit?: boolean) => {
|
||||
})
|
||||
.catch(() => {
|
||||
categoryAttrs.value = [];
|
||||
})
|
||||
.finally(() => {
|
||||
// 分类属性加载完成后,保存原始数据用于最小化传参
|
||||
buildRequestBody().then((originalBody) => {
|
||||
assetFormDiff.saveOriginal(JSON.parse(JSON.stringify(originalBody)));
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 没有分类属性,直接保存原始数据
|
||||
buildRequestBody().then((originalBody) => {
|
||||
assetFormDiff.saveOriginal(JSON.parse(JSON.stringify(originalBody)));
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -1073,6 +1152,7 @@ const buildRequestBody = async (): Promise<any> => {
|
||||
name: ruleForm.name,
|
||||
type: ruleForm.type,
|
||||
categoryId: ruleForm.categoryId,
|
||||
categoryPath: ruleForm.categoryPath || '',
|
||||
description: ruleForm.description || '',
|
||||
};
|
||||
|
||||
@@ -1089,6 +1169,14 @@ const buildRequestBody = async (): Promise<any> => {
|
||||
body.offlineTime = ruleForm.offlineTime;
|
||||
}
|
||||
|
||||
// 库存类型
|
||||
body.unlimitedStock = ruleForm.unlimitedStock;
|
||||
|
||||
// 库存存储模式(仅租户ID为1时提交)
|
||||
if (tenantId.value == 1) {
|
||||
body.stockMode = ruleForm.stockMode;
|
||||
}
|
||||
|
||||
// 主图 (已在上传时直接赋值给 ruleForm.mainImage)
|
||||
if (ruleForm.mainImage) {
|
||||
body.imageURL = ruleForm.mainImage;
|
||||
@@ -1158,6 +1246,7 @@ const buildRequestBody = async (): Promise<any> => {
|
||||
name: attr.name,
|
||||
type: attr.type,
|
||||
value: value,
|
||||
...(attr.dictType ? { dictType: attr.dictType } : {}),
|
||||
};
|
||||
|
||||
// 只有单选和多选类型才传递 options,且只传递选中的值对应的选项
|
||||
@@ -1185,7 +1274,21 @@ const onSubmit = async () => {
|
||||
if (valid) {
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
const requestBody = await buildRequestBody();
|
||||
const fullRequestBody = await buildRequestBody();
|
||||
|
||||
let requestBody: any;
|
||||
if (isEdit.value) {
|
||||
// 编辑模式:通过 _originalData 让拦截器自动处理最小化传参
|
||||
const originalData = assetFormDiff.getOriginal();
|
||||
requestBody = {
|
||||
...fullRequestBody,
|
||||
_originalData: originalData,
|
||||
};
|
||||
} else {
|
||||
// 新增模式:传递所有字段
|
||||
requestBody = fullRequestBody;
|
||||
}
|
||||
|
||||
const request = isEdit.value ? updateAsset(requestBody) : createAsset(requestBody);
|
||||
|
||||
await request;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog v-model="dialogVisible" :title="`SKU管理 - ${assetName}`" width="1000px" :close-on-click-modal="false" @close="onClose">
|
||||
<el-dialog v-model="dialogVisible" :title="`SKU管理 - ${assetName}`" width="1200px" :close-on-click-modal="false" @close="onClose">
|
||||
<!-- 搜索区域 -->
|
||||
<div class="sku-search mb15">
|
||||
<el-button type="primary" @click="onOpenAddSku">
|
||||
@@ -21,9 +21,14 @@
|
||||
<!-- SKU 列表 -->
|
||||
<el-table :data="tableData" v-loading="loading" border style="width: 100%">
|
||||
<el-table-column prop="skuName" label="SKU名称" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="specValues" label="规格属性" min-width="150">
|
||||
<el-table-column prop="specValues" label="规格属性" min-width="140">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.specValues && Object.keys(scope.row.specValues).length > 0">
|
||||
<span v-if="scope.row.specValues && Array.isArray(scope.row.specValues) && scope.row.specValues.length > 0">
|
||||
<el-tag v-for="(item, idx) in scope.row.specValues" :key="idx" size="small" style="margin-right: 4px">
|
||||
{{ item.name }}: {{ item.options?.[0]?.label || (Array.isArray(item.value) ? item.value[0] : item.value) }}
|
||||
</el-tag>
|
||||
</span>
|
||||
<span v-else-if="scope.row.specValues && typeof scope.row.specValues === 'object' && !Array.isArray(scope.row.specValues) && Object.keys(scope.row.specValues).length > 0">
|
||||
<el-tag v-for="(value, key) in scope.row.specValues" :key="key" size="small" style="margin-right: 4px">
|
||||
{{ key }}: {{ value }}
|
||||
</el-tag>
|
||||
@@ -31,7 +36,17 @@
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="price" label="价格" width="100" align="center">
|
||||
<el-table-column prop="specsUnit" label="规格单位" width="80" align="center">
|
||||
<template #default="scope">
|
||||
{{ scope.row.specsUnit?.value || getSpecsUnitLabel(scope.row.specsUnit) || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="specsCount" label="规格数量" width="80" align="center">
|
||||
<template #default="scope">
|
||||
{{ scope.row.specsCount || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="price" label="价格" width="90" align="center">
|
||||
<template #default="scope">
|
||||
¥{{ (scope.row.price / 100).toFixed(2) }}
|
||||
</template>
|
||||
@@ -48,12 +63,26 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort" label="排序" width="80" align="center" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="160" align="center" />
|
||||
<el-table-column prop="updatedAt" label="修改时间" width="160" align="center" />
|
||||
<el-table-column label="操作" width="120" align="center">
|
||||
<el-table-column prop="sort" label="排序" width="60" align="center" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tooltip :content="scope.row.createdAt" placement="top">
|
||||
<span>{{ formatShortTime(scope.row.createdAt) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updatedAt" label="修改时间" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tooltip :content="scope.row.updatedAt" placement="top">
|
||||
<span>{{ formatShortTime(scope.row.updatedAt) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" align="center">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text type="primary" @click="onEditSku(scope.row)">编辑</el-button>
|
||||
<el-button v-if="!scope.row.unlimitedStock" size="small" text type="success" @click="onGenerateStock(scope.row)">生成库存</el-button>
|
||||
<el-button size="small" text type="info" @click="onViewLog(scope.row)">日志</el-button>
|
||||
<el-button size="small" text type="danger" @click="onDeleteSku(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -62,7 +91,7 @@
|
||||
<!-- 分页 -->
|
||||
<div class="mt15" style="text-align: right">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:current-page="queryParams.pageNum"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
@@ -81,12 +110,20 @@
|
||||
<el-form-item label="SKU名称" prop="skuName">
|
||||
<el-input v-model="skuForm.skuName" placeholder="请输入SKU名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="规格属性" v-if="assetSpecAttrs.length > 0">
|
||||
<el-form-item label="规格数量" prop="specsCount">
|
||||
<el-input-number v-model="skuForm.specsCount" :min="1" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item label="规格单位" prop="specsUnit">
|
||||
<el-select v-model="skuForm.specsUnit" placeholder="请选择规格单位" style="width: 150px" clearable>
|
||||
<el-option v-for="opt in specsUnitOptions" :key="opt.key" :label="opt.value" :value="opt.key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="assetSpecAttrs.length > 0">
|
||||
<div class="spec-values-container">
|
||||
<div v-for="attr in assetSpecAttrs" :key="attr.name" class="spec-item">
|
||||
<span class="spec-label">{{ attr.name }}:</span>
|
||||
<el-select v-if="attr.options && attr.options.length > 0" v-model="specValuesMap[attr.name]" placeholder="请选择" style="width: 120px" filterable allow-create clearable>
|
||||
<el-option v-for="opt in attr.options" :key="opt" :label="opt" :value="opt" />
|
||||
<el-option v-for="opt in attr.options" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<el-input v-else v-model="specValuesMap[attr.name]" placeholder="请输入" style="width: 120px" />
|
||||
</div>
|
||||
@@ -96,10 +133,6 @@
|
||||
<el-input-number v-model="skuForm.price" :min="0" :precision="2" :step="0.01" controls-position="right" />
|
||||
<span style="margin-left: 8px">元</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="库存数量" prop="stock">
|
||||
<el-input-number v-model="skuForm.stock" :min="0" :disabled="skuForm.unlimitedStock" controls-position="right" />
|
||||
<el-checkbox v-model="skuForm.unlimitedStock" style="margin-left: 10px">无限库存</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="skuForm.status">
|
||||
<el-radio :value="1">激活</el-radio>
|
||||
@@ -131,14 +164,62 @@
|
||||
<el-button type="primary" :loading="submitLoading" @click="onSubmitSku">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 生成库存弹窗 -->
|
||||
<el-dialog v-model="stockFormVisible" title="生成库存" width="450px" :close-on-click-modal="false" append-to-body>
|
||||
<el-form ref="stockFormRef" :model="stockForm" :rules="getStockFormRules()" label-width="100px" v-loading="stockFormLoading">
|
||||
<el-form-item label="SKU名称">
|
||||
<el-input :model-value="currentSkuName" disabled />
|
||||
</el-form-item>
|
||||
<template v-for="field in stockFormFields" :key="field.name">
|
||||
<el-form-item :label="field.label" :prop="field.name">
|
||||
<!-- 数字类型 -->
|
||||
<el-input-number
|
||||
v-if="field.type === 'number'"
|
||||
v-model="stockForm[field.name]"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
:controls="!field.maxLength"
|
||||
controls-position="right"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<!-- 日期类型 -->
|
||||
<el-date-picker
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="stockForm[field.name]"
|
||||
type="date"
|
||||
placeholder="请选择日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<!-- 文本类型 -->
|
||||
<el-input
|
||||
v-else
|
||||
v-model="stockForm[field.name]"
|
||||
:maxlength="field.maxLength"
|
||||
placeholder="请输入"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="stockFormVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="stockSubmitLoading" @click="onSubmitStock">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-dialog>
|
||||
<OperationLogDialog ref="operationLogRef" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue';
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
import { listAssetSkus, createAssetSku, updateAssetSku, deleteAssetSku, getAssetSku, getAsset, uploadAssetImage } from '/@/api/assets/asset';
|
||||
import { listAssetSkus, createAssetSku, updateAssetSku, deleteAssetSku, getAssetSku, getAsset, uploadAssetImage, getSpecsUnitOptions, getStockFormFields, stockOperation } from '/@/api/assets/asset';
|
||||
import { createFormDiff } from '/@/utils/diffUtils';
|
||||
import OperationLogDialog from '../../component/operationLogDialog.vue';
|
||||
import type { UploadRequestOptions, UploadUserFile } from 'element-plus';
|
||||
|
||||
interface SpecValueItem {
|
||||
@@ -148,7 +229,20 @@ interface SpecValueItem {
|
||||
|
||||
interface AssetSpecAttr {
|
||||
name: string;
|
||||
options?: string[];
|
||||
options?: { value: string; label: string }[];
|
||||
dictType?: string;
|
||||
}
|
||||
|
||||
// 库存表单字段接口
|
||||
interface StockFormField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
maxLength?: number;
|
||||
default?: string | number;
|
||||
}
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
@@ -158,19 +252,33 @@ const editLoading = ref(false);
|
||||
const skuFormVisible = ref(false);
|
||||
const isEditSku = ref(false);
|
||||
const editSkuId = ref('');
|
||||
const operationLogRef = ref();
|
||||
|
||||
// 库存弹窗相关
|
||||
const stockFormVisible = ref(false);
|
||||
const stockFormLoading = ref(false);
|
||||
const stockSubmitLoading = ref(false);
|
||||
const stockFormFields = ref<StockFormField[]>([]);
|
||||
const stockFormRef = ref<FormInstance>();
|
||||
const stockForm = reactive<Record<string, any>>({});
|
||||
const currentSkuId = ref('');
|
||||
const currentSkuName = ref('');
|
||||
|
||||
const assetId = ref('');
|
||||
const assetName = ref('');
|
||||
const assetType = ref('');
|
||||
const unlimitedStock = ref(false); // 资产是否无限库存
|
||||
const assetSpecAttrs = ref<AssetSpecAttr[]>([]);
|
||||
const fileAddressPrefix = ref('');
|
||||
const skuImagePreview = ref('');
|
||||
const specsUnitOptions = ref<{ key: string; value: string }[]>([]);
|
||||
const tableData = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
|
||||
const queryParams = reactive({
|
||||
keyword: '',
|
||||
status: undefined as number | undefined,
|
||||
page: 1,
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
@@ -184,13 +292,23 @@ const skuForm = reactive({
|
||||
sort: 0,
|
||||
imageUrl: '',
|
||||
description: '',
|
||||
specsUnit: '',
|
||||
specsCount: 1,
|
||||
});
|
||||
|
||||
const specValuesList = ref<SpecValueItem[]>([{ key: '', value: '' }]);
|
||||
const specValuesMap = reactive<Record<string, string>>({});
|
||||
// 使用通用工具函数保存原始数据,用于最小化传参
|
||||
const skuFormDiff = createFormDiff<Record<string, any>>();
|
||||
const specValuesMapDiff = createFormDiff<Record<string, string>>();
|
||||
|
||||
const skuRules: FormRules = {
|
||||
skuName: [{ required: true, message: '请输入SKU名称', trigger: 'blur' }],
|
||||
specsUnit: [{ required: true, message: '请选择规格单位', trigger: 'change' }],
|
||||
specsCount: [
|
||||
{ required: true, message: '请输入规格数量', trigger: 'blur' },
|
||||
{ type: 'number', min: 1, message: '规格数量必须大于0', trigger: 'blur' }
|
||||
],
|
||||
price: [
|
||||
{ required: true, message: '请输入价格', trigger: 'blur' },
|
||||
{ type: 'number', min: 0.01, message: '价格必须大于0', trigger: 'blur' }
|
||||
@@ -215,13 +333,42 @@ const skuRules: FormRules = {
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
const openDialog = (row: { id: string; name: string }) => {
|
||||
const openDialog = (row: { id: string; name: string; type?: string; unlimitedStock?: boolean }) => {
|
||||
assetId.value = row.id;
|
||||
assetName.value = row.name;
|
||||
assetType.value = row.type || '';
|
||||
unlimitedStock.value = row.unlimitedStock || false;
|
||||
dialogVisible.value = true;
|
||||
resetQuery();
|
||||
getSkuList();
|
||||
fetchAssetSpecAttrs();
|
||||
fetchSpecsUnitOptions();
|
||||
};
|
||||
|
||||
// 获取规格单位选项
|
||||
const fetchSpecsUnitOptions = () => {
|
||||
if (!assetType.value) return;
|
||||
getSpecsUnitOptions(assetType.value)
|
||||
.then((res: any) => {
|
||||
specsUnitOptions.value = res.data?.options || [];
|
||||
})
|
||||
.catch(() => {
|
||||
specsUnitOptions.value = [];
|
||||
});
|
||||
};
|
||||
|
||||
// 根据规格单位 key 获取对应的 label
|
||||
const getSpecsUnitLabel = (key: string) => {
|
||||
if (!key) return '';
|
||||
const option = specsUnitOptions.value.find((opt) => opt.key === key);
|
||||
return option?.value || key;
|
||||
};
|
||||
|
||||
// 格式化时间为短格式(只显示日期)
|
||||
const formatShortTime = (time: string) => {
|
||||
if (!time) return '-';
|
||||
// 只取日期部分 YYYY-MM-DD
|
||||
return time.substring(0, 10);
|
||||
};
|
||||
|
||||
// 获取资产规格属性(只获取多选类型)
|
||||
@@ -240,7 +387,8 @@ const fetchAssetSpecAttrs = () => {
|
||||
.filter((item: any) => item.type === 'multi_select')
|
||||
.map((item: any) => ({
|
||||
name: item.name,
|
||||
options: item.options?.map((opt: any) => opt.label || opt.value) || [],
|
||||
options: item.options?.map((opt: any) => ({ value: opt.value, label: opt.label || opt.value })) || [],
|
||||
dictType: item.dictType || '',
|
||||
}));
|
||||
} else {
|
||||
assetSpecAttrs.value = [];
|
||||
@@ -275,7 +423,7 @@ const getSkuList = () => {
|
||||
const resetQuery = () => {
|
||||
queryParams.keyword = '';
|
||||
queryParams.status = undefined;
|
||||
queryParams.page = 1;
|
||||
queryParams.pageNum = 1;
|
||||
};
|
||||
|
||||
const onResetQuery = () => {
|
||||
@@ -286,12 +434,12 @@ const onResetQuery = () => {
|
||||
// 分页
|
||||
const onSizeChange = (size: number) => {
|
||||
queryParams.pageSize = size;
|
||||
queryParams.page = 1;
|
||||
queryParams.pageNum = 1;
|
||||
getSkuList();
|
||||
};
|
||||
|
||||
const onCurrentChange = (page: number) => {
|
||||
queryParams.page = page;
|
||||
const onCurrentChange = (pageNum: number) => {
|
||||
queryParams.pageNum = pageNum;
|
||||
getSkuList();
|
||||
};
|
||||
|
||||
@@ -321,24 +469,63 @@ const onEditSku = async (row: any) => {
|
||||
skuForm.sort = data.sort || 0;
|
||||
skuForm.imageUrl = data.imageUrl || '';
|
||||
skuForm.description = data.description || '';
|
||||
// specsUnit 可能是对象格式 { key, value } 或字符串
|
||||
if (data.specsUnit && typeof data.specsUnit === 'object') {
|
||||
skuForm.specsUnit = data.specsUnit.key || '';
|
||||
} else {
|
||||
skuForm.specsUnit = data.specsUnit || '';
|
||||
}
|
||||
skuForm.specsCount = data.specsCount || 1;
|
||||
// 使用工具函数保存原始数据用于最小化传参
|
||||
skuFormDiff.saveOriginal({
|
||||
skuName: skuForm.skuName,
|
||||
price: skuForm.price,
|
||||
stock: skuForm.stock,
|
||||
unlimitedStock: skuForm.unlimitedStock,
|
||||
status: skuForm.status,
|
||||
sort: skuForm.sort,
|
||||
imageUrl: skuForm.imageUrl,
|
||||
description: skuForm.description,
|
||||
specsUnit: skuForm.specsUnit,
|
||||
specsCount: skuForm.specsCount,
|
||||
});
|
||||
// 图片预览回显
|
||||
if (data.imageUrl) {
|
||||
skuImagePreview.value = formatImageUrl(data.imageUrl);
|
||||
}
|
||||
|
||||
// 处理规格属性
|
||||
if (data.specValues && Object.keys(data.specValues).length > 0) {
|
||||
specValuesList.value = Object.entries(data.specValues).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value),
|
||||
}));
|
||||
// 回显到 specValuesMap
|
||||
Object.entries(data.specValues).forEach(([key, value]) => {
|
||||
specValuesMap[key] = String(value);
|
||||
});
|
||||
// 处理规格属性(支持新格式数组和旧格式对象)
|
||||
if (data.specValues) {
|
||||
if (Array.isArray(data.specValues)) {
|
||||
// 新格式:与 metadata 相同的数组格式
|
||||
data.specValues.forEach((item: any) => {
|
||||
if (item.name) {
|
||||
// value 可能是数组或字符串
|
||||
const val = Array.isArray(item.value) ? item.value[0] : item.value;
|
||||
specValuesMap[item.name] = String(val || '');
|
||||
}
|
||||
});
|
||||
specValuesList.value = data.specValues.map((item: any) => ({
|
||||
key: item.name,
|
||||
value: String(Array.isArray(item.value) ? item.value[0] : item.value || ''),
|
||||
}));
|
||||
} else if (typeof data.specValues === 'object' && Object.keys(data.specValues).length > 0) {
|
||||
// 旧格式:对象格式 { key: value }
|
||||
specValuesList.value = Object.entries(data.specValues).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value),
|
||||
}));
|
||||
Object.entries(data.specValues).forEach(([key, value]) => {
|
||||
specValuesMap[key] = String(value);
|
||||
});
|
||||
} else {
|
||||
specValuesList.value = [{ key: '', value: '' }];
|
||||
}
|
||||
} else {
|
||||
specValuesList.value = [{ key: '', value: '' }];
|
||||
}
|
||||
// 保存原始规格属性数据
|
||||
specValuesMapDiff.saveOriginal({ ...specValuesMap });
|
||||
} catch (error) {
|
||||
skuFormVisible.value = false;
|
||||
} finally {
|
||||
@@ -346,6 +533,11 @@ const onEditSku = async (row: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 查看日志
|
||||
const onViewLog = (row: any) => {
|
||||
operationLogRef.value?.openDialog(row.id);
|
||||
};
|
||||
|
||||
// 删除 SKU
|
||||
const onDeleteSku = (row: any) => {
|
||||
ElMessageBox.confirm(`确定要删除SKU "${row.skuName}" 吗?`, '提示', {
|
||||
@@ -362,6 +554,124 @@ const onDeleteSku = (row: any) => {
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
// 生成库存
|
||||
const onGenerateStock = async (row: any) => {
|
||||
currentSkuId.value = row.id;
|
||||
currentSkuName.value = row.skuName;
|
||||
stockFormVisible.value = true;
|
||||
stockFormLoading.value = true;
|
||||
|
||||
// 重置表单
|
||||
Object.keys(stockForm).forEach((key) => delete stockForm[key]);
|
||||
|
||||
try {
|
||||
const res = await getStockFormFields(row.id);
|
||||
stockFormFields.value = res.data.fields || [];
|
||||
|
||||
// 设置默认值,根据字段类型转换
|
||||
stockFormFields.value.forEach((field) => {
|
||||
if (field.default !== undefined) {
|
||||
// 如果是数字类型,确保默认值也是数字
|
||||
if (field.type === 'number') {
|
||||
stockForm[field.name] = Number(field.default);
|
||||
} else {
|
||||
stockForm[field.name] = field.default;
|
||||
}
|
||||
} else if (field.type === 'number') {
|
||||
stockForm[field.name] = field.min || 0;
|
||||
} else {
|
||||
stockForm[field.name] = '';
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取库存表单字段失败:', error);
|
||||
ElMessage.error('获取库存表单字段失败');
|
||||
stockFormVisible.value = false;
|
||||
} finally {
|
||||
stockFormLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 提交库存操作
|
||||
const onSubmitStock = async () => {
|
||||
const form = stockFormRef.value;
|
||||
if (!form) return;
|
||||
|
||||
form.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
stockSubmitLoading.value = true;
|
||||
try {
|
||||
// 根据字段类型转换数据
|
||||
const submitData: Record<string, any> = {
|
||||
assetSkuId: currentSkuId.value,
|
||||
};
|
||||
stockFormFields.value.forEach((field) => {
|
||||
const value = stockForm[field.name];
|
||||
if (field.type === 'number' && value !== undefined && value !== '') {
|
||||
submitData[field.name] = Number(value);
|
||||
} else if (value !== undefined && value !== '') {
|
||||
submitData[field.name] = value;
|
||||
}
|
||||
});
|
||||
|
||||
await stockOperation(submitData as any);
|
||||
ElMessage.success('库存生成成功');
|
||||
stockFormVisible.value = false;
|
||||
getSkuList();
|
||||
} catch (error) {
|
||||
console.error('库存操作失败:', error);
|
||||
} finally {
|
||||
stockSubmitLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 生成库存表单验证规则
|
||||
const getStockFormRules = () => {
|
||||
const rules: Record<string, any[]> = {};
|
||||
stockFormFields.value.forEach((field) => {
|
||||
const fieldRules: any[] = [];
|
||||
if (field.required) {
|
||||
fieldRules.push({ required: true, message: `${field.label}不能为空`, trigger: 'blur' });
|
||||
}
|
||||
if (field.type === 'number') {
|
||||
// 数字类型的最小值和最大值验证
|
||||
if (field.min !== undefined) {
|
||||
fieldRules.push({ type: 'number', min: field.min, message: `${field.label}最小值为${field.min}`, trigger: 'blur' });
|
||||
}
|
||||
if (field.max !== undefined) {
|
||||
fieldRules.push({ type: 'number', max: field.max, message: `${field.label}最大值为${field.max}`, trigger: 'blur' });
|
||||
}
|
||||
// 数字位数验证
|
||||
if (field.maxLength) {
|
||||
fieldRules.push({
|
||||
validator: (_rule: any, value: any, callback: any) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
const strValue = String(value);
|
||||
if (strValue.length > field.maxLength!) {
|
||||
callback(new Error(`${field.label}不能超过${field.maxLength}位`));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: 'blur',
|
||||
});
|
||||
}
|
||||
} else if (field.maxLength) {
|
||||
// 字符串类型的最大长度验证
|
||||
fieldRules.push({ max: field.maxLength, message: `${field.label}最大长度为${field.maxLength}`, trigger: 'blur' });
|
||||
}
|
||||
if (fieldRules.length > 0) {
|
||||
rules[field.name] = fieldRules;
|
||||
}
|
||||
});
|
||||
return rules;
|
||||
};
|
||||
|
||||
// 重置 SKU 表单
|
||||
const resetSkuForm = () => {
|
||||
skuForm.skuName = '';
|
||||
@@ -372,6 +682,8 @@ const resetSkuForm = () => {
|
||||
skuForm.sort = 0;
|
||||
skuForm.imageUrl = '';
|
||||
skuForm.description = '';
|
||||
skuForm.specsUnit = '';
|
||||
skuForm.specsCount = 1;
|
||||
specValuesList.value = [{ key: '', value: '' }];
|
||||
// 清空 specValuesMap
|
||||
Object.keys(specValuesMap).forEach((key) => delete specValuesMap[key]);
|
||||
@@ -428,7 +740,7 @@ const removeSkuImage = () => {
|
||||
};
|
||||
|
||||
// 获取属性的可选值
|
||||
const getAttrOptions = (key: string): string[] => {
|
||||
const getAttrOptions = (key: string): { value: string; label: string }[] => {
|
||||
if (!key) return [];
|
||||
const attr = assetSpecAttrs.value.find((a) => a.name === key);
|
||||
return attr?.options || [];
|
||||
@@ -469,39 +781,96 @@ const onSubmitSku = async () => {
|
||||
|
||||
submitLoading.value = true;
|
||||
|
||||
// 构建规格属性对象(优先使用 specValuesMap)
|
||||
const specValues: Record<string, string> = {};
|
||||
// 从 specValuesMap 获取(直接展示的属性)
|
||||
Object.entries(specValuesMap).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
specValues[key] = value;
|
||||
}
|
||||
});
|
||||
// 兼容旧的 specValuesList
|
||||
specValuesList.value.forEach((item) => {
|
||||
if (item.key.trim() && !specValues[item.key.trim()]) {
|
||||
specValues[item.key.trim()] = item.value.trim();
|
||||
// 构建规格属性数组(与 metadata 格式相同)
|
||||
const specValues: any[] = [];
|
||||
assetSpecAttrs.value.forEach((attr) => {
|
||||
const selectedValue = specValuesMap[attr.name];
|
||||
if (selectedValue) {
|
||||
// 构建与 metadata 相同的数据结构
|
||||
const specItem: any = {
|
||||
name: attr.name,
|
||||
type: 'multi_select',
|
||||
value: [selectedValue],
|
||||
};
|
||||
// 添加 dictType
|
||||
if (attr.dictType) {
|
||||
specItem.dictType = attr.dictType;
|
||||
}
|
||||
// 添加 options(只包含选中的值,格式与 metadata 一致)
|
||||
if (attr.options && attr.options.length > 0) {
|
||||
// 从 options 中找到选中值对应的完整选项
|
||||
const selectedOpt = attr.options.find((opt) => opt.value === selectedValue || opt.label === selectedValue);
|
||||
if (selectedOpt) {
|
||||
specItem.options = [{ value: selectedOpt.value, label: selectedOpt.label }];
|
||||
} else {
|
||||
specItem.options = [{ value: selectedValue, label: selectedValue }];
|
||||
}
|
||||
}
|
||||
specValues.push(specItem);
|
||||
}
|
||||
});
|
||||
|
||||
const data = {
|
||||
assetId: assetId.value,
|
||||
assetName: assetName.value,
|
||||
skuName: skuForm.skuName,
|
||||
imageUrl: skuForm.imageUrl || undefined,
|
||||
specValues: Object.keys(specValues).length > 0 ? specValues : undefined,
|
||||
price: Math.round(skuForm.price * 100),
|
||||
unlimitedStock: skuForm.unlimitedStock,
|
||||
stock: skuForm.stock,
|
||||
sort: skuForm.sort,
|
||||
status: skuForm.status,
|
||||
description:skuForm.description,
|
||||
};
|
||||
// 构建 specsUnit 对象格式
|
||||
let specsUnitObj: { key: string; value: string } | undefined = undefined;
|
||||
if (skuForm.specsUnit) {
|
||||
const unitOption = specsUnitOptions.value.find((opt) => opt.key === skuForm.specsUnit);
|
||||
specsUnitObj = {
|
||||
key: skuForm.specsUnit,
|
||||
value: unitOption?.value || skuForm.specsUnit,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEditSku.value) {
|
||||
await updateAssetSku({ ...data, id: editSkuId.value });
|
||||
// 编辑模式:使用工具函数获取修改过的字段
|
||||
const currentFormData = {
|
||||
skuName: skuForm.skuName,
|
||||
price: skuForm.price,
|
||||
stock: skuForm.stock,
|
||||
unlimitedStock: skuForm.unlimitedStock,
|
||||
status: skuForm.status,
|
||||
sort: skuForm.sort,
|
||||
imageUrl: skuForm.imageUrl,
|
||||
description: skuForm.description,
|
||||
specsUnit: skuForm.specsUnit,
|
||||
specsCount: skuForm.specsCount,
|
||||
};
|
||||
|
||||
const changedFields = skuFormDiff.getChanges(currentFormData, {
|
||||
alwaysInclude: ['id'],
|
||||
transformers: {
|
||||
price: (val) => Math.round(val * 100),
|
||||
specsUnit: () => specsUnitObj,
|
||||
imageUrl: (val) => val || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// 添加 id
|
||||
const changedData: Record<string, any> = { id: editSkuId.value, ...changedFields };
|
||||
|
||||
// 比较规格属性
|
||||
if (specValuesMapDiff.hasChanges(specValuesMap) && specValues.length > 0) {
|
||||
changedData.specValues = specValues;
|
||||
}
|
||||
|
||||
await updateAssetSku(changedData as any);
|
||||
} else {
|
||||
// 新增模式:传递所有字段
|
||||
const data = {
|
||||
assetId: assetId.value,
|
||||
assetName: assetName.value,
|
||||
skuName: skuForm.skuName,
|
||||
imageUrl: skuForm.imageUrl || undefined,
|
||||
specValues: specValues.length > 0 ? specValues : undefined,
|
||||
price: Math.round(skuForm.price * 100),
|
||||
unlimitedStock: skuForm.unlimitedStock,
|
||||
stock: skuForm.stock,
|
||||
sort: skuForm.sort,
|
||||
status: skuForm.status,
|
||||
description: skuForm.description,
|
||||
specsUnit: specsUnitObj,
|
||||
specsCount: skuForm.specsCount || undefined,
|
||||
};
|
||||
await createAssetSku(data);
|
||||
}
|
||||
ElMessage.success(isEditSku.value ? '编辑成功' : '添加成功');
|
||||
|
||||
@@ -61,10 +61,11 @@
|
||||
<el-table-column prop="offlineTime" label="下线时间" width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="updatedAt" label="修改时间" width="170" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||
<el-table-column label="操作" width="250" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text type="primary" @click="onEdit(scope.row)">修改</el-button>
|
||||
<el-button size="small" text type="success" @click="onAddSku(scope.row)">管理SKU</el-button>
|
||||
<el-button size="small" text type="success" @click="onAddSku(scope.row)">规格管理</el-button>
|
||||
<el-button size="small" text type="info" @click="onViewLog(scope.row)">日志</el-button>
|
||||
<el-button size="small" text type="danger" @click="onRowDel(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -72,7 +73,7 @@
|
||||
<!-- 分页 -->
|
||||
<div class="mt15" style="text-align: right">
|
||||
<el-pagination
|
||||
v-model:current-page="tableData.param.page"
|
||||
v-model:current-page="tableData.param.pageNum"
|
||||
v-model:page-size="tableData.param.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="tableData.total"
|
||||
@@ -85,6 +86,7 @@
|
||||
</div>
|
||||
<EditAsset ref="editAssetRef" @getAssetList="getAssetList" />
|
||||
<SkuDialog ref="skuDialogRef" />
|
||||
<OperationLogDialog ref="operationLogRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -100,6 +102,7 @@ import { ElMessageBox, ElMessage } from 'element-plus';
|
||||
import { listAssets, updateAssetStatus, deleteAsset } from '/@/api/assets/asset';
|
||||
import EditAsset from './component/editAsset.vue';
|
||||
import SkuDialog from './component/skuDialog.vue';
|
||||
import OperationLogDialog from '../component/operationLogDialog.vue';
|
||||
|
||||
interface AssetRow {
|
||||
id: string;
|
||||
@@ -112,12 +115,14 @@ interface AssetRow {
|
||||
onlineTime: string;
|
||||
offlineTime: string;
|
||||
status: number;
|
||||
unlimitedStock: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const editAssetRef = ref();
|
||||
const skuDialogRef = ref();
|
||||
const operationLogRef = ref();
|
||||
|
||||
const tableData = reactive({
|
||||
data: [] as AssetRow[],
|
||||
@@ -127,7 +132,7 @@ const tableData = reactive({
|
||||
keyword: '',
|
||||
type: '',
|
||||
status: undefined as number | undefined,
|
||||
page: 1,
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
},
|
||||
});
|
||||
@@ -178,7 +183,7 @@ const onResetQuery = () => {
|
||||
tableData.param.keyword = '';
|
||||
tableData.param.type = '';
|
||||
tableData.param.status = undefined;
|
||||
tableData.param.page = 1;
|
||||
tableData.param.pageNum = 1;
|
||||
getAssetList();
|
||||
};
|
||||
|
||||
@@ -222,19 +227,29 @@ const onEdit = (row: AssetRow) => {
|
||||
|
||||
// 管理SKU
|
||||
const onAddSku = (row: AssetRow) => {
|
||||
skuDialogRef.value.openDialog(row);
|
||||
skuDialogRef.value.openDialog({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
unlimitedStock: row.unlimitedStock,
|
||||
});
|
||||
};
|
||||
|
||||
// 查看日志
|
||||
const onViewLog = (row: AssetRow) => {
|
||||
operationLogRef.value?.openDialog(row.id);
|
||||
};
|
||||
|
||||
// 分页大小改变
|
||||
const onSizeChange = (size: number) => {
|
||||
tableData.param.pageSize = size;
|
||||
tableData.param.page = 1;
|
||||
tableData.param.pageNum = 1;
|
||||
getAssetList();
|
||||
};
|
||||
|
||||
// 当前页改变
|
||||
const onCurrentChange = (page: number) => {
|
||||
tableData.param.page = page;
|
||||
const onCurrentChange = (pageNum: number) => {
|
||||
tableData.param.pageNum = pageNum;
|
||||
getAssetList();
|
||||
};
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
:max-collapse-tags="2"
|
||||
>
|
||||
<el-option
|
||||
v-for="(item, idx) in getDictValuesByType(attr.name)"
|
||||
v-for="(item, idx) in getDictValuesByType(attr.name, attr.dictType)"
|
||||
:key="idx"
|
||||
:label="item.value"
|
||||
:value="item.key"
|
||||
@@ -126,6 +126,7 @@ import { ElMessage } from 'element-plus';
|
||||
import { Plus, Delete } from '@element-plus/icons-vue';
|
||||
import { getCategoryTree, getCategory, addCategory, updateCategory, getCategoryAttrTypeOptions } from '/@/api/assets/category';
|
||||
import { getDicts } from '/@/api/system/dict/data';
|
||||
import { createFormDiff } from '/@/utils/diffUtils';
|
||||
|
||||
interface CategoryRow {
|
||||
id: string;
|
||||
@@ -145,10 +146,12 @@ interface CustomAttr {
|
||||
sort?: number;
|
||||
dictKey?: string;
|
||||
dictValues?: string[];
|
||||
dictType?: string;
|
||||
}
|
||||
|
||||
interface DictInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
@@ -179,6 +182,8 @@ const attrTypeOptions = ref<{ key: string; value: string }[]>([]);
|
||||
const dictTypeOptions = ref<DictInfo[]>([]);
|
||||
const dictValueOptions = ref<any[]>([]);
|
||||
const dictLoading = ref(false);
|
||||
// 使用通用工具函数保存原始数据,用于最小化传参
|
||||
const categoryFormDiff = createFormDiff<Record<string, any>>();
|
||||
|
||||
const ruleForm = reactive<RuleForm>({
|
||||
id: '',
|
||||
@@ -210,9 +215,8 @@ const fetchAttrTypeOptions = () => {
|
||||
// 获取字典类型数据
|
||||
const fetchDictTypeOptions = () => {
|
||||
dictLoading.value = true;
|
||||
getDicts('assets')
|
||||
return getDicts('assets')
|
||||
.then((res: any) => {
|
||||
console.log('字典接口返回数据:', res);
|
||||
const list = res.data?.list ?? [];
|
||||
// 提取所有字典类型信息
|
||||
dictTypeOptions.value = list
|
||||
@@ -220,11 +224,10 @@ const fetchDictTypeOptions = () => {
|
||||
.filter((info: DictInfo) => info && info.name);
|
||||
// 保存完整的字典数据列表(包含info和values)
|
||||
dictValueOptions.value = list;
|
||||
console.log('字典类型选项:', dictTypeOptions.value);
|
||||
console.log('字典值选项:', dictValueOptions.value);
|
||||
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error('获取字典数据失败:', err);
|
||||
|
||||
dictTypeOptions.value = [];
|
||||
dictValueOptions.value = [];
|
||||
})
|
||||
@@ -233,11 +236,18 @@ const fetchDictTypeOptions = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// 根据字典类型获取对应的字典值
|
||||
const getDictValuesByType = (dictKey: string) => {
|
||||
if (!dictKey) return [];
|
||||
// 根据字典类型名称找到对应的字典数据
|
||||
const dictItem = dictValueOptions.value.find((item: any) => item.info?.name === dictKey);
|
||||
// 根据字典类型获取对应的字典值(优先使用 dictType 匹配)
|
||||
const getDictValuesByType = (dictName: string, dictType?: string) => {
|
||||
if (!dictName && !dictType) return [];
|
||||
// 优先使用 dictType 匹配,这样即使字典名称修改也能正确匹配
|
||||
let dictItem;
|
||||
if (dictType) {
|
||||
dictItem = dictValueOptions.value.find((item: any) => item.info?.type === dictType);
|
||||
}
|
||||
// 如果 dictType 没匹配到,再用 name 匹配(兼容旧数据)
|
||||
if (!dictItem && dictName) {
|
||||
dictItem = dictValueOptions.value.find((item: any) => item.info?.name === dictName);
|
||||
}
|
||||
return dictItem?.values ?? [];
|
||||
};
|
||||
|
||||
@@ -248,13 +258,19 @@ const isDictType = (type?: string) => type === 'select' || type === 'multi_selec
|
||||
const onDictKeyChange = (attr: CustomAttr) => {
|
||||
// 清空已选的字典值
|
||||
attr.options = [];
|
||||
// 根据选择的字典名称,找到对应的 dictType 并保存
|
||||
const selectedDict = dictTypeOptions.value.find((item) => item.name === attr.name);
|
||||
attr.dictType = selectedDict?.type || '';
|
||||
};
|
||||
|
||||
// 判断字典选项是否应被禁用
|
||||
const isDictOptionDisabled = (dictName: string, currentAttr: CustomAttr) => {
|
||||
if (!dictName) return false;
|
||||
// 检查该字典名称是否已被其他属性使用
|
||||
return ruleForm.attrs.some((attr) => attr !== currentAttr && isDictType(attr.type) && attr.name === dictName);
|
||||
// 找到该字典对应的 type
|
||||
const dictInfo = dictTypeOptions.value.find((item) => item.name === dictName);
|
||||
const dictType = dictInfo?.type || '';
|
||||
// 检查该字典是否已被其他属性使用(使用 dictType 判断)
|
||||
return ruleForm.attrs.some((attr) => attr !== currentAttr && isDictType(attr.type) && (attr.dictType === dictType || (!attr.dictType && attr.name === dictName)));
|
||||
};
|
||||
|
||||
// 添加自定义属性
|
||||
@@ -337,9 +353,24 @@ const openDialog = (row?: CategoryRow | string, edit?: boolean) => {
|
||||
}
|
||||
return { ...attr, options: options || [] };
|
||||
});
|
||||
// 如果有单选/多选属性,预加载字典类型数据
|
||||
// 如果有单选/多选属性,预加载字典类型数据并更新字典名称
|
||||
if (ruleForm.attrs.some((attr: CustomAttr) => attr.type === 'select' || attr.type === 'multi_select')) {
|
||||
fetchDictTypeOptions();
|
||||
fetchDictTypeOptions().then(() => {
|
||||
// 根据 dictType 更新字典名称(字典名称可能已被修改)
|
||||
ruleForm.attrs.forEach((attr: CustomAttr) => {
|
||||
if (isDictType(attr.type) && attr.dictType) {
|
||||
const dictInfo = dictTypeOptions.value.find((item) => item.type === attr.dictType);
|
||||
if (dictInfo) {
|
||||
attr.name = dictInfo.name;
|
||||
}
|
||||
}
|
||||
});
|
||||
// 保存原始数据用于最小化传参(使用与提交相同的结构)
|
||||
categoryFormDiff.saveOriginal(JSON.parse(JSON.stringify(buildSubmitData())));
|
||||
});
|
||||
} else {
|
||||
// 保存原始数据用于最小化传参(使用与提交相同的结构)
|
||||
categoryFormDiff.saveOriginal(JSON.parse(JSON.stringify(buildSubmitData())));
|
||||
}
|
||||
});
|
||||
} else if (row && typeof row === 'string') {
|
||||
@@ -370,7 +401,7 @@ const formatDictOptions = (attr: CustomAttr) => {
|
||||
value: opt.value ?? opt.key ?? '',
|
||||
}));
|
||||
}
|
||||
const dictValues = getDictValuesByType(attr.name || '');
|
||||
const dictValues = getDictValuesByType(attr.name || '', attr.dictType);
|
||||
return options.map((optValue: string) => {
|
||||
const dictItem = dictValues.find((d: any) => d.key === optValue);
|
||||
return {
|
||||
@@ -380,43 +411,57 @@ const formatDictOptions = (attr: CustomAttr) => {
|
||||
});
|
||||
};
|
||||
|
||||
// 构建提交数据(用于保存原始数据和提交)
|
||||
const buildSubmitData = () => {
|
||||
// 处理 attrs:统一清理脏数据
|
||||
const processedAttrs = ruleForm.attrs.map((attr) => {
|
||||
const base = {
|
||||
type: attr.type,
|
||||
required: attr.required ?? false,
|
||||
multiple: attr.type === 'multi_select',
|
||||
sort: attr.sort ?? 0,
|
||||
};
|
||||
|
||||
if (isDictType(attr.type)) {
|
||||
return {
|
||||
...base,
|
||||
name: attr.name || '',
|
||||
dictType: attr.dictType || '',
|
||||
options: formatDictOptions(attr),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
name: (attr.name || '').trim(),
|
||||
options: [],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...ruleForm,
|
||||
attrs: processedAttrs,
|
||||
};
|
||||
};
|
||||
|
||||
// 提交
|
||||
const onSubmit = () => {
|
||||
formRef.value.validate((valid: boolean) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true;
|
||||
// 处理 attrs:统一清理脏数据
|
||||
const processedAttrs = ruleForm.attrs.map((attr) => {
|
||||
const base = {
|
||||
type: attr.type,
|
||||
required: attr.required ?? false,
|
||||
multiple: attr.type === 'multi_select',
|
||||
sort: attr.sort ?? 0,
|
||||
};
|
||||
|
||||
if (isDictType(attr.type)) {
|
||||
return {
|
||||
...base,
|
||||
name: attr.name || '',
|
||||
options: formatDictOptions(attr),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
name: (attr.name || '').trim(),
|
||||
options: [],
|
||||
};
|
||||
});
|
||||
const submitData = { ...ruleForm, attrs: processedAttrs };
|
||||
const submitData = buildSubmitData();
|
||||
|
||||
if (isEdit.value) {
|
||||
// 修改
|
||||
updateCategory(submitData)
|
||||
// 编辑模式:通过 _originalData 让拦截器自动处理最小化传参
|
||||
const originalData = categoryFormDiff.getOriginal();
|
||||
const requestData = {
|
||||
...submitData,
|
||||
_originalData: originalData,
|
||||
};
|
||||
|
||||
updateCategory(requestData)
|
||||
.then(() => {
|
||||
ElMessage.success('修改成功');
|
||||
console.log(submitData,'111');
|
||||
|
||||
closeDialog();
|
||||
emit('getCategoryList');
|
||||
})
|
||||
@@ -424,7 +469,7 @@ const onSubmit = () => {
|
||||
submitLoading.value = false;
|
||||
});
|
||||
} else {
|
||||
// 新增
|
||||
// 新增模式:传递所有字段
|
||||
addCategory(submitData)
|
||||
.then(() => {
|
||||
ElMessage.success('添加成功');
|
||||
|
||||
@@ -44,16 +44,18 @@
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<el-table-column label="操作" width="250" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" class="op-btn-add" text type="primary" @click="onOpenAddCategory(scope.row)">新增</el-button>
|
||||
<el-button size="small" text type="primary" @click="onOpenEditCategory(scope.row)">修改</el-button>
|
||||
<el-button size="small" text type="info" @click="onViewLog(scope.row)">日志</el-button>
|
||||
<el-button size="small" text type="danger" @click="onRowDel(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<EditCategory ref="editCategoryRef" @getCategoryList="getCategoryList" />
|
||||
<OperationLogDialog ref="operationLogRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -67,6 +69,7 @@ export default {
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ElMessageBox, ElMessage } from 'element-plus';
|
||||
import EditCategory from './component/editCategory.vue';
|
||||
import OperationLogDialog from '../component/operationLogDialog.vue';
|
||||
import { getCategoryTree, deleteCategory, updateCategoryStatus,listCategories } from '/@/api/assets/category';
|
||||
|
||||
interface CategoryRow {
|
||||
@@ -82,6 +85,7 @@ interface CategoryRow {
|
||||
}
|
||||
|
||||
const editCategoryRef = ref();
|
||||
const operationLogRef = ref();
|
||||
const tableData = reactive({
|
||||
data: [] as CategoryRow[],
|
||||
loading: false,
|
||||
@@ -169,6 +173,11 @@ const onStatusChange = (row: CategoryRow) => {
|
||||
});
|
||||
};
|
||||
|
||||
// 查看日志
|
||||
const onViewLog = (row: CategoryRow) => {
|
||||
operationLogRef.value?.openDialog(row.id);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getCategoryList();
|
||||
});
|
||||
|
||||
306
src/views/assets/component/operationLogDialog.vue
Normal file
306
src/views/assets/component/operationLogDialog.vue
Normal file
@@ -0,0 +1,306 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" title="操作日志" size="70%" direction="rtl" :close-on-click-modal="true" :modal="true" append-to-body>
|
||||
<div class="log-container" v-loading="loading" style="padding: 20px;">
|
||||
<div class="log-layout">
|
||||
<!-- 左侧时间线 -->
|
||||
<div class="timeline-panel">
|
||||
<el-scrollbar height="100%">
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="(log, index) in logList"
|
||||
:key="log.id"
|
||||
:type="getOperationTagType(log.operation)"
|
||||
:hollow="selectedLogIndex !== index"
|
||||
:timestamp="log.createdAt"
|
||||
placement="top"
|
||||
@click="selectLog(index)"
|
||||
:class="{ 'timeline-item-active': selectedLogIndex === index }"
|
||||
>
|
||||
<div class="timeline-content" @click="selectLog(index)">
|
||||
<div class="timeline-header">
|
||||
<el-tag size="small" :type="getOperationTagType(log.operation)">{{ getOperationLabel(log.operation) }}</el-tag>
|
||||
</div>
|
||||
<div class="timeline-info">
|
||||
<span class="creator">{{ log.creator }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<!-- 加载更多 -->
|
||||
<div class="load-more" v-if="logList.length > 0 && logList.length < total">
|
||||
<el-button text type="primary" @click="loadMore" :loading="loadingMore">加载更多</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!loading && logList.length === 0" description="暂无日志记录" />
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
<!-- 右侧详情表格 -->
|
||||
<div class="detail-panel">
|
||||
<template v-if="selectedLog">
|
||||
<div class="detail-header">
|
||||
<el-tag :type="getOperationTagType(selectedLog.operation)">{{ getOperationLabel(selectedLog.operation) }}</el-tag>
|
||||
<span class="detail-time">{{ selectedLog.createdAt }}</span>
|
||||
<span class="detail-creator">操作人:{{ selectedLog.creator }}</span>
|
||||
</div>
|
||||
<el-table :data="selectedLogWithOldValue" border style="width: 100%" max-height="calc(100vh - 200px)">
|
||||
<el-table-column prop="FieldName" label="字段名" width="150" />
|
||||
<el-table-column prop="OldValue" label="原值" min-width="200">
|
||||
<template #default="scope">
|
||||
<span class="cell-value old-value">{{ formatFieldValue(scope.row.OldValue) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="NewValue" label="新值" min-width="200">
|
||||
<template #default="scope">
|
||||
<span class="cell-value new-value">{{ formatFieldValue(scope.row.NewValue) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="selectedLogWithOldValue.length === 0" description="无变更内容" />
|
||||
</template>
|
||||
<el-empty v-else description="请选择左侧日志记录查看详情" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
import { listLogs, OperationLogInfo } from '/@/api/assets/asset';
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const logList = ref<OperationLogInfo[]>([]);
|
||||
const total = ref(0);
|
||||
const selectedLogIndex = ref<number | null>(null);
|
||||
|
||||
const queryParams = reactive({
|
||||
collection_id: '',
|
||||
pageNum: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
// 当前选中的日志
|
||||
const selectedLog = computed(() => {
|
||||
if (selectedLogIndex.value !== null && logList.value[selectedLogIndex.value]) {
|
||||
return logList.value[selectedLogIndex.value];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// 获取下一条日志(更早的记录)中某字段的值作为原值
|
||||
const getOldValue = (fieldName: string) => {
|
||||
if (selectedLogIndex.value === null) return null;
|
||||
// 日志按时间倒序,下一条是更早的记录
|
||||
const nextIndex = selectedLogIndex.value + 1;
|
||||
if (nextIndex >= logList.value.length) {
|
||||
// 没有更早的记录,返回 null
|
||||
return null;
|
||||
}
|
||||
const nextLog = logList.value[nextIndex];
|
||||
if (!nextLog.data) return null;
|
||||
const field = nextLog.data.find((item) => item.FieldName === fieldName);
|
||||
return field ? field.FieldValue : null;
|
||||
};
|
||||
|
||||
// 带有原值的选中日志数据
|
||||
const selectedLogWithOldValue = computed(() => {
|
||||
if (!selectedLog.value || !selectedLog.value.data) return [];
|
||||
return selectedLog.value.data.map((item) => ({
|
||||
FieldName: item.FieldName,
|
||||
OldValue: getOldValue(item.FieldName),
|
||||
NewValue: item.FieldValue,
|
||||
}));
|
||||
});
|
||||
|
||||
// 打开抽屉
|
||||
const openDialog = (collectionId: string) => {
|
||||
queryParams.collection_id = collectionId;
|
||||
queryParams.pageNum = 1;
|
||||
logList.value = [];
|
||||
selectedLogIndex.value = null;
|
||||
drawerVisible.value = true;
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
// 获取日志列表
|
||||
const fetchLogs = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await listLogs(queryParams);
|
||||
if (res.code === 0 && res.data) {
|
||||
logList.value = res.data.logs || [];
|
||||
total.value = res.data.total || 0;
|
||||
// 默认选中第一条
|
||||
if (logList.value.length > 0) {
|
||||
selectedLogIndex.value = 0;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 错误已由拦截器处理
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载更多
|
||||
const loadMore = async () => {
|
||||
loadingMore.value = true;
|
||||
queryParams.pageNum++;
|
||||
try {
|
||||
const res: any = await listLogs(queryParams);
|
||||
if (res.code === 0 && res.data) {
|
||||
logList.value.push(...(res.data.logs || []));
|
||||
total.value = res.data.total || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
queryParams.pageNum--;
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 选择日志
|
||||
const selectLog = (index: number) => {
|
||||
selectedLogIndex.value = index;
|
||||
};
|
||||
|
||||
// 操作类型标签
|
||||
const getOperationTagType = (operation: string) => {
|
||||
switch (operation) {
|
||||
case 'insert':
|
||||
return 'success';
|
||||
case 'update':
|
||||
return 'warning';
|
||||
case 'delete':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
// 操作类型文本
|
||||
const getOperationLabel = (operation: string) => {
|
||||
switch (operation) {
|
||||
case 'insert':
|
||||
return '新增';
|
||||
case 'update':
|
||||
return '修改';
|
||||
case 'delete':
|
||||
return '删除';
|
||||
default:
|
||||
return operation;
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化字段值
|
||||
const formatFieldValue = (value: any) => {
|
||||
if (value === null || value === undefined) return '-';
|
||||
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
openDialog,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.log-container {
|
||||
height: calc(100vh - 120px);
|
||||
padding: 20px !important;
|
||||
}
|
||||
.log-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
gap: 24px;
|
||||
}
|
||||
.timeline-panel {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #ebeef5;
|
||||
padding-right: 16px;
|
||||
height: 100%;
|
||||
:deep(.el-timeline) {
|
||||
padding-top: 4px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
:deep(.el-timeline-item) {
|
||||
cursor: pointer;
|
||||
padding-bottom: 16px;
|
||||
.el-timeline-item__timestamp {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
&.timeline-item-active {
|
||||
.el-timeline-item__node {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
.timeline-content {
|
||||
background: #f0f9ff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
}
|
||||
}
|
||||
.timeline-content {
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ebeef5;
|
||||
transition: all 0.2s;
|
||||
&:hover {
|
||||
border-color: #c0c4cc;
|
||||
background: #fafafa;
|
||||
}
|
||||
}
|
||||
.timeline-header {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.timeline-info {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.creator {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
.load-more {
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
}
|
||||
.detail-panel {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding-right: 10px;
|
||||
.detail-header {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
.detail-time {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
.detail-creator {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
.cell-value {
|
||||
word-break: break-all;
|
||||
display: block;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.old-value {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.new-value {
|
||||
color: #67c23a;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -171,7 +171,7 @@ const onSubmit = async () => {
|
||||
emit('refresh');
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error);
|
||||
ElMessage.error(state.formData.id ? '修改失败' : '添加失败');
|
||||
// 错误已由请求拦截器统一处理
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
@@ -144,10 +144,11 @@ const onSubmit = async () => {
|
||||
closeDialog();
|
||||
emit('refresh');
|
||||
} else {
|
||||
ElMessage.error(res.message || '配置失败');
|
||||
// 错误已由请求拦截器统一处理
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '配置失败');
|
||||
console.error('配置失败:', error);
|
||||
// 错误已由请求拦截器统一处理
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ const getList = async () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取客服账号列表失败:', error);
|
||||
ElMessage.error('获取数据失败');
|
||||
// 错误已由请求拦截器统一处理
|
||||
tableData.data = [];
|
||||
tableData.total = 0;
|
||||
} finally {
|
||||
@@ -267,7 +267,7 @@ const handleStatusChange = async (row: TableDataItem) => {
|
||||
return;
|
||||
}
|
||||
console.error('状态切换失败:', error);
|
||||
ElMessage.error('状态切换失败');
|
||||
// 错误已由请求拦截器统一处理
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
row.statusLoading = false;
|
||||
|
||||
@@ -115,7 +115,7 @@ const openDialog = async (row?: ProductFormData) => {
|
||||
state.isShowDialog = true;
|
||||
} catch (error) {
|
||||
console.error('打开对话框失败:', error);
|
||||
ElMessage.error('初始化数据失败');
|
||||
// 错误已由请求拦截器统一处理
|
||||
}
|
||||
};
|
||||
|
||||
@@ -155,7 +155,7 @@ const onCancel = () => {
|
||||
*/
|
||||
const onSubmit = async () => {
|
||||
if (!formRef.value) {
|
||||
ElMessage.error('表单引用不存在');
|
||||
console.error('表单引用不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ const onSubmit = async () => {
|
||||
emit('refresh');
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error);
|
||||
ElMessage.error(state.formData.id === 0 ? '添加失败' : '修改失败');
|
||||
// 错误已由请求拦截器统一处理
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ const handleExport = async () => {
|
||||
isShowDialog.value = false;
|
||||
} catch (error: any) {
|
||||
console.error('导出失败:', error);
|
||||
ElMessage.error(`导出失败:${error.message || '未知错误'}`);
|
||||
// 错误已由请求拦截器统一处理
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ const handleCustomUpload = async (file: File) => {
|
||||
success: false,
|
||||
message: error.message || '导入失败',
|
||||
};
|
||||
ElMessage.error(`导入失败:${error.message || '未知错误'}`);
|
||||
// 错误已由请求拦截器统一处理
|
||||
}
|
||||
};
|
||||
|
||||
@@ -324,7 +324,8 @@ const handleDownloadTemplate = async () => {
|
||||
saveAs(zipBlob, filename);
|
||||
ElMessage.success(`模板下载成功!`);
|
||||
} catch (error: any) {
|
||||
ElMessage.error(`模板下载失败:${error.message || '未知错误'}`);
|
||||
console.error('模板下载失败:', error);
|
||||
// 错误已由请求拦截器统一处理
|
||||
} finally {
|
||||
downloadLoading.value = false;
|
||||
}
|
||||
@@ -343,7 +344,7 @@ const handleUploadSuccess = (response: any, file: UploadFile, fileList: UploadFi
|
||||
*/
|
||||
const handleUploadError = (error: Error, file: UploadFile, fileList: UploadFiles) => {
|
||||
console.error('上传失败回调', error);
|
||||
ElMessage.error('文件上传失败');
|
||||
// 错误已由请求拦截器统一处理
|
||||
// 手动上传模式下不会执行到这里
|
||||
};
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ const getProductList = async () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取产品列表失败:', error);
|
||||
ElMessage.error('获取产品列表失败');
|
||||
// 错误已由请求拦截器统一处理,此处不再重复提示
|
||||
} finally {
|
||||
tableData.loading = false;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ const handleExport = async () => {
|
||||
isShowDialog.value = false;
|
||||
} catch (error: any) {
|
||||
console.error('导出失败:', error);
|
||||
ElMessage.error(`导出失败:${error.message || '未知错误'}`);
|
||||
// 错误已由请求拦截器统一处理
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ const dataList = async () => {
|
||||
tableData.total = res.data.total;
|
||||
} catch (error) {
|
||||
console.error('获取数据失败:', error);
|
||||
ElMessage.error('获取数据失败');
|
||||
// 错误已由请求拦截器统一处理
|
||||
} finally {
|
||||
tableData.loading = false;
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ const onSubmit = async () => {
|
||||
emit('refresh');
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error);
|
||||
ElMessage.error('操作失败');
|
||||
// 错误已由请求拦截器统一处理
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ const loadTableData = async () => {
|
||||
tableData.total = total;
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
ElMessage.error('数据加载失败');
|
||||
// 错误已由请求拦截器统一处理
|
||||
tableData.data = [];
|
||||
tableData.total = 0;
|
||||
} finally {
|
||||
@@ -238,7 +238,7 @@ const handleDelete = async (row: ScriptItem) => {
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除失败:', error);
|
||||
ElMessage.error('删除失败');
|
||||
// 错误已由请求拦截器统一处理
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
368
src/views/digitalHuman/audioAssets/index.vue
Normal file
368
src/views/digitalHuman/audioAssets/index.vue
Normal file
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<div class="digital-human-audio-container">
|
||||
<el-card shadow="hover">
|
||||
<div class="search-header mb15">
|
||||
<el-form :inline="true" :model="queryParams">
|
||||
<el-form-item label="音频名称">
|
||||
<el-input v-model="queryParams.name" placeholder="请输入音频名称" clearable style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="音色类型">
|
||||
<el-select v-model="queryParams.voiceType" placeholder="请选择音色类型" clearable style="width: 150px">
|
||||
<el-option label="男声" value="male" />
|
||||
<el-option label="女声" value="female" />
|
||||
<el-option label="童声" value="child" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 150px">
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><ele-Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><ele-Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
<el-button type="success" @click="handleAdd">
|
||||
<el-icon><ele-Plus /></el-icon>
|
||||
上传音频
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 音频资产表格 -->
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="name" label="音频名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="voiceType" label="音色类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getVoiceTypeTag(row.voiceType)">{{ getVoiceTypeLabel(row.voiceType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="duration" label="时长" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatDuration(row.duration) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="fileSize" label="文件大小" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatFileSize(row.fileSize) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sampleRate" label="采样率" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.sampleRate }}Hz
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="试听" width="200" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="audio-player">
|
||||
<el-button
|
||||
:type="currentPlayingId === row.id ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
circle
|
||||
@click="togglePlay(row)"
|
||||
>
|
||||
<el-icon>
|
||||
<ele-VideoPlay v-if="currentPlayingId !== row.id" />
|
||||
<ele-VideoPause v-else />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-progress
|
||||
:percentage="currentPlayingId === row.id ? playProgress : 0"
|
||||
:show-text="false"
|
||||
style="width: 100px; margin-left: 10px"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
@change="handleStatusChange(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="170" align="center" />
|
||||
<el-table-column label="操作" width="150" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="danger" text size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container mt15">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.pageNum"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
|
||||
interface AudioItem {
|
||||
id: number;
|
||||
name: string;
|
||||
voiceType: string;
|
||||
duration: number;
|
||||
fileSize: number;
|
||||
sampleRate: number;
|
||||
audioUrl: string;
|
||||
status: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const queryParams = reactive({
|
||||
name: '',
|
||||
voiceType: '',
|
||||
status: undefined as number | undefined,
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const tableData = ref<AudioItem[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const currentPlayingId = ref<number | null>(null);
|
||||
const playProgress = ref(0);
|
||||
let progressTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// 模拟数据
|
||||
const mockData: AudioItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: '商务男声-标准普通话',
|
||||
voiceType: 'male',
|
||||
duration: 125,
|
||||
fileSize: 2048000,
|
||||
sampleRate: 44100,
|
||||
audioUrl: '',
|
||||
status: 1,
|
||||
createdAt: '2024-01-15 10:30:00',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '甜美女声-温柔风格',
|
||||
voiceType: 'female',
|
||||
duration: 98,
|
||||
fileSize: 1536000,
|
||||
sampleRate: 44100,
|
||||
audioUrl: '',
|
||||
status: 1,
|
||||
createdAt: '2024-01-14 14:20:00',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '活泼童声-儿童教育',
|
||||
voiceType: 'child',
|
||||
duration: 67,
|
||||
fileSize: 1024000,
|
||||
sampleRate: 22050,
|
||||
audioUrl: '',
|
||||
status: 1,
|
||||
createdAt: '2024-01-13 09:15:00',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '知性女声-新闻播报',
|
||||
voiceType: 'female',
|
||||
duration: 156,
|
||||
fileSize: 2560000,
|
||||
sampleRate: 48000,
|
||||
audioUrl: '',
|
||||
status: 0,
|
||||
createdAt: '2024-01-12 16:45:00',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '磁性男声-广告配音',
|
||||
voiceType: 'male',
|
||||
duration: 89,
|
||||
fileSize: 1408000,
|
||||
sampleRate: 44100,
|
||||
audioUrl: '',
|
||||
status: 1,
|
||||
createdAt: '2024-01-11 11:00:00',
|
||||
},
|
||||
];
|
||||
|
||||
// 获取音色类型标签
|
||||
const getVoiceTypeTag = (type: string) => {
|
||||
const tagMap: Record<string, string> = {
|
||||
male: 'primary',
|
||||
female: 'danger',
|
||||
child: 'warning',
|
||||
};
|
||||
return tagMap[type] || 'info';
|
||||
};
|
||||
|
||||
// 获取音色类型文本
|
||||
const getVoiceTypeLabel = (type: string) => {
|
||||
const labelMap: Record<string, string> = {
|
||||
male: '男声',
|
||||
female: '女声',
|
||||
child: '童声',
|
||||
};
|
||||
return labelMap[type] || type;
|
||||
};
|
||||
|
||||
// 格式化时长
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + 'B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB';
|
||||
return (bytes / 1024 / 1024).toFixed(1) + 'MB';
|
||||
};
|
||||
|
||||
// 获取列表数据
|
||||
const getList = () => {
|
||||
loading.value = true;
|
||||
setTimeout(() => {
|
||||
let filteredData = [...mockData];
|
||||
if (queryParams.name) {
|
||||
filteredData = filteredData.filter((item) => item.name.includes(queryParams.name));
|
||||
}
|
||||
if (queryParams.voiceType) {
|
||||
filteredData = filteredData.filter((item) => item.voiceType === queryParams.voiceType);
|
||||
}
|
||||
if (queryParams.status !== undefined) {
|
||||
filteredData = filteredData.filter((item) => item.status === queryParams.status);
|
||||
}
|
||||
tableData.value = filteredData;
|
||||
total.value = filteredData.length;
|
||||
loading.value = false;
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// 播放/暂停
|
||||
const togglePlay = (row: AudioItem) => {
|
||||
if (currentPlayingId.value === row.id) {
|
||||
// 停止播放
|
||||
currentPlayingId.value = null;
|
||||
playProgress.value = 0;
|
||||
if (progressTimer) {
|
||||
clearInterval(progressTimer);
|
||||
progressTimer = null;
|
||||
}
|
||||
} else {
|
||||
// 开始播放
|
||||
currentPlayingId.value = row.id;
|
||||
playProgress.value = 0;
|
||||
if (progressTimer) {
|
||||
clearInterval(progressTimer);
|
||||
}
|
||||
progressTimer = setInterval(() => {
|
||||
playProgress.value += 2;
|
||||
if (playProgress.value >= 100) {
|
||||
currentPlayingId.value = null;
|
||||
playProgress.value = 0;
|
||||
if (progressTimer) {
|
||||
clearInterval(progressTimer);
|
||||
progressTimer = null;
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
queryParams.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.name = '';
|
||||
queryParams.voiceType = '';
|
||||
queryParams.status = undefined;
|
||||
queryParams.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
ElMessage.info('上传音频功能开发中...');
|
||||
};
|
||||
|
||||
const handleEdit = (row: AudioItem) => {
|
||||
ElMessage.info(`编辑音频: ${row.name}`);
|
||||
};
|
||||
|
||||
const handleDelete = (row: AudioItem) => {
|
||||
ElMessageBox.confirm(`确定要删除音频 "${row.name}" 吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}).then(() => {
|
||||
ElMessage.success('删除成功');
|
||||
getList();
|
||||
});
|
||||
};
|
||||
|
||||
const handleStatusChange = (row: AudioItem) => {
|
||||
ElMessage.success(`状态已${row.status === 1 ? '启用' : '禁用'}`);
|
||||
};
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
queryParams.pageSize = size;
|
||||
queryParams.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
queryParams.pageNum = page;
|
||||
getList();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (progressTimer) {
|
||||
clearInterval(progressTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.digital-human-audio-container {
|
||||
padding: 15px;
|
||||
|
||||
.audio-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
340
src/views/digitalHuman/avatar/index.vue
Normal file
340
src/views/digitalHuman/avatar/index.vue
Normal file
@@ -0,0 +1,340 @@
|
||||
<template>
|
||||
<div class="digital-human-avatar-container">
|
||||
<el-card shadow="hover">
|
||||
<div class="search-header mb15">
|
||||
<el-form :inline="true" :model="queryParams">
|
||||
<el-form-item label="形象名称">
|
||||
<el-input v-model="queryParams.name" placeholder="请输入形象名称" clearable style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 150px">
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><ele-Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><ele-Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
<el-button type="success" @click="handleAdd">
|
||||
<el-icon><ele-Plus /></el-icon>
|
||||
新增形象
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 数字人形象卡片列表 -->
|
||||
<div class="avatar-grid">
|
||||
<el-card v-for="item in tableData" :key="item.id" class="avatar-card" shadow="hover">
|
||||
<div class="avatar-image">
|
||||
<img :src="item.avatar" :alt="item.name" />
|
||||
<div class="avatar-overlay">
|
||||
<el-button type="primary" size="small" circle @click="handlePreview(item)">
|
||||
<el-icon><ele-View /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="avatar-info">
|
||||
<h4>{{ item.name }}</h4>
|
||||
<p class="avatar-desc">{{ item.description }}</p>
|
||||
<div class="avatar-meta">
|
||||
<el-tag :type="item.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ item.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
<span class="avatar-type">{{ item.type }}</span>
|
||||
</div>
|
||||
<div class="avatar-actions">
|
||||
<el-button type="primary" text size="small" @click="handleEdit(item)">编辑</el-button>
|
||||
<el-button type="danger" text size="small" @click="handleDelete(item)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container mt15">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.pageNum"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:page-sizes="[12, 24, 36, 48]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
|
||||
interface AvatarItem {
|
||||
id: number;
|
||||
name: string;
|
||||
avatar: string;
|
||||
description: string;
|
||||
type: string;
|
||||
status: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const queryParams = reactive({
|
||||
name: '',
|
||||
status: undefined as number | undefined,
|
||||
pageNum: 1,
|
||||
pageSize: 12,
|
||||
});
|
||||
|
||||
const tableData = ref<AvatarItem[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
|
||||
// 模拟数据
|
||||
const mockData: AvatarItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: '商务男性形象',
|
||||
avatar: 'https://img.icons8.com/3d-fluency/94/businessman.png',
|
||||
description: '专业商务风格的男性数字人形象,适合企业宣传',
|
||||
type: '真人形象',
|
||||
status: 1,
|
||||
createdAt: '2024-01-15 10:30:00',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '甜美女性形象',
|
||||
avatar: 'https://img.icons8.com/3d-fluency/94/businesswoman.png',
|
||||
description: '甜美可爱的女性数字人形象,适合直播带货',
|
||||
type: '真人形象',
|
||||
status: 1,
|
||||
createdAt: '2024-01-14 14:20:00',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '卡通男孩形象',
|
||||
avatar: 'https://img.icons8.com/3d-fluency/94/person-male.png',
|
||||
description: '活泼可爱的卡通男孩形象,适合儿童教育',
|
||||
type: '卡通形象',
|
||||
status: 1,
|
||||
createdAt: '2024-01-13 09:15:00',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '知性女性形象',
|
||||
avatar: 'https://img.icons8.com/3d-fluency/94/woman-profile.png',
|
||||
description: '知性优雅的女性数字人形象,适合知识讲解',
|
||||
type: '真人形象',
|
||||
status: 0,
|
||||
createdAt: '2024-01-12 16:45:00',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '科技机器人形象',
|
||||
avatar: 'https://img.icons8.com/3d-fluency/94/robot-2.png',
|
||||
description: '未来科技风格的机器人形象,适合科技产品',
|
||||
type: '3D形象',
|
||||
status: 1,
|
||||
createdAt: '2024-01-11 11:00:00',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: '客服助手形象',
|
||||
avatar: 'https://img.icons8.com/3d-fluency/94/technical-support.png',
|
||||
description: '专业友好的客服助手形象,适合在线客服场景',
|
||||
type: '3D形象',
|
||||
status: 1,
|
||||
createdAt: '2024-01-10 08:30:00',
|
||||
},
|
||||
];
|
||||
|
||||
// 获取列表数据
|
||||
const getList = () => {
|
||||
loading.value = true;
|
||||
// 模拟接口请求
|
||||
setTimeout(() => {
|
||||
let filteredData = [...mockData];
|
||||
if (queryParams.name) {
|
||||
filteredData = filteredData.filter((item) => item.name.includes(queryParams.name));
|
||||
}
|
||||
if (queryParams.status !== undefined) {
|
||||
filteredData = filteredData.filter((item) => item.status === queryParams.status);
|
||||
}
|
||||
tableData.value = filteredData;
|
||||
total.value = filteredData.length;
|
||||
loading.value = false;
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
queryParams.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.name = '';
|
||||
queryParams.status = undefined;
|
||||
queryParams.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
ElMessage.info('新增数字人形象功能开发中...');
|
||||
};
|
||||
|
||||
const handleEdit = (row: AvatarItem) => {
|
||||
ElMessage.info(`编辑形象: ${row.name}`);
|
||||
};
|
||||
|
||||
const handleDelete = (row: AvatarItem) => {
|
||||
ElMessageBox.confirm(`确定要删除形象 "${row.name}" 吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}).then(() => {
|
||||
ElMessage.success('删除成功');
|
||||
getList();
|
||||
});
|
||||
};
|
||||
|
||||
const handlePreview = (row: AvatarItem) => {
|
||||
ElMessage.info(`预览形象: ${row.name}`);
|
||||
};
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
queryParams.pageSize = size;
|
||||
queryParams.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
queryParams.pageNum = page;
|
||||
getList();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.digital-human-avatar-container {
|
||||
padding: 15px;
|
||||
|
||||
.avatar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.avatar-card {
|
||||
overflow: hidden;
|
||||
transition: transform 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.2));
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover img {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.avatar-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
&:hover .avatar-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-info {
|
||||
padding: 15px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.avatar-desc {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.avatar-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.avatar-type {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid #ebeef5;
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
483
src/views/digitalHuman/videoAssets/index.vue
Normal file
483
src/views/digitalHuman/videoAssets/index.vue
Normal file
@@ -0,0 +1,483 @@
|
||||
<template>
|
||||
<div class="digital-human-video-container">
|
||||
<el-card shadow="hover">
|
||||
<div class="search-header mb15">
|
||||
<el-form :inline="true" :model="queryParams">
|
||||
<el-form-item label="视频名称">
|
||||
<el-input v-model="queryParams.name" placeholder="请输入视频名称" clearable style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="视频类型">
|
||||
<el-select v-model="queryParams.videoType" placeholder="请选择视频类型" clearable style="width: 150px">
|
||||
<el-option label="数字人视频" value="avatar" />
|
||||
<el-option label="背景视频" value="background" />
|
||||
<el-option label="素材视频" value="material" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 150px">
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><ele-Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><ele-Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
<el-button type="success" @click="handleAdd">
|
||||
<el-icon><ele-Plus /></el-icon>
|
||||
上传视频
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 视频资产卡片列表 -->
|
||||
<div class="video-grid">
|
||||
<el-card v-for="item in tableData" :key="item.id" class="video-card" shadow="hover">
|
||||
<div class="video-thumbnail">
|
||||
<img :src="item.thumbnail" :alt="item.name" />
|
||||
<div class="video-duration">{{ formatDuration(item.duration) }}</div>
|
||||
<div class="video-overlay">
|
||||
<el-button type="primary" size="default" circle @click="handlePreview(item)">
|
||||
<el-icon size="24"><ele-VideoPlay /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-info">
|
||||
<h4>{{ item.name }}</h4>
|
||||
<div class="video-meta">
|
||||
<el-tag :type="getVideoTypeTag(item.videoType)" size="small">
|
||||
{{ getVideoTypeLabel(item.videoType) }}
|
||||
</el-tag>
|
||||
<span class="video-size">{{ formatFileSize(item.fileSize) }}</span>
|
||||
</div>
|
||||
<div class="video-specs">
|
||||
<span>{{ item.resolution }}</span>
|
||||
<span>{{ item.fps }}fps</span>
|
||||
</div>
|
||||
<div class="video-status">
|
||||
<el-switch
|
||||
v-model="item.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
size="small"
|
||||
@change="handleStatusChange(item)"
|
||||
/>
|
||||
<span class="status-text">{{ item.status === 1 ? '启用' : '禁用' }}</span>
|
||||
</div>
|
||||
<div class="video-actions">
|
||||
<el-button type="primary" text size="small" @click="handleEdit(item)">编辑</el-button>
|
||||
<el-button type="primary" text size="small" @click="handleDownload(item)">下载</el-button>
|
||||
<el-button type="danger" text size="small" @click="handleDelete(item)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container mt15">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.pageNum"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:page-sizes="[8, 16, 24, 32]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 视频预览弹窗 -->
|
||||
<el-dialog v-model="previewVisible" :title="previewVideo?.name" width="800px" destroy-on-close>
|
||||
<div class="video-preview-container">
|
||||
<div class="video-placeholder">
|
||||
<el-icon size="64" color="#909399"><ele-VideoPlay /></el-icon>
|
||||
<p>视频预览区域</p>
|
||||
<p class="video-preview-info">{{ previewVideo?.resolution }} | {{ previewVideo?.fps }}fps | {{ formatDuration(previewVideo?.duration || 0) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
|
||||
interface VideoItem {
|
||||
id: number;
|
||||
name: string;
|
||||
thumbnail: string;
|
||||
videoType: string;
|
||||
duration: number;
|
||||
fileSize: number;
|
||||
resolution: string;
|
||||
fps: number;
|
||||
videoUrl: string;
|
||||
status: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const queryParams = reactive({
|
||||
name: '',
|
||||
videoType: '',
|
||||
status: undefined as number | undefined,
|
||||
pageNum: 1,
|
||||
pageSize: 8,
|
||||
});
|
||||
|
||||
const tableData = ref<VideoItem[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const previewVisible = ref(false);
|
||||
const previewVideo = ref<VideoItem | null>(null);
|
||||
|
||||
// 模拟数据
|
||||
const mockData: VideoItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: '商务男性数字人-产品介绍',
|
||||
thumbnail: 'https://picsum.photos/400/225?random=10',
|
||||
videoType: 'avatar',
|
||||
duration: 125,
|
||||
fileSize: 52428800,
|
||||
resolution: '1920x1080',
|
||||
fps: 30,
|
||||
videoUrl: '',
|
||||
status: 1,
|
||||
createdAt: '2024-01-15 10:30:00',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '甜美女性数字人-直播带货',
|
||||
thumbnail: 'https://picsum.photos/400/225?random=11',
|
||||
videoType: 'avatar',
|
||||
duration: 245,
|
||||
fileSize: 104857600,
|
||||
resolution: '1920x1080',
|
||||
fps: 60,
|
||||
videoUrl: '',
|
||||
status: 1,
|
||||
createdAt: '2024-01-14 14:20:00',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '科技感背景-蓝色粒子',
|
||||
thumbnail: 'https://picsum.photos/400/225?random=12',
|
||||
videoType: 'background',
|
||||
duration: 30,
|
||||
fileSize: 15728640,
|
||||
resolution: '1920x1080',
|
||||
fps: 30,
|
||||
videoUrl: '',
|
||||
status: 1,
|
||||
createdAt: '2024-01-13 09:15:00',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '办公室场景背景',
|
||||
thumbnail: 'https://picsum.photos/400/225?random=13',
|
||||
videoType: 'background',
|
||||
duration: 60,
|
||||
fileSize: 31457280,
|
||||
resolution: '3840x2160',
|
||||
fps: 30,
|
||||
videoUrl: '',
|
||||
status: 0,
|
||||
createdAt: '2024-01-12 16:45:00',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '转场特效-渐变',
|
||||
thumbnail: 'https://picsum.photos/400/225?random=14',
|
||||
videoType: 'material',
|
||||
duration: 3,
|
||||
fileSize: 2097152,
|
||||
resolution: '1920x1080',
|
||||
fps: 60,
|
||||
videoUrl: '',
|
||||
status: 1,
|
||||
createdAt: '2024-01-11 11:00:00',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: '知性女性数字人-新闻播报',
|
||||
thumbnail: 'https://picsum.photos/400/225?random=15',
|
||||
videoType: 'avatar',
|
||||
duration: 180,
|
||||
fileSize: 78643200,
|
||||
resolution: '1920x1080',
|
||||
fps: 30,
|
||||
videoUrl: '',
|
||||
status: 1,
|
||||
createdAt: '2024-01-10 08:30:00',
|
||||
},
|
||||
];
|
||||
|
||||
// 获取视频类型标签
|
||||
const getVideoTypeTag = (type: string) => {
|
||||
const tagMap: Record<string, string> = {
|
||||
avatar: 'primary',
|
||||
background: 'success',
|
||||
material: 'warning',
|
||||
};
|
||||
return tagMap[type] || 'info';
|
||||
};
|
||||
|
||||
// 获取视频类型文本
|
||||
const getVideoTypeLabel = (type: string) => {
|
||||
const labelMap: Record<string, string> = {
|
||||
avatar: '数字人视频',
|
||||
background: '背景视频',
|
||||
material: '素材视频',
|
||||
};
|
||||
return labelMap[type] || type;
|
||||
};
|
||||
|
||||
// 格式化时长
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + 'B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB';
|
||||
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + 'MB';
|
||||
return (bytes / 1024 / 1024 / 1024).toFixed(2) + 'GB';
|
||||
};
|
||||
|
||||
// 获取列表数据
|
||||
const getList = () => {
|
||||
loading.value = true;
|
||||
setTimeout(() => {
|
||||
let filteredData = [...mockData];
|
||||
if (queryParams.name) {
|
||||
filteredData = filteredData.filter((item) => item.name.includes(queryParams.name));
|
||||
}
|
||||
if (queryParams.videoType) {
|
||||
filteredData = filteredData.filter((item) => item.videoType === queryParams.videoType);
|
||||
}
|
||||
if (queryParams.status !== undefined) {
|
||||
filteredData = filteredData.filter((item) => item.status === queryParams.status);
|
||||
}
|
||||
tableData.value = filteredData;
|
||||
total.value = filteredData.length;
|
||||
loading.value = false;
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
queryParams.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.name = '';
|
||||
queryParams.videoType = '';
|
||||
queryParams.status = undefined;
|
||||
queryParams.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
ElMessage.info('上传视频功能开发中...');
|
||||
};
|
||||
|
||||
const handleEdit = (row: VideoItem) => {
|
||||
ElMessage.info(`编辑视频: ${row.name}`);
|
||||
};
|
||||
|
||||
const handleDelete = (row: VideoItem) => {
|
||||
ElMessageBox.confirm(`确定要删除视频 "${row.name}" 吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}).then(() => {
|
||||
ElMessage.success('删除成功');
|
||||
getList();
|
||||
});
|
||||
};
|
||||
|
||||
const handlePreview = (row: VideoItem) => {
|
||||
previewVideo.value = row;
|
||||
previewVisible.value = true;
|
||||
};
|
||||
|
||||
const handleDownload = (row: VideoItem) => {
|
||||
ElMessage.info(`下载视频: ${row.name}`);
|
||||
};
|
||||
|
||||
const handleStatusChange = (row: VideoItem) => {
|
||||
ElMessage.success(`状态已${row.status === 1 ? '启用' : '禁用'}`);
|
||||
};
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
queryParams.pageSize = size;
|
||||
queryParams.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
queryParams.pageNum = page;
|
||||
getList();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.digital-human-video-container {
|
||||
padding: 15px;
|
||||
|
||||
.video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.video-card {
|
||||
overflow: hidden;
|
||||
transition: transform 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.video-thumbnail {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.video-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
&:hover .video-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.video-info {
|
||||
padding: 15px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 15px;
|
||||
color: #303133;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.video-size {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.video-specs {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.video-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.status-text {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid #ebeef5;
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.video-preview-container {
|
||||
.video-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
|
||||
p {
|
||||
margin: 10px 0 0;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.video-preview-info {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -67,13 +67,13 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col> -->
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<!-- <el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="岗位" prop="postIds">
|
||||
<el-select v-model="ruleForm.postIds" placeholder="请选择" clearable class="w100" multiple>
|
||||
<el-option v-for="post in postList" :key="'post-' + post.postId" :label="post.postName" :value="post.postId"> </el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-col> -->
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="用户状态">
|
||||
<el-switch
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="system-user-container">
|
||||
<el-row :gutter="10" style="width: 100%">
|
||||
<el-col :span="4">
|
||||
<!-- <el-col :span="4">
|
||||
<el-card shadow="hover">
|
||||
<el-aside>
|
||||
<el-scrollbar>
|
||||
@@ -18,8 +18,8 @@
|
||||
</el-scrollbar>
|
||||
</el-aside>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="20">
|
||||
</el-col> -->
|
||||
<el-col >
|
||||
<el-card shadow="hover">
|
||||
<div class="system-user-search mb15">
|
||||
<el-form :model="tableData.param" ref="queryRef" :inline="true" label-width="68px">
|
||||
@@ -95,7 +95,7 @@
|
||||
<el-table-column prop="userName" label="账户名称" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="userNickname" label="用户昵称" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="dept.deptName" label="部门" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="TenantId" label="租户" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="tenantName" label="租户" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column label="角色" align="center" prop="roleInfo" :show-overflow-tooltip="true">
|
||||
<template #default="scope">
|
||||
<span v-for="(item, index) of scope.row.roleInfo" :key="'role-' + index"> {{ item.name + ' ' }} </span>
|
||||
@@ -119,8 +119,8 @@
|
||||
<el-table-column prop="createdAt" label="创建时间" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text type="primary" class="op-btn-edit" @click="onOpenEditUser(scope.row)">修改</el-button>
|
||||
<el-button size="small" text type="primary" class="op-btn-del" @click="onRowDel(scope.row)">删除</el-button>
|
||||
<el-button size="small" text type="primary" class="op-btn-edit" :disabled="!scope.row.isOperation" @click="onOpenEditUser(scope.row)">修改</el-button>
|
||||
<el-button size="small" text type="primary" class="op-btn-del" :disabled="!scope.row.isOperation" @click="onRowDel(scope.row)">删除</el-button>
|
||||
<el-button size="small" text type="primary" @click="handleResetPwd(scope.row)">重置</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
Reference in New Issue
Block a user