fix: 冲突解决
This commit is contained in:
@@ -77,14 +77,6 @@ export namespace IotDeviceApi {
|
||||
}
|
||||
}
|
||||
|
||||
/** IoT 设备状态枚举 */
|
||||
// TODO @haohao:packages/constants/src/biz-iot-enum.ts 枚举;
|
||||
export enum DeviceStateEnum {
|
||||
INACTIVE = 0, // 未激活
|
||||
OFFLINE = 2, // 离线
|
||||
ONLINE = 1, // 在线
|
||||
}
|
||||
|
||||
/** 查询设备分页 */
|
||||
export function getDevicePage(params: PageParam) {
|
||||
return requestClient.get<PageResult<IotDeviceApi.Device>>(
|
||||
@@ -154,6 +146,14 @@ export function importDeviceTemplate() {
|
||||
return requestClient.download('/iot/device/get-import-template');
|
||||
}
|
||||
|
||||
/** 导入设备 */
|
||||
export function importDevice(file: File, updateSupport: boolean) {
|
||||
return requestClient.upload('/iot/device/import', {
|
||||
file,
|
||||
updateSupport,
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取设备属性最新数据 */
|
||||
export function getLatestDeviceProperties(params: any) {
|
||||
return requestClient.get<IotDeviceApi.DevicePropertyDetail[]>(
|
||||
|
||||
@@ -27,33 +27,6 @@ export namespace IotProductApi {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO @haohao:packages/constants/src/biz-iot-enum.ts 枚举;
|
||||
|
||||
/** IOT 产品设备类型枚举类 */
|
||||
export enum DeviceTypeEnum {
|
||||
DEVICE = 0, // 直连设备
|
||||
GATEWAY = 2, // 网关设备
|
||||
GATEWAY_SUB = 1, // 网关子设备
|
||||
}
|
||||
|
||||
/** IOT 产品定位类型枚举类 */
|
||||
export enum LocationTypeEnum {
|
||||
IP = 1, // IP 定位
|
||||
MANUAL = 3, // 手动定位
|
||||
MODULE = 2, // 设备定位
|
||||
}
|
||||
|
||||
/** IOT 数据格式(编解码器类型)枚举类 */
|
||||
export enum CodecTypeEnum {
|
||||
ALINK = 'Alink', // 阿里云 Alink 协议
|
||||
}
|
||||
|
||||
/** IOT 产品状态枚举类 */
|
||||
export enum ProductStatusEnum {
|
||||
UNPUBLISHED = 0, // 开发中
|
||||
PUBLISHED = 1, // 已发布
|
||||
}
|
||||
|
||||
/** 查询产品分页 */
|
||||
export function getProductPage(params: PageParam) {
|
||||
return requestClient.get<PageResult<IotProductApi.Product>>(
|
||||
|
||||
@@ -48,20 +48,25 @@ const editingListenerIndex = ref(-1); // 监听器所在下标,-1 为新增
|
||||
const editingListenerFieldIndex = ref(-1); // 字段所在下标,-1 为新增
|
||||
const listenerTypeObject = ref(listenerType);
|
||||
const fieldTypeObject = ref(fieldType);
|
||||
const bpmnElement = ref();
|
||||
const otherExtensionList = ref();
|
||||
const bpmnElementListeners = ref();
|
||||
const listenerFormRef = ref();
|
||||
const bpmnInstances = () => (window as any)?.bpmnInstances;
|
||||
|
||||
const resetListenersList = () => {
|
||||
bpmnElement.value = bpmnInstances().bpmnElement;
|
||||
const instances = bpmnInstances();
|
||||
if (!instances || !instances.bpmnElement) return;
|
||||
|
||||
// 直接使用原始BPMN元素,避免Vue响应式代理问题
|
||||
const bpmnElement = instances.bpmnElement;
|
||||
const businessObject = bpmnElement.businessObject;
|
||||
|
||||
otherExtensionList.value =
|
||||
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
|
||||
businessObject?.extensionElements?.values?.filter(
|
||||
(ex: any) => ex.$type !== `${prefix}:ExecutionListener`,
|
||||
) ?? []; // 保留非监听器类型的扩展属性,避免移除监听器时清空其他配置(如审批人等)。相关案例:https://gitee.com/yudaocode/yudao-ui-admin-vue3/issues/ICMSYC
|
||||
bpmnElementListeners.value =
|
||||
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
|
||||
businessObject?.extensionElements?.values?.filter(
|
||||
(ex: any) => ex.$type === `${prefix}:ExecutionListener`,
|
||||
) ?? [];
|
||||
elementListenersList.value = bpmnElementListeners.value.map((listener: any) =>
|
||||
@@ -126,9 +131,11 @@ const removeListener = (index: number) => {
|
||||
title: '提示',
|
||||
content: '确认移除该监听器吗?',
|
||||
}).then(() => {
|
||||
const instances = bpmnInstances();
|
||||
if (!instances || !instances.bpmnElement) return;
|
||||
bpmnElementListeners.value.splice(index, 1);
|
||||
elementListenersList.value.splice(index, 1);
|
||||
updateElementExtensions(bpmnElement.value, [
|
||||
updateElementExtensions(instances.bpmnElement, [
|
||||
...otherExtensionList.value,
|
||||
...bpmnElementListeners.value,
|
||||
]);
|
||||
@@ -146,6 +153,12 @@ const saveListenerConfig = async () => {
|
||||
false,
|
||||
prefix,
|
||||
);
|
||||
|
||||
const instances = bpmnInstances();
|
||||
if (!instances || !instances.bpmnElement) return;
|
||||
|
||||
const bpmnElement = instances.bpmnElement;
|
||||
|
||||
if (editingListenerIndex.value === -1) {
|
||||
bpmnElementListeners.value.push(listenerObject);
|
||||
elementListenersList.value.push(listenerForm.value);
|
||||
@@ -162,10 +175,10 @@ const saveListenerConfig = async () => {
|
||||
);
|
||||
}
|
||||
otherExtensionList.value =
|
||||
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
|
||||
bpmnElement.businessObject?.extensionElements?.values?.filter(
|
||||
(ex: any) => ex.$type !== `${prefix}:ExecutionListener`,
|
||||
) ?? [];
|
||||
updateElementExtensions(bpmnElement.value, [
|
||||
updateElementExtensions(bpmnElement, [
|
||||
...otherExtensionList.value,
|
||||
...bpmnElementListeners.value,
|
||||
]);
|
||||
@@ -266,6 +279,10 @@ const openProcessListenerDialog = async () => {
|
||||
processListenerSelectModalApi.setData({ type: 'execution' }).open();
|
||||
};
|
||||
const selectProcessListener = (listener: any) => {
|
||||
const instances = bpmnInstances();
|
||||
if (!instances || !instances.bpmnElement) return;
|
||||
|
||||
const bpmnElement = instances.bpmnElement;
|
||||
const listenerForm = initListenerForm2(listener);
|
||||
const listenerObject = createListenerObject(listenerForm, false, prefix);
|
||||
bpmnElementListeners.value.push(listenerObject);
|
||||
@@ -273,10 +290,10 @@ const selectProcessListener = (listener: any) => {
|
||||
|
||||
// 保存其他配置
|
||||
otherExtensionList.value =
|
||||
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
|
||||
bpmnElement.businessObject?.extensionElements?.values?.filter(
|
||||
(ex: any) => ex.$type !== `${prefix}:ExecutionListener`,
|
||||
) ?? [];
|
||||
updateElementExtensions(bpmnElement.value, [
|
||||
updateElementExtensions(bpmnElement, [
|
||||
...otherExtensionList.value,
|
||||
...bpmnElementListeners.value,
|
||||
]);
|
||||
|
||||
@@ -48,7 +48,6 @@ const fieldTypeObject = ref(fieldType);
|
||||
const fieldsListOfListener = ref<any[]>([]);
|
||||
const editingListenerIndex = ref(-1);
|
||||
const editingListenerFieldIndex = ref<any>(-1);
|
||||
const bpmnElement = ref<any>();
|
||||
const bpmnElementListeners = ref<any[]>([]);
|
||||
const otherExtensionList = ref<any[]>([]);
|
||||
const listenerFormRef = ref<any>({});
|
||||
@@ -56,13 +55,19 @@ const listenerFormRef = ref<any>({});
|
||||
const bpmnInstances = () => (window as any)?.bpmnInstances;
|
||||
|
||||
const resetListenersList = () => {
|
||||
bpmnElement.value = bpmnInstances()?.bpmnElement;
|
||||
const instances = bpmnInstances();
|
||||
if (!instances || !instances.bpmnElement) return;
|
||||
|
||||
// 直接使用原始BPMN元素,避免Vue响应式代理问题
|
||||
const bpmnElement = instances.bpmnElement;
|
||||
const businessObject = bpmnElement.businessObject;
|
||||
|
||||
otherExtensionList.value =
|
||||
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
|
||||
businessObject?.extensionElements?.values?.filter(
|
||||
(ex: any) => ex.$type !== `${prefix}:TaskListener`,
|
||||
) ?? [];
|
||||
bpmnElementListeners.value =
|
||||
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
|
||||
businessObject?.extensionElements?.values?.filter(
|
||||
(ex: any) => ex.$type === `${prefix}:TaskListener`,
|
||||
) ?? [];
|
||||
elementListenersList.value = bpmnElementListeners.value.map((listener) =>
|
||||
@@ -98,9 +103,12 @@ const removeListener = (_: any, index: number) => {
|
||||
title: '提示',
|
||||
content: '确认移除该监听器吗?',
|
||||
}).then(() => {
|
||||
const instances = bpmnInstances();
|
||||
if (!instances || !instances.bpmnElement) return;
|
||||
|
||||
bpmnElementListeners.value.splice(index, 1);
|
||||
elementListenersList.value.splice(index, 1);
|
||||
updateElementExtensions(bpmnElement.value, [
|
||||
updateElementExtensions(instances.bpmnElement, [
|
||||
...otherExtensionList.value,
|
||||
...bpmnElementListeners.value,
|
||||
]);
|
||||
@@ -113,7 +121,13 @@ async function saveListenerConfig() {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const instances = bpmnInstances();
|
||||
if (!instances || !instances.bpmnElement) return;
|
||||
|
||||
const bpmnElement = instances.bpmnElement;
|
||||
const listenerObject = createListenerObject(listenerForm.value, true, prefix);
|
||||
|
||||
if (editingListenerIndex.value === -1) {
|
||||
bpmnElementListeners.value.push(listenerObject);
|
||||
elementListenersList.value.push(listenerForm.value);
|
||||
@@ -130,10 +144,10 @@ async function saveListenerConfig() {
|
||||
);
|
||||
}
|
||||
otherExtensionList.value =
|
||||
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
|
||||
bpmnElement.businessObject?.extensionElements?.values?.filter(
|
||||
(ex: any) => ex.$type !== `${prefix}:TaskListener`,
|
||||
) ?? [];
|
||||
updateElementExtensions(bpmnElement.value, [
|
||||
updateElementExtensions(bpmnElement, [
|
||||
...otherExtensionList.value,
|
||||
...bpmnElementListeners.value,
|
||||
]);
|
||||
@@ -209,6 +223,10 @@ const openProcessListenerDialog = async () => {
|
||||
processListenerSelectModalApi.setData({ type: 'task' }).open();
|
||||
};
|
||||
const selectProcessListener = (listener: any) => {
|
||||
const instances = bpmnInstances();
|
||||
if (!instances || !instances.bpmnElement) return;
|
||||
|
||||
const bpmnElement = instances.bpmnElement;
|
||||
const listenerForm = initListenerForm2(listener);
|
||||
listenerForm.id = listener.id;
|
||||
const listenerObject = createListenerObject(listenerForm, true, prefix);
|
||||
@@ -216,11 +234,11 @@ const selectProcessListener = (listener: any) => {
|
||||
elementListenersList.value.push(listenerForm);
|
||||
|
||||
otherExtensionList.value =
|
||||
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
|
||||
bpmnElement.businessObject?.extensionElements?.values?.filter(
|
||||
(ex: any) => ex.$type !== `${prefix}:TaskListener`,
|
||||
) ?? [];
|
||||
updateElementExtensions(
|
||||
bpmnElement.value,
|
||||
bpmnElement,
|
||||
otherExtensionList.value?.concat(bpmnElementListeners.value),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { inject, nextTick, ref, toRaw, watch } from 'vue';
|
||||
import { inject, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { confirm, useVbenModal } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
@@ -27,7 +27,6 @@ const prefix = inject('prefix');
|
||||
const elementPropertyList = ref<Array<{ name: string; value: string }>>([]);
|
||||
const propertyForm = ref<{ name?: string; value?: string }>({});
|
||||
const editingPropertyIndex = ref(-1);
|
||||
const bpmnElement = ref<any>();
|
||||
const otherExtensionList = ref<any[]>([]);
|
||||
const bpmnElementProperties = ref<any[]>([]);
|
||||
const bpmnElementPropertyList = ref<any[]>([]);
|
||||
@@ -35,17 +34,21 @@ const attributeFormRef = ref<any>();
|
||||
const bpmnInstances = () => (window as any)?.bpmnInstances;
|
||||
|
||||
const resetAttributesList = () => {
|
||||
bpmnElement.value = bpmnInstances().bpmnElement;
|
||||
otherExtensionList.value = [];
|
||||
const instances = bpmnInstances();
|
||||
if (!instances || !instances.bpmnElement) return;
|
||||
|
||||
// 直接使用原始BPMN元素,避免Vue响应式代理问题
|
||||
const bpmnElement = instances.bpmnElement;
|
||||
const businessObject = bpmnElement.businessObject;
|
||||
|
||||
otherExtensionList.value = []; // 其他扩展配置
|
||||
bpmnElementProperties.value =
|
||||
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
|
||||
(ex: any) => {
|
||||
if (ex.$type !== `${prefix}:Properties`) {
|
||||
otherExtensionList.value.push(ex);
|
||||
}
|
||||
return ex.$type === `${prefix}:Properties`;
|
||||
},
|
||||
) ?? [];
|
||||
businessObject?.extensionElements?.values?.filter((ex: any) => {
|
||||
if (ex.$type !== `${prefix}:Properties`) {
|
||||
otherExtensionList.value.push(ex);
|
||||
}
|
||||
return ex.$type === `${prefix}:Properties`;
|
||||
}) ?? [];
|
||||
|
||||
bpmnElementPropertyList.value = bpmnElementProperties.value.flatMap(
|
||||
(current: any) => current.values,
|
||||
@@ -83,27 +86,26 @@ const saveAttribute = async () => {
|
||||
}
|
||||
|
||||
const { name, value } = propertyForm.value;
|
||||
const instances = bpmnInstances();
|
||||
if (!instances || !instances.bpmnElement) return;
|
||||
|
||||
const bpmnElement = instances.bpmnElement;
|
||||
|
||||
if (editingPropertyIndex.value === -1) {
|
||||
// 新建属性字段
|
||||
const newPropertyObject = bpmnInstances().moddle.create(
|
||||
`${prefix}:Property`,
|
||||
{
|
||||
name,
|
||||
value,
|
||||
},
|
||||
);
|
||||
const newPropertyObject = instances.moddle.create(`${prefix}:Property`, {
|
||||
name,
|
||||
value,
|
||||
});
|
||||
// 新建一个属性字段的保存列表
|
||||
const propertiesObject = bpmnInstances().moddle.create(
|
||||
`${prefix}:Properties`,
|
||||
{
|
||||
values: [...bpmnElementPropertyList.value, newPropertyObject],
|
||||
},
|
||||
);
|
||||
const propertiesObject = instances.moddle.create(`${prefix}:Properties`, {
|
||||
values: [...bpmnElementPropertyList.value, newPropertyObject],
|
||||
});
|
||||
updateElementExtensions(propertiesObject);
|
||||
} else {
|
||||
bpmnInstances().modeling.updateModdleProperties(
|
||||
toRaw(bpmnElement.value),
|
||||
toRaw(bpmnElementPropertyList.value)[toRaw(editingPropertyIndex.value)],
|
||||
instances.modeling.updateModdleProperties(
|
||||
bpmnElement,
|
||||
bpmnElementPropertyList.value[editingPropertyIndex.value],
|
||||
{
|
||||
name,
|
||||
value,
|
||||
@@ -115,10 +117,14 @@ const saveAttribute = async () => {
|
||||
};
|
||||
|
||||
const updateElementExtensions = (properties: any) => {
|
||||
const extensions = bpmnInstances().moddle.create('bpmn:ExtensionElements', {
|
||||
const instances = bpmnInstances();
|
||||
if (!instances || !instances.bpmnElement) return;
|
||||
|
||||
const bpmnElement = instances.bpmnElement;
|
||||
const extensions = instances.moddle.create('bpmn:ExtensionElements', {
|
||||
values: [...otherExtensionList.value, properties],
|
||||
});
|
||||
bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), {
|
||||
instances.modeling.updateProperties(bpmnElement, {
|
||||
extensionElements: extensions,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -50,7 +50,6 @@ const generateStandardId = (type: string): string => {
|
||||
};
|
||||
|
||||
const initDataList = () => {
|
||||
// console.log(window, 'window');
|
||||
rootElements.value = bpmnInstances().modeler.getDefinitions().rootElements;
|
||||
messageIdMap.value = {};
|
||||
signalIdMap.value = {};
|
||||
@@ -143,6 +142,8 @@ const addNewObject = async () => {
|
||||
}
|
||||
}
|
||||
modelModalApi.close();
|
||||
// 触发建模器更新以保存更改。
|
||||
saveChanges();
|
||||
initDataList();
|
||||
};
|
||||
|
||||
@@ -166,6 +167,39 @@ const removeObject = (type: any, row: any) => {
|
||||
});
|
||||
};
|
||||
|
||||
// 触发建模器更新以保存更改
|
||||
const saveChanges = () => {
|
||||
const modeler = bpmnInstances().modeler;
|
||||
if (!modeler) return;
|
||||
|
||||
try {
|
||||
// 获取 canvas,通过它来触发图表的重新渲染
|
||||
const canvas = modeler.get('canvas');
|
||||
|
||||
// 获取根元素(Process)
|
||||
const rootElement = canvas.getRootElement();
|
||||
|
||||
// 触发 changed 事件,通知建模器数据已更改
|
||||
const eventBus = modeler.get('eventBus');
|
||||
if (eventBus) {
|
||||
eventBus.fire('root.added', { element: rootElement });
|
||||
eventBus.fire('elements.changed', { elements: [rootElement] });
|
||||
}
|
||||
|
||||
// 标记建模器为已修改状态
|
||||
const commandStack = modeler.get('commandStack');
|
||||
if (commandStack && commandStack._stack) {
|
||||
// 添加一个空命令以标记为已修改
|
||||
commandStack.execute('element.updateProperties', {
|
||||
element: rootElement,
|
||||
properties: {},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('保存更改时出错:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const [MessageGrid, messageGridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: [
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { toRaw } from 'vue';
|
||||
|
||||
const bpmnInstances = () => (window as any)?.bpmnInstances;
|
||||
// 创建监听器实例
|
||||
export function createListenerObject(options, isTask, prefix) {
|
||||
@@ -76,7 +74,8 @@ export function updateElementExtensions(element, extensionList) {
|
||||
const extensions = bpmnInstances().moddle.create('bpmn:ExtensionElements', {
|
||||
values: extensionList,
|
||||
});
|
||||
bpmnInstances().modeling.updateProperties(toRaw(element), {
|
||||
// 直接使用原始元素对象,不需要toRaw包装
|
||||
bpmnInstances().modeling.updateProperties(element, {
|
||||
extensionElements: extensions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { DeviceTypeEnum, DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getSimpleDeviceList } from '#/api/iot/device/device';
|
||||
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
|
||||
import {
|
||||
DeviceTypeEnum,
|
||||
getSimpleProductList,
|
||||
} from '#/api/iot/product/product';
|
||||
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||
|
||||
/** 新增/修改的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
@@ -33,6 +30,10 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
valueField: 'id',
|
||||
placeholder: '请选择产品',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['id'],
|
||||
disabled: (values: any) => !!values?.id,
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
@@ -42,6 +43,10 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
componentProps: {
|
||||
placeholder: '请输入 DeviceName',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['id'],
|
||||
disabled: (values: any) => !!values?.id,
|
||||
},
|
||||
rules: z
|
||||
.string()
|
||||
.min(4, 'DeviceName 长度不能少于 4 个字符')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
@@ -32,10 +33,10 @@ import { getSimpleProductList } from '#/api/iot/product/product';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useGridColumns } from './data';
|
||||
import DeviceCardView from './modules/device-card-view.vue';
|
||||
import DeviceForm from './modules/device-form.vue';
|
||||
import DeviceGroupForm from './modules/device-group-form.vue';
|
||||
import DeviceImportForm from './modules/device-import-form.vue';
|
||||
import DeviceCardView from './modules/card-view.vue';
|
||||
import DeviceForm from './modules/form.vue';
|
||||
import DeviceGroupForm from './modules/group-form.vue';
|
||||
import DeviceImportForm from './modules/import-form.vue';
|
||||
|
||||
/** IoT 设备列表 */
|
||||
defineOptions({ name: 'IoTDevice' });
|
||||
@@ -47,8 +48,6 @@ const deviceGroups = ref<any[]>([]);
|
||||
const viewMode = ref<'card' | 'list'>('card');
|
||||
const cardViewRef = ref();
|
||||
|
||||
// Modal instances
|
||||
// TODO @haohao:这个界面,等 product 改完,在一起看看怎么弄更好。
|
||||
const [DeviceFormModal, deviceFormModalApi] = useVbenModal({
|
||||
connectedComponent: DeviceForm,
|
||||
destroyOnClose: true,
|
||||
@@ -64,17 +63,17 @@ const [DeviceImportFormModal, deviceImportFormModalApi] = useVbenModal({
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 搜索参数
|
||||
const searchParams = ref({
|
||||
const queryParams = ref({
|
||||
deviceName: '',
|
||||
nickname: '',
|
||||
productId: undefined as number | undefined,
|
||||
deviceType: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
groupId: undefined as number | undefined,
|
||||
});
|
||||
}); // 搜索参数
|
||||
|
||||
// 获取字典选项
|
||||
// TODO @haohao:直接使用 getDictOptions 哈,不用包装方法;
|
||||
const getIntDictOptions = (dictType: string) => {
|
||||
return getDictOptions(dictType, 'number');
|
||||
};
|
||||
@@ -82,23 +81,22 @@ const getIntDictOptions = (dictType: string) => {
|
||||
/** 搜索 */
|
||||
function handleSearch() {
|
||||
if (viewMode.value === 'list') {
|
||||
gridApi.formApi.setValues(searchParams.value);
|
||||
gridApi.formApi.setValues(queryParams.value);
|
||||
gridApi.query();
|
||||
} else {
|
||||
cardViewRef.value?.search(searchParams.value);
|
||||
// todo @haohao:改成 query 方法,更统一;
|
||||
cardViewRef.value?.search(queryParams.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置 */
|
||||
function handleReset() {
|
||||
searchParams.value = {
|
||||
deviceName: '',
|
||||
nickname: '',
|
||||
productId: undefined,
|
||||
deviceType: undefined,
|
||||
status: undefined,
|
||||
groupId: undefined,
|
||||
};
|
||||
queryParams.value.deviceName = '';
|
||||
queryParams.value.nickname = '';
|
||||
queryParams.value.productId = undefined;
|
||||
queryParams.value.deviceType = undefined;
|
||||
queryParams.value.status = undefined;
|
||||
queryParams.value.groupId = undefined;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
@@ -113,7 +111,7 @@ function handleRefresh() {
|
||||
|
||||
/** 导出表格 */
|
||||
async function handleExport() {
|
||||
const data = await exportDeviceExcel(searchParams.value);
|
||||
const data = await exportDeviceExcel(queryParams.value);
|
||||
downloadFileFromBlobPart({ fileName: '物联网设备.xls', source: data });
|
||||
}
|
||||
|
||||
@@ -142,18 +140,18 @@ function handleCreate() {
|
||||
}
|
||||
|
||||
/** 编辑设备 */
|
||||
function handleEdit(row: any) {
|
||||
function handleEdit(row: IotDeviceApi.Device) {
|
||||
deviceFormModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 删除设备 */
|
||||
async function handleDelete(row: any) {
|
||||
async function handleDelete(row: IotDeviceApi.Device) {
|
||||
const hideLoading = message.loading({
|
||||
content: `正在删除设备...`,
|
||||
content: $t('ui.actionMessage.deleting', [row.deviceName]),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteDevice(row.id);
|
||||
await deleteDevice(row.id!);
|
||||
message.success($t('ui.actionMessage.deleteSuccess'));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
@@ -163,7 +161,8 @@ async function handleDelete(row: any) {
|
||||
|
||||
/** 批量删除设备 */
|
||||
async function handleDeleteBatch() {
|
||||
const checkedRows = gridApi.grid?.getCheckboxRecords() || [];
|
||||
const checkedRows = (gridApi.grid?.getCheckboxRecords() ||
|
||||
[]) as IotDeviceApi.Device[];
|
||||
if (checkedRows.length === 0) {
|
||||
message.warning('请选择要删除的设备');
|
||||
return;
|
||||
@@ -173,7 +172,7 @@ async function handleDeleteBatch() {
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
const ids = checkedRows.map((row: any) => row.id);
|
||||
const ids = checkedRows.map((row) => row.id!);
|
||||
await deleteDeviceList(ids);
|
||||
message.success($t('ui.actionMessage.deleteSuccess'));
|
||||
handleRefresh();
|
||||
@@ -184,12 +183,13 @@ async function handleDeleteBatch() {
|
||||
|
||||
/** 添加到分组 */
|
||||
function handleAddToGroup() {
|
||||
const checkedRows = gridApi.grid?.getCheckboxRecords() || [];
|
||||
const checkedRows = (gridApi.grid?.getCheckboxRecords() ||
|
||||
[]) as IotDeviceApi.Device[];
|
||||
if (checkedRows.length === 0) {
|
||||
message.warning('请选择要添加到分组的设备');
|
||||
return;
|
||||
}
|
||||
const ids = checkedRows.map((row: any) => row.id);
|
||||
const ids = checkedRows.map((row) => row.id!);
|
||||
deviceGroupFormModalApi.setData(ids).open();
|
||||
}
|
||||
|
||||
@@ -199,9 +199,6 @@ function handleImport() {
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: [],
|
||||
},
|
||||
gridOptions: {
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
@@ -216,7 +213,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
return await getDevicePage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...searchParams.value,
|
||||
...queryParams.value,
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -229,7 +226,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions,
|
||||
} as VxeTableGridOptions<IotDeviceApi.Device>,
|
||||
});
|
||||
|
||||
/** 初始化 */
|
||||
@@ -242,7 +239,7 @@ onMounted(async () => {
|
||||
// 处理 productId 参数
|
||||
const { productId } = route.query;
|
||||
if (productId) {
|
||||
searchParams.value.productId = Number(productId);
|
||||
queryParams.value.productId = Number(productId);
|
||||
// 自动触发搜索
|
||||
handleSearch();
|
||||
}
|
||||
@@ -260,7 +257,7 @@ onMounted(async () => {
|
||||
<!-- 搜索表单 -->
|
||||
<div class="mb-3 flex flex-wrap items-center gap-3">
|
||||
<Select
|
||||
v-model:value="searchParams.productId"
|
||||
v-model:value="queryParams.productId"
|
||||
placeholder="请选择产品"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@@ -274,21 +271,21 @@ onMounted(async () => {
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Input
|
||||
v-model:value="searchParams.deviceName"
|
||||
v-model:value="queryParams.deviceName"
|
||||
placeholder="请输入 DeviceName"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="searchParams.nickname"
|
||||
v-model:value="queryParams.nickname"
|
||||
placeholder="请输入备注名称"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
<Select
|
||||
v-model:value="searchParams.deviceType"
|
||||
v-model:value="queryParams.deviceType"
|
||||
placeholder="请选择设备类型"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@@ -302,7 +299,7 @@ onMounted(async () => {
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
v-model:value="searchParams.status"
|
||||
v-model:value="queryParams.status"
|
||||
placeholder="请选择设备状态"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@@ -316,7 +313,7 @@ onMounted(async () => {
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
v-model:value="searchParams.groupId"
|
||||
v-model:value="queryParams.groupId"
|
||||
placeholder="请选择设备分组"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@@ -341,45 +338,50 @@ onMounted(async () => {
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<Space :size="12">
|
||||
<Button
|
||||
type="primary"
|
||||
@click="handleCreate"
|
||||
v-access:code="['iot:device:create']"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:plus-outlined" class="mr-1" />
|
||||
新增
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
@click="handleExport"
|
||||
v-access:code="['iot:device:export']"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:download-outlined" class="mr-1" />
|
||||
导出
|
||||
</Button>
|
||||
<Button @click="handleImport" v-access:code="['iot:device:import']">
|
||||
<IconifyIcon icon="ant-design:upload-outlined" class="mr-1" />
|
||||
导入
|
||||
</Button>
|
||||
<Button
|
||||
v-show="viewMode === 'list'"
|
||||
@click="handleAddToGroup"
|
||||
v-access:code="['iot:device:update']"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:folder-add-outlined" class="mr-1" />
|
||||
添加到分组
|
||||
</Button>
|
||||
<Button
|
||||
v-show="viewMode === 'list'"
|
||||
danger
|
||||
@click="handleDeleteBatch"
|
||||
v-access:code="['iot:device:delete']"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:delete-outlined" class="mr-1" />
|
||||
批量删除
|
||||
</Button>
|
||||
</Space>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['iot:device:create'],
|
||||
onClick: handleCreate,
|
||||
},
|
||||
{
|
||||
label: '导出',
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.DOWNLOAD,
|
||||
auth: ['iot:device:export'],
|
||||
onClick: handleExport,
|
||||
},
|
||||
{
|
||||
label: '导入',
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.UPLOAD,
|
||||
auth: ['iot:device:import'],
|
||||
onClick: handleImport,
|
||||
},
|
||||
// TODO @haohao:应该是选中后,才可用
|
||||
{
|
||||
label: '添加到分组',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:folder-add-outlined',
|
||||
auth: ['iot:device:update'],
|
||||
ifShow: () => viewMode === 'list',
|
||||
onClick: handleAddToGroup,
|
||||
},
|
||||
// TODO @haohao:应该是选中后,才可用;然后,然后 danger 颜色;
|
||||
{
|
||||
label: '批量删除',
|
||||
type: 'primary',
|
||||
color: 'error',
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['iot:device:delete'],
|
||||
ifShow: () => viewMode === 'list',
|
||||
onClick: handleDeleteBatch,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- 视图切换 -->
|
||||
<Space :size="4">
|
||||
@@ -399,7 +401,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Grid v-show="viewMode === 'list'">
|
||||
<Grid table-title="设备列表" v-show="viewMode === 'list'">
|
||||
<template #toolbar-tools>
|
||||
<div></div>
|
||||
</template>
|
||||
@@ -436,12 +438,12 @@ onMounted(async () => {
|
||||
{
|
||||
label: '查看',
|
||||
type: 'link',
|
||||
onClick: openDetail.bind(null, row.id),
|
||||
onClick: openDetail.bind(null, row.id!),
|
||||
},
|
||||
{
|
||||
label: '日志',
|
||||
type: 'link',
|
||||
onClick: openModel.bind(null, row.id),
|
||||
onClick: openModel.bind(null, row.id!),
|
||||
},
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
@@ -455,7 +457,7 @@ onMounted(async () => {
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
popConfirm: {
|
||||
title: `确认删除设备吗?`,
|
||||
title: `确认删除设备 ${row.deviceName} 吗?`,
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
@@ -470,7 +472,7 @@ onMounted(async () => {
|
||||
ref="cardViewRef"
|
||||
:products="products"
|
||||
:device-groups="deviceGroups"
|
||||
:search-params="searchParams"
|
||||
:search-params="queryParams"
|
||||
@create="handleCreate"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
|
||||
553
apps/web-antd/src/views/iot/device/device/modules/card-view.vue
Normal file
553
apps/web-antd/src/views/iot/device/device/modules/card-view.vue
Normal file
@@ -0,0 +1,553 @@
|
||||
<script lang="ts" setup>
|
||||
// TODO @haohao:product 的 card-view 的意见,这里看看要不要也改改下。
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { DeviceStateEnum, DICT_TYPE } from '@vben/constants';
|
||||
import { getDictLabel, getDictObj } from '@vben/hooks';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { isValidColor, TinyColor } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { getDevicePage } from '#/api/iot/device/device';
|
||||
|
||||
interface Props {
|
||||
products: any[];
|
||||
deviceGroups: any[];
|
||||
searchParams?: {
|
||||
deviceName: string;
|
||||
deviceType?: number;
|
||||
groupId?: number;
|
||||
nickname: string;
|
||||
productId?: number;
|
||||
status?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [];
|
||||
delete: [row: any];
|
||||
detail: [id: number];
|
||||
edit: [row: any];
|
||||
model: [id: number];
|
||||
productDetail: [productId: number];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const queryParams = ref({
|
||||
pageNo: 1,
|
||||
pageSize: 12,
|
||||
});
|
||||
|
||||
/** 默认状态映射 */
|
||||
const DEFAULT_STATUS_MAP: Record<
|
||||
'default' | number,
|
||||
{ bgColor: string; borderColor: string; color: string; text: string }
|
||||
> = {
|
||||
[DeviceStateEnum.ONLINE]: {
|
||||
text: '在线',
|
||||
color: '#52c41a',
|
||||
bgColor: '#f6ffed',
|
||||
borderColor: '#b7eb8f',
|
||||
},
|
||||
[DeviceStateEnum.OFFLINE]: {
|
||||
text: '离线',
|
||||
color: '#faad14',
|
||||
bgColor: '#fffbe6',
|
||||
borderColor: '#ffe58f',
|
||||
},
|
||||
[DeviceStateEnum.INACTIVE]: {
|
||||
text: '未激活',
|
||||
color: '#ff4d4f',
|
||||
bgColor: '#fff1f0',
|
||||
borderColor: '#ffccc7',
|
||||
},
|
||||
default: {
|
||||
text: '未知状态',
|
||||
color: '#595959',
|
||||
bgColor: '#fafafa',
|
||||
borderColor: '#d9d9d9',
|
||||
},
|
||||
};
|
||||
|
||||
/** 颜色类型预设 */
|
||||
const COLOR_TYPE_PRESETS: Record<
|
||||
string,
|
||||
{ bgColor: string; borderColor: string; color: string }
|
||||
> = {
|
||||
success: {
|
||||
color: '#52c41a',
|
||||
bgColor: '#f6ffed',
|
||||
borderColor: '#b7eb8f',
|
||||
},
|
||||
processing: {
|
||||
color: '#1890ff',
|
||||
bgColor: '#e6f7ff',
|
||||
borderColor: '#91d5ff',
|
||||
},
|
||||
warning: {
|
||||
color: '#faad14',
|
||||
bgColor: '#fffbe6',
|
||||
borderColor: '#ffe58f',
|
||||
},
|
||||
error: {
|
||||
color: '#ff4d4f',
|
||||
bgColor: '#fff1f0',
|
||||
borderColor: '#ffccc7',
|
||||
},
|
||||
default: {
|
||||
color: '#595959',
|
||||
bgColor: '#fafafa',
|
||||
borderColor: '#d9d9d9',
|
||||
},
|
||||
};
|
||||
|
||||
/** 规范化颜色类型 */
|
||||
function normalizeColorType(colorType?: string) {
|
||||
switch (colorType) {
|
||||
case 'danger': {
|
||||
return 'error';
|
||||
}
|
||||
case 'default':
|
||||
case 'error':
|
||||
case 'processing':
|
||||
case 'success':
|
||||
case 'warning': {
|
||||
return colorType;
|
||||
}
|
||||
case 'info': {
|
||||
return 'default';
|
||||
}
|
||||
case 'primary': {
|
||||
return 'processing';
|
||||
}
|
||||
default: {
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取产品名称 */
|
||||
function getProductName(productId: number) {
|
||||
const product = props.products.find((p: any) => p.id === productId);
|
||||
return product?.name || '-';
|
||||
}
|
||||
|
||||
/** 获取设备列表 */
|
||||
async function getList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getDevicePage({
|
||||
...queryParams.value,
|
||||
...props.searchParams,
|
||||
});
|
||||
list.value = data.list || [];
|
||||
total.value = data.total || 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理页码变化 */
|
||||
function handlePageChange(page: number, pageSize: number) {
|
||||
queryParams.value.pageNo = page;
|
||||
queryParams.value.pageSize = pageSize;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 获取设备类型颜色 */
|
||||
function getDeviceTypeColor(deviceType: number) {
|
||||
const colors: Record<number, string> = {
|
||||
0: 'blue',
|
||||
1: 'cyan',
|
||||
};
|
||||
return colors[deviceType] || 'default';
|
||||
}
|
||||
|
||||
/** 获取设备状态信息 */
|
||||
// TODO @haohao:这里可以简化下么?体感看着有点复杂哈;
|
||||
function getStatusInfo(state: null | number | string | undefined) {
|
||||
const parsedState = Number(state);
|
||||
const hasNumericState = Number.isFinite(parsedState);
|
||||
const fallback = hasNumericState
|
||||
? DEFAULT_STATUS_MAP[parsedState] || DEFAULT_STATUS_MAP.default
|
||||
: DEFAULT_STATUS_MAP.default;
|
||||
const dict = getDictObj(
|
||||
DICT_TYPE.IOT_DEVICE_STATE,
|
||||
hasNumericState ? parsedState : state,
|
||||
);
|
||||
if (dict) {
|
||||
if (!dict.colorType && !dict.cssClass) {
|
||||
return {
|
||||
...fallback,
|
||||
text: dict.label || fallback.text,
|
||||
};
|
||||
}
|
||||
const presetKey = normalizeColorType(dict.colorType);
|
||||
if (isValidColor(dict.cssClass)) {
|
||||
const baseColor = new TinyColor(dict.cssClass);
|
||||
return {
|
||||
text: dict.label || fallback.text,
|
||||
color: baseColor.toHexString(),
|
||||
bgColor: baseColor.clone().setAlpha(0.15).toRgbString(),
|
||||
borderColor: baseColor.clone().lighten(30).toHexString(),
|
||||
};
|
||||
}
|
||||
const preset = COLOR_TYPE_PRESETS[presetKey] || COLOR_TYPE_PRESETS.default;
|
||||
return {
|
||||
text: dict.label || fallback.text,
|
||||
...preset,
|
||||
};
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
reload: getList,
|
||||
search: () => {
|
||||
queryParams.value.pageNo = 1;
|
||||
getList();
|
||||
},
|
||||
});
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="device-card-view">
|
||||
<!-- 设备卡片列表 -->
|
||||
<div v-loading="loading" class="min-h-96">
|
||||
<Row v-if="list.length > 0" :gutter="[16, 16]">
|
||||
<Col
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="12"
|
||||
:lg="6"
|
||||
>
|
||||
<Card
|
||||
:body-style="{ padding: '16px' }"
|
||||
class="device-card h-full rounded-lg transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
>
|
||||
<!-- 顶部标题区域 -->
|
||||
<div class="mb-3 flex items-center">
|
||||
<div class="device-icon">
|
||||
<IconifyIcon icon="mdi:chip" class="text-xl" />
|
||||
</div>
|
||||
<div class="ml-3 min-w-0 flex-1">
|
||||
<div class="device-title">{{ item.deviceName }}</div>
|
||||
</div>
|
||||
<div
|
||||
class="status-badge"
|
||||
:style="{
|
||||
color: getStatusInfo(item.state).color,
|
||||
backgroundColor: getStatusInfo(item.state).bgColor,
|
||||
borderColor: getStatusInfo(item.state).borderColor,
|
||||
}"
|
||||
>
|
||||
<span class="status-dot"></span>
|
||||
{{ getStatusInfo(item.state).text }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 内容区域 -->
|
||||
<div class="mb-3">
|
||||
<div class="info-list">
|
||||
<div class="info-item">
|
||||
<span class="info-label">所属产品</span>
|
||||
<a
|
||||
class="info-value cursor-pointer text-primary"
|
||||
@click="
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
emit('productDetail', item.productId);
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ getProductName(item.productId) }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">设备类型</span>
|
||||
<Tag
|
||||
:color="getDeviceTypeColor(item.deviceType)"
|
||||
class="info-tag m-0"
|
||||
>
|
||||
{{
|
||||
getDictLabel(
|
||||
DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE,
|
||||
item.deviceType,
|
||||
)
|
||||
}}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Deviceid</span>
|
||||
<Tooltip :title="item.Deviceid || item.id" placement="top">
|
||||
<span class="info-value device-id cursor-pointer">
|
||||
{{ item.Deviceid || item.id }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 按钮组 -->
|
||||
<div class="action-buttons">
|
||||
<Button
|
||||
size="small"
|
||||
class="action-btn action-btn-edit"
|
||||
@click="emit('edit', item)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:edit" class="mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
class="action-btn action-btn-detail"
|
||||
@click="emit('detail', item.id)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:eye" class="mr-1" />
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
class="action-btn action-btn-data"
|
||||
@click="emit('model', item.id)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:database" class="mr-1" />
|
||||
数据
|
||||
</Button>
|
||||
<Popconfirm
|
||||
:title="`确认删除设备 ${item.deviceName} 吗?`"
|
||||
@confirm="emit('delete', item)"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
class="action-btn action-btn-delete !w-8"
|
||||
>
|
||||
<IconifyIcon icon="lucide:trash-2" class="text-sm" />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<!-- 空状态 -->
|
||||
<Empty v-else description="暂无设备数据" class="my-20" />
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="list.length > 0" class="mt-3 flex justify-end">
|
||||
<Pagination
|
||||
v-model:current="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
:show-total="(total) => `共 ${total} 条`"
|
||||
show-quick-jumper
|
||||
show-size-changer
|
||||
:page-size-options="['12', '24', '36', '48']"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.device-card-view {
|
||||
.device-card {
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// 设备图标
|
||||
.device-icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// 设备标题
|
||||
.device-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 36px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 状态徽章
|
||||
.status-badge {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
border: 1px solid;
|
||||
border-radius: 12px;
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: currentcolor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
// 信息列表
|
||||
.info-list {
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
flex-shrink: 0;
|
||||
margin-right: 8px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
&.text-primary {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.device-id {
|
||||
display: inline-block;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.info-tag {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮组
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid var(--ant-color-split);
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.action-btn-edit {
|
||||
color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-btn-detail {
|
||||
color: #52c41a;
|
||||
border-color: #52c41a;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background: #52c41a;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-btn-data {
|
||||
color: #722ed1;
|
||||
border-color: #722ed1;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background: #722ed1;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-btn-delete {
|
||||
flex: 0 0 32px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 夜间模式适配
|
||||
html.dark {
|
||||
.device-card-view {
|
||||
.device-card {
|
||||
.device-title {
|
||||
color: rgb(255 255 255 / 85%);
|
||||
}
|
||||
|
||||
.info-list {
|
||||
.info-label {
|
||||
color: rgb(255 255 255 / 65%);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: rgb(255 255 255 / 85%);
|
||||
}
|
||||
|
||||
.device-id {
|
||||
color: rgb(255 255 255 / 75%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,402 +0,0 @@
|
||||
<!-- IoT 设备选择,使用弹窗展示 -->
|
||||
<script setup lang="ts">
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { ContentWrap } from '@vben/common-ui';
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { formatDate } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Pagination,
|
||||
Radio,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { getDevicePage } from '#/api/iot/device/device';
|
||||
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
|
||||
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||
import { DictTag } from '#/components/dict-tag';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceTableSelect' });
|
||||
|
||||
const props = defineProps({
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
productId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
/** 提交表单 */
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
// 获取字典选项
|
||||
function getIntDictOptions(dictType: string) {
|
||||
return getDictOptions(dictType, 'number');
|
||||
}
|
||||
|
||||
// 日期格式化
|
||||
function dateFormatter(_row: any, _column: any, cellValue: any) {
|
||||
return cellValue ? formatDate(cellValue, 'YYYY-MM-DD HH:mm:ss') : '';
|
||||
}
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const dialogTitle = ref('设备选择器');
|
||||
const formLoading = ref(false);
|
||||
const loading = ref(true); // 列表的加载中
|
||||
const list = ref<IotDeviceApi.Device[]>([]); // 列表的数据
|
||||
const total = ref(0); // 列表的总页数
|
||||
const selectedDevices = ref<IotDeviceApi.Device[]>([]); // 选中的设备列表
|
||||
const selectedId = ref<number>(); // 单选模式下选中的ID
|
||||
const products = ref<IotProductApi.Product[]>([]); // 产品列表
|
||||
const deviceGroups = ref<IotDeviceGroupApi.DeviceGroup[]>([]); // 设备分组列表
|
||||
const selectedRowKeys = ref<number[]>([]); // 多选模式下选中的keys
|
||||
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
deviceName: undefined as string | undefined,
|
||||
productId: undefined as number | undefined,
|
||||
deviceType: undefined as number | undefined,
|
||||
nickname: undefined as string | undefined,
|
||||
status: undefined as number | undefined,
|
||||
groupId: undefined as number | undefined,
|
||||
});
|
||||
const queryFormRef = ref(); // 搜索的表单
|
||||
|
||||
// 表格列定义
|
||||
const columns = computed(() => {
|
||||
const baseColumns = [
|
||||
{
|
||||
title: 'DeviceName',
|
||||
dataIndex: 'deviceName',
|
||||
key: 'deviceName',
|
||||
},
|
||||
{
|
||||
title: '备注名称',
|
||||
dataIndex: 'nickname',
|
||||
key: 'nickname',
|
||||
},
|
||||
{
|
||||
title: '所属产品',
|
||||
key: 'productId',
|
||||
},
|
||||
{
|
||||
title: '设备类型',
|
||||
key: 'deviceType',
|
||||
},
|
||||
{
|
||||
title: '所属分组',
|
||||
key: 'groupIds',
|
||||
},
|
||||
{
|
||||
title: '设备状态',
|
||||
key: 'status',
|
||||
},
|
||||
{
|
||||
title: '最后上线时间',
|
||||
key: 'onlineTime',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
|
||||
// 单选模式添加单选列
|
||||
if (!props.multiple) {
|
||||
baseColumns.unshift({
|
||||
title: '',
|
||||
key: 'radio',
|
||||
width: 55,
|
||||
align: 'center',
|
||||
} as any);
|
||||
}
|
||||
|
||||
return baseColumns;
|
||||
});
|
||||
|
||||
// 多选配置
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys: any[], rows: IotDeviceApi.Device[]) => {
|
||||
selectedRowKeys.value = keys;
|
||||
selectedDevices.value = rows;
|
||||
},
|
||||
}));
|
||||
|
||||
/** 查询列表 */
|
||||
async function getList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
if (props.productId) {
|
||||
queryParams.productId = props.productId;
|
||||
}
|
||||
const data = await getDevicePage(queryParams);
|
||||
list.value = data.list;
|
||||
total.value = data.total;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
function handleQuery() {
|
||||
queryParams.pageNo = 1;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
function resetQuery() {
|
||||
queryFormRef.value.resetFields();
|
||||
handleQuery();
|
||||
}
|
||||
|
||||
/** 打开弹窗 */
|
||||
async function open() {
|
||||
dialogVisible.value = true;
|
||||
// 重置选择状态
|
||||
selectedDevices.value = [];
|
||||
selectedId.value = undefined;
|
||||
selectedRowKeys.value = [];
|
||||
if (!props.productId) {
|
||||
// 获取产品列表
|
||||
products.value = await getSimpleProductList();
|
||||
}
|
||||
// 获取设备列表
|
||||
await getList();
|
||||
}
|
||||
defineExpose({ open });
|
||||
|
||||
/** 处理行点击事件 */
|
||||
const tableRef = ref();
|
||||
function handleRowClick(row: IotDeviceApi.Device) {
|
||||
if (!props.multiple) {
|
||||
selectedId.value = row.id;
|
||||
selectedDevices.value = [row];
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理单选变更事件 */
|
||||
function handleRadioChange(row: IotDeviceApi.Device) {
|
||||
selectedId.value = row.id;
|
||||
selectedDevices.value = [row];
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (selectedDevices.value.length === 0) {
|
||||
message.warning({
|
||||
content: props.multiple ? '请至少选择一个设备' : '请选择一个设备',
|
||||
});
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
'success',
|
||||
props.multiple ? selectedDevices.value : selectedDevices.value[0],
|
||||
);
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
// 获取产品列表
|
||||
products.value = await getSimpleProductList();
|
||||
// 获取分组列表
|
||||
deviceGroups.value = await getSimpleDeviceGroupList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:title="dialogTitle"
|
||||
v-model:open="dialogVisible"
|
||||
width="60%"
|
||||
:footer="null"
|
||||
>
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<Form
|
||||
ref="queryFormRef"
|
||||
layout="inline"
|
||||
:model="queryParams"
|
||||
class="-mb-15px"
|
||||
>
|
||||
<Form.Item v-if="!props.productId" label="产品" name="productId">
|
||||
<Select
|
||||
v-model:value="queryParams.productId"
|
||||
placeholder="请选择产品"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="product in products"
|
||||
:key="product.id"
|
||||
:value="product.id"
|
||||
>
|
||||
{{ product.name }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="DeviceName" name="deviceName">
|
||||
<Input
|
||||
v-model:value="queryParams.deviceName"
|
||||
placeholder="请输入 DeviceName"
|
||||
allow-clear
|
||||
@press-enter="handleQuery"
|
||||
style="width: 240px"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="备注名称" name="nickname">
|
||||
<Input
|
||||
v-model:value="queryParams.nickname"
|
||||
placeholder="请输入备注名称"
|
||||
allow-clear
|
||||
@press-enter="handleQuery"
|
||||
style="width: 240px"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="设备类型" name="deviceType">
|
||||
<Select
|
||||
v-model:value="queryParams.deviceType"
|
||||
placeholder="请选择设备类型"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="dict in getIntDictOptions(
|
||||
DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE,
|
||||
)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="设备状态" name="status">
|
||||
<Select
|
||||
v-model:value="queryParams.status"
|
||||
placeholder="请选择设备状态"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATE)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="设备分组" name="groupId">
|
||||
<Select
|
||||
v-model:value="queryParams.groupId"
|
||||
placeholder="请选择设备分组"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="group in deviceGroups"
|
||||
:key="group.id"
|
||||
:value="group.id"
|
||||
>
|
||||
{{ group.name }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button @click="handleQuery">
|
||||
<IconifyIcon class="mr-5px" icon="ep:search" />
|
||||
搜索
|
||||
</Button>
|
||||
<Button @click="resetQuery">
|
||||
<IconifyIcon class="mr-5px" icon="ep:refresh" />
|
||||
重置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<Table
|
||||
ref="tableRef"
|
||||
:loading="loading"
|
||||
:data-source="list"
|
||||
:columns="columns"
|
||||
:pagination="false"
|
||||
:row-selection="multiple ? rowSelection : undefined"
|
||||
@row-click="handleRowClick"
|
||||
:row-key="(record: IotDeviceApi.Device) => record.id?.toString() ?? ''"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'radio'">
|
||||
<Radio
|
||||
:checked="selectedId === record.id"
|
||||
@click="() => handleRadioChange(record as IotDeviceApi.Device)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'productId'">
|
||||
{{ products.find((p) => p.id === record.productId)?.name || '-' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'deviceType'">
|
||||
<DictTag
|
||||
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
|
||||
:value="record.deviceType"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'groupIds'">
|
||||
<template v-if="record.groupIds?.length">
|
||||
<Tag
|
||||
v-for="id in record.groupIds"
|
||||
:key="id"
|
||||
class="ml-5px"
|
||||
size="small"
|
||||
>
|
||||
{{ deviceGroups.find((g) => g.id === id)?.name }}
|
||||
</Tag>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<DictTag
|
||||
:type="DICT_TYPE.IOT_DEVICE_STATE"
|
||||
:value="record.status"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'onlineTime'">
|
||||
{{ dateFormatter(null, null, record.onlineTime) }}
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
v-model:limit="queryParams.pageSize"
|
||||
v-model:page="queryParams.pageNo"
|
||||
:total="total"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
|
||||
<template #footer>
|
||||
<Button @click="submitForm" type="primary" :disabled="formLoading">
|
||||
确 定
|
||||
</Button>
|
||||
<Button @click="dialogVisible = false">取 消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -7,20 +7,21 @@ import { onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { DeviceTypeEnum } from '@vben/constants';
|
||||
|
||||
import { message, Tabs } from 'ant-design-vue';
|
||||
|
||||
import { getDevice } from '#/api/iot/device/device';
|
||||
import { DeviceTypeEnum, getProduct } from '#/api/iot/product/product';
|
||||
import { getProduct } from '#/api/iot/product/product';
|
||||
import { getThingModelListByProductId } from '#/api/iot/thingmodel';
|
||||
|
||||
import DeviceDetailConfig from './device-detail-config.vue';
|
||||
import DeviceDetailsHeader from './device-details-header.vue';
|
||||
import DeviceDetailsInfo from './device-details-info.vue';
|
||||
import DeviceDetailsMessage from './device-details-message.vue';
|
||||
import DeviceDetailsSimulator from './device-details-simulator.vue';
|
||||
import DeviceDetailsSubDevice from './device-details-sub-device.vue';
|
||||
import DeviceDetailsThingModel from './device-details-thing-model.vue';
|
||||
import DeviceDetailConfig from './modules/config.vue';
|
||||
import DeviceDetailsHeader from './modules/header.vue';
|
||||
import DeviceDetailsInfo from './modules/info.vue';
|
||||
import DeviceDetailsMessage from './modules/message.vue';
|
||||
import DeviceDetailsSimulator from './modules/simulator.vue';
|
||||
import DeviceDetailsSubDevice from './modules/sub-device.vue';
|
||||
import DeviceDetailsThingModel from './modules/thing-model.vue';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceDetail' });
|
||||
|
||||
@@ -34,6 +35,8 @@ const device = ref<IotDeviceApi.Device>({} as IotDeviceApi.Device);
|
||||
const activeTab = ref('info');
|
||||
const thingModelList = ref<ThingModelData[]>([]);
|
||||
|
||||
// TODO @haohao:类似 device/detail/index.vue 挪出去哈。
|
||||
|
||||
/** 获取设备详情 */
|
||||
async function getDeviceData(deviceId: number) {
|
||||
loading.value = true;
|
||||
@@ -52,8 +55,8 @@ async function getDeviceData(deviceId: number) {
|
||||
async function getProductData(productId: number) {
|
||||
try {
|
||||
product.value = await getProduct(productId);
|
||||
} catch (error) {
|
||||
console.error('获取产品详情失败:', error);
|
||||
} catch {
|
||||
message.error('获取产品详情失败');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +65,8 @@ async function getThingModelList(productId: number) {
|
||||
try {
|
||||
const data = await getThingModelListByProductId(productId);
|
||||
thingModelList.value = data || [];
|
||||
} catch (error) {
|
||||
console.error('获取物模型列表失败:', error);
|
||||
} catch {
|
||||
message.error('获取物模型列表失败');
|
||||
thingModelList.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,14 +56,14 @@ const hasConfigData = computed(() => {
|
||||
});
|
||||
|
||||
/** 启用编辑模式的函数 */
|
||||
function enableEdit() {
|
||||
function handleEdit() {
|
||||
isEditing.value = true;
|
||||
// 重新同步编辑器内容
|
||||
configString.value = JSON.stringify(config.value, null, 2);
|
||||
}
|
||||
|
||||
/** 取消编辑的函数 */
|
||||
function cancelEdit() {
|
||||
function handleCancelEdit() {
|
||||
try {
|
||||
config.value = props.device.config ? JSON.parse(props.device.config) : {};
|
||||
configString.value = JSON.stringify(config.value, null, 2);
|
||||
@@ -84,29 +84,23 @@ async function saveConfig() {
|
||||
message.error({ content: 'JSON格式错误,请修正后再提交!' });
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO @haohao:这里要不要做个类似下面的 pushLoading 避免重复提交;
|
||||
await updateDeviceConfig();
|
||||
isEditing.value = false;
|
||||
}
|
||||
|
||||
/** 配置推送处理函数 */
|
||||
async function handleConfigPush() {
|
||||
pushLoading.value = true;
|
||||
try {
|
||||
pushLoading.value = true;
|
||||
|
||||
// 调用配置推送接口
|
||||
await sendDeviceMessage({
|
||||
deviceId: props.device.id!,
|
||||
method: IotDeviceMessageMethodEnum.CONFIG_PUSH.method,
|
||||
params: config.value,
|
||||
});
|
||||
|
||||
// 提示成功
|
||||
message.success({ content: '配置推送成功!' });
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
message.error({ content: '配置推送失败!' });
|
||||
console.error('配置推送错误:', error);
|
||||
}
|
||||
} finally {
|
||||
pushLoading.value = false;
|
||||
}
|
||||
@@ -124,8 +118,6 @@ async function updateDeviceConfig() {
|
||||
message.success({ content: '更新成功!' });
|
||||
// 触发 success 事件
|
||||
emit('success');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -143,8 +135,9 @@ async function updateDeviceConfig() {
|
||||
class="my-4"
|
||||
description="如需编辑文件,请点击下方编辑按钮"
|
||||
/>
|
||||
<!-- TODO @haohao:应该按钮,是在下方,可以参考 element-plus 的版本 -->
|
||||
<div class="mt-5 text-center">
|
||||
<Button v-if="isEditing" @click="cancelEdit">取消</Button>
|
||||
<Button v-if="isEditing" @click="handleCancelEdit">取消</Button>
|
||||
<Button
|
||||
v-if="isEditing"
|
||||
type="primary"
|
||||
@@ -153,7 +146,7 @@ async function updateDeviceConfig() {
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button v-else @click="enableEdit">编辑</Button>
|
||||
<Button v-else @click="handleEdit">编辑</Button>
|
||||
<Button
|
||||
v-if="!isEditing"
|
||||
type="primary"
|
||||
@@ -1,14 +1,14 @@
|
||||
<!-- 设备信息(头部) -->
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Card, Descriptions, message } from 'ant-design-vue';
|
||||
|
||||
import DeviceForm from '../device-form.vue';
|
||||
import DeviceForm from '../../form.vue';
|
||||
|
||||
interface Props {
|
||||
product: IotProductApi.Product;
|
||||
@@ -26,20 +26,19 @@ const emit = defineEmits<{
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
/** 操作修改 */
|
||||
const formRef = ref();
|
||||
function openForm(type: string, id?: number) {
|
||||
formRef.value.open(type, id);
|
||||
}
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: DeviceForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 复制到剪贴板方法 */
|
||||
/** 复制到剪贴板 */
|
||||
async function copyToClipboard(text: string | undefined) {
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
message.success({ content: '复制成功' });
|
||||
message.success('复制成功');
|
||||
} catch {
|
||||
message.error({ content: '复制失败' });
|
||||
message.error('复制失败');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,19 +48,25 @@ function goToProductDetail(productId: number | undefined) {
|
||||
router.push({ name: 'IoTProductDetail', params: { id: productId } });
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开编辑表单 */
|
||||
function openEditForm(row: IotDeviceApi.Device) {
|
||||
formModalApi.setData(row).open();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="mb-4">
|
||||
<FormModal @success="emit('refresh')" />
|
||||
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">{{ device.deviceName }}</h2>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<!-- 右上:按钮 -->
|
||||
<Button
|
||||
v-if="product.status === 0"
|
||||
v-access:code="['iot:device:update']"
|
||||
@click="openForm('update', device.id)"
|
||||
@click="openEditForm(device)"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
@@ -72,8 +77,8 @@ function goToProductDetail(productId: number | undefined) {
|
||||
<Descriptions :column="1">
|
||||
<Descriptions.Item label="产品">
|
||||
<a
|
||||
@click="goToProductDetail(product.id)"
|
||||
class="cursor-pointer text-blue-600"
|
||||
@click="goToProductDetail(product.id)"
|
||||
>
|
||||
{{ product.name }}
|
||||
</a>
|
||||
@@ -90,8 +95,5 @@ function goToProductDetail(productId: number | undefined) {
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<!-- 表单弹窗:添加/修改 -->
|
||||
<DeviceForm ref="formRef" @success="emit('refresh')" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,4 @@
|
||||
<!-- 设备信息 -->
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
|
||||
@@ -24,51 +23,46 @@ import {
|
||||
import { getDeviceAuthInfo } from '#/api/iot/device/device';
|
||||
import { DictTag } from '#/components/dict-tag';
|
||||
|
||||
// 消息提示
|
||||
|
||||
const { product, device } = defineProps<{
|
||||
interface Props {
|
||||
device: IotDeviceApi.Device;
|
||||
product: IotProductApi.Product;
|
||||
}>(); // 定义 Props
|
||||
// const emit = defineEmits(['refresh']); // 定义 Emits
|
||||
}
|
||||
|
||||
const authDialogVisible = ref(false); // 定义设备认证信息弹框的可见性
|
||||
const authPasswordVisible = ref(false); // 定义密码可见性状态
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const authDialogVisible = ref(false);
|
||||
const authPasswordVisible = ref(false);
|
||||
const authInfo = ref<IotDeviceApi.DeviceAuthInfo>(
|
||||
{} as IotDeviceApi.DeviceAuthInfo,
|
||||
); // 定义设备认证信息对象
|
||||
);
|
||||
|
||||
/** 控制地图显示的标志 */
|
||||
const showMap = computed(() => {
|
||||
return !!(device.longitude && device.latitude);
|
||||
return !!(props.device.longitude && props.device.latitude);
|
||||
});
|
||||
|
||||
/** 复制到剪贴板方法 */
|
||||
/** 复制到剪贴板 */
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
message.success({ content: '复制成功' });
|
||||
message.success('复制成功');
|
||||
} catch {
|
||||
message.error({ content: '复制失败' });
|
||||
message.error('复制失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开设备认证信息弹框的方法 */
|
||||
/** 打开设备认证信息弹框 */
|
||||
async function handleAuthInfoDialogOpen() {
|
||||
if (!device.id) return;
|
||||
if (!props.device.id) return;
|
||||
try {
|
||||
authInfo.value = await getDeviceAuthInfo(device.id);
|
||||
// 显示设备认证信息弹框
|
||||
authInfo.value = await getDeviceAuthInfo(props.device.id);
|
||||
authDialogVisible.value = true;
|
||||
} catch (error) {
|
||||
console.error('获取设备认证信息出错:', error);
|
||||
message.error({
|
||||
content: '获取设备认证信息失败,请检查网络连接或联系管理员',
|
||||
});
|
||||
} catch {
|
||||
message.error('获取设备认证信息失败,请检查网络连接或联系管理员');
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭设备认证信息弹框的方法 */
|
||||
/** 关闭设备认证信息弹框 */
|
||||
function handleAuthInfoDialogClose() {
|
||||
authDialogVisible.value = false;
|
||||
}
|
||||
@@ -87,40 +81,40 @@ function handleAuthInfoDialogClose() {
|
||||
</template>
|
||||
<Descriptions :column="1" bordered size="small">
|
||||
<Descriptions.Item label="产品名称">
|
||||
{{ product.name }}
|
||||
{{ props.product.name }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="ProductKey">
|
||||
{{ product.productKey }}
|
||||
{{ props.product.productKey }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="设备类型">
|
||||
<DictTag
|
||||
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
|
||||
:value="product.deviceType"
|
||||
:value="props.product.deviceType"
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="DeviceName">
|
||||
{{ device.deviceName }}
|
||||
{{ props.device.deviceName }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注名称">
|
||||
{{ device.nickname || '--' }}
|
||||
{{ props.device.nickname || '--' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="当前状态">
|
||||
<DictTag
|
||||
:type="DICT_TYPE.IOT_DEVICE_STATE"
|
||||
:value="device.state"
|
||||
:value="props.device.state"
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{{ formatDate(device.createTime) }}
|
||||
{{ formatDate(props.device.createTime) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="激活时间">
|
||||
{{ formatDate(device.activeTime) }}
|
||||
{{ formatDate(props.device.activeTime) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最后上线时间">
|
||||
{{ formatDate(device.onlineTime) }}
|
||||
{{ formatDate(props.device.onlineTime) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最后离线时间">
|
||||
{{ formatDate(device.offlineTime) }}
|
||||
{{ formatDate(props.device.offlineTime) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="MQTT 连接参数">
|
||||
<Button
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
@@ -27,6 +27,8 @@ import { getDeviceMessagePage } from '#/api/iot/device/device';
|
||||
import { DictTag } from '#/components/dict-tag';
|
||||
import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants';
|
||||
|
||||
// TODO @haohao:看看能不能调整成 Grid 风格~方便 element-plus 的迁移
|
||||
|
||||
const props = defineProps<{
|
||||
deviceId: number;
|
||||
}>();
|
||||
@@ -9,6 +9,7 @@ import type { ThingModelData } from '#/api/iot/thingmodel';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { ContentWrap } from '@vben/common-ui';
|
||||
import { DeviceStateEnum } from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
@@ -21,14 +22,14 @@ import {
|
||||
Textarea,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { DeviceStateEnum, sendDeviceMessage } from '#/api/iot/device/device';
|
||||
import DataDefinition from '#/views/iot/thingmodel/modules/components/data-definition.vue';
|
||||
import { sendDeviceMessage } from '#/api/iot/device/device';
|
||||
import {
|
||||
IotDeviceMessageMethodEnum,
|
||||
IoTThingModelTypeEnum,
|
||||
} from '#/views/iot/utils/constants';
|
||||
|
||||
import DeviceDetailsMessage from './device-details-message.vue';
|
||||
import DataDefinition from '../../../../../thingmodel/modules/components/data-definition.vue';
|
||||
import DeviceDetailsMessage from './message.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
device: IotDeviceApi.Device;
|
||||
@@ -339,6 +340,7 @@ async function handleServiceInvoke(row: ThingModelData) {
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 上方:指令调试区域 -->
|
||||
<!-- TODO @haohao:要不要改成左右; -->
|
||||
<Card class="simulator-tabs mb-4">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -3,6 +3,8 @@ import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Card, Empty } from 'ant-design-vue';
|
||||
|
||||
// TODO @haohao:这里要实现一把么?
|
||||
|
||||
interface Props {
|
||||
deviceId: number;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<!-- 设备事件管理 -->
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
// TODO @haohao:看看能不能用 Grid 实现下,方便 element-plus 迁移
|
||||
import type { ThingModelData } from '#/api/iot/thingmodel';
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
@@ -1,6 +1,5 @@
|
||||
<!-- 设备物模型 -> 运行状态 -> 查看数据(设备的属性值历史)-->
|
||||
// 重新关闭打开图表,图表不显示可能图例注销失败等大佬修复
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
@@ -11,14 +10,13 @@ import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
import { beginOfDay, endOfDay, formatDate, formatDateTime } from '@vben/utils';
|
||||
import { formatDate, formatDateTime } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Empty,
|
||||
message,
|
||||
Modal,
|
||||
RangePicker,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
@@ -27,6 +25,7 @@ import {
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { getHistoryDevicePropertyList } from '#/api/iot/device/device';
|
||||
import ShortcutDateRangePicker from '#/components/shortcut-date-range-picker/shortcut-date-range-picker.vue';
|
||||
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants';
|
||||
|
||||
/** IoT 设备属性历史数据详情 */
|
||||
@@ -42,41 +41,37 @@ const list = ref<IotDeviceApi.DevicePropertyDetail[]>([]); // 列表的数据
|
||||
const total = ref(0); // 总数据量
|
||||
const thingModelDataType = ref<string>(''); // 物模型数据类型
|
||||
const propertyIdentifier = ref<string>(''); // 属性标识符
|
||||
const dateRange = ref<[Dayjs, Dayjs]>([
|
||||
dayjs().subtract(7, 'day').startOf('day'),
|
||||
dayjs().endOf('day'),
|
||||
]);
|
||||
|
||||
const dateRange = ref<[string, string]>([
|
||||
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
|
||||
dayjs().format('YYYY-MM-DD'),
|
||||
]); // 时间范围(仅日期,不包含时分秒)
|
||||
|
||||
const queryParams = reactive({
|
||||
deviceId: -1,
|
||||
identifier: '',
|
||||
times: [
|
||||
formatDateTime(beginOfDay(new Date(Date.now() - 3600 * 1000 * 24 * 7))),
|
||||
formatDateTime(endOfDay(new Date())),
|
||||
],
|
||||
times: formatDateRangeWithTime(dateRange.value),
|
||||
});
|
||||
|
||||
// Echarts 相关
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
// 判断是否为复杂数据类型(struct 或 array)
|
||||
const isComplexDataType = computed(() => {
|
||||
if (!thingModelDataType.value) return false;
|
||||
return [
|
||||
IoTDataSpecsDataTypeEnum.ARRAY,
|
||||
IoTDataSpecsDataTypeEnum.STRUCT,
|
||||
].includes(thingModelDataType.value as any);
|
||||
});
|
||||
}); // 判断是否为复杂数据类型(struct 或 array)
|
||||
|
||||
// 统计数据
|
||||
const maxValue = computed(() => {
|
||||
if (isComplexDataType.value || list.value.length === 0) return '-';
|
||||
const values = list.value
|
||||
.map((item) => Number(item.value))
|
||||
.filter((v) => !Number.isNaN(v));
|
||||
return values.length > 0 ? Math.max(...values).toFixed(2) : '-';
|
||||
});
|
||||
}); // 统计数据
|
||||
|
||||
const minValue = computed(() => {
|
||||
if (isComplexDataType.value || list.value.length === 0) return '-';
|
||||
@@ -96,6 +91,11 @@ const avgValue = computed(() => {
|
||||
return (sum / values.length).toFixed(2);
|
||||
});
|
||||
|
||||
/** 将日期范围转换为带时分秒的格式 */
|
||||
function formatDateRangeWithTime(dates: [string, string]): [string, string] {
|
||||
return [`${dates[0]} 00:00:00`, `${dates[1]} 23:59:59`];
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const tableColumns = computed(() => [
|
||||
{
|
||||
@@ -142,16 +142,13 @@ async function getList() {
|
||||
) as IotDeviceApi.DevicePropertyDetail[];
|
||||
total.value = list.value.length;
|
||||
|
||||
// 如果是图表模式且不是复杂数据类型,渲染图表
|
||||
// 如果是图表模式且不是复杂数据类型,等待渲染图表
|
||||
if (
|
||||
viewMode.value === 'chart' &&
|
||||
!isComplexDataType.value &&
|
||||
list.value.length > 0
|
||||
) {
|
||||
// 等待 DOM 更新完成后再渲染图表
|
||||
await nextTick();
|
||||
await nextTick(); // 双重 nextTick 确保 DOM 完全准备好
|
||||
renderChart();
|
||||
await renderChartWhenReady();
|
||||
}
|
||||
} catch {
|
||||
message.error('获取数据失败');
|
||||
@@ -162,126 +159,115 @@ async function getList() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 确保图表容器已经可见后再渲染 */
|
||||
async function renderChartWhenReady() {
|
||||
if (!list.value || list.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
// 等待 Modal、Card loading 状态、v-show 等 DOM 更新完成
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
renderChart();
|
||||
}
|
||||
|
||||
/** 渲染图表 */
|
||||
function renderChart() {
|
||||
if (!list.value || list.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chartData = list.value.map((item) => [item.updateTime, item.value]);
|
||||
const times = list.value.map((item) =>
|
||||
formatDate(new Date(item.updateTime), 'YYYY-MM-DD HH:mm:ss'),
|
||||
);
|
||||
const values = list.value.map((item) => Number(item.value));
|
||||
|
||||
// 使用 setTimeout 延迟渲染,避免 ECharts 主进程冲突
|
||||
setTimeout(() => {
|
||||
// 检查 chartRef 是否存在且已挂载
|
||||
if (!chartRef.value || !chartRef.value.$el) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderEcharts({
|
||||
title: {
|
||||
text: '属性值趋势',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
renderEcharts({
|
||||
title: {
|
||||
text: '属性值趋势',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
grid: {
|
||||
left: 60,
|
||||
right: 60,
|
||||
bottom: 100,
|
||||
top: 80,
|
||||
containLabel: true,
|
||||
},
|
||||
grid: {
|
||||
left: 60,
|
||||
right: 60,
|
||||
bottom: 100,
|
||||
top: 80,
|
||||
containLabel: true,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
},
|
||||
formatter: (params: any) => {
|
||||
const param = params[0];
|
||||
return `
|
||||
<div style="padding: 8px;">
|
||||
<div style="margin-bottom: 4px; font-weight: bold;">
|
||||
${formatDate(new Date(param.value[0]), 'YYYY-MM-DD HH:mm:ss')}
|
||||
</div>
|
||||
<div>
|
||||
<span style="display:inline-block;margin-right:5px;border-radius:50%;width:10px;height:10px;background-color:${param.color};"></span>
|
||||
<span>属性值: <strong>${param.value[1]}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
name: '时间',
|
||||
nameTextStyle: {
|
||||
padding: [10, 0, 0, 0],
|
||||
},
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
name: '时间',
|
||||
nameTextStyle: {
|
||||
padding: [10, 0, 0, 0],
|
||||
},
|
||||
axisLabel: {
|
||||
formatter: (value: number) => {
|
||||
return String(formatDate(new Date(value), 'MM-DD HH:mm') || '');
|
||||
},
|
||||
},
|
||||
data: times,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '属性值',
|
||||
nameTextStyle: {
|
||||
padding: [0, 0, 10, 0],
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '属性值',
|
||||
nameTextStyle: {
|
||||
padding: [0, 0, 10, 0],
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: '#1890FF',
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#1890FF',
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0,
|
||||
color: 'rgba(24, 144, 255, 0.3)',
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: 'rgba(24, 144, 255, 0.05)',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
data: values,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '属性值',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: '#1890FF',
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#1890FF',
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0,
|
||||
color: 'rgba(24, 144, 255, 0.3)',
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: 'rgba(24, 144, 255, 0.05)',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
data: chartData,
|
||||
},
|
||||
],
|
||||
dataZoom: [
|
||||
{
|
||||
type: 'inside',
|
||||
start: 0,
|
||||
end: 100,
|
||||
},
|
||||
{
|
||||
type: 'slider',
|
||||
height: 30,
|
||||
bottom: 20,
|
||||
},
|
||||
],
|
||||
});
|
||||
}, 300); // 延迟300ms渲染,确保 DOM 完全准备好
|
||||
],
|
||||
dataZoom: [
|
||||
{
|
||||
type: 'inside',
|
||||
start: 0,
|
||||
end: 100,
|
||||
},
|
||||
{
|
||||
type: 'slider',
|
||||
height: 30,
|
||||
bottom: 20,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 打开弹窗 */
|
||||
@@ -294,42 +280,33 @@ async function open(deviceId: number, identifier: string, dataType: string) {
|
||||
|
||||
// 重置时间范围为最近7天
|
||||
dateRange.value = [
|
||||
dayjs().subtract(7, 'day').startOf('day'),
|
||||
dayjs().endOf('day'),
|
||||
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
|
||||
dayjs().format('YYYY-MM-DD'),
|
||||
];
|
||||
|
||||
// 更新查询参数的时间
|
||||
queryParams.times = [
|
||||
formatDateTime(dateRange.value[0].toDate()),
|
||||
formatDateTime(dateRange.value[1].toDate()),
|
||||
];
|
||||
queryParams.times = formatDateRangeWithTime(dateRange.value);
|
||||
|
||||
// 如果物模型是 struct、array,需要默认使用 list 模式
|
||||
viewMode.value = isComplexDataType.value ? 'list' : 'chart';
|
||||
|
||||
// 等待弹窗完全渲染后再获取数据
|
||||
await nextTick();
|
||||
await nextTick(); // 双重 nextTick 确保 Modal 完全渲染
|
||||
await getList();
|
||||
|
||||
// 如果是图表模式,延迟渲染图表
|
||||
if (viewMode.value === 'chart' && !isComplexDataType.value) {
|
||||
setTimeout(() => {
|
||||
renderChart();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/** 时间变化处理 */
|
||||
function handleTimeChange() {
|
||||
if (!dateRange.value || dateRange.value.length !== 2) {
|
||||
/** 处理时间范围变化 */
|
||||
function handleDateRangeChange(times?: [Dayjs, Dayjs]) {
|
||||
if (!times || times.length !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryParams.times = [
|
||||
formatDateTime(dateRange.value[0].toDate()),
|
||||
formatDateTime(dateRange.value[1].toDate()),
|
||||
dateRange.value = [
|
||||
dayjs(times[0]).format('YYYY-MM-DD'),
|
||||
dayjs(times[1]).format('YYYY-MM-DD'),
|
||||
];
|
||||
|
||||
// 将选择的日期转换为带时分秒的格式(开始日期 00:00:00,结束日期 23:59:59)
|
||||
queryParams.times = formatDateRangeWithTime(dateRange.value);
|
||||
getList();
|
||||
}
|
||||
|
||||
@@ -408,14 +385,7 @@ watch(viewMode, async (newMode) => {
|
||||
!isComplexDataType.value &&
|
||||
list.value.length > 0
|
||||
) {
|
||||
// 等待 DOM 显示完成
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// 延迟渲染图表
|
||||
setTimeout(() => {
|
||||
renderChart();
|
||||
}, 300);
|
||||
await renderChartWhenReady();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -434,14 +404,12 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
|
||||
<div class="toolbar-wrapper mb-4">
|
||||
<Space :size="12" class="w-full" wrap>
|
||||
<!-- 时间选择 -->
|
||||
<RangePicker
|
||||
v-model:value="dateRange"
|
||||
:show-time="{ format: 'HH:mm:ss' }"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
:placeholder="['开始时间', '结束时间']"
|
||||
class="!w-[400px]"
|
||||
@change="handleTimeChange"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="whitespace-nowrap text-sm text-gray-500">
|
||||
时间范围
|
||||
</span>
|
||||
<ShortcutDateRangePicker @change="handleDateRangeChange" />
|
||||
</div>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<Button @click="handleRefresh" :loading="loading">
|
||||
@@ -501,7 +469,7 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
|
||||
|
||||
<!-- 数据展示区域 -->
|
||||
<Spin :spinning="loading" :delay="200">
|
||||
<!-- 图表模式 -->
|
||||
<!-- 图表模式 - 使用 v-show 确保图表组件始终挂载 -->
|
||||
<div v-show="viewMode === 'chart'" class="chart-container">
|
||||
<Empty
|
||||
v-if="list.length === 0"
|
||||
@@ -509,7 +477,7 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
|
||||
description="暂无数据"
|
||||
class="py-20"
|
||||
/>
|
||||
<div v-else>
|
||||
<div v-show="list.length > 0">
|
||||
<EchartsUI ref="chartRef" height="500px" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -561,7 +529,10 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
|
||||
.chart-container,
|
||||
.table-container {
|
||||
padding: 16px;
|
||||
background-color: hsl(var(--card));
|
||||
background-color: hsl(
|
||||
var(--card)
|
||||
); // TODO @haohao:看看这个能不能 fix 下~ idea 爆红了;
|
||||
|
||||
border: 1px solid hsl(var(--border) / 60%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<!-- 设备属性管理 -->
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
// TODO @haohao:看看能不能用 Grid 实现下,方便 element-plus 迁移
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
|
||||
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
|
||||
import { getLatestDeviceProperties } from '#/api/iot/device/device';
|
||||
|
||||
import DeviceDetailsThingModelPropertyHistory from './device-details-thing-model-property-history.vue';
|
||||
import DeviceDetailsThingModelPropertyHistory from './thing-model-property-history.vue';
|
||||
|
||||
const props = defineProps<{ deviceId: number }>();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<!-- 设备服务调用 -->
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
// TODO @haohao:看看能不能调整成 Grid 风格~方便 element-plus 的迁移
|
||||
import type { ThingModelData } from '#/api/iot/thingmodel';
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- 设备物模型:设备属性、事件管理、服务调用 -->
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import type { ThingModelData } from '#/api/iot/thingmodel';
|
||||
|
||||
import { ref } from 'vue';
|
||||
@@ -8,9 +8,9 @@ import { ContentWrap } from '@vben/common-ui';
|
||||
|
||||
import { Tabs } from 'ant-design-vue';
|
||||
|
||||
import DeviceDetailsThingModelEvent from './device-details-thing-model-event.vue';
|
||||
import DeviceDetailsThingModelProperty from './device-details-thing-model-property.vue';
|
||||
import DeviceDetailsThingModelService from './device-details-thing-model-service.vue';
|
||||
import DeviceDetailsThingModelEvent from './thing-model-event.vue';
|
||||
import DeviceDetailsThingModelProperty from './thing-model-property.vue';
|
||||
import DeviceDetailsThingModelService from './thing-model-service.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
deviceId: number;
|
||||
@@ -1,619 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictLabel, getDictObj } from '@vben/hooks';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { isValidColor, TinyColor } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { DeviceStateEnum, getDevicePage } from '#/api/iot/device/device';
|
||||
|
||||
defineOptions({ name: 'DeviceCardView' });
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [];
|
||||
delete: [row: any];
|
||||
detail: [id: number];
|
||||
edit: [row: any];
|
||||
model: [id: number];
|
||||
productDetail: [productId: number];
|
||||
}>();
|
||||
|
||||
interface Props {
|
||||
products: any[];
|
||||
deviceGroups: any[];
|
||||
searchParams?: {
|
||||
deviceName: string;
|
||||
deviceType?: number;
|
||||
groupId?: number;
|
||||
nickname: string;
|
||||
productId?: number;
|
||||
status?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const queryParams = ref({
|
||||
pageNo: 1,
|
||||
pageSize: 12,
|
||||
});
|
||||
|
||||
const DEFAULT_STATUS_MAP: Record<
|
||||
'default' | number,
|
||||
{ bgColor: string; borderColor: string; color: string; text: string }
|
||||
> = {
|
||||
[DeviceStateEnum.ONLINE]: {
|
||||
text: '在线',
|
||||
color: '#52c41a',
|
||||
bgColor: '#f6ffed',
|
||||
borderColor: '#b7eb8f',
|
||||
},
|
||||
[DeviceStateEnum.OFFLINE]: {
|
||||
text: '离线',
|
||||
color: '#faad14',
|
||||
bgColor: '#fffbe6',
|
||||
borderColor: '#ffe58f',
|
||||
},
|
||||
[DeviceStateEnum.INACTIVE]: {
|
||||
text: '未激活',
|
||||
color: '#ff4d4f',
|
||||
bgColor: '#fff1f0',
|
||||
borderColor: '#ffccc7',
|
||||
},
|
||||
default: {
|
||||
text: '未知状态',
|
||||
color: '#595959',
|
||||
bgColor: '#fafafa',
|
||||
borderColor: '#d9d9d9',
|
||||
},
|
||||
};
|
||||
|
||||
const COLOR_TYPE_PRESETS: Record<
|
||||
string,
|
||||
{ bgColor: string; borderColor: string; color: string }
|
||||
> = {
|
||||
success: {
|
||||
color: '#52c41a',
|
||||
bgColor: '#f6ffed',
|
||||
borderColor: '#b7eb8f',
|
||||
},
|
||||
processing: {
|
||||
color: '#1890ff',
|
||||
bgColor: '#e6f7ff',
|
||||
borderColor: '#91d5ff',
|
||||
},
|
||||
warning: {
|
||||
color: '#faad14',
|
||||
bgColor: '#fffbe6',
|
||||
borderColor: '#ffe58f',
|
||||
},
|
||||
error: {
|
||||
color: '#ff4d4f',
|
||||
bgColor: '#fff1f0',
|
||||
borderColor: '#ffccc7',
|
||||
},
|
||||
default: {
|
||||
color: '#595959',
|
||||
bgColor: '#fafafa',
|
||||
borderColor: '#d9d9d9',
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeColorType(colorType?: string) {
|
||||
switch (colorType) {
|
||||
case 'danger': {
|
||||
return 'error';
|
||||
}
|
||||
case 'default':
|
||||
case 'error':
|
||||
case 'processing':
|
||||
case 'success':
|
||||
case 'warning': {
|
||||
return colorType;
|
||||
}
|
||||
case 'info': {
|
||||
return 'default';
|
||||
}
|
||||
case 'primary': {
|
||||
return 'processing';
|
||||
}
|
||||
default: {
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取产品名称
|
||||
function getProductName(productId: number) {
|
||||
const product = props.products.find((p: any) => p.id === productId);
|
||||
return product?.name || '-';
|
||||
}
|
||||
|
||||
// 获取设备列表
|
||||
async function getList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getDevicePage({
|
||||
...queryParams.value,
|
||||
...props.searchParams,
|
||||
});
|
||||
list.value = data.list || [];
|
||||
total.value = data.total || 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理页码变化
|
||||
function handlePageChange(page: number, pageSize: number) {
|
||||
queryParams.value.pageNo = page;
|
||||
queryParams.value.pageSize = pageSize;
|
||||
getList();
|
||||
}
|
||||
|
||||
// 获取设备类型颜色
|
||||
function getDeviceTypeColor(deviceType: number) {
|
||||
const colors: Record<number, string> = {
|
||||
0: 'blue',
|
||||
1: 'cyan',
|
||||
};
|
||||
return colors[deviceType] || 'default';
|
||||
}
|
||||
|
||||
// 获取设备状态信息
|
||||
function getStatusInfo(state: null | number | string | undefined) {
|
||||
const parsedState = Number(state);
|
||||
const hasNumericState = Number.isFinite(parsedState);
|
||||
const fallback = hasNumericState
|
||||
? DEFAULT_STATUS_MAP[parsedState] || DEFAULT_STATUS_MAP.default
|
||||
: DEFAULT_STATUS_MAP.default;
|
||||
const dict = getDictObj(
|
||||
DICT_TYPE.IOT_DEVICE_STATE,
|
||||
hasNumericState ? parsedState : state,
|
||||
);
|
||||
if (dict) {
|
||||
if (!dict.colorType && !dict.cssClass) {
|
||||
return {
|
||||
...fallback,
|
||||
text: dict.label || fallback.text,
|
||||
};
|
||||
}
|
||||
const presetKey = normalizeColorType(dict.colorType);
|
||||
if (isValidColor(dict.cssClass)) {
|
||||
const baseColor = new TinyColor(dict.cssClass);
|
||||
return {
|
||||
text: dict.label || fallback.text,
|
||||
color: baseColor.toHexString(),
|
||||
bgColor: baseColor.clone().setAlpha(0.15).toRgbString(),
|
||||
borderColor: baseColor.clone().lighten(30).toHexString(),
|
||||
};
|
||||
}
|
||||
const preset = COLOR_TYPE_PRESETS[presetKey] || COLOR_TYPE_PRESETS.default;
|
||||
return {
|
||||
text: dict.label || fallback.text,
|
||||
...preset,
|
||||
};
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
|
||||
// 暴露方法供父组件调用
|
||||
defineExpose({
|
||||
reload: getList,
|
||||
search: () => {
|
||||
queryParams.value.pageNo = 1;
|
||||
getList();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="device-card-view">
|
||||
<!-- 设备卡片列表 -->
|
||||
<div v-loading="loading" class="min-h-[400px]">
|
||||
<Row v-if="list.length > 0" :gutter="[16, 16]">
|
||||
<Col
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="8"
|
||||
:lg="6"
|
||||
>
|
||||
<Card
|
||||
:body-style="{ padding: 0 }"
|
||||
class="device-card"
|
||||
:bordered="false"
|
||||
>
|
||||
<!-- 卡片内容 -->
|
||||
<div class="card-content">
|
||||
<!-- 头部:图标和状态 -->
|
||||
<div class="card-header">
|
||||
<div class="device-icon">
|
||||
<IconifyIcon icon="mdi:chip" />
|
||||
</div>
|
||||
<div
|
||||
class="status-badge"
|
||||
:style="{
|
||||
color: getStatusInfo(item.state).color,
|
||||
backgroundColor: getStatusInfo(item.state).bgColor,
|
||||
borderColor: getStatusInfo(item.state).borderColor,
|
||||
}"
|
||||
>
|
||||
<span class="status-dot"></span>
|
||||
{{ getStatusInfo(item.state).text }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备名称 -->
|
||||
<div class="device-name" :title="item.deviceName">
|
||||
{{ item.deviceName }}
|
||||
</div>
|
||||
|
||||
<!-- 信息区域 -->
|
||||
<div class="info-section">
|
||||
<div class="info-item">
|
||||
<span class="label">所属产品</span>
|
||||
<a
|
||||
class="value link"
|
||||
@click="
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
emit('productDetail', item.productId);
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ getProductName(item.productId) }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">设备类型</span>
|
||||
<Tag
|
||||
:color="getDeviceTypeColor(item.deviceType)"
|
||||
size="small"
|
||||
>
|
||||
{{
|
||||
getDictLabel(
|
||||
DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE,
|
||||
item.deviceType,
|
||||
)
|
||||
}}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Deviceid</span>
|
||||
<span class="value code" :title="item.Deviceid || item.id">
|
||||
{{ item.Deviceid || item.id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-bar">
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
class="action-btn btn-edit"
|
||||
@click="
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
emit('edit', item);
|
||||
}
|
||||
"
|
||||
>
|
||||
<IconifyIcon icon="ph:note-pencil" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
class="action-btn btn-view"
|
||||
@click="
|
||||
(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
emit('detail', item.id);
|
||||
}
|
||||
"
|
||||
>
|
||||
<IconifyIcon icon="ph:eye" />
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
class="action-btn btn-data"
|
||||
@click="
|
||||
(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
emit('model', item.id);
|
||||
}
|
||||
"
|
||||
>
|
||||
<IconifyIcon icon="ph:database" />
|
||||
数据
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除该设备吗?"
|
||||
@confirm="() => emit('delete', item)"
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
class="action-btn btn-delete"
|
||||
@click="(e: MouseEvent) => e.stopPropagation()"
|
||||
>
|
||||
<IconifyIcon icon="ph:trash" />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<Empty v-else description="暂无设备数据" class="my-20" />
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="list.length > 0" class="mt-6 flex justify-center">
|
||||
<Pagination
|
||||
v-model:current="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
:show-total="(total) => `共 ${total} 条`"
|
||||
show-quick-jumper
|
||||
show-size-changer
|
||||
:page-size-options="['12', '24', '36', '48']"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.device-card-view {
|
||||
.device-card {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--card) / 95%);
|
||||
border: 1px solid hsl(var(--border) / 60%);
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 1px 2px 0 hsl(var(--foreground) / 4%),
|
||||
0 1px 6px -1px hsl(var(--foreground) / 5%),
|
||||
0 2px 4px 0 hsl(var(--foreground) / 5%);
|
||||
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
|
||||
&:hover {
|
||||
border-color: hsl(var(--border));
|
||||
box-shadow:
|
||||
0 1px 2px -2px hsl(var(--foreground) / 12%),
|
||||
0 3px 6px 0 hsl(var(--foreground) / 10%),
|
||||
0 5px 12px 4px hsl(var(--foreground) / 8%);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
// 头部区域
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.device-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgb(102 126 234 / 25%);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
border: 1px solid;
|
||||
border-radius: 12px;
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: currentcolor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设备名称
|
||||
.device-name {
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
color: hsl(var(--foreground) / 90%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 信息区域
|
||||
.info-section {
|
||||
flex: 1;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--foreground) / 60%);
|
||||
}
|
||||
|
||||
.value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--foreground) / 85%);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
|
||||
&.link {
|
||||
color: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: hsl(var(--primary) / 85%);
|
||||
}
|
||||
}
|
||||
|
||||
&.code {
|
||||
font-family:
|
||||
'SF Mono', Monaco, Inconsolata, 'Fira Code', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground) / 60%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 操作按钮栏
|
||||
.action-bar {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid hsl(var(--border) / 40%);
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
|
||||
:deep(.anticon) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&.btn-edit {
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 12%);
|
||||
border-color: hsl(var(--primary) / 25%);
|
||||
|
||||
&:hover {
|
||||
color: hsl(var(--primary-foreground));
|
||||
background: hsl(var(--primary));
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-view {
|
||||
color: hsl(var(--warning));
|
||||
background: hsl(var(--warning) / 12%);
|
||||
border-color: hsl(var(--warning) / 25%);
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: hsl(var(--warning));
|
||||
border-color: hsl(var(--warning));
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-data {
|
||||
color: hsl(var(--accent-foreground));
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
hsl(var(--accent)) 40%,
|
||||
hsl(var(--card)) 60%
|
||||
);
|
||||
border-color: color-mix(in srgb, hsl(var(--accent)) 55%, transparent);
|
||||
|
||||
&:hover {
|
||||
color: hsl(var(--accent-foreground));
|
||||
background: hsl(var(--accent));
|
||||
border-color: hsl(var(--accent));
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-delete {
|
||||
flex: 0 0 32px;
|
||||
padding: 4px;
|
||||
color: hsl(var(--destructive));
|
||||
background: hsl(var(--destructive) / 12%);
|
||||
border-color: hsl(var(--destructive) / 30%);
|
||||
|
||||
&:hover {
|
||||
color: hsl(var(--destructive-foreground));
|
||||
background: hsl(var(--destructive));
|
||||
border-color: hsl(var(--destructive));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,133 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
||||
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { importDeviceTemplate } from '#/api/iot/device/device';
|
||||
|
||||
import { useImportFormSchema } from '../data';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceImportForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const getTitle = computed(() => '设备导入');
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useImportFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await formApi.getValues();
|
||||
const file = values.file;
|
||||
|
||||
if (!file || file.length === 0) {
|
||||
message.error('请上传文件');
|
||||
return;
|
||||
}
|
||||
|
||||
modalApi.lock();
|
||||
try {
|
||||
// 构建表单数据
|
||||
const formData = new FormData();
|
||||
formData.append('file', file[0].originFileObj);
|
||||
formData.append('updateSupport', values.updateSupport ? 'true' : 'false');
|
||||
|
||||
// 使用 fetch 上传文件
|
||||
const accessToken = localStorage.getItem('accessToken') || '';
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_GLOB_API_URL}/iot/device/import?updateSupport=${values.updateSupport}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code !== 0) {
|
||||
message.error(result.msg || '导入失败');
|
||||
return;
|
||||
}
|
||||
|
||||
// 拼接提示语
|
||||
const data = result.data;
|
||||
let text = `上传成功数量:${data.createDeviceNames?.length || 0};`;
|
||||
if (data.createDeviceNames) {
|
||||
for (const deviceName of data.createDeviceNames) {
|
||||
text += `< ${deviceName} >`;
|
||||
}
|
||||
}
|
||||
text += `更新成功数量:${data.updateDeviceNames?.length || 0};`;
|
||||
if (data.updateDeviceNames) {
|
||||
for (const deviceName of data.updateDeviceNames) {
|
||||
text += `< ${deviceName} >`;
|
||||
}
|
||||
}
|
||||
text += `更新失败数量:${Object.keys(data.failureDeviceNames || {}).length};`;
|
||||
if (data.failureDeviceNames) {
|
||||
for (const deviceName in data.failureDeviceNames) {
|
||||
text += `< ${deviceName}: ${data.failureDeviceNames[deviceName]} >`;
|
||||
}
|
||||
}
|
||||
message.info(text);
|
||||
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '导入失败');
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
// 重置表单
|
||||
await formApi.resetForm();
|
||||
await formApi.setValues({
|
||||
updateSupport: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 下载模板 */
|
||||
async function handleDownloadTemplate() {
|
||||
try {
|
||||
const res = await importDeviceTemplate();
|
||||
downloadFileFromBlobPart({ fileName: '设备导入模版.xls', source: res });
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '下载失败');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="getTitle" class="w-1/3">
|
||||
<Form class="mx-4" />
|
||||
<div class="mx-4 mt-4 text-center">
|
||||
<a class="cursor-pointer text-primary" @click="handleDownloadTemplate">
|
||||
下载导入模板
|
||||
</a>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -3,10 +3,11 @@ import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { createDevice, getDevice, updateDevice } from '#/api/iot/device/device';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
@@ -15,9 +16,11 @@ import { useFormSchema } from '../data';
|
||||
defineOptions({ name: 'IoTDeviceForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<any>();
|
||||
const formData = ref<IotDeviceApi.Device>();
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id ? '编辑设备' : '新增设备';
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['设备'])
|
||||
: $t('ui.actionTitle.create', ['设备']);
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
@@ -57,14 +60,17 @@ const [Modal, modalApi] = useVbenModal({
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
const data = modalApi.getData<any>();
|
||||
const data = modalApi.getData<IotDeviceApi.Device>();
|
||||
if (!data || !data.id) {
|
||||
// 新增模式:设置默认值(如果需要)
|
||||
// TODO @haohao:是不是 return 就好啦;不用这里 undefined 啦;
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
// 编辑模式:加载数据
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getDevice(data.id);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { updateDeviceGroup } from '#/api/iot/device/device';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FileType } from 'ant-design-vue/es/upload/interface';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||
|
||||
import { Button, message, Upload } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { importDevice, importDeviceTemplate } from '#/api/iot/device/device';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useImportFormSchema } from '../data';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceImportForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 120,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useImportFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data = await formApi.getValues();
|
||||
try {
|
||||
const result = await importDevice(data.file, data.updateSupport);
|
||||
// 处理导入结果提示
|
||||
const importData = result.data || result;
|
||||
if (importData) {
|
||||
let text = `上传成功数量:${importData.createDeviceNames?.length || 0};`;
|
||||
if (importData.createDeviceNames?.length) {
|
||||
for (const deviceName of importData.createDeviceNames) {
|
||||
text += `< ${deviceName} >`;
|
||||
}
|
||||
}
|
||||
text += `更新成功数量:${importData.updateDeviceNames?.length || 0};`;
|
||||
if (importData.updateDeviceNames?.length) {
|
||||
for (const deviceName of importData.updateDeviceNames) {
|
||||
text += `< ${deviceName} >`;
|
||||
}
|
||||
}
|
||||
text += `更新失败数量:${Object.keys(importData.failureDeviceNames || {}).length};`;
|
||||
if (importData.failureDeviceNames) {
|
||||
for (const deviceName in importData.failureDeviceNames) {
|
||||
text += `< ${deviceName}: ${importData.failureDeviceNames[deviceName]} >`;
|
||||
}
|
||||
}
|
||||
message.info(text);
|
||||
}
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 上传前 */
|
||||
function beforeUpload(file: FileType) {
|
||||
formApi.setFieldValue('file', file);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 下载模版 */
|
||||
async function handleDownload() {
|
||||
const data = await importDeviceTemplate();
|
||||
downloadFileFromBlobPart({ fileName: '设备导入模板.xls', source: data });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="$t('ui.actionTitle.import', ['设备'])" class="w-1/3">
|
||||
<Form class="mx-4">
|
||||
<template #file>
|
||||
<div class="w-full">
|
||||
<Upload
|
||||
:max-count="1"
|
||||
accept=".xls,.xlsx"
|
||||
:before-upload="beforeUpload"
|
||||
>
|
||||
<Button type="primary"> 选择 Excel 文件</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
</template>
|
||||
</Form>
|
||||
<template #prepend-footer>
|
||||
<div class="flex flex-auto items-center">
|
||||
<Button @click="handleDownload"> 下载导入模板</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -69,6 +69,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
const data = modalApi.getData<IotProductCategoryApi.ProductCategory>();
|
||||
if (!data || !data.id) {
|
||||
// 新增模式:设置默认值
|
||||
// TODO @AI:可以参考部门,进一步简化代码;通过 defaultValue 在 schema 里设置默认值
|
||||
formData.value = undefined;
|
||||
await formApi.setValues({
|
||||
sort: 0,
|
||||
|
||||
@@ -21,6 +21,7 @@ async function loadCategoryData() {
|
||||
}
|
||||
|
||||
// 初始化加载分类数据
|
||||
// TODO @haohao:可以参考 /Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/views/system/tenant/data.ts 简洁一点。
|
||||
loadCategoryData();
|
||||
|
||||
/** 新增/修改产品的表单 */
|
||||
|
||||
@@ -7,6 +7,7 @@ import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { ProductStatusEnum } from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||
|
||||
@@ -31,7 +32,7 @@ const router = useRouter();
|
||||
const categoryList = ref<IotProductCategoryApi.ProductCategory[]>([]);
|
||||
const viewMode = ref<'card' | 'list'>('card');
|
||||
const cardViewRef = ref();
|
||||
const searchParams = ref({
|
||||
const queryParams = ref({
|
||||
name: '',
|
||||
productKey: '',
|
||||
}); // 搜索参数
|
||||
@@ -49,17 +50,18 @@ async function loadCategories() {
|
||||
/** 搜索产品 */
|
||||
function handleSearch() {
|
||||
if (viewMode.value === 'list') {
|
||||
gridApi.formApi.setValues(searchParams.value);
|
||||
gridApi.formApi.setValues(queryParams.value);
|
||||
gridApi.query();
|
||||
} else {
|
||||
cardViewRef.value?.search(searchParams.value);
|
||||
// TODO @haohao:要不 search 也改成 query 方法,更统一一点哈。
|
||||
cardViewRef.value?.search(queryParams.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置搜索 */
|
||||
function handleReset() {
|
||||
searchParams.value.name = '';
|
||||
searchParams.value.productKey = '';
|
||||
queryParams.value.name = '';
|
||||
queryParams.value.productKey = '';
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
@@ -74,7 +76,7 @@ function handleRefresh() {
|
||||
|
||||
/** 导出表格 */
|
||||
async function handleExport() {
|
||||
const data = await exportProduct(searchParams.value);
|
||||
const data = await exportProduct(queryParams.value);
|
||||
downloadFileFromBlobPart({ fileName: '产品列表.xls', source: data });
|
||||
}
|
||||
|
||||
@@ -131,7 +133,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
return await getProductPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...searchParams.value,
|
||||
...queryParams.value,
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -162,7 +164,7 @@ onMounted(() => {
|
||||
<!-- 搜索表单 -->
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<Input
|
||||
v-model:value="searchParams.name"
|
||||
v-model:value="queryParams.name"
|
||||
placeholder="请输入产品名称"
|
||||
allow-clear
|
||||
class="w-[220px]"
|
||||
@@ -173,7 +175,7 @@ onMounted(() => {
|
||||
</template>
|
||||
</Input>
|
||||
<Input
|
||||
v-model:value="searchParams.productKey"
|
||||
v-model:value="queryParams.productKey"
|
||||
placeholder="请输入产品标识"
|
||||
allow-clear
|
||||
class="w-[220px]"
|
||||
@@ -228,7 +230,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Grid v-show="viewMode === 'list'">
|
||||
<Grid table-title="产品列表" v-show="viewMode === 'list'">
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
@@ -253,6 +255,7 @@ onMounted(() => {
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
disabled: row.status === ProductStatusEnum.PUBLISHED,
|
||||
popConfirm: {
|
||||
title: `确认删除产品 ${row.name} 吗?`,
|
||||
confirm: handleDelete.bind(null, row),
|
||||
@@ -268,14 +271,13 @@ onMounted(() => {
|
||||
v-show="viewMode === 'card'"
|
||||
ref="cardViewRef"
|
||||
:category-list="categoryList"
|
||||
:search-params="searchParams"
|
||||
:search-params="queryParams"
|
||||
@create="handleCreate"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
@detail="openProductDetail"
|
||||
@thing-model="openThingModel"
|
||||
/>
|
||||
|
||||
</Page>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -137,6 +137,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">产品类型</span>
|
||||
<!-- TODO @AI:这个要不完全用字典的 dict-tag? -->
|
||||
<Tag
|
||||
:color="getDeviceTypeColor(item.deviceType)"
|
||||
class="info-tag m-0"
|
||||
@@ -231,7 +232,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="list.length > 0" class="flex justify-end">
|
||||
<div v-if="list.length > 0" class="mt-3 flex justify-end">
|
||||
<Pagination
|
||||
v-model:current="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
@@ -266,6 +267,7 @@ onMounted(() => {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: white;
|
||||
// TODO @haohao:这里的紫色,和下面的紫色按钮,看看能不能换下。嘿嘿,感觉 AI 比较喜欢用紫色,但是放现有的后台,有点突兀
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ const activeTab = ref('info');
|
||||
provide('product', product); // 提供产品信息给子组件
|
||||
|
||||
/** 获取产品详情 */
|
||||
// TODO @haohao:因为 detail 是独立界面,所以不放在 modules 里,应该放在 web-antd/src/views/iot/product/product/detail/index.vue 里,更合理一些哈。
|
||||
async function getProductData(productId: number) {
|
||||
loading.value = true;
|
||||
try {
|
||||
|
||||
@@ -4,13 +4,11 @@ import type { IotProductApi } from '#/api/iot/product/product';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { ProductStatusEnum } from '@vben/constants';
|
||||
|
||||
import { Button, Card, Descriptions, message, Modal } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
ProductStatusEnum,
|
||||
updateProductStatus,
|
||||
} from '#/api/iot/product/product';
|
||||
import { updateProductStatus } from '#/api/iot/product/product';
|
||||
|
||||
import Form from '../../form.vue';
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { DeviceTypeEnum, DICT_TYPE } from '@vben/constants';
|
||||
|
||||
import { Card, Descriptions } from 'ant-design-vue';
|
||||
|
||||
import { DeviceTypeEnum } from '#/api/iot/product/product';
|
||||
import { DictTag } from '#/components/dict-tag';
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -40,6 +40,7 @@ const [Form, formApi] = useVbenForm({
|
||||
wrapperClass: 'grid-cols-2',
|
||||
});
|
||||
|
||||
// TODO @haohao:这个要不还是一行一个?这样样式好看点哈。
|
||||
const [AdvancedForm, advancedFormApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: { class: 'w-full' },
|
||||
@@ -51,10 +52,10 @@ const [AdvancedForm, advancedFormApi] = useVbenForm({
|
||||
});
|
||||
|
||||
/** 基础表单需要 formApi 引用,所以通过 setState 设置 schema */
|
||||
// TODO haohao:@haohao:要不要把 generateProductKey 拿到这个 vue 里,作为参数传递到 useBasicFormSchema 里?
|
||||
formApi.setState({ schema: useBasicFormSchema(formApi) });
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
/** 提交表单 */
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
@@ -63,6 +64,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.lock();
|
||||
// 合并两个表单的值
|
||||
const basicValues = await formApi.getValues();
|
||||
// TODO @haohao:有 linter 修复下;“formData.value?.id”;另外,这里直接两个表单合并,是不是就可以了呀?因为 2 个 schema 本身不同,字段就不同,不会冲突。
|
||||
const advancedValues = activeKey.value.includes('advanced')
|
||||
? await advancedFormApi.getValues()
|
||||
: formData.value?.id
|
||||
@@ -85,7 +87,6 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
/** 弹窗打开/关闭 */
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
@@ -96,9 +97,11 @@ const [Modal, modalApi] = useVbenModal({
|
||||
const data = modalApi.getData<IotProductApi.Product>();
|
||||
if (!data || !data.id) {
|
||||
// 新增:设置默认值
|
||||
// TODO @AI:
|
||||
await formApi.setValues({
|
||||
// TODO @haohao:要不要把 generateProductKey 拿到这个 vue 里,作为参数传递到 useBasicFormSchema 里?
|
||||
productKey: generateProductKey(),
|
||||
status: 0,
|
||||
status: 0, // TODO @haohao:通过 defaultValue 即可;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -108,12 +111,15 @@ const [Modal, modalApi] = useVbenModal({
|
||||
formData.value = await getProduct(data.id);
|
||||
await formApi.setValues(formData.value);
|
||||
// 设置高级表单(不等待)
|
||||
// TODO @haohao:直接把 formData 传过去?没关系的哈。因为会 filter 掉不存在的值,可以试试哈。
|
||||
// TODO @haohao:这里是不是要 await 下呀?有黄色的告警;
|
||||
advancedFormApi.setValues({
|
||||
icon: formData.value.icon,
|
||||
picUrl: formData.value.picUrl,
|
||||
description: formData.value.description,
|
||||
});
|
||||
// 有高级字段时自动展开
|
||||
// TODO @haohao:默认不用展开哈。
|
||||
if (
|
||||
formData.value.icon ||
|
||||
formData.value.picUrl ||
|
||||
|
||||
@@ -34,6 +34,7 @@ const emit = defineEmits<{
|
||||
(e: 'selectMaterial', item: any): void;
|
||||
}>();
|
||||
|
||||
// TODO @dlyan @AI:这里是不是应该都用 grid;
|
||||
const loading = ref(false); // 遮罩层
|
||||
const total = ref(0); // 总条数
|
||||
const list = ref<any[]>([]); // 数据列表
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
.mp-card {
|
||||
&__item {
|
||||
box-sizing: border-box;
|
||||
height: 200px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
font-feature-settings: 'tnum';
|
||||
font-variant: tabular-nums;
|
||||
line-height: 1.5;
|
||||
color: rgb(0 0 0 / 65%);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
background-color: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
|
||||
&:hover {
|
||||
border-color: rgb(0 0 0 / 9%);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 9%);
|
||||
}
|
||||
|
||||
&--add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
background-color: #fff;
|
||||
border: 1px dashed #000;
|
||||
border-color: #d9d9d9;
|
||||
border-radius: 2px;
|
||||
|
||||
i {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
background-color: #fff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
&__detail {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-right: 12px;
|
||||
overflow: hidden;
|
||||
border-radius: 48px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-bottom: 12px;
|
||||
font-size: 16px;
|
||||
color: rgb(0 0 0 / 85%);
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: -webkit-box;
|
||||
height: 64px;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 3;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
&__menu {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
text-align: center;
|
||||
background: #f7f9fa;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** joolun 额外加的 */
|
||||
.mp-comment__main {
|
||||
flex: unset !important;
|
||||
margin: 0 8px !important;
|
||||
border-radius: 5px !important;
|
||||
}
|
||||
|
||||
.mp-comment__header {
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
}
|
||||
|
||||
.mp-comment__body {
|
||||
border-bottom-right-radius: 5px;
|
||||
border-bottom-left-radius: 5px;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
/* 来自 https://github.com/nmxiaowei/avue/blob/master/styles/src/element-ui/comment.scss */
|
||||
.mp-comment {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 30px;
|
||||
|
||||
&--reverse {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.mp-comment__main {
|
||||
&::before,
|
||||
&::after {
|
||||
right: -8px;
|
||||
left: auto;
|
||||
border-width: 8px 0 8px 8px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
border-left-color: #dedede;
|
||||
}
|
||||
|
||||
&::after {
|
||||
margin-right: 1px;
|
||||
margin-left: auto;
|
||||
border-left-color: #f8f8f8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
box-sizing: border-box;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
vertical-align: middle;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 5px 15px;
|
||||
background: #f8f8f8;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
&__author {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
&__main {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
margin: 0 20px;
|
||||
border: 1px solid #dedede;
|
||||
border-radius: 2px;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 100%;
|
||||
left: -8px;
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
content: ' ';
|
||||
border-color: transparent;
|
||||
border-style: solid solid outset;
|
||||
border-width: 8px 8px 8px 0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
z-index: 1;
|
||||
border-right-color: #dedede;
|
||||
}
|
||||
|
||||
&::after {
|
||||
z-index: 2;
|
||||
margin-left: 1px;
|
||||
border-right-color: #f8f8f8;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 15px;
|
||||
overflow: hidden;
|
||||
font-family:
|
||||
'Segoe UI', 'Lucida Grande', Helvetica, Arial, 'Microsoft YaHei',
|
||||
FreeSans, Arimo, 'Droid Sans', 'wenquanyi micro hei', 'Hiragino Sans GB',
|
||||
'Hiragino Sans GB W3', FontAwesome, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding: 1px 0 1px 15px;
|
||||
margin: 0;
|
||||
font-family:
|
||||
Georgia, 'Times New Roman', Times, Kai, 'Kaiti SC', KaiTi, BiauKai,
|
||||
FontAwesome, serif;
|
||||
border-left: 4px solid #ddd;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ const total = ref(0); // 总条数
|
||||
const accountId = ref(-1);
|
||||
provide('accountId', accountId);
|
||||
|
||||
// TODO @dlyan @AI:这里是不是应该都用 grid;类似 yudao-ui-admin-vben-v5/apps/web-ele/src/views/mp/autoReply/index.vue
|
||||
const queryParams = reactive({
|
||||
accountId,
|
||||
pageNo: 1,
|
||||
@@ -118,7 +119,13 @@ async function handleDelete(id: number) {
|
||||
</span>
|
||||
</template>
|
||||
<!-- 列表 -->
|
||||
<ImageTable :list="list" :loading="loading" @delete="handleDelete">
|
||||
<ImageTable
|
||||
:key="`image-${type}`"
|
||||
:list="list"
|
||||
:loading="loading"
|
||||
@delete="handleDelete"
|
||||
@refresh="getList"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<UploadFile
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
@@ -149,7 +156,13 @@ async function handleDelete(id: number) {
|
||||
</span>
|
||||
</template>
|
||||
<!-- 列表 -->
|
||||
<VoiceTable :list="list" :loading="loading" @delete="handleDelete">
|
||||
<VoiceTable
|
||||
:key="`voice-${type}`"
|
||||
:list="list"
|
||||
:loading="loading"
|
||||
@delete="handleDelete"
|
||||
@refresh="getList"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<UploadFile
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
@@ -180,7 +193,13 @@ async function handleDelete(id: number) {
|
||||
</span>
|
||||
</template>
|
||||
<!-- 列表 -->
|
||||
<VideoTable :list="list" :loading="loading" @delete="handleDelete">
|
||||
<VideoTable
|
||||
:key="`video-${type}`"
|
||||
:list="list"
|
||||
:loading="loading"
|
||||
@delete="handleDelete"
|
||||
@refresh="getList"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<Button
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { nextTick, onMounted, watch } from 'vue';
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
|
||||
import { useImageGridColumns } from './data';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
const props = defineProps<{
|
||||
list: MpMaterialApi.Material[];
|
||||
@@ -14,6 +15,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [v: number];
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
const columns = useImageGridColumns();
|
||||
@@ -35,6 +37,21 @@ const [Grid, gridApi] = useVbenVxeGrid<MpMaterialApi.Material>({
|
||||
refresh: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async () => {
|
||||
// 数据由父组件管理,触发刷新事件后返回当前数据
|
||||
emit('refresh');
|
||||
// 返回当前数据,避免覆盖
|
||||
return {
|
||||
list: Array.isArray(props.list) ? props.list : [],
|
||||
total: props.list?.length || 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
autoLoad: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,7 +70,7 @@ watch(
|
||||
await nextTick();
|
||||
updateGridData(data);
|
||||
},
|
||||
{ flush: 'post' },
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
watch(
|
||||
@@ -89,7 +106,7 @@ onMounted(async () => {
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '删除',
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
import { watch } from 'vue';
|
||||
import { nextTick, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
import { openWindow } from '@vben/utils';
|
||||
@@ -19,6 +19,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [v: number];
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
const columns = useVideoGridColumns();
|
||||
@@ -39,20 +40,40 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
refresh: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async () => {
|
||||
// 数据由父组件管理,触发刷新事件后返回当前数据
|
||||
emit('refresh');
|
||||
// 返回当前数据,避免覆盖
|
||||
return {
|
||||
list: Array.isArray(props.list) ? props.list : [],
|
||||
total: props.list?.length || 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
autoLoad: false,
|
||||
},
|
||||
} as VxeTableGridOptions<MpMaterialApi.Material>,
|
||||
});
|
||||
|
||||
function updateGridData(data: MpMaterialApi.Material[]) {
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
(list: MpMaterialApi.Material[]) => {
|
||||
async (list: MpMaterialApi.Material[]) => {
|
||||
const data = Array.isArray(list) ? list : [];
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
await nextTick();
|
||||
updateGridData(data);
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
watch(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
import { watch } from 'vue';
|
||||
import { nextTick, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
import { openWindow } from '@vben/utils';
|
||||
@@ -19,6 +19,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [v: number];
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
const columns = useVoiceGridColumns();
|
||||
@@ -39,20 +40,40 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
refresh: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async () => {
|
||||
// 数据由父组件管理,触发刷新事件后返回当前数据
|
||||
emit('refresh');
|
||||
// 返回当前数据,避免覆盖
|
||||
return {
|
||||
list: Array.isArray(props.list) ? props.list : [],
|
||||
total: props.list?.length || 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
autoLoad: false,
|
||||
},
|
||||
} as VxeTableGridOptions<MpMaterialApi.Material>,
|
||||
});
|
||||
|
||||
function updateGridData(data: MpMaterialApi.Material[]) {
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
(list: MpMaterialApi.Material[]) => {
|
||||
async (list: MpMaterialApi.Material[]) => {
|
||||
const data = Array.isArray(list) ? list : [];
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
await nextTick();
|
||||
updateGridData(data);
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
watch(
|
||||
|
||||
94
apps/web-antd/src/views/mp/message/data.ts
Normal file
94
apps/web-antd/src/views/mp/message/data.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMessageApi } from '#/api/mp/message';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'accountId',
|
||||
label: '公众号',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'type',
|
||||
label: '消息类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '请选择消息类型',
|
||||
options: getDictOptions(DICT_TYPE.MP_MESSAGE_TYPE),
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'openid',
|
||||
label: '用户标识',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入用户标识',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '创建时间',
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
...getRangePickerDefaultProps(),
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions<MpMessageApi.Message>['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '发送时间',
|
||||
width: 180,
|
||||
align: 'center',
|
||||
slots: { default: 'createTime' },
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
title: '消息类型',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'sendFrom',
|
||||
title: '发送方',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
slots: { default: 'sendFrom' },
|
||||
},
|
||||
{
|
||||
field: 'openid',
|
||||
title: '用户标识',
|
||||
width: 300,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'content',
|
||||
title: '内容',
|
||||
align: 'left',
|
||||
minWidth: 320,
|
||||
slots: { default: 'content' },
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
title: '操作',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,185 +1,228 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMessageApi } from '#/api/mp/message';
|
||||
|
||||
import { reactive, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { DICT_TYPE, MpMsgType } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { MpMsgType as MsgType } from '@vben/constants';
|
||||
import { formatDate2 } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Form,
|
||||
FormItem,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
} from 'ant-design-vue';
|
||||
import { Image, Modal, Tag } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getMessagePage } from '#/api/mp/message';
|
||||
import { WxAccountSelect, WxMsg } from '#/views/mp/components';
|
||||
import {
|
||||
WxAccountSelect,
|
||||
WxLocation,
|
||||
WxMsg,
|
||||
WxMusic,
|
||||
WxNews,
|
||||
WxVideoPlayer,
|
||||
WxVoicePlayer,
|
||||
} from '#/views/mp/components';
|
||||
|
||||
import MessageTable from './message-table.vue';
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
|
||||
defineOptions({ name: 'MpMessage' });
|
||||
|
||||
const loading = ref(false);
|
||||
const total = ref(0); // 数据的总页数
|
||||
const list = ref<any[]>([]); // 当前页的列表数据
|
||||
|
||||
const queryParams = reactive<{
|
||||
accountId: number;
|
||||
createTime: [Dayjs, Dayjs] | undefined;
|
||||
openid: string;
|
||||
pageNo: number;
|
||||
pageSize: number;
|
||||
type: string;
|
||||
}>({
|
||||
accountId: -1,
|
||||
createTime: undefined,
|
||||
openid: '',
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
type: MpMsgType.Text,
|
||||
}); // 搜索参数
|
||||
|
||||
const queryFormRef = ref(); // 搜索的表单
|
||||
|
||||
// 消息对话框
|
||||
const messageBoxVisible = ref(false);
|
||||
const messageBoxUserId = ref(0);
|
||||
|
||||
/** 侦听 accountId */
|
||||
function onAccountChanged(id: number) {
|
||||
queryParams.accountId = id;
|
||||
queryParams.pageNo = 1;
|
||||
handleQuery();
|
||||
}
|
||||
|
||||
/** 查询列表 */
|
||||
function handleQuery() {
|
||||
queryParams.pageNo = 1;
|
||||
getList();
|
||||
}
|
||||
|
||||
async function getList() {
|
||||
try {
|
||||
loading.value = true;
|
||||
const data = await getMessagePage(queryParams);
|
||||
list.value = data.list;
|
||||
total.value = data.total;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
async function resetQuery() {
|
||||
// 暂存 accountId,并在 reset 后恢复
|
||||
const accountId = queryParams.accountId;
|
||||
queryFormRef.value?.resetFields();
|
||||
queryParams.accountId = accountId;
|
||||
handleQuery();
|
||||
/** 公众号变化时查询数据 */
|
||||
function handleAccountChange(accountId: number) {
|
||||
gridApi.formApi.setValues({ accountId });
|
||||
gridApi.formApi.submitForm();
|
||||
}
|
||||
|
||||
/** 打开消息发送窗口 */
|
||||
async function handleSend(userId: number) {
|
||||
function handleSend(userId: number) {
|
||||
messageBoxUserId.value = userId;
|
||||
messageBoxVisible.value = true;
|
||||
}
|
||||
|
||||
/** 分页改变事件 */
|
||||
function handlePageChange(page: number, pageSize: number) {
|
||||
queryParams.pageNo = page;
|
||||
queryParams.pageSize = pageSize;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 显示总条数 */
|
||||
function showTotal(total: number) {
|
||||
return `共 ${total} 条`;
|
||||
}
|
||||
// TODO @dylan:是不是应该都用 Grid 哈:1)message-table 大部分合并到 index.vue;2)message-table 的 schema 放到 data.ts 里;
|
||||
const [Grid, gridApi] = useVbenVxeGrid<MpMessageApi.Message>({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getMessagePage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
autoLoad: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
} as VxeTableGridOptions<MpMessageApi.Message>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height class="flex flex-col">
|
||||
<!-- 搜索工作栏 -->
|
||||
<div class="mb-4 rounded-lg bg-background p-4">
|
||||
<Form
|
||||
ref="queryFormRef"
|
||||
:model="queryParams"
|
||||
layout="inline"
|
||||
class="search-form"
|
||||
>
|
||||
<FormItem label="公众号" name="accountId">
|
||||
<WxAccountSelect @change="onAccountChanged" />
|
||||
</FormItem>
|
||||
<FormItem label="消息类型" name="type">
|
||||
<Select
|
||||
v-model:value="queryParams.type"
|
||||
placeholder="请选择消息类型"
|
||||
class="!w-[240px]"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="dict in getDictOptions(DICT_TYPE.MP_MESSAGE_TYPE)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="用户标识" name="openid">
|
||||
<Input
|
||||
v-model:value="queryParams.openid"
|
||||
placeholder="请输入用户标识"
|
||||
allow-clear
|
||||
class="!w-[240px]"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="创建时间" name="createTime">
|
||||
<DatePicker.RangePicker
|
||||
v-model:value="queryParams.createTime"
|
||||
:show-time="true"
|
||||
class="!w-[240px]"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<Button type="primary" @click="handleQuery">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="mdi:magnify" />
|
||||
</template>
|
||||
搜索
|
||||
</Button>
|
||||
<Button class="ml-2" @click="resetQuery">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="mdi:refresh" />
|
||||
</template>
|
||||
重置
|
||||
</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #form-accountId>
|
||||
<WxAccountSelect @change="handleAccountChange" />
|
||||
</template>
|
||||
<template #createTime="{ row }">
|
||||
{{ row.createTime ? formatDate2(row.createTime) : '' }}
|
||||
</template>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="flex-1 rounded-lg bg-background p-4">
|
||||
<MessageTable :list="list" :loading="loading" @send="handleSend" />
|
||||
<div v-show="total > 0" class="mt-4 flex justify-end">
|
||||
<Pagination
|
||||
v-model:current="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
show-quick-jumper
|
||||
:show-total="showTotal"
|
||||
@change="handlePageChange"
|
||||
<template #sendFrom="{ row }">
|
||||
<Tag v-if="row.sendFrom === 1" color="success">粉丝</Tag>
|
||||
<Tag v-else>公众号</Tag>
|
||||
</template>
|
||||
|
||||
<template #content="{ row }">
|
||||
<div
|
||||
v-if="
|
||||
(row.type as string) === (MsgType.Event as string) &&
|
||||
(row.event as string) === 'subscribe'
|
||||
"
|
||||
>
|
||||
<Tag color="success">关注</Tag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
(row.type as string) === (MsgType.Event as string) &&
|
||||
(row.event as string) === 'unsubscribe'
|
||||
"
|
||||
>
|
||||
<Tag color="error">取消关注</Tag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
(row.type as string) === (MsgType.Event as string) &&
|
||||
(row.event as string) === 'CLICK'
|
||||
"
|
||||
>
|
||||
<Tag>点击菜单</Tag>
|
||||
【{{ row.eventKey }}】
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Event && row.event === 'VIEW'">
|
||||
<Tag>点击菜单链接</Tag>
|
||||
【{{ row.eventKey }}】
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
row.type === MsgType.Event && row.event === 'scancode_waitmsg'
|
||||
"
|
||||
>
|
||||
<Tag>扫码结果</Tag>
|
||||
【{{ row.eventKey }}】
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
row.type === MsgType.Event && row.event === 'scancode_push'
|
||||
"
|
||||
>
|
||||
<Tag>扫码结果</Tag>
|
||||
【{{ row.eventKey }}】
|
||||
</div>
|
||||
<div
|
||||
v-else-if="row.type === MsgType.Event && row.event === 'pic_sysphoto'"
|
||||
>
|
||||
<Tag>系统拍照发图</Tag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
row.type === MsgType.Event && row.event === 'pic_photo_or_album'
|
||||
"
|
||||
>
|
||||
<Tag>拍照或者相册</Tag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="row.type === MsgType.Event && row.event === 'pic_weixin'"
|
||||
>
|
||||
<Tag>微信相册</Tag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
row.type === MsgType.Event && row.event === 'location_select'
|
||||
"
|
||||
>
|
||||
<Tag>选择地理位置</Tag>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Event">
|
||||
<Tag color="error">未知事件类型</Tag>
|
||||
</div>
|
||||
|
||||
<div v-else-if="row.type === MsgType.Text">{{ row.content }}</div>
|
||||
<div v-else-if="row.type === MsgType.Voice">
|
||||
<WxVoicePlayer
|
||||
:url="row.mediaUrl || ''"
|
||||
:content="row.recognition || ''"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Image">
|
||||
<a :href="row.mediaUrl" target="_blank">
|
||||
<Image :src="row.mediaUrl" :width="100" :preview="false" />
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="row.type === MsgType.Video || row.type === 'shortvideo'"
|
||||
>
|
||||
<WxVideoPlayer :url="row.mediaUrl || ''" class="mt-2" />
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Link">
|
||||
<Tag>链接</Tag>
|
||||
:
|
||||
<a :href="row.url" target="_blank">{{ row.title }}</a>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Location">
|
||||
<WxLocation
|
||||
:label="row.label || ''"
|
||||
:location-y="row.locationY || 0"
|
||||
:location-x="row.locationX || 0"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Music">
|
||||
<WxMusic
|
||||
:title="row.title"
|
||||
:description="row.description"
|
||||
:thumb-media-url="row.thumbMediaUrl || ''"
|
||||
:music-url="row.musicUrl"
|
||||
:hq-music-url="row.hqMusicUrl"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.News">
|
||||
<WxNews :articles="row.articles" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<Tag color="error">未知消息类型</Tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '消息',
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.VIEW,
|
||||
onClick: () => handleSend(row.userId || 0),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Grid>
|
||||
|
||||
<!-- 发送消息的弹窗 -->
|
||||
<Modal
|
||||
@@ -193,9 +236,3 @@ function showTotal(total: number) {
|
||||
</Modal>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-form :deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMessageApi } from '#/api/mp/message';
|
||||
|
||||
import { onMounted, watch } from 'vue';
|
||||
|
||||
import { MpMsgType as MsgType } from '@vben/constants';
|
||||
import { formatDate2 } from '@vben/utils';
|
||||
|
||||
import { Button, Image, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
WxLocation,
|
||||
WxMusic,
|
||||
WxNews,
|
||||
WxVideoPlayer,
|
||||
WxVoicePlayer,
|
||||
} from '#/views/mp/components';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
list?: MpMessageApi.Message[];
|
||||
loading?: boolean;
|
||||
}>(),
|
||||
{
|
||||
list() {
|
||||
return [];
|
||||
},
|
||||
loading: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'send', userId: number): void;
|
||||
}>();
|
||||
|
||||
const columns: VxeTableGridOptions<MpMessageApi.Message>['columns'] = [
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '发送时间',
|
||||
width: 180,
|
||||
align: 'center',
|
||||
slots: { default: 'createTime' },
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
title: '消息类型',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'sendFrom',
|
||||
title: '发送方',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
slots: { default: 'sendFrom' },
|
||||
},
|
||||
{
|
||||
field: 'openid',
|
||||
title: '用户标识',
|
||||
width: 300,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'content',
|
||||
title: '内容',
|
||||
align: 'left',
|
||||
minWidth: 320,
|
||||
slots: { default: 'content' },
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
title: '操作',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid<MpMessageApi.Message>({
|
||||
gridOptions: {
|
||||
border: true,
|
||||
columns,
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
});
|
||||
|
||||
function normalizeList(list?: MpMessageApi.Message[]) {
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
function updateGridData(data: MpMessageApi.Message[]) {
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
(list) => {
|
||||
updateGridData(normalizeList(list));
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading) => {
|
||||
gridApi.setLoading(loading);
|
||||
},
|
||||
);
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
updateGridData(normalizeList(props.list));
|
||||
gridApi.setLoading(props.loading);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Grid>
|
||||
<template #createTime="{ row }">
|
||||
{{ row.createTime ? formatDate2(row.createTime) : '' }}
|
||||
</template>
|
||||
|
||||
<template #sendFrom="{ row }">
|
||||
<Tag v-if="row.sendFrom === 1" color="success">粉丝</Tag>
|
||||
<Tag v-else color="default">公众号</Tag>
|
||||
</template>
|
||||
|
||||
<template #content="{ row }">
|
||||
<div
|
||||
v-if="
|
||||
(row.type as string) === (MsgType.Event as string) &&
|
||||
(row.event as string) === 'subscribe'
|
||||
"
|
||||
>
|
||||
<Tag color="success">关注</Tag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
(row.type as string) === (MsgType.Event as string) &&
|
||||
(row.event as string) === 'unsubscribe'
|
||||
"
|
||||
>
|
||||
<Tag color="error">取消关注</Tag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
(row.type as string) === (MsgType.Event as string) &&
|
||||
(row.event as string) === 'CLICK'
|
||||
"
|
||||
>
|
||||
<Tag>点击菜单</Tag>
|
||||
【{{ row.eventKey }}】
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Event && row.event === 'VIEW'">
|
||||
<Tag>点击菜单链接</Tag>
|
||||
【{{ row.eventKey }}】
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
row.type === MsgType.Event && row.event === 'scancode_waitmsg'
|
||||
"
|
||||
>
|
||||
<Tag>扫码结果</Tag>
|
||||
【{{ row.eventKey }}】
|
||||
</div>
|
||||
<div
|
||||
v-else-if="row.type === MsgType.Event && row.event === 'scancode_push'"
|
||||
>
|
||||
<Tag>扫码结果</Tag>
|
||||
【{{ row.eventKey }}】
|
||||
</div>
|
||||
<div
|
||||
v-else-if="row.type === MsgType.Event && row.event === 'pic_sysphoto'"
|
||||
>
|
||||
<Tag>系统拍照发图</Tag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
row.type === MsgType.Event && row.event === 'pic_photo_or_album'
|
||||
"
|
||||
>
|
||||
<Tag>拍照或者相册</Tag>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Event && row.event === 'pic_weixin'">
|
||||
<Tag>微信相册</Tag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
row.type === MsgType.Event && row.event === 'location_select'
|
||||
"
|
||||
>
|
||||
<Tag>选择地理位置</Tag>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Event">
|
||||
<Tag color="error">未知事件类型</Tag>
|
||||
</div>
|
||||
|
||||
<div v-else-if="row.type === MsgType.Text">{{ row.content }}</div>
|
||||
<div v-else-if="row.type === MsgType.Voice">
|
||||
<WxVoicePlayer
|
||||
:url="row.mediaUrl || ''"
|
||||
:content="row.recognition || ''"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Image">
|
||||
<a :href="row.mediaUrl" target="_blank">
|
||||
<Image :src="row.mediaUrl" :width="100" :preview="false" />
|
||||
</a>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Video || row.type === 'shortvideo'">
|
||||
<WxVideoPlayer :url="row.mediaUrl || ''" class="mt-2" />
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Link">
|
||||
<Tag>链接</Tag>
|
||||
:
|
||||
<a :href="row.url" target="_blank">{{ row.title }}</a>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Location">
|
||||
<WxLocation
|
||||
:label="row.label || ''"
|
||||
:location-y="row.locationY || 0"
|
||||
:location-x="row.locationX || 0"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Music">
|
||||
<WxMusic
|
||||
:title="row.title"
|
||||
:description="row.description"
|
||||
:thumb-media-url="row.thumbMediaUrl || ''"
|
||||
:music-url="row.musicUrl"
|
||||
:hq-music-url="row.hqMusicUrl"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.News">
|
||||
<WxNews :articles="row.articles" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<Tag color="error">未知消息类型</Tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions="{ row }">
|
||||
<Button type="link" @click="emit('send', row.userId || 0)"> 消息 </Button>
|
||||
</template>
|
||||
</Grid>
|
||||
</template>
|
||||
@@ -36,6 +36,7 @@ const emit = defineEmits<{
|
||||
const loading = ref(false); // 遮罩层
|
||||
const total = ref(0); // 总条数
|
||||
const list = ref<any[]>([]); // 数据列表
|
||||
// TODO @dlyan @AI:这里是不是应该都用 grid;
|
||||
const queryParams = reactive({
|
||||
accountId: props.accountId,
|
||||
pageNo: 1,
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
.mp-card {
|
||||
&__item {
|
||||
box-sizing: border-box;
|
||||
height: 200px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
font-feature-settings: 'tnum';
|
||||
font-variant: tabular-nums;
|
||||
line-height: 1.5;
|
||||
color: rgb(0 0 0 / 65%);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
background-color: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
|
||||
&:hover {
|
||||
border-color: rgb(0 0 0 / 9%);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 9%);
|
||||
}
|
||||
|
||||
&--add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
background-color: #fff;
|
||||
border: 1px dashed #000;
|
||||
border-color: #d9d9d9;
|
||||
border-radius: 2px;
|
||||
|
||||
i {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
background-color: #fff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
&__detail {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-right: 12px;
|
||||
overflow: hidden;
|
||||
border-radius: 48px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-bottom: 12px;
|
||||
font-size: 16px;
|
||||
color: rgb(0 0 0 / 85%);
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: -webkit-box;
|
||||
height: 64px;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 3;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
&__menu {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
text-align: center;
|
||||
background: #f7f9fa;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** joolun 额外加的 */
|
||||
.mp-comment__main {
|
||||
flex: unset !important;
|
||||
margin: 0 8px !important;
|
||||
border-radius: 5px !important;
|
||||
}
|
||||
|
||||
.mp-comment__header {
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
}
|
||||
|
||||
.mp-comment__body {
|
||||
border-bottom-right-radius: 5px;
|
||||
border-bottom-left-radius: 5px;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
/* 来自 https://github.com/nmxiaowei/avue/blob/master/styles/src/element-ui/comment.scss */
|
||||
.mp-comment {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 30px;
|
||||
|
||||
&--reverse {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.mp-comment__main {
|
||||
&::before,
|
||||
&::after {
|
||||
right: -8px;
|
||||
left: auto;
|
||||
border-width: 8px 0 8px 8px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
border-left-color: #dedede;
|
||||
}
|
||||
|
||||
&::after {
|
||||
margin-right: 1px;
|
||||
margin-left: auto;
|
||||
border-left-color: #f8f8f8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
box-sizing: border-box;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
vertical-align: middle;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 5px 15px;
|
||||
background: #f8f8f8;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
&__author {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
&__main {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
margin: 0 20px;
|
||||
border: 1px solid #dedede;
|
||||
border-radius: 2px;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 100%;
|
||||
left: -8px;
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
content: ' ';
|
||||
border-color: transparent;
|
||||
border-style: solid solid outset;
|
||||
border-width: 8px 8px 8px 0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
z-index: 1;
|
||||
border-right-color: #dedede;
|
||||
}
|
||||
|
||||
&::after {
|
||||
z-index: 2;
|
||||
margin-left: 1px;
|
||||
border-right-color: #f8f8f8;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 15px;
|
||||
overflow: hidden;
|
||||
font-family:
|
||||
'Segoe UI', 'Lucida Grande', Helvetica, Arial, 'Microsoft YaHei',
|
||||
FreeSans, Arimo, 'Droid Sans', 'wenquanyi micro hei', 'Hiragino Sans GB',
|
||||
'Hiragino Sans GB W3', FontAwesome, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding: 1px 0 1px 15px;
|
||||
margin: 0;
|
||||
font-family:
|
||||
Georgia, 'Times New Roman', Times, Kai, 'Kaiti SC', KaiTi, BiauKai,
|
||||
FontAwesome, serif;
|
||||
border-left: 4px solid #ddd;
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ const total = ref(0); // 总条数
|
||||
const accountId = ref(-1);
|
||||
provide('accountId', accountId);
|
||||
|
||||
// TODO @AI:这里是不是应该都用 grid;类似 yudao-ui-admin-vben-v5/apps/web-ele/src/views/mp/autoReply/index.vue
|
||||
// TODO @dlyan @AI:这里是不是应该都用 grid;类似 yudao-ui-admin-vben-v5/apps/web-ele/src/views/mp/autoReply/index.vue
|
||||
const queryParams = reactive({
|
||||
accountId,
|
||||
pageNo: 1,
|
||||
@@ -142,7 +142,13 @@ function handleSizeChange(pageSize: number) {
|
||||
</span>
|
||||
</template>
|
||||
<!-- 列表 -->
|
||||
<ImageTable :list="list" :loading="loading" @delete="handleDelete">
|
||||
<ImageTable
|
||||
:key="`image-${type}`"
|
||||
:list="list"
|
||||
:loading="loading"
|
||||
@delete="handleDelete"
|
||||
@refresh="getList"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<UploadFile
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
@@ -174,7 +180,13 @@ function handleSizeChange(pageSize: number) {
|
||||
</span>
|
||||
</template>
|
||||
<!-- 列表 -->
|
||||
<VoiceTable :list="list" :loading="loading" @delete="handleDelete">
|
||||
<VoiceTable
|
||||
:key="`voice-${type}`"
|
||||
:list="list"
|
||||
:loading="loading"
|
||||
@delete="handleDelete"
|
||||
@refresh="getList"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<UploadFile
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
@@ -206,7 +218,13 @@ function handleSizeChange(pageSize: number) {
|
||||
</span>
|
||||
</template>
|
||||
<!-- 列表 -->
|
||||
<VideoTable :list="list" :loading="loading" @delete="handleDelete">
|
||||
<VideoTable
|
||||
:key="`video-${type}`"
|
||||
:list="list"
|
||||
:loading="loading"
|
||||
@delete="handleDelete"
|
||||
@refresh="getList"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<ElButton
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
@@ -218,7 +236,7 @@ function handleSizeChange(pageSize: number) {
|
||||
</template>
|
||||
</VideoTable>
|
||||
<!-- 新建视频的弹窗 -->
|
||||
<UploadVideo v-model="showCreateVideo" @uploaded="getList" />
|
||||
<UploadVideo v-model:open="showCreateVideo" @uploaded="getList" />
|
||||
<!-- 分页组件 -->
|
||||
<div class="mt-4 flex justify-end">
|
||||
<ElPagination
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
|
||||
const props = defineProps<{ type: UploadType }>();
|
||||
|
||||
// TODO @dylan:是不是要和 antd 的 props 定义相同哈?这样后续两侧维护方便点
|
||||
const emit = defineEmits<{
|
||||
uploaded: [v: void];
|
||||
}>();
|
||||
@@ -60,8 +59,11 @@ const customRequest: UploadProps['httpRequest'] = async function (options) {
|
||||
|
||||
if (res.code !== 0) {
|
||||
ElMessage.error(`上传出错:${res.msg}`);
|
||||
// TODO @dylan:这里有个 linter 错误。
|
||||
onError?.(new Error(res.msg));
|
||||
const error = new Error(res.msg) as any;
|
||||
error.status = 200;
|
||||
error.method = 'POST';
|
||||
error.url = UPLOAD_URL;
|
||||
onError?.(error);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,18 +20,17 @@ import {
|
||||
|
||||
import { beforeVideoUpload, HEADERS, UPLOAD_URL, UploadType } from './upload';
|
||||
|
||||
// TODO @dylan:是不是要和 antd 的 props 定义相同哈?这样后续两侧维护方便点
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean;
|
||||
open?: boolean;
|
||||
}>(),
|
||||
{
|
||||
modelValue: false,
|
||||
open: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [v: boolean];
|
||||
'update:open': [v: boolean];
|
||||
uploaded: [v: void];
|
||||
}>();
|
||||
|
||||
@@ -45,7 +44,7 @@ const uploadRules = {
|
||||
};
|
||||
|
||||
function handleCancel() {
|
||||
emit('update:modelValue', false);
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
const fileList = ref<any[]>([]);
|
||||
@@ -87,7 +86,11 @@ const customRequest: UploadProps['httpRequest'] = async function (options) {
|
||||
|
||||
if (res.code !== 0) {
|
||||
ElMessage.error(`上传出错:${res.msg}`);
|
||||
onError?.(new Error(res.msg));
|
||||
const error = new Error(res.msg) as any;
|
||||
error.status = 200;
|
||||
error.method = 'POST';
|
||||
error.url = UPLOAD_URL;
|
||||
onError?.(error);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,7 +99,7 @@ const customRequest: UploadProps['httpRequest'] = async function (options) {
|
||||
uploadData.title = '';
|
||||
uploadData.introduction = '';
|
||||
|
||||
emit('update:modelValue', false);
|
||||
emit('update:open', false);
|
||||
ElMessage.success('上传成功');
|
||||
onSuccess?.(res);
|
||||
emit('uploaded');
|
||||
@@ -109,7 +112,7 @@ const customRequest: UploadProps['httpRequest'] = async function (options) {
|
||||
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="modelValue"
|
||||
:model-value="open"
|
||||
title="新建视频"
|
||||
width="600px"
|
||||
@close="handleCancel"
|
||||
|
||||
@@ -16,6 +16,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [v: number];
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
const columns = useImageGridColumns();
|
||||
@@ -37,6 +38,21 @@ const [Grid, gridApi] = useVbenVxeGrid<MpMaterialApi.Material>({
|
||||
refresh: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async () => {
|
||||
// 数据由父组件管理,触发刷新事件后返回当前数据
|
||||
emit('refresh');
|
||||
// 返回当前数据,避免覆盖
|
||||
return {
|
||||
list: Array.isArray(props.list) ? props.list : [],
|
||||
total: props.list?.length || 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
autoLoad: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -55,7 +71,7 @@ watch(
|
||||
await nextTick();
|
||||
updateGridData(data);
|
||||
},
|
||||
{ flush: 'post' },
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
watch(
|
||||
@@ -92,7 +108,7 @@ onMounted(async () => {
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'primary',
|
||||
type: 'danger',
|
||||
link: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['mp:material:delete'],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
import { watch } from 'vue';
|
||||
import { nextTick, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
import { openWindow } from '@vben/utils';
|
||||
@@ -19,6 +19,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [v: number];
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
const columns = useVideoGridColumns();
|
||||
@@ -39,20 +40,40 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
refresh: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async () => {
|
||||
// 数据由父组件管理,触发刷新事件后返回当前数据
|
||||
emit('refresh');
|
||||
// 返回当前数据,避免覆盖
|
||||
return {
|
||||
list: Array.isArray(props.list) ? props.list : [],
|
||||
total: props.list?.length || 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
autoLoad: false,
|
||||
},
|
||||
} as VxeTableGridOptions<MpMaterialApi.Material>,
|
||||
});
|
||||
|
||||
function updateGridData(data: MpMaterialApi.Material[]) {
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
(list: MpMaterialApi.Material[]) => {
|
||||
async (list: MpMaterialApi.Material[]) => {
|
||||
const data = Array.isArray(list) ? list : [];
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
await nextTick();
|
||||
updateGridData(data);
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
watch(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
import { watch } from 'vue';
|
||||
import { nextTick, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
import { openWindow } from '@vben/utils';
|
||||
@@ -19,6 +19,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [v: number];
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
const columns = useVoiceGridColumns();
|
||||
@@ -39,20 +40,40 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
refresh: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async () => {
|
||||
// 数据由父组件管理,触发刷新事件后返回当前数据
|
||||
emit('refresh');
|
||||
// 返回当前数据,避免覆盖
|
||||
return {
|
||||
list: Array.isArray(props.list) ? props.list : [],
|
||||
total: props.list?.length || 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
autoLoad: false,
|
||||
},
|
||||
} as VxeTableGridOptions<MpMaterialApi.Material>,
|
||||
});
|
||||
|
||||
function updateGridData(data: MpMaterialApi.Material[]) {
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
(list: MpMaterialApi.Material[]) => {
|
||||
async (list: MpMaterialApi.Material[]) => {
|
||||
const data = Array.isArray(list) ? list : [];
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
await nextTick();
|
||||
updateGridData(data);
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
watch(
|
||||
|
||||
94
apps/web-ele/src/views/mp/message/data.ts
Normal file
94
apps/web-ele/src/views/mp/message/data.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMessageApi } from '#/api/mp/message';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'accountId',
|
||||
label: '公众号',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'type',
|
||||
label: '消息类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '请选择消息类型',
|
||||
options: getDictOptions(DICT_TYPE.MP_MESSAGE_TYPE),
|
||||
clearable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'openid',
|
||||
label: '用户标识',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入用户标识',
|
||||
clearable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '创建时间',
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
...getRangePickerDefaultProps(),
|
||||
clearable: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions<MpMessageApi.Message>['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '发送时间',
|
||||
width: 180,
|
||||
align: 'center',
|
||||
slots: { default: 'createTime' },
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
title: '消息类型',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'sendFrom',
|
||||
title: '发送方',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
slots: { default: 'sendFrom' },
|
||||
},
|
||||
{
|
||||
field: 'openid',
|
||||
title: '用户标识',
|
||||
width: 300,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'content',
|
||||
title: '内容',
|
||||
align: 'left',
|
||||
minWidth: 320,
|
||||
slots: { default: 'content' },
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
title: '操作',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,195 +1,229 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMessageApi } from '#/api/mp/message';
|
||||
|
||||
import { reactive, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { DICT_TYPE, MpMsgType } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { MpMsgType as MsgType } from '@vben/constants';
|
||||
import { formatDate2 } from '@vben/utils';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElDatePicker,
|
||||
ElDialog,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElOption,
|
||||
ElPagination,
|
||||
ElSelect,
|
||||
} from 'element-plus';
|
||||
import { ElDialog, ElImage, ElTag } from 'element-plus';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getMessagePage } from '#/api/mp/message';
|
||||
import { WxAccountSelect, WxMsg } from '#/views/mp/components';
|
||||
import {
|
||||
WxAccountSelect,
|
||||
WxLocation,
|
||||
WxMsg,
|
||||
WxMusic,
|
||||
WxNews,
|
||||
WxVideoPlayer,
|
||||
WxVoicePlayer,
|
||||
} from '#/views/mp/components';
|
||||
|
||||
import MessageTable from './message-table.vue';
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
|
||||
defineOptions({ name: 'MpMessage' });
|
||||
|
||||
const loading = ref(false);
|
||||
const total = ref(0); // 数据的总页数
|
||||
const list = ref<any[]>([]); // 当前页的列表数据
|
||||
|
||||
const queryParams = reactive<{
|
||||
accountId: number;
|
||||
createTime: [Dayjs, Dayjs] | undefined;
|
||||
openid: string;
|
||||
pageNo: number;
|
||||
pageSize: number;
|
||||
type: string;
|
||||
}>({
|
||||
accountId: -1,
|
||||
createTime: undefined,
|
||||
openid: '',
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
type: MpMsgType.Text,
|
||||
}); // 搜索参数
|
||||
|
||||
const queryFormRef = ref(); // 搜索的表单
|
||||
|
||||
// 消息对话框
|
||||
const messageBoxVisible = ref(false);
|
||||
const messageBoxUserId = ref(0);
|
||||
|
||||
/** 侦听 accountId */
|
||||
function onAccountChanged(id: number) {
|
||||
queryParams.accountId = id;
|
||||
queryParams.pageNo = 1;
|
||||
handleQuery();
|
||||
}
|
||||
|
||||
/** 查询列表 */
|
||||
function handleQuery() {
|
||||
queryParams.pageNo = 1;
|
||||
getList();
|
||||
}
|
||||
|
||||
async function getList() {
|
||||
try {
|
||||
loading.value = true;
|
||||
const data = await getMessagePage(queryParams);
|
||||
list.value = data.list;
|
||||
total.value = data.total;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
async function resetQuery() {
|
||||
// 暂存 accountId,并在 reset 后恢复
|
||||
const accountId = queryParams.accountId;
|
||||
queryFormRef.value?.resetFields();
|
||||
queryParams.accountId = accountId;
|
||||
handleQuery();
|
||||
/** 公众号变化时查询数据 */
|
||||
function handleAccountChange(accountId: number) {
|
||||
gridApi.formApi.setValues({ accountId });
|
||||
gridApi.formApi.submitForm();
|
||||
}
|
||||
|
||||
/** 打开消息发送窗口 */
|
||||
async function handleSend(userId: number) {
|
||||
function handleSend(userId: number) {
|
||||
messageBoxUserId.value = userId;
|
||||
messageBoxVisible.value = true;
|
||||
}
|
||||
|
||||
/** 分页改变事件 */
|
||||
function handlePageChange(page: number) {
|
||||
queryParams.pageNo = page;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 每页条数改变事件 */
|
||||
function handleSizeChange(pageSize: number) {
|
||||
queryParams.pageSize = pageSize;
|
||||
queryParams.pageNo = 1;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 显示总条数 */
|
||||
function showTotal(total: number) {
|
||||
return `共 ${total} 条`;
|
||||
}
|
||||
// TODO @dylan:是不是应该都用 Grid 哈?
|
||||
const [Grid, gridApi] = useVbenVxeGrid<MpMessageApi.Message>({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getMessagePage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
autoLoad: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
} as VxeTableGridOptions<MpMessageApi.Message>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height class="flex flex-col">
|
||||
<!-- 搜索工作栏 -->
|
||||
<div class="mb-4 rounded-lg bg-background p-4">
|
||||
<ElForm
|
||||
ref="queryFormRef"
|
||||
:model="queryParams"
|
||||
:inline="true"
|
||||
class="search-form"
|
||||
>
|
||||
<ElFormItem label="公众号" prop="accountId">
|
||||
<WxAccountSelect @change="onAccountChanged" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="消息类型" prop="type">
|
||||
<ElSelect
|
||||
v-model="queryParams.type"
|
||||
placeholder="请选择消息类型"
|
||||
class="!w-[240px]"
|
||||
>
|
||||
<ElOption
|
||||
v-for="dict in getDictOptions(DICT_TYPE.MP_MESSAGE_TYPE)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
:label="dict.label"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="用户标识" prop="openid">
|
||||
<ElInput
|
||||
v-model="queryParams.openid"
|
||||
placeholder="请输入用户标识"
|
||||
clearable
|
||||
class="!w-[240px]"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="创建时间" prop="createTime">
|
||||
<ElDatePicker
|
||||
v-model="queryParams.createTime"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
class="!w-[240px]"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem>
|
||||
<ElButton type="primary" @click="handleQuery">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="mdi:magnify" />
|
||||
</template>
|
||||
搜索
|
||||
</ElButton>
|
||||
<ElButton class="ml-2" @click="resetQuery">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="mdi:refresh" />
|
||||
</template>
|
||||
重置
|
||||
</ElButton>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #form-accountId>
|
||||
<WxAccountSelect @change="handleAccountChange" />
|
||||
</template>
|
||||
<template #createTime="{ row }">
|
||||
{{ row.createTime ? formatDate2(row.createTime) : '' }}
|
||||
</template>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="flex-1 rounded-lg bg-background p-4">
|
||||
<MessageTable :list="list" :loading="loading" @send="handleSend" />
|
||||
<div v-show="total > 0" class="mt-4 flex justify-end">
|
||||
<ElPagination
|
||||
v-model:current-page="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:show-total="showTotal"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
<template #sendFrom="{ row }">
|
||||
<ElTag v-if="row.sendFrom === 1" type="success">粉丝</ElTag>
|
||||
<ElTag v-else>公众号</ElTag>
|
||||
</template>
|
||||
|
||||
<template #content="{ row }">
|
||||
<div
|
||||
v-if="
|
||||
(row.type as string) === (MsgType.Event as string) &&
|
||||
(row.event as string) === 'subscribe'
|
||||
"
|
||||
>
|
||||
<ElTag type="success">关注</ElTag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
(row.type as string) === (MsgType.Event as string) &&
|
||||
(row.event as string) === 'unsubscribe'
|
||||
"
|
||||
>
|
||||
<ElTag type="danger">取消关注</ElTag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
(row.type as string) === (MsgType.Event as string) &&
|
||||
(row.event as string) === 'CLICK'
|
||||
"
|
||||
>
|
||||
<ElTag>点击菜单</ElTag>
|
||||
【{{ row.eventKey }}】
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Event && row.event === 'VIEW'">
|
||||
<ElTag>点击菜单链接</ElTag>
|
||||
【{{ row.eventKey }}】
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
row.type === MsgType.Event && row.event === 'scancode_waitmsg'
|
||||
"
|
||||
>
|
||||
<ElTag>扫码结果</ElTag>
|
||||
【{{ row.eventKey }}】
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
row.type === MsgType.Event && row.event === 'scancode_push'
|
||||
"
|
||||
>
|
||||
<ElTag>扫码结果</ElTag>
|
||||
【{{ row.eventKey }}】
|
||||
</div>
|
||||
<div
|
||||
v-else-if="row.type === MsgType.Event && row.event === 'pic_sysphoto'"
|
||||
>
|
||||
<ElTag>系统拍照发图</ElTag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
row.type === MsgType.Event && row.event === 'pic_photo_or_album'
|
||||
"
|
||||
>
|
||||
<ElTag>拍照或者相册</ElTag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="row.type === MsgType.Event && row.event === 'pic_weixin'"
|
||||
>
|
||||
<ElTag>微信相册</ElTag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
row.type === MsgType.Event && row.event === 'location_select'
|
||||
"
|
||||
>
|
||||
<ElTag>选择地理位置</ElTag>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Event">
|
||||
<ElTag type="danger">未知事件类型</ElTag>
|
||||
</div>
|
||||
|
||||
<div v-else-if="row.type === MsgType.Text">{{ row.content }}</div>
|
||||
<div v-else-if="row.type === MsgType.Voice">
|
||||
<WxVoicePlayer
|
||||
:url="row.mediaUrl || ''"
|
||||
:content="row.recognition || ''"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Image">
|
||||
<a :href="row.mediaUrl" target="_blank">
|
||||
<ElImage :src="row.mediaUrl" :width="100" :preview-src-list="[]" />
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="row.type === MsgType.Video || row.type === 'shortvideo'"
|
||||
>
|
||||
<WxVideoPlayer :url="row.mediaUrl || ''" class="mt-2" />
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Link">
|
||||
<ElTag>链接</ElTag>
|
||||
:
|
||||
<a :href="row.url" target="_blank">{{ row.title }}</a>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Location">
|
||||
<WxLocation
|
||||
:label="row.label || ''"
|
||||
:location-y="row.locationY || 0"
|
||||
:location-x="row.locationX || 0"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Music">
|
||||
<WxMusic
|
||||
:title="row.title"
|
||||
:description="row.description"
|
||||
:thumb-media-url="row.thumbMediaUrl || ''"
|
||||
:music-url="row.musicUrl"
|
||||
:hq-music-url="row.hqMusicUrl"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.News">
|
||||
<WxNews :articles="row.articles" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<ElTag type="danger">未知消息类型</ElTag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '消息',
|
||||
type: 'primary',
|
||||
link: true,
|
||||
icon: ACTION_ICON.VIEW,
|
||||
onClick: () => handleSend(row.userId || 0),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Grid>
|
||||
|
||||
<!-- 发送消息的弹窗 -->
|
||||
<ElDialog
|
||||
@@ -203,9 +237,3 @@ function showTotal(total: number) {
|
||||
</ElDialog>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-form :deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMessageApi } from '#/api/mp/message';
|
||||
|
||||
import { onMounted, watch } from 'vue';
|
||||
|
||||
import { MpMsgType as MsgType } from '@vben/constants';
|
||||
import { formatDate2 } from '@vben/utils';
|
||||
|
||||
import { ElButton, ElImage, ElTag } from 'element-plus';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
WxLocation,
|
||||
WxMusic,
|
||||
WxNews,
|
||||
WxVideoPlayer,
|
||||
WxVoicePlayer,
|
||||
} from '#/views/mp/components';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
list?: MpMessageApi.Message[];
|
||||
loading?: boolean;
|
||||
}>(),
|
||||
{
|
||||
list() {
|
||||
return [];
|
||||
},
|
||||
loading: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'send', userId: number): void;
|
||||
}>();
|
||||
|
||||
const columns: VxeTableGridOptions<MpMessageApi.Message>['columns'] = [
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '发送时间',
|
||||
width: 180,
|
||||
align: 'center',
|
||||
slots: { default: 'createTime' },
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
title: '消息类型',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'sendFrom',
|
||||
title: '发送方',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
slots: { default: 'sendFrom' },
|
||||
},
|
||||
{
|
||||
field: 'openid',
|
||||
title: '用户标识',
|
||||
width: 300,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'content',
|
||||
title: '内容',
|
||||
align: 'left',
|
||||
minWidth: 320,
|
||||
slots: { default: 'content' },
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
title: '操作',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid<MpMessageApi.Message>({
|
||||
gridOptions: {
|
||||
border: true,
|
||||
columns,
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
});
|
||||
|
||||
function normalizeList(list?: MpMessageApi.Message[]) {
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
function updateGridData(data: MpMessageApi.Message[]) {
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
(list) => {
|
||||
updateGridData(normalizeList(list));
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading) => {
|
||||
gridApi.setLoading(loading);
|
||||
},
|
||||
);
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
updateGridData(normalizeList(props.list));
|
||||
gridApi.setLoading(props.loading);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Grid>
|
||||
<template #createTime="{ row }">
|
||||
{{ row.createTime ? formatDate2(row.createTime) : '' }}
|
||||
</template>
|
||||
|
||||
<template #sendFrom="{ row }">
|
||||
<ElTag v-if="row.sendFrom === 1" type="success">粉丝</ElTag>
|
||||
<ElTag v-else>公众号</ElTag>
|
||||
</template>
|
||||
|
||||
<template #content="{ row }">
|
||||
<div
|
||||
v-if="
|
||||
(row.type as string) === (MsgType.Event as string) &&
|
||||
(row.event as string) === 'subscribe'
|
||||
"
|
||||
>
|
||||
<ElTag type="success">关注</ElTag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
(row.type as string) === (MsgType.Event as string) &&
|
||||
(row.event as string) === 'unsubscribe'
|
||||
"
|
||||
>
|
||||
<ElTag type="danger">取消关注</ElTag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
(row.type as string) === (MsgType.Event as string) &&
|
||||
(row.event as string) === 'CLICK'
|
||||
"
|
||||
>
|
||||
<ElTag>点击菜单</ElTag>
|
||||
【{{ row.eventKey }}】
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Event && row.event === 'VIEW'">
|
||||
<ElTag>点击菜单链接</ElTag>
|
||||
【{{ row.eventKey }}】
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
row.type === MsgType.Event && row.event === 'scancode_waitmsg'
|
||||
"
|
||||
>
|
||||
<ElTag>扫码结果</ElTag>
|
||||
【{{ row.eventKey }}】
|
||||
</div>
|
||||
<div
|
||||
v-else-if="row.type === MsgType.Event && row.event === 'scancode_push'"
|
||||
>
|
||||
<ElTag>扫码结果</ElTag>
|
||||
【{{ row.eventKey }}】
|
||||
</div>
|
||||
<div
|
||||
v-else-if="row.type === MsgType.Event && row.event === 'pic_sysphoto'"
|
||||
>
|
||||
<ElTag>系统拍照发图</ElTag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
row.type === MsgType.Event && row.event === 'pic_photo_or_album'
|
||||
"
|
||||
>
|
||||
<ElTag>拍照或者相册</ElTag>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Event && row.event === 'pic_weixin'">
|
||||
<ElTag>微信相册</ElTag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
row.type === MsgType.Event && row.event === 'location_select'
|
||||
"
|
||||
>
|
||||
<ElTag>选择地理位置</ElTag>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Event">
|
||||
<ElTag type="danger">未知事件类型</ElTag>
|
||||
</div>
|
||||
|
||||
<div v-else-if="row.type === MsgType.Text">{{ row.content }}</div>
|
||||
<div v-else-if="row.type === MsgType.Voice">
|
||||
<WxVoicePlayer
|
||||
:url="row.mediaUrl || ''"
|
||||
:content="row.recognition || ''"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Image">
|
||||
<a :href="row.mediaUrl" target="_blank">
|
||||
<ElImage :src="row.mediaUrl" :width="100" :preview-src-list="[]" />
|
||||
</a>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Video || row.type === 'shortvideo'">
|
||||
<WxVideoPlayer :url="row.mediaUrl || ''" class="mt-2" />
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Link">
|
||||
<ElTag>链接</ElTag>
|
||||
:
|
||||
<a :href="row.url" target="_blank">{{ row.title }}</a>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Location">
|
||||
<WxLocation
|
||||
:label="row.label || ''"
|
||||
:location-y="row.locationY || 0"
|
||||
:location-x="row.locationX || 0"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.Music">
|
||||
<WxMusic
|
||||
:title="row.title"
|
||||
:description="row.description"
|
||||
:thumb-media-url="row.thumbMediaUrl || ''"
|
||||
:music-url="row.musicUrl"
|
||||
:hq-music-url="row.hqMusicUrl"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="row.type === MsgType.News">
|
||||
<WxNews :articles="row.articles" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<ElTag type="danger">未知消息类型</ElTag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions="{ row }">
|
||||
<ElButton type="primary" link @click="emit('send', row.userId || 0)">
|
||||
消息
|
||||
</ElButton>
|
||||
</template>
|
||||
</Grid>
|
||||
</template>
|
||||
@@ -1,116 +0,0 @@
|
||||
.mp-card {
|
||||
&__item {
|
||||
box-sizing: border-box;
|
||||
height: 200px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
font-feature-settings: 'tnum';
|
||||
font-variant: tabular-nums;
|
||||
line-height: 1.5;
|
||||
color: rgb(0 0 0 / 65%);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
background-color: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
|
||||
&:hover {
|
||||
border-color: rgb(0 0 0 / 9%);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 9%);
|
||||
}
|
||||
|
||||
&--add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
background-color: #fff;
|
||||
border: 1px dashed #000;
|
||||
border-color: #d9d9d9;
|
||||
border-radius: 2px;
|
||||
|
||||
i {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
background-color: #fff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
&__detail {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-right: 12px;
|
||||
overflow: hidden;
|
||||
border-radius: 48px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-bottom: 12px;
|
||||
font-size: 16px;
|
||||
color: rgb(0 0 0 / 85%);
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: -webkit-box;
|
||||
height: 64px;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 3;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
&__menu {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
text-align: center;
|
||||
background: #f7f9fa;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** joolun 额外加的 */
|
||||
.mp-comment__main {
|
||||
flex: unset !important;
|
||||
margin: 0 8px !important;
|
||||
border-radius: 5px !important;
|
||||
}
|
||||
|
||||
.mp-comment__header {
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
}
|
||||
|
||||
.mp-comment__body {
|
||||
border-bottom-right-radius: 5px;
|
||||
border-bottom-left-radius: 5px;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
/* 来自 https://github.com/nmxiaowei/avue/blob/master/styles/src/element-ui/comment.scss */
|
||||
.mp-comment {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 30px;
|
||||
|
||||
&--reverse {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.mp-comment__main {
|
||||
&::before,
|
||||
&::after {
|
||||
right: -8px;
|
||||
left: auto;
|
||||
border-width: 8px 0 8px 8px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
border-left-color: #dedede;
|
||||
}
|
||||
|
||||
&::after {
|
||||
margin-right: 1px;
|
||||
margin-left: auto;
|
||||
border-left-color: #f8f8f8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
box-sizing: border-box;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
vertical-align: middle;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 5px 15px;
|
||||
background: #f8f8f8;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
&__author {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
&__main {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
margin: 0 20px;
|
||||
border: 1px solid #dedede;
|
||||
border-radius: 2px;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 100%;
|
||||
left: -8px;
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
content: ' ';
|
||||
border-color: transparent;
|
||||
border-style: solid solid outset;
|
||||
border-width: 8px 8px 8px 0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
z-index: 1;
|
||||
border-right-color: #dedede;
|
||||
}
|
||||
|
||||
&::after {
|
||||
z-index: 2;
|
||||
margin-left: 1px;
|
||||
border-right-color: #f8f8f8;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 15px;
|
||||
overflow: hidden;
|
||||
font-family:
|
||||
'Segoe UI', 'Lucida Grande', Helvetica, Arial, 'Microsoft YaHei',
|
||||
FreeSans, Arimo, 'Droid Sans', 'wenquanyi micro hei', 'Hiragino Sans GB',
|
||||
'Hiragino Sans GB W3', FontAwesome, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding: 1px 0 1px 15px;
|
||||
margin: 0;
|
||||
font-family:
|
||||
Georgia, 'Times New Roman', Times, Kai, 'Kaiti SC', KaiTi, BiauKai,
|
||||
FontAwesome, serif;
|
||||
border-left: 4px solid #ddd;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user