This commit is contained in:
Cold
2026-01-26 14:43:47 +08:00
34 changed files with 3382 additions and 179 deletions

View File

@@ -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,
});
}

View File

@@ -23,7 +23,7 @@ export function getCategory(id: string) {
// 获取属性类型选项
export function getCategoryAttrTypeOptions() {
return newService({
url: '/assets/category/getCategoryAttrTypeOptions',
url: '/assets/enum/getCategoryAttrType',
method: 'get',
});
}

View File

@@ -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'
})
}

View File

@@ -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'
})
}

View File

@@ -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,
};
},
});

View File

@@ -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,

View File

@@ -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;
}

View 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
View 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;
},
};
}

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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 ? '编辑成功' : '添加成功');

View File

@@ -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();
};

View File

@@ -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('添加成功');

View File

@@ -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();
});

View 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>

View File

@@ -171,7 +171,7 @@ const onSubmit = async () => {
emit('refresh');
} catch (error) {
console.error('操作失败:', error);
ElMessage.error(state.formData.id ? '修改失败' : '添加失败');
// 错误已由请求拦截器统一处理
} finally {
state.loading = false;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -74,7 +74,7 @@ const handleExport = async () => {
isShowDialog.value = false;
} catch (error: any) {
console.error('导出失败:', error);
ElMessage.error(`导出失败:${error.message || '未知错误'}`);
// 错误已由请求拦截器统一处理
} finally {
loading.value = false;
}

View File

@@ -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('文件上传失败');
// 错误已由请求拦截器统一处理
// 手动上传模式下不会执行到这里
};

View File

@@ -173,7 +173,7 @@ const getProductList = async () => {
}
} catch (error) {
console.error('获取产品列表失败:', error);
ElMessage.error('获取产品列表失败');
// 错误已由请求拦截器统一处理,此处不再重复提示
} finally {
tableData.loading = false;
}

View File

@@ -74,7 +74,7 @@ const handleExport = async () => {
isShowDialog.value = false;
} catch (error: any) {
console.error('导出失败:', error);
ElMessage.error(`导出失败:${error.message || '未知错误'}`);
// 错误已由请求拦截器统一处理
} finally {
loading.value = false;
}

View File

@@ -201,7 +201,7 @@ const dataList = async () => {
tableData.total = res.data.total;
} catch (error) {
console.error('获取数据失败:', error);
ElMessage.error('获取数据失败');
// 错误已由请求拦截器统一处理
} finally {
tableData.loading = false;
}

View File

@@ -162,7 +162,7 @@ const onSubmit = async () => {
emit('refresh');
} catch (error) {
console.error('提交失败:', error);
ElMessage.error('操作失败');
// 错误已由请求拦截器统一处理
} finally {
state.loading = false;
}

View File

@@ -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('删除失败');
// 错误已由请求拦截器统一处理
}
}
};

View 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>

View 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>

View 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>

View File

@@ -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

View File

@@ -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>