mirror of
https://gitee.com/yudaocode/yudao-ui-admin-vben.git
synced 2025-12-30 02:22:25 +00:00
Merge remote-tracking branch 'yudao/dev' into dev
This commit is contained in:
@@ -17,6 +17,7 @@ export namespace InfraFileConfigApi {
|
||||
accessSecret?: string;
|
||||
pathStyle?: boolean;
|
||||
enablePublicAccess?: boolean;
|
||||
region?: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export namespace SystemSocialClientApi {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
agentId?: string;
|
||||
publicKey?: string;
|
||||
status: number;
|
||||
createTime?: Date;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export default {
|
||||
'Append Gateway': '追加网关',
|
||||
'Append Task': '追加任务',
|
||||
'Append Intermediate/Boundary Event': '追加中间抛出事件/边界事件',
|
||||
|
||||
TextAnnotation: '文本注释',
|
||||
'Activate the global connect tool': '激活全局连接工具',
|
||||
'Append {type}': '添加 {type}',
|
||||
'Add Lane above': '在上面添加道',
|
||||
@@ -31,10 +31,16 @@ export default {
|
||||
'Create expanded SubProcess': '创建扩展子过程',
|
||||
'Create IntermediateThrowEvent/BoundaryEvent': '创建中间抛出事件/边界事件',
|
||||
'Create Pool/Participant': '创建池/参与者',
|
||||
'Parallel Multi Instance': '并行多重事件',
|
||||
'Sequential Multi Instance': '时序多重事件',
|
||||
'Participant Multiplicity': '参与者多重性',
|
||||
'Empty pool/participant (removes content)': '清空池/参与者(移除内容)',
|
||||
'Empty pool/participant': '收缩池/参与者',
|
||||
'Expanded pool/participant': '展开池/参与者',
|
||||
'Parallel Multi-Instance': '并行多重事件',
|
||||
'Sequential Multi-Instance': '时序多重事件',
|
||||
DataObjectReference: '数据对象参考',
|
||||
DataStoreReference: '数据存储参考',
|
||||
'Data object reference': '数据对象引用 ',
|
||||
'Data store reference': '数据存储引用 ',
|
||||
Loop: '循环',
|
||||
'Ad-hoc': '即席',
|
||||
'Create {type}': '创建 {type}',
|
||||
@@ -49,6 +55,9 @@ export default {
|
||||
'Call Activity': '调用活动',
|
||||
'Sub-Process (collapsed)': '子流程(折叠的)',
|
||||
'Sub-Process (expanded)': '子流程(展开的)',
|
||||
'Ad-hoc sub-process': '即席子流程',
|
||||
'Ad-hoc sub-process (collapsed)': '即席子流程(折叠的)',
|
||||
'Ad-hoc sub-process (expanded)': '即席子流程(展开的)',
|
||||
'Start Event': '开始事件',
|
||||
StartEvent: '开始事件',
|
||||
'Intermediate Throw Event': '中间事件',
|
||||
@@ -111,10 +120,10 @@ export default {
|
||||
'Parallel Gateway': '并行网关',
|
||||
'Inclusive Gateway': '相容网关',
|
||||
'Complex Gateway': '复杂网关',
|
||||
'Event based Gateway': '事件网关',
|
||||
'Event-based Gateway': '事件网关',
|
||||
Transaction: '转运',
|
||||
'Sub Process': '子流程',
|
||||
'Event Sub Process': '事件子流程',
|
||||
'sub-process': '子流程',
|
||||
'Event sub-process': '事件子流程',
|
||||
'Collapsed Pool': '折叠池',
|
||||
'Expanded Pool': '展开池',
|
||||
|
||||
|
||||
@@ -390,8 +390,9 @@ watch(() => props.businessObject, syncFromBusinessObject, { deep: true });
|
||||
<template #extra>
|
||||
<IconifyIcon icon="ep:timer" />
|
||||
</template>
|
||||
<!-- 相关 issue:https://gitee.com/yudaocode/yudao-ui-admin-vue3/issues/ICNRW2 -->
|
||||
<TimeEventConfig
|
||||
:business-object="bpmnElement.value?.businessObject"
|
||||
:business-object="elementBusinessObject"
|
||||
:key="elementId"
|
||||
/>
|
||||
</CollapsePanel>
|
||||
|
||||
@@ -74,9 +74,16 @@ const assignEmptyUserIdsEl = ref<any>();
|
||||
const assignEmptyUserIds = ref<any>();
|
||||
|
||||
// 操作按钮
|
||||
// TODO @puhui999:这块迁移有点问题,按钮不能操作;另外,label 也没展示。
|
||||
const buttonsSettingEl = ref<any>();
|
||||
const { btnDisplayNameEdit, changeBtnDisplayName, btnDisplayNameBlurEvent } =
|
||||
useButtonsSetting();
|
||||
const { btnDisplayNameEdit, changeBtnDisplayName } = useButtonsSetting();
|
||||
const btnDisplayNameBlurEvent = (index: number) => {
|
||||
btnDisplayNameEdit.value[index] = false;
|
||||
const buttonItem = buttonsSettingEl.value[index];
|
||||
buttonItem.displayName =
|
||||
buttonItem.displayName || OPERATION_BUTTON_NAME.get(buttonItem.id)!;
|
||||
updateElementExtensions();
|
||||
};
|
||||
|
||||
// 字段权限
|
||||
const fieldsPermissionEl = ref<any[]>([]);
|
||||
@@ -358,19 +365,10 @@ function useButtonsSetting() {
|
||||
const changeBtnDisplayName = (index: number) => {
|
||||
btnDisplayNameEdit.value[index] = true;
|
||||
};
|
||||
const btnDisplayNameBlurEvent = (index: number) => {
|
||||
btnDisplayNameEdit.value[index] = false;
|
||||
const buttonItem = buttonsSetting.value?.[index];
|
||||
if (buttonItem) {
|
||||
buttonItem.displayName =
|
||||
buttonItem.displayName || OPERATION_BUTTON_NAME.get(buttonItem.id)!;
|
||||
}
|
||||
};
|
||||
return {
|
||||
buttonsSetting,
|
||||
btnDisplayNameEdit,
|
||||
changeBtnDisplayName,
|
||||
btnDisplayNameBlurEvent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -532,7 +530,7 @@ onMounted(async () => {
|
||||
</Button>
|
||||
</div>
|
||||
<div class="button-setting-item-label">
|
||||
<Switch v-model:checked="item.enable" />
|
||||
<Switch v-model:checked="item.enable" @change="updateElementExtensions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,7 +61,10 @@ const bpmnInstances = () => (window as any)?.bpmnInstances;
|
||||
|
||||
const resetListenersList = () => {
|
||||
bpmnElement.value = bpmnInstances().bpmnElement;
|
||||
otherExtensionList.value = [];
|
||||
otherExtensionList.value =
|
||||
bpmnElement.value.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(
|
||||
(ex: any) => ex.$type === `${prefix}:ExecutionListener`,
|
||||
|
||||
@@ -77,9 +77,12 @@ const resetListenersList = () => {
|
||||
// 'window.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElement',
|
||||
// );
|
||||
bpmnElement.value = bpmnInstances()?.bpmnElement;
|
||||
otherExtensionList.value = [];
|
||||
otherExtensionList.value =
|
||||
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
|
||||
(ex: any) => ex.$type !== `${prefix}:TaskListener`,
|
||||
) ?? []; // 保留非监听器类型的扩展属性,避免移除监听器时清空其他配置(如审批人等)。相关案例:https://gitee.com/yudaocode/yudao-ui-admin-vue3/issues/ICMSYC
|
||||
bpmnElementListeners.value =
|
||||
bpmnElement.value.businessObject?.extensionElements?.values.filter(
|
||||
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
|
||||
(ex: any) => ex.$type === `${prefix}:TaskListener`,
|
||||
) ?? [];
|
||||
elementListenersList.value = bpmnElementListeners.value.map((listener) =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Form,
|
||||
FormItem,
|
||||
Input,
|
||||
@@ -23,13 +24,31 @@ const modelObjectForm = ref<any>({});
|
||||
const rootElements = ref();
|
||||
const messageIdMap = ref();
|
||||
const signalIdMap = ref();
|
||||
const editingIndex = ref(-1); // 正在编辑的索引,-1 表示新建
|
||||
const modelConfig = computed(() => {
|
||||
const isEdit = editingIndex.value !== -1;
|
||||
return modelType.value === 'message'
|
||||
? { title: '创建消息', idLabel: '消息ID', nameLabel: '消息名称' }
|
||||
: { title: '创建信号', idLabel: '信号ID', nameLabel: '信号名称' };
|
||||
? {
|
||||
title: isEdit ? '编辑消息' : '创建消息',
|
||||
idLabel: '消息ID',
|
||||
nameLabel: '消息名称',
|
||||
}
|
||||
: {
|
||||
title: isEdit ? '编辑信号' : '创建信号',
|
||||
idLabel: '信号ID',
|
||||
nameLabel: '信号名称',
|
||||
};
|
||||
});
|
||||
const bpmnInstances = () => (window as any)?.bpmnInstances;
|
||||
|
||||
// 生成规范化的ID
|
||||
const generateStandardId = (type: string): string => {
|
||||
const prefix = type === 'message' ? 'Message_' : 'Signal_';
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `${prefix}${timestamp}_${random}`;
|
||||
};
|
||||
|
||||
const initDataList = () => {
|
||||
// console.log(window, 'window');
|
||||
rootElements.value = bpmnInstances().modeler.getDefinitions().rootElements;
|
||||
@@ -48,35 +67,104 @@ const initDataList = () => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const openModel = (type: any) => {
|
||||
modelType.value = type;
|
||||
modelObjectForm.value = {};
|
||||
editingIndex.value = -1;
|
||||
modelObjectForm.value = {
|
||||
id: generateStandardId(type),
|
||||
name: '',
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEditModel = (type: any, row: any, index: number) => {
|
||||
modelType.value = type;
|
||||
editingIndex.value = index;
|
||||
modelObjectForm.value = { ...row };
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const addNewObject = () => {
|
||||
if (modelType.value === 'message') {
|
||||
if (messageIdMap.value[modelObjectForm.value.id]) {
|
||||
message.error('该消息已存在,请修改id后重新保存');
|
||||
// 编辑模式
|
||||
if (editingIndex.value === -1) {
|
||||
// 新建模式
|
||||
if (messageIdMap.value[modelObjectForm.value.id]) {
|
||||
message.error('该消息已存在,请修改id后重新保存');
|
||||
return;
|
||||
}
|
||||
const messageRef = bpmnInstances().moddle.create(
|
||||
'bpmn:Message',
|
||||
modelObjectForm.value,
|
||||
);
|
||||
rootElements.value.push(messageRef);
|
||||
} else {
|
||||
const targetMessage = messageList.value[editingIndex.value];
|
||||
// 查找 rootElements 中的原始对象
|
||||
const rootMessage = rootElements.value.find(
|
||||
(el: any) => el.$type === 'bpmn:Message' && el.id === targetMessage.id,
|
||||
);
|
||||
if (rootMessage) {
|
||||
rootMessage.id = modelObjectForm.value.id;
|
||||
rootMessage.name = modelObjectForm.value.name;
|
||||
}
|
||||
}
|
||||
const messageRef = bpmnInstances().moddle.create(
|
||||
'bpmn:Message',
|
||||
modelObjectForm.value,
|
||||
);
|
||||
rootElements.value.push(messageRef);
|
||||
} else {
|
||||
if (signalIdMap.value[modelObjectForm.value.id]) {
|
||||
message.error('该信号已存在,请修改id后重新保存');
|
||||
// 编辑模式
|
||||
if (editingIndex.value === -1) {
|
||||
// 新建模式
|
||||
if (signalIdMap.value[modelObjectForm.value.id]) {
|
||||
message.error('该信号已存在,请修改id后重新保存');
|
||||
return;
|
||||
}
|
||||
const signalRef = bpmnInstances().moddle.create(
|
||||
'bpmn:Signal',
|
||||
modelObjectForm.value,
|
||||
);
|
||||
rootElements.value.push(signalRef);
|
||||
} else {
|
||||
const targetSignal = signalList.value[editingIndex.value];
|
||||
// 查找 rootElements 中的原始对象
|
||||
const rootSignal = rootElements.value.find(
|
||||
(el: any) => el.$type === 'bpmn:Signal' && el.id === targetSignal.id,
|
||||
);
|
||||
if (rootSignal) {
|
||||
rootSignal.id = modelObjectForm.value.id;
|
||||
rootSignal.name = modelObjectForm.value.name;
|
||||
}
|
||||
}
|
||||
const signalRef = bpmnInstances().moddle.create(
|
||||
'bpmn:Signal',
|
||||
modelObjectForm.value,
|
||||
);
|
||||
rootElements.value.push(signalRef);
|
||||
}
|
||||
dialogVisible.value = false;
|
||||
initDataList();
|
||||
};
|
||||
|
||||
// 补充"编辑"、"移除"功能。相关 issue:https://github.com/YunaiV/yudao-cloud/issues/270
|
||||
const removeObject = (type: any, row: any) => {
|
||||
Modal.confirm({
|
||||
title: '提示',
|
||||
content: `确认移除该${type === 'message' ? '消息' : '信号'}吗?`,
|
||||
okText: '确 认',
|
||||
cancelText: '取 消',
|
||||
onOk() {
|
||||
// 从 rootElements 中移除
|
||||
const targetType = type === 'message' ? 'bpmn:Message' : 'bpmn:Signal';
|
||||
const elementIndex = rootElements.value.findIndex(
|
||||
(el: any) => el.$type === targetType && el.id === row.id,
|
||||
);
|
||||
if (elementIndex !== -1) {
|
||||
rootElements.value.splice(elementIndex, 1);
|
||||
}
|
||||
// 刷新列表
|
||||
initDataList();
|
||||
message.success('移除成功');
|
||||
},
|
||||
onCancel() {
|
||||
// console.info('操作取消');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initDataList();
|
||||
});
|
||||
@@ -103,6 +191,26 @@ onMounted(() => {
|
||||
</TableColumn>
|
||||
<TableColumn title="消息ID" data-index="id" />
|
||||
<TableColumn title="消息名称" data-index="name" />
|
||||
<TableColumn title="操作" width="110px">
|
||||
<template #default="{ record, index }">
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
@click="openEditModel('message', record, index)"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Divider type="vertical" />
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
danger
|
||||
@click="removeObject('message', record)"
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</template>
|
||||
</TableColumn>
|
||||
</Table>
|
||||
<div class="panel-tab__content--title mt-2 border-t border-gray-200 pt-2">
|
||||
<span class="flex items-center">
|
||||
@@ -124,6 +232,26 @@ onMounted(() => {
|
||||
</TableColumn>
|
||||
<TableColumn title="信号ID" data-index="id" />
|
||||
<TableColumn title="信号名称" data-index="name" />
|
||||
<TableColumn title="操作" width="110px">
|
||||
<template #default="{ record, index }">
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
@click="openEditModel('signal', record, index)"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Divider type="vertical" />
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
danger
|
||||
@click="removeObject('signal', record)"
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</template>
|
||||
</TableColumn>
|
||||
</Table>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { Button, Input, Modal } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'HttpHeaderEditor' });
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
headers: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save']);
|
||||
|
||||
interface HeaderItem {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const headerList = ref<HeaderItem[]>([]);
|
||||
|
||||
// 解析请求头字符串为列表
|
||||
const parseHeaders = (headersStr: string): HeaderItem[] => {
|
||||
if (!headersStr || !headersStr.trim()) {
|
||||
return [{ key: '', value: '' }];
|
||||
}
|
||||
|
||||
const lines = headersStr.split('\n').filter((line) => line.trim());
|
||||
const parsed = lines.map((line) => {
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
return {
|
||||
key: line.slice(0, Math.max(0, colonIndex)).trim(),
|
||||
value: line.slice(Math.max(0, colonIndex + 1)).trim(),
|
||||
};
|
||||
}
|
||||
return { key: line.trim(), value: '' };
|
||||
});
|
||||
|
||||
return parsed.length > 0 ? parsed : [{ key: '', value: '' }];
|
||||
};
|
||||
|
||||
// 将列表转换为请求头字符串
|
||||
const stringifyHeaders = (headers: HeaderItem[]): string => {
|
||||
return headers
|
||||
.filter((item) => item.key.trim())
|
||||
.map((item) => `${item.key}: ${item.value}`)
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
// 添加请求头
|
||||
const addHeader = () => {
|
||||
headerList.value.push({ key: '', value: '' });
|
||||
};
|
||||
|
||||
// 移除请求头
|
||||
const removeHeader = (index: number) => {
|
||||
if (headerList.value.length === 1) {
|
||||
// 至少保留一行
|
||||
headerList.value = [{ key: '', value: '' }];
|
||||
} else {
|
||||
headerList.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存
|
||||
const handleSave = () => {
|
||||
const headersStr = stringifyHeaders(headerList.value);
|
||||
emit('save', headersStr);
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
// 关闭
|
||||
const handleClose = () => {
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
// 监听对话框打开,初始化数据
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val) {
|
||||
headerList.value = parseHeaders(props.headers);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
v-model:open="dialogVisible"
|
||||
title="编辑请求头"
|
||||
width="600px"
|
||||
:mask-closable="false"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<div class="header-editor">
|
||||
<div class="header-list">
|
||||
<div
|
||||
v-for="(item, index) in headerList"
|
||||
:key="index"
|
||||
class="header-item"
|
||||
>
|
||||
<Input
|
||||
v-model:value="item.key"
|
||||
placeholder="请输入参数名"
|
||||
class="header-key"
|
||||
allow-clear
|
||||
/>
|
||||
<span class="separator">:</span>
|
||||
<Input
|
||||
v-model:value="item.value"
|
||||
placeholder="请输入参数值 (支持表达式 ${变量名})"
|
||||
class="header-value"
|
||||
allow-clear
|
||||
/>
|
||||
<Button type="text" danger size="small" @click="removeHeader(index)">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="ep:delete" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" class="add-btn" @click="addHeader">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="ep:plus" />
|
||||
</template>
|
||||
添加请求头
|
||||
</Button>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button @click="handleClose">取消</Button>
|
||||
<Button type="primary" @click="handleSave">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header-editor {
|
||||
.header-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.header-key {
|
||||
flex: 0 0 180px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-value {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onBeforeUnmount, ref, toRaw, watch } from 'vue';
|
||||
import { inject, nextTick, onBeforeUnmount, ref, toRaw, watch } from 'vue';
|
||||
|
||||
import { FormItem, Input, Select } from 'ant-design-vue';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormItem,
|
||||
Input,
|
||||
RadioButton,
|
||||
RadioGroup,
|
||||
Select,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { updateElementExtensions } from '../../../utils';
|
||||
import HttpHeaderEditor from './HttpHeaderEditor.vue';
|
||||
|
||||
defineOptions({ name: 'ServiceTask' });
|
||||
const props = defineProps({
|
||||
@@ -9,38 +23,281 @@ const props = defineProps({
|
||||
type: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const defaultTaskForm = ref({
|
||||
const prefix = (inject('prefix', 'flowable') || 'flowable') as string;
|
||||
const flowableTypeKey = `${prefix}:type`;
|
||||
const flowableFieldType = `${prefix}:Field`;
|
||||
|
||||
const HTTP_FIELD_NAMES = [
|
||||
'requestMethod',
|
||||
'requestUrl',
|
||||
'requestHeaders',
|
||||
'disallowRedirects',
|
||||
'ignoreException',
|
||||
'saveResponseParameters',
|
||||
'resultVariablePrefix',
|
||||
'saveResponseParametersTransient',
|
||||
'saveResponseVariableAsJson',
|
||||
];
|
||||
const HTTP_BOOLEAN_FIELDS = new Set([
|
||||
'disallowRedirects',
|
||||
'ignoreException',
|
||||
'saveResponseParameters',
|
||||
'saveResponseParametersTransient',
|
||||
'saveResponseVariableAsJson',
|
||||
]);
|
||||
|
||||
const DEFAULT_TASK_FORM = {
|
||||
executeType: '',
|
||||
class: '',
|
||||
expression: '',
|
||||
delegateExpression: '',
|
||||
});
|
||||
};
|
||||
|
||||
const serviceTaskForm = ref<any>({});
|
||||
const DEFAULT_HTTP_FORM = {
|
||||
requestMethod: 'GET',
|
||||
requestUrl: '',
|
||||
requestHeaders: 'Content-Type: application/json',
|
||||
resultVariablePrefix: '',
|
||||
disallowRedirects: false,
|
||||
ignoreException: false,
|
||||
saveResponseParameters: false,
|
||||
saveResponseParametersTransient: false,
|
||||
saveResponseVariableAsJson: false,
|
||||
};
|
||||
|
||||
const serviceTaskForm = ref({ ...DEFAULT_TASK_FORM });
|
||||
const httpTaskForm = ref<any>({ ...DEFAULT_HTTP_FORM });
|
||||
const bpmnElement = ref();
|
||||
const httpInitializing = ref(false);
|
||||
const showHeaderEditor = ref(false);
|
||||
|
||||
const bpmnInstances = () => (window as any)?.bpmnInstances;
|
||||
|
||||
const resetTaskForm = () => {
|
||||
for (const key in defaultTaskForm.value) {
|
||||
const value =
|
||||
// @ts-ignore
|
||||
bpmnElement.value?.businessObject[key] || defaultTaskForm.value[key];
|
||||
serviceTaskForm.value[key] = value;
|
||||
if (value) {
|
||||
serviceTaskForm.value.executeType = key;
|
||||
// 判断字符串是否包含表达式
|
||||
const isExpression = (value: string): boolean => {
|
||||
if (!value) return false;
|
||||
// 检测 ${...} 或 #{...} 格式的表达式
|
||||
return /\$\{[^}]+\}/.test(value) || /#\{[^}]+\}/.test(value);
|
||||
};
|
||||
|
||||
const collectHttpExtensionInfo = () => {
|
||||
const businessObject = bpmnElement.value?.businessObject;
|
||||
const extensionElements = businessObject?.extensionElements;
|
||||
const httpFields = new Map<string, string>();
|
||||
const httpFieldTypes = new Map<string, 'expression' | 'string'>();
|
||||
const otherExtensions: any[] = [];
|
||||
|
||||
extensionElements?.values?.forEach((item: any) => {
|
||||
if (
|
||||
item?.$type === flowableFieldType &&
|
||||
HTTP_FIELD_NAMES.includes(item.name)
|
||||
) {
|
||||
const value = item.string ?? item.stringValue ?? item.expression ?? '';
|
||||
const fieldType = item.expression ? 'expression' : 'string';
|
||||
httpFields.set(item.name, value);
|
||||
httpFieldTypes.set(item.name, fieldType);
|
||||
} else {
|
||||
otherExtensions.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return { httpFields, httpFieldTypes, otherExtensions };
|
||||
};
|
||||
|
||||
const resetHttpDefaults = () => {
|
||||
httpInitializing.value = true;
|
||||
httpTaskForm.value = { ...DEFAULT_HTTP_FORM };
|
||||
nextTick(() => {
|
||||
httpInitializing.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const resetHttpForm = () => {
|
||||
httpInitializing.value = true;
|
||||
const { httpFields } = collectHttpExtensionInfo();
|
||||
const nextForm: any = { ...DEFAULT_HTTP_FORM };
|
||||
|
||||
HTTP_FIELD_NAMES.forEach((name) => {
|
||||
const stored = httpFields.get(name);
|
||||
if (stored !== undefined) {
|
||||
nextForm[name] = HTTP_BOOLEAN_FIELDS.has(name)
|
||||
? stored === 'true'
|
||||
: stored;
|
||||
}
|
||||
});
|
||||
|
||||
httpTaskForm.value = nextForm;
|
||||
nextTick(() => {
|
||||
httpInitializing.value = false;
|
||||
updateHttpExtensions(true);
|
||||
});
|
||||
};
|
||||
|
||||
const resetServiceTaskForm = () => {
|
||||
const businessObject = bpmnElement.value?.businessObject;
|
||||
const nextForm = { ...DEFAULT_TASK_FORM };
|
||||
|
||||
if (businessObject) {
|
||||
if (businessObject.class) {
|
||||
nextForm.class = businessObject.class;
|
||||
nextForm.executeType = 'class';
|
||||
}
|
||||
if (businessObject.expression) {
|
||||
nextForm.expression = businessObject.expression;
|
||||
nextForm.executeType = 'expression';
|
||||
}
|
||||
if (businessObject.delegateExpression) {
|
||||
nextForm.delegateExpression = businessObject.delegateExpression;
|
||||
nextForm.executeType = 'delegateExpression';
|
||||
}
|
||||
if (businessObject.$attrs?.[flowableTypeKey] === 'http') {
|
||||
nextForm.executeType = 'http';
|
||||
} else {
|
||||
// 兜底:如缺少 flowable:type=http,但扩展里已有 HTTP 的字段,也认为是 HTTP
|
||||
const { httpFields } = collectHttpExtensionInfo();
|
||||
if (httpFields.size > 0) {
|
||||
nextForm.executeType = 'http';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serviceTaskForm.value = nextForm;
|
||||
|
||||
if (nextForm.executeType === 'http') {
|
||||
resetHttpForm();
|
||||
} else {
|
||||
resetHttpDefaults();
|
||||
}
|
||||
};
|
||||
|
||||
const updateElementTask = () => {
|
||||
const taskAttr = Object.create(null);
|
||||
const type = serviceTaskForm.value.executeType;
|
||||
for (const key in serviceTaskForm.value) {
|
||||
if (key !== 'executeType' && key !== type) taskAttr[key] = null;
|
||||
const shouldPersistField = (name: string, value: any) => {
|
||||
if (HTTP_BOOLEAN_FIELDS.has(name)) return true;
|
||||
if (name === 'requestMethod') return true;
|
||||
if (name === 'requestUrl') return !!value;
|
||||
return value !== undefined && value !== '';
|
||||
};
|
||||
|
||||
const updateHttpExtensions = (force = false) => {
|
||||
if (!bpmnElement.value) return;
|
||||
if (
|
||||
!force &&
|
||||
(httpInitializing.value || serviceTaskForm.value.executeType !== 'http')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
taskAttr[type] = serviceTaskForm.value[type] || '';
|
||||
|
||||
const {
|
||||
httpFields: existingFields,
|
||||
httpFieldTypes: existingTypes,
|
||||
otherExtensions,
|
||||
} = collectHttpExtensionInfo();
|
||||
|
||||
const desiredEntries: [string, string][] = [];
|
||||
HTTP_FIELD_NAMES.forEach((name) => {
|
||||
const rawValue = httpTaskForm.value[name];
|
||||
if (!shouldPersistField(name, rawValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const persisted = HTTP_BOOLEAN_FIELDS.has(name)
|
||||
? String(!!rawValue)
|
||||
: (rawValue === undefined
|
||||
? ''
|
||||
: String(rawValue));
|
||||
|
||||
desiredEntries.push([name, persisted]);
|
||||
});
|
||||
|
||||
// 检查是否有变化:不仅比较值,还要比较字段类型(string vs expression)
|
||||
if (!force && desiredEntries.length === existingFields.size) {
|
||||
let noChange = true;
|
||||
for (const [name, value] of desiredEntries) {
|
||||
const existingValue = existingFields.get(name);
|
||||
const existingType = existingTypes.get(name);
|
||||
const currentType = isExpression(value) ? 'expression' : 'string';
|
||||
if (existingValue !== value || existingType !== currentType) {
|
||||
noChange = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (noChange) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const moddle = bpmnInstances().moddle;
|
||||
const httpFieldElements = desiredEntries.map(([name, value]) => {
|
||||
// 根据值是否包含表达式来决定使用 string 还是 expression 属性
|
||||
const isExpr = isExpression(value);
|
||||
return moddle.create(flowableFieldType, {
|
||||
name,
|
||||
...(isExpr ? { expression: value } : { string: value }),
|
||||
});
|
||||
});
|
||||
|
||||
updateElementExtensions(bpmnElement.value, [
|
||||
...otherExtensions,
|
||||
...httpFieldElements,
|
||||
]);
|
||||
};
|
||||
|
||||
const removeHttpExtensions = () => {
|
||||
if (!bpmnElement.value) return;
|
||||
const { httpFields, otherExtensions } = collectHttpExtensionInfo();
|
||||
if (httpFields.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (otherExtensions.length === 0) {
|
||||
bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), {
|
||||
extensionElements: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateElementExtensions(bpmnElement.value, otherExtensions);
|
||||
};
|
||||
|
||||
const updateElementTask = () => {
|
||||
if (!bpmnElement.value) return;
|
||||
|
||||
const taskAttr: Record<string, any> = {
|
||||
class: null,
|
||||
expression: null,
|
||||
delegateExpression: null,
|
||||
[flowableTypeKey]: null,
|
||||
};
|
||||
|
||||
const type = serviceTaskForm.value.executeType;
|
||||
if (
|
||||
type === 'class' ||
|
||||
type === 'expression' ||
|
||||
type === 'delegateExpression'
|
||||
) {
|
||||
taskAttr[type] = serviceTaskForm.value[type] || null;
|
||||
} else if (type === 'http') {
|
||||
taskAttr[flowableTypeKey] = 'http';
|
||||
}
|
||||
|
||||
bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), taskAttr);
|
||||
|
||||
if (type === 'http') {
|
||||
updateHttpExtensions(true);
|
||||
} else {
|
||||
removeHttpExtensions();
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecuteTypeChange = (value: any) => {
|
||||
serviceTaskForm.value.executeType = value;
|
||||
if (value === 'http') {
|
||||
resetHttpForm();
|
||||
}
|
||||
updateElementTask();
|
||||
};
|
||||
|
||||
const handleHeadersSave = (headersStr: string) => {
|
||||
httpTaskForm.value.requestHeaders = headersStr;
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -52,11 +309,19 @@ watch(
|
||||
() => {
|
||||
bpmnElement.value = bpmnInstances().bpmnElement;
|
||||
nextTick(() => {
|
||||
resetTaskForm();
|
||||
resetServiceTaskForm();
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => httpTaskForm.value,
|
||||
() => {
|
||||
updateHttpExtensions();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -68,7 +333,9 @@ watch(
|
||||
{ label: 'Java类', value: 'class' },
|
||||
{ label: '表达式', value: 'expression' },
|
||||
{ label: '代理表达式', value: 'delegateExpression' },
|
||||
{ label: 'HTTP 调用', value: 'http' },
|
||||
]"
|
||||
@change="handleExecuteTypeChange"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
@@ -107,5 +374,62 @@ watch(
|
||||
@change="updateElementTask"
|
||||
/>
|
||||
</FormItem>
|
||||
<template v-if="serviceTaskForm.executeType === 'http'">
|
||||
<FormItem label="请求方法" key="http-method">
|
||||
<RadioGroup v-model:value="httpTaskForm.requestMethod">
|
||||
<RadioButton value="GET">GET</RadioButton>
|
||||
<RadioButton value="POST">POST</RadioButton>
|
||||
<RadioButton value="PUT">PUT</RadioButton>
|
||||
<RadioButton value="DELETE">DELETE</RadioButton>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
<FormItem label="请求地址" key="http-url" name="requestUrl">
|
||||
<Input v-model:value="httpTaskForm.requestUrl" allow-clear />
|
||||
</FormItem>
|
||||
<FormItem label="请求头" key="http-headers">
|
||||
<div class="flex w-full items-start gap-2">
|
||||
<Textarea
|
||||
v-model:value="httpTaskForm.requestHeaders"
|
||||
:auto-size="{ minRows: 4, maxRows: 8 }"
|
||||
readonly
|
||||
placeholder="点击右侧编辑按钮添加请求头"
|
||||
class="min-w-0 flex-1"
|
||||
/>
|
||||
<Button type="primary" @click="showHeaderEditor = true">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="ep:edit" />
|
||||
</template>
|
||||
编辑
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem label="禁止重定向" key="http-disallow-redirects">
|
||||
<Switch v-model:checked="httpTaskForm.disallowRedirects" />
|
||||
</FormItem>
|
||||
<FormItem label="忽略异常" key="http-ignore-exception">
|
||||
<Switch v-model:checked="httpTaskForm.ignoreException" />
|
||||
</FormItem>
|
||||
<FormItem label="保存返回变量" key="http-save-response">
|
||||
<Switch v-model:checked="httpTaskForm.saveResponseParameters" />
|
||||
</FormItem>
|
||||
<FormItem label="是否瞬间变量" key="http-save-transient">
|
||||
<Switch
|
||||
v-model:checked="httpTaskForm.saveResponseParametersTransient"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="返回变量前缀" key="http-result-variable-prefix">
|
||||
<Input v-model:value="httpTaskForm.resultVariablePrefix" />
|
||||
</FormItem>
|
||||
<FormItem label="格式化返回为JSON" key="http-save-json">
|
||||
<Switch v-model:checked="httpTaskForm.saveResponseVariableAsJson" />
|
||||
</FormItem>
|
||||
</template>
|
||||
|
||||
<!-- 请求头编辑器 -->
|
||||
<HttpHeaderEditor
|
||||
v-model="showHeaderEditor"
|
||||
:headers="httpTaskForm.requestHeaders"
|
||||
@save="handleHeadersSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -146,7 +146,6 @@
|
||||
background: url('./svg/simple-process-bg.svg') 0 0 repeat;
|
||||
transform: scale(1);
|
||||
transform-origin: 50% 0 0;
|
||||
transition: transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
// 节点容器 定义节点宽度
|
||||
.node-container {
|
||||
width: 200px;
|
||||
|
||||
@@ -8,22 +8,22 @@ import { z } from '#/adapter/form';
|
||||
|
||||
export const EVENT_EXECUTION_OPTIONS = [
|
||||
{
|
||||
label: 'start',
|
||||
label: '开始',
|
||||
value: 'start',
|
||||
},
|
||||
{
|
||||
label: 'end',
|
||||
label: '结束',
|
||||
value: 'end',
|
||||
},
|
||||
];
|
||||
|
||||
export const EVENT_OPTIONS = [
|
||||
{ label: 'create', value: 'create' },
|
||||
{ label: 'assignment', value: 'assignment' },
|
||||
{ label: 'complete', value: 'complete' },
|
||||
{ label: 'delete', value: 'delete' },
|
||||
{ label: 'update', value: 'update' },
|
||||
{ label: 'timeout', value: 'timeout' },
|
||||
{ label: '创建', value: 'create' },
|
||||
{ label: '指派', value: 'assignment' },
|
||||
{ label: '完成', value: 'complete' },
|
||||
{ label: '删除', value: 'delete' },
|
||||
{ label: '更新', value: 'update' },
|
||||
{ label: '超时', value: 'timeout' },
|
||||
];
|
||||
|
||||
/** 新增/修改的表单 */
|
||||
|
||||
@@ -78,7 +78,8 @@ function handleRowCheckboxChange({
|
||||
}: {
|
||||
records: InfraDataSourceConfigApi.DataSourceConfig[];
|
||||
}) {
|
||||
checkedIds.value = records.map((item) => item.id!);
|
||||
// 过滤掉id为 0 的主数据源
|
||||
checkedIds.value = records.map((item) => item.id!).filter((id) => id !== 0);
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
@@ -140,6 +141,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['infra:data-source-config:update'],
|
||||
disabled: row.id === 0,
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
@@ -148,6 +150,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['infra:data-source-config:delete'],
|
||||
disabled: row.id === 0,
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
|
||||
@@ -229,6 +229,18 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
},
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
fieldName: 'config.region',
|
||||
label: '区域',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请填写区域,一般仅 AWS 需要填写',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['storage'],
|
||||
show: (formValues) => formValues.storage === 20,
|
||||
},
|
||||
},
|
||||
// 通用
|
||||
{
|
||||
fieldName: 'config.domain',
|
||||
|
||||
@@ -86,6 +86,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- TODO @puhui999:这里缺少商品的选择; -->
|
||||
<Modal class="w-2/5" :title="getTitle">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
|
||||
@@ -106,7 +106,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
},
|
||||
},
|
||||
{
|
||||
// TODO
|
||||
// TODO <!-- TODO @puhui999:这里缺少商品的选择; -->
|
||||
fieldName: 'spuId',
|
||||
label: '拼团商品',
|
||||
component: 'Input',
|
||||
|
||||
@@ -133,7 +133,7 @@ function handleSliderChange(prop: string) {
|
||||
</TabPane>
|
||||
|
||||
<!-- 每个组件的通用内容 -->
|
||||
<!-- TODO @xingyu:这里的样式,貌似没 ele 版本的好看。 -->
|
||||
<!-- TODO @xingyu:【装修】这里的样式,貌似没 ele 版本的好看。 -->
|
||||
<TabPane tab="样式" key="style" force-render>
|
||||
<p class="text-lg font-bold">组件样式:</p>
|
||||
<div class="flex flex-col gap-2 rounded-md p-4 shadow-lg">
|
||||
|
||||
@@ -109,8 +109,9 @@ const handleDeleteComponent = () => {
|
||||
class="component-toolbar"
|
||||
v-if="showToolbar && component.name && active"
|
||||
>
|
||||
<!-- TODO @xingyu:按钮少的时候,会存在遮住的情况; -->
|
||||
<!-- TODO @xingyu:貌似中间的选中框框,没全部框柱。上面多了点,下面少了点。 -->
|
||||
<!-- TODO @xingyu:【装修】按钮少的时候,会存在遮住的情况; -->
|
||||
<!-- TODO @xingyu:【装修】貌似中间的选中框框,没全部框柱。上面多了点,下面少了点。 -->
|
||||
<!-- TODO @xingyu:【装修】从左侧的组件,拖拽到中间时,没有这个组件的小卡片预览。ele 版本是有的 -->
|
||||
<VerticalButtonGroup size="small">
|
||||
<Button
|
||||
:disabled="!canMoveUp"
|
||||
|
||||
@@ -100,5 +100,5 @@ function handleCloneComponent(component: DiyComponent<any>) {
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
<!-- TODO @xingyu:ele 里面有一些 style,看看是不是都迁移完了;特别是 drag-area 是全局样式; -->
|
||||
<!-- TODO @xingyu:【装修】ele 里面有一些 style,看看是不是都迁移完了;特别是 drag-area 是全局样式; -->
|
||||
</template>
|
||||
|
||||
@@ -38,7 +38,7 @@ const props = defineProps({
|
||||
|
||||
const emits = defineEmits(['reset', 'save', 'update:modelValue']); // 工具栏操作
|
||||
|
||||
// TODO @xingyu:要不要加这个?
|
||||
// TODO @xingyu:要不要加这个?ele 里是有这个的。
|
||||
// const qrcode = useQRCode(props.previewUrl, {
|
||||
// errorCorrectionLevel: 'H',
|
||||
// margin: 4,
|
||||
@@ -175,7 +175,7 @@ function handleComponentSelected(
|
||||
index: number = -1,
|
||||
) {
|
||||
// 使用深拷贝避免响应式追踪循环警告
|
||||
// TODO @xingyu:这个是必须的么?ele 没有哈。
|
||||
// TODO @xingyu:【装修】这个是必须的么?ele 没有哈。
|
||||
selectedComponent.value = cloneDeep(component);
|
||||
selectedComponentIndex.value = index;
|
||||
}
|
||||
@@ -508,5 +508,5 @@ onMounted(() => {
|
||||
</div>
|
||||
</PreviewModal>
|
||||
</Page>
|
||||
<!-- TODO @xingyu:这里改造完后,类似 web-ele/src/views/mall/promotion/components/diy-editor/index.vue 里的全局样式(递推到子组件)里的就没没了,类似 property-group -->
|
||||
<!-- TODO @xingyu:【装修】这里改造完后,类似 web-ele/src/views/mall/promotion/components/diy-editor/index.vue 里的全局样式(递推到子组件)里的就没没了,类似 property-group -->
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Space } from 'ant-design-vue';
|
||||
|
||||
// TODO @芋艿、@xingyu:貌似上下移动的按钮,被遮住了!
|
||||
// TODO @xingyu:【装修】貌似上下移动的按钮,被遮住了!
|
||||
/**
|
||||
* 垂直按钮组
|
||||
* Ant Design Vue 的按钮组,通过 Space 实现垂直布局
|
||||
|
||||
@@ -68,7 +68,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
rows: 4,
|
||||
},
|
||||
},
|
||||
// TODO
|
||||
// TODO @puhui999:少了商品选择~
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '#/api/mall/promotion/diy/template';
|
||||
import { DiyEditor, PAGE_LIBS } from '#/views/mall/promotion/components';
|
||||
|
||||
// TODO @xingyu:【装修】左上角的“基础设施”、“首页”、“我的”切换时,中间的编辑器内容没有正确切换。可对比 ele 版本的效果!
|
||||
/** 装修模板表单 */
|
||||
defineOptions({ name: 'DiyTemplateDecorate' });
|
||||
|
||||
|
||||
@@ -126,12 +126,14 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
},
|
||||
// TODO @puhui999:商品图太大了。
|
||||
{
|
||||
fieldName: 'spuId',
|
||||
label: '活动商品',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
formItemClass: 'col-span-2',
|
||||
// TODO @puhui999:貌似 renderComponentContent 不需要哇?
|
||||
renderComponentContent: () => ({
|
||||
default: () => null,
|
||||
}),
|
||||
|
||||
@@ -32,6 +32,7 @@ const formData = ref<Partial<MallRewardActivityApi.RewardActivity>>({
|
||||
rules: [],
|
||||
});
|
||||
|
||||
// TODO @puhui999:点击“编辑”后,会出现 Cannot read properties of null (reading 'type') 报错;
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['满减送'])
|
||||
|
||||
@@ -102,46 +102,53 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<Button type="link" class="pl-0" @click="handleSelect">添加优惠券</Button>
|
||||
|
||||
<div
|
||||
v-for="(item, index) in list"
|
||||
:key="item.id"
|
||||
class="coupon-list-item mb-2 flex justify-between rounded-lg border border-dashed border-gray-300 p-2"
|
||||
>
|
||||
<div class="coupon-list-item-left flex flex-wrap items-center gap-2">
|
||||
<div>优惠券名称:{{ item.name }}</div>
|
||||
<div>
|
||||
范围:
|
||||
<DictTag
|
||||
:type="DICT_TYPE.PROMOTION_PRODUCT_SCOPE"
|
||||
:value="item.productScope"
|
||||
/>
|
||||
<div>
|
||||
<!-- 已选优惠券列表 -->
|
||||
<div v-if="list.length > 0" class="mb-2 flex flex-col gap-2">
|
||||
<div
|
||||
v-for="(item, index) in list"
|
||||
:key="item.id"
|
||||
class="flex items-center justify-between rounded-md border border-gray-200 bg-white px-3 py-2 transition-all hover:border-blue-400 hover:shadow-sm"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="font-medium text-gray-800">{{ item.name }}</span>
|
||||
<span class="flex items-center gap-1 text-sm text-gray-500">
|
||||
<DictTag
|
||||
:type="DICT_TYPE.PROMOTION_PRODUCT_SCOPE"
|
||||
:value="item.productScope"
|
||||
/>
|
||||
</span>
|
||||
<span class="flex items-center gap-1 text-sm text-gray-500">
|
||||
<DictTag
|
||||
:type="DICT_TYPE.PROMOTION_DISCOUNT_TYPE"
|
||||
:value="item.discountType"
|
||||
/>
|
||||
{{ discountFormat(item) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
优惠:
|
||||
<DictTag
|
||||
:type="DICT_TYPE.PROMOTION_DISCOUNT_TYPE"
|
||||
:value="item.discountType"
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span class="text-gray-500">送</span>
|
||||
<Input
|
||||
v-model:value="item.giveCount"
|
||||
class="!w-20"
|
||||
placeholder=""
|
||||
type="number"
|
||||
size="small"
|
||||
/>
|
||||
{{ discountFormat(item) }}
|
||||
<span class="text-gray-500">张</span>
|
||||
<Button type="link" danger size="small" @click="handleDelete(index)">
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="coupon-list-item-right flex items-center gap-2">
|
||||
<span>送</span>
|
||||
<Input
|
||||
v-model:value="item.giveCount"
|
||||
class="!w-150px"
|
||||
placeholder=""
|
||||
type="number"
|
||||
/>
|
||||
<span>张</span>
|
||||
<Button type="link" danger @click="handleDelete(index)">删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 优惠券选择 -->
|
||||
<!-- 添加按钮 -->
|
||||
<Button type="link" class="!pl-0" @click="handleSelect">
|
||||
+ 添加优惠券
|
||||
</Button>
|
||||
|
||||
<!-- 优惠券选择弹窗 -->
|
||||
<CouponSelect
|
||||
ref="selectRef"
|
||||
:take-type="CouponTemplateTakeTypeEnum.ADMIN.type"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PromotionConditionTypeEnum } from '@vben/constants';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
FormItem,
|
||||
@@ -61,76 +62,92 @@ function handleDelete(ruleIndex: number) {
|
||||
<Row :gutter="[16, 16]">
|
||||
<template v-if="formData.rules">
|
||||
<Col v-for="(rule, index) in formData.rules" :key="index" :span="24">
|
||||
<!-- 规则标题 -->
|
||||
<div class="mb-4 flex items-center">
|
||||
<span class="text-base font-bold">活动层级 {{ index + 1 }}</span>
|
||||
<Button
|
||||
v-if="index !== 0"
|
||||
type="link"
|
||||
danger
|
||||
class="ml-2"
|
||||
@click="handleDelete(index)"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form :model="rule" layout="horizontal">
|
||||
<!-- 优惠门槛 -->
|
||||
<FormItem label="优惠门槛" :label-col="{ span: 4 }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>满</span>
|
||||
<InputNumber
|
||||
v-if="isPriceCondition"
|
||||
v-model:value="rule.limit"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
class="!w-40"
|
||||
placeholder="请输入金额"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
v-model:value="rule.limit"
|
||||
:min="0"
|
||||
class="!w-40"
|
||||
placeholder="请输入数量"
|
||||
type="number"
|
||||
/>
|
||||
<span>{{ isPriceCondition ? '元' : '件' }}</span>
|
||||
<Card size="small" class="rounded-lg">
|
||||
<!-- 规则标题 -->
|
||||
<template #title>
|
||||
<div class="flex items-center">
|
||||
<span class="text-base font-medium">
|
||||
活动层级 {{ index + 1 }}
|
||||
</span>
|
||||
</div>
|
||||
</FormItem>
|
||||
<!-- 优惠内容 -->
|
||||
<FormItem
|
||||
label="优惠内容"
|
||||
:label-col="{ span: 4 }"
|
||||
:wrapper-col="{ span: 20 }"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span>订单金额优惠</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>减</span>
|
||||
</template>
|
||||
<template v-if="index !== 0" #extra>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
@click="handleDelete(index)"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<Form :model="rule" layout="horizontal">
|
||||
<!-- 优惠门槛 -->
|
||||
<FormItem label="优惠门槛:" :colon="false" class="mb-3">
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md bg-gray-50 px-3 py-2"
|
||||
>
|
||||
<span>满</span>
|
||||
<InputNumber
|
||||
v-model:value="rule.discountPrice"
|
||||
v-if="isPriceCondition"
|
||||
v-model:value="rule.limit"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
class="!w-32"
|
||||
class="!w-40"
|
||||
placeholder="请输入金额"
|
||||
/>
|
||||
<span>元</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>包邮:</span>
|
||||
<Switch
|
||||
v-model:checked="rule.freeDelivery"
|
||||
checked-children="是"
|
||||
un-checked-children="否"
|
||||
<Input
|
||||
v-else
|
||||
v-model:value="rule.limit"
|
||||
:min="0"
|
||||
class="!w-40"
|
||||
placeholder="请输入数量"
|
||||
type="number"
|
||||
/>
|
||||
<span>{{ isPriceCondition ? '元' : '件' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div>送积分:</div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
</FormItem>
|
||||
<!-- 优惠内容 -->
|
||||
<FormItem label="优惠内容:" :colon="false" class="!mb-0">
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- 订单金额优惠 -->
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md bg-gray-50 px-3 py-2"
|
||||
>
|
||||
<span class="!w-21 shrink-0 text-sm text-gray-500">
|
||||
订单金额优惠
|
||||
</span>
|
||||
<span>减</span>
|
||||
<InputNumber
|
||||
v-model:value="rule.discountPrice"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
class="!w-32"
|
||||
placeholder="请输入金额"
|
||||
/>
|
||||
<span>元</span>
|
||||
</div>
|
||||
<!-- 包邮 -->
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md bg-gray-50 px-3 py-2"
|
||||
>
|
||||
<span class="w-20 shrink-0 text-sm text-gray-500">包邮</span>
|
||||
<Switch
|
||||
v-model:checked="rule.freeDelivery"
|
||||
checked-children="是"
|
||||
un-checked-children="否"
|
||||
/>
|
||||
</div>
|
||||
<!-- 送积分 -->
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md bg-gray-50 px-3 py-2"
|
||||
>
|
||||
<span class="w-20 shrink-0 text-sm text-gray-500">
|
||||
送积分
|
||||
</span>
|
||||
<span>送</span>
|
||||
<InputNumber
|
||||
v-model:value="rule.point"
|
||||
@@ -140,17 +157,24 @@ function handleDelete(ruleIndex: number) {
|
||||
/>
|
||||
<span>积分</span>
|
||||
</div>
|
||||
<!-- 送优惠券 -->
|
||||
<div
|
||||
class="flex flex-col items-start gap-2 rounded-md bg-gray-50 px-3 py-2"
|
||||
>
|
||||
<span class="w-20 shrink-0 text-sm text-gray-500">
|
||||
送优惠券
|
||||
</span>
|
||||
<RewardRuleCouponSelect
|
||||
:model-value="rule"
|
||||
@update:model-value="
|
||||
(val) => (formData.rules![index] = val)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-20">送优惠券:</span>
|
||||
<RewardRuleCouponSelect
|
||||
:model-value="rule"
|
||||
@update:model-value="(val) => (formData.rules![index] = val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
<Form />
|
||||
|
||||
<!-- 商品选择区域 -->
|
||||
<!-- TODO @puhui999:这里缺少商品的选择; -->
|
||||
<div class="mt-4">
|
||||
<div class="mb-2 flex items-center">
|
||||
<span class="text-sm font-medium">秒杀活动商品:</span>
|
||||
|
||||
@@ -43,7 +43,6 @@ const queryParams = reactive({
|
||||
pageSize: 10,
|
||||
}); // 查询参数
|
||||
|
||||
// TODO @dylan:可以把【点击上传】3 个 tab 的按钮,放到右侧的 toolbar 一起,和刷新按钮放在一行;
|
||||
const voiceGridColumns: VxeTableGridOptions<MpMaterialApi.Material>['columns'] =
|
||||
[
|
||||
{
|
||||
|
||||
@@ -10,12 +10,12 @@ import { Button, Card, Form, message, Pagination, Tabs } from 'ant-design-vue';
|
||||
import { deletePermanentMaterial, getMaterialPage } from '#/api/mp/material';
|
||||
import { WxAccountSelect } from '#/views/mp/components';
|
||||
|
||||
import ImageTable from './components/image-table.vue';
|
||||
import { UploadType } from './components/upload';
|
||||
import UploadFile from './components/UploadFile.vue';
|
||||
import UploadVideo from './components/UploadVideo.vue';
|
||||
import VideoTable from './components/video-table.vue';
|
||||
import VoiceTable from './components/voice-table.vue';
|
||||
import ImageTable from './modules/image-table.vue';
|
||||
import { UploadType } from './modules/upload';
|
||||
import UploadFile from './modules/UploadFile.vue';
|
||||
import UploadVideo from './modules/UploadVideo.vue';
|
||||
import VideoTable from './modules/video-table.vue';
|
||||
import VoiceTable from './modules/voice-table.vue';
|
||||
|
||||
defineOptions({ name: 'MpMaterial' });
|
||||
|
||||
@@ -117,19 +117,16 @@ async function handleDelete(id: number) {
|
||||
图片
|
||||
</span>
|
||||
</template>
|
||||
<UploadFile
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
:type="UploadType.Image"
|
||||
@uploaded="getList"
|
||||
>
|
||||
支持 bmp/png/jpeg/jpg/gif 格式,大小不超过 2M
|
||||
</UploadFile>
|
||||
<!-- 列表 -->
|
||||
<ImageTable
|
||||
:list="list"
|
||||
:loading="loading"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
<ImageTable :list="list" :loading="loading" @delete="handleDelete">
|
||||
<template #toolbar-tools>
|
||||
<UploadFile
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
:type="UploadType.Image"
|
||||
@uploaded="getList"
|
||||
/>
|
||||
</template>
|
||||
</ImageTable>
|
||||
<!-- 分页组件 -->
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Pagination
|
||||
@@ -151,19 +148,16 @@ async function handleDelete(id: number) {
|
||||
语音
|
||||
</span>
|
||||
</template>
|
||||
<UploadFile
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
:type="UploadType.Voice"
|
||||
@uploaded="getList"
|
||||
>
|
||||
格式支持 mp3/wma/wav/amr,文件大小不超过 2M,播放长度不超过 60s
|
||||
</UploadFile>
|
||||
<!-- 列表 -->
|
||||
<VoiceTable
|
||||
:list="list"
|
||||
:loading="loading"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
<VoiceTable :list="list" :loading="loading" @delete="handleDelete">
|
||||
<template #toolbar-tools>
|
||||
<UploadFile
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
:type="UploadType.Voice"
|
||||
@uploaded="getList"
|
||||
/>
|
||||
</template>
|
||||
</VoiceTable>
|
||||
<!-- 分页组件 -->
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Pagination
|
||||
@@ -185,21 +179,20 @@ async function handleDelete(id: number) {
|
||||
视频
|
||||
</span>
|
||||
</template>
|
||||
<Button
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
type="primary"
|
||||
@click="showCreateVideo = true"
|
||||
>
|
||||
新建视频
|
||||
</Button>
|
||||
<!-- 列表 -->
|
||||
<VideoTable :list="list" :loading="loading" @delete="handleDelete">
|
||||
<template #toolbar-tools>
|
||||
<Button
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
type="primary"
|
||||
@click="showCreateVideo = true"
|
||||
>
|
||||
新建视频
|
||||
</Button>
|
||||
</template>
|
||||
</VideoTable>
|
||||
<!-- 新建视频的弹窗 -->
|
||||
<UploadVideo v-model:open="showCreateVideo" @uploaded="getList" />
|
||||
<!-- 列表 -->
|
||||
<VideoTable
|
||||
:list="list"
|
||||
:loading="loading"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
<!-- 分页组件 -->
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Pagination
|
||||
|
||||
@@ -86,7 +86,6 @@ const customRequest: UploadProps['customRequest'] = async function (options) {
|
||||
:file-list="fileList"
|
||||
:headers="HEADERS"
|
||||
:multiple="true"
|
||||
class="mb-4"
|
||||
>
|
||||
<Button type="primary">
|
||||
<IconifyIcon icon="lucide:upload" class="mr-1" />
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
// TODO @dylan:看看 ele 要迁移一个么?
|
||||
/** 视频表格列配置 */
|
||||
export function useVideoGridColumns(): VxeTableGridOptions<MpMaterialApi.Material>['columns'] {
|
||||
return [
|
||||
@@ -31,6 +31,9 @@ const [Grid, gridApi] = useVbenVxeGrid<MpMaterialApi.Material>({
|
||||
isHover: true,
|
||||
height: 220,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
});
|
||||
@@ -70,6 +73,9 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<Grid class="image-table-grid mt-4 pb-0">
|
||||
<template #toolbar-tools>
|
||||
<slot name="toolbar-tools"></slot>
|
||||
</template>
|
||||
<template #image="{ row }">
|
||||
<div class="flex items-center justify-center" style="height: 192px">
|
||||
<img
|
||||
@@ -10,6 +10,7 @@ import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { WxVideoPlayer } from '#/views/mp/components';
|
||||
|
||||
import { useVideoGridColumns } from './data';
|
||||
import {$t} from '@vben/locales';
|
||||
|
||||
const props = defineProps<{
|
||||
list: MpMaterialApi.Material[];
|
||||
@@ -34,6 +35,9 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
} as VxeTableGridOptions<MpMaterialApi.Material>,
|
||||
});
|
||||
@@ -62,6 +66,9 @@ watch(
|
||||
|
||||
<template>
|
||||
<Grid class="mt-4">
|
||||
<template #toolbar-tools>
|
||||
<slot name="toolbar-tools"></slot>
|
||||
</template>
|
||||
<template #video="{ row }">
|
||||
<WxVideoPlayer v-if="row.url" :url="row.url" />
|
||||
</template>
|
||||
@@ -75,7 +82,7 @@ watch(
|
||||
onClick: () => openWindow(row.url),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
@@ -4,13 +4,13 @@ import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
import { watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { WxVoicePlayer } from '#/views/mp/components';
|
||||
|
||||
import { useVoiceGridColumns } from './data';
|
||||
// TODO @dylan:组件内,尽量用 modules 哈。只有对外共享,才用 components
|
||||
|
||||
const props = defineProps<{
|
||||
list: MpMaterialApi.Material[];
|
||||
@@ -35,6 +35,9 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
} as VxeTableGridOptions<MpMaterialApi.Material>,
|
||||
});
|
||||
@@ -63,6 +66,9 @@ watch(
|
||||
|
||||
<template>
|
||||
<Grid class="mt-4">
|
||||
<template #toolbar-tools>
|
||||
<slot name="toolbar-tools"></slot>
|
||||
</template>
|
||||
<template #voice="{ row }">
|
||||
<WxVoicePlayer v-if="row.url" :url="row.url" />
|
||||
</template>
|
||||
@@ -76,7 +82,7 @@ watch(
|
||||
onClick: () => openWindow(row.url),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
@@ -102,6 +102,7 @@ function handlePageChange(page: number, pageSize: number) {
|
||||
function showTotal(total: number) {
|
||||
return `共 ${total} 条`;
|
||||
}
|
||||
// TODO @dylan:是不是应该都用 Grid 哈:1)message-table 大部分合并到 index.vue;2)message-table 的 schema 放到 data.ts 里;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { formatDate2 } from '@vben/utils';
|
||||
|
||||
import { Button, Image, Tag } from 'ant-design-vue';
|
||||
|
||||
// TODO @dylan:看看 ele 要迁移一个么?
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
WxLocation,
|
||||
|
||||
@@ -82,6 +82,18 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
values.socialType === SystemUserSocialTypeEnum.WECHAT_ENTERPRISE.type,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'publicKey',
|
||||
label: 'publicKey',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入 publicKey 公钥',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['socialType'],
|
||||
show: (values) => values.socialType === 40,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
|
||||
@@ -17,6 +17,7 @@ export namespace InfraFileConfigApi {
|
||||
accessSecret?: string;
|
||||
pathStyle?: boolean;
|
||||
enablePublicAccess?: boolean;
|
||||
region?: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,41 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
import { MessageType } from '@vben/constants';
|
||||
|
||||
// TODO @dylan:这个类的代码,应该和对应的 antd 是一致的。调整下~看看相关的 vue 是不是也要调整掉。
|
||||
/** 消息类型枚举 */
|
||||
export enum MessageType {
|
||||
IMAGE = 'image', // 图片消息
|
||||
MPNEWS = 'mpnews', // 公众号图文消息
|
||||
MUSIC = 'music', // 音乐消息
|
||||
NEWS = 'news', // 图文消息
|
||||
TEXT = 'text', // 文本消息
|
||||
VIDEO = 'video', // 视频消息
|
||||
VOICE = 'voice', // 语音消息
|
||||
WXCARD = 'wxcard', // 卡券消息
|
||||
}
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace MpMessageApi {
|
||||
/** 消息信息 */
|
||||
export interface Message {
|
||||
id?: number;
|
||||
accountId: number;
|
||||
type: MessageType;
|
||||
type: MessageType | string;
|
||||
openid: string;
|
||||
content: string;
|
||||
mediaId?: string;
|
||||
status: number;
|
||||
remark?: string;
|
||||
createTime?: Date;
|
||||
sendFrom?: number;
|
||||
userId?: number;
|
||||
event?: string;
|
||||
eventKey?: string;
|
||||
mediaUrl?: string;
|
||||
recognition?: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
label?: string;
|
||||
locationX?: number;
|
||||
locationY?: number;
|
||||
thumbMediaUrl?: string;
|
||||
musicUrl?: string;
|
||||
hqMusicUrl?: string;
|
||||
description?: string;
|
||||
articles?: any[];
|
||||
}
|
||||
|
||||
/** 发送消息请求 */
|
||||
export interface SendMessageRequest {
|
||||
export interface MessageSendRequestVO {
|
||||
accountId: number;
|
||||
openid: string;
|
||||
type: MessageType;
|
||||
@@ -50,6 +55,6 @@ export function getMessagePage(params: PageParam) {
|
||||
}
|
||||
|
||||
/** 发送消息 */
|
||||
export function sendMessage(data: MpMessageApi.SendMessageRequest) {
|
||||
export function sendMessage(data: MpMessageApi.MessageSendRequestVO) {
|
||||
return requestClient.post('/mp/message/send', data);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export namespace SystemSocialClientApi {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
agentId?: string;
|
||||
publicKey?: string;
|
||||
status: number;
|
||||
createTime?: Date;
|
||||
}
|
||||
|
||||
@@ -146,7 +146,6 @@
|
||||
background: url('./svg/simple-process-bg.svg') 0 0 repeat;
|
||||
transform: scale(1);
|
||||
transform-origin: 50% 0 0;
|
||||
transition: transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
// 节点容器 定义节点宽度
|
||||
.node-container {
|
||||
width: 200px;
|
||||
|
||||
@@ -76,7 +76,8 @@ function handleRowCheckboxChange({
|
||||
}: {
|
||||
records: InfraDataSourceConfigApi.DataSourceConfig[];
|
||||
}) {
|
||||
checkedIds.value = records.map((item) => item.id!);
|
||||
// 过滤掉id为 0 的主数据源
|
||||
checkedIds.value = records.map((item) => item.id!).filter((id) => id !== 0);
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
@@ -138,6 +139,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
link: true,
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['infra:data-source-config:update'],
|
||||
disabled: row.id === 0,
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
@@ -146,6 +148,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
link: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['infra:data-source-config:delete'],
|
||||
disabled: row.id === 0,
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
|
||||
@@ -225,6 +225,18 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
},
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
fieldName: 'config.region',
|
||||
label: '区域',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请填写区域,一般仅 AWS 需要填写',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['storage'],
|
||||
show: (formValues) => formValues.storage === 20,
|
||||
},
|
||||
},
|
||||
// 通用
|
||||
{
|
||||
fieldName: 'config.domain',
|
||||
|
||||
@@ -15,6 +15,7 @@ const props = defineProps<{
|
||||
takeType?: number; // 领取方式
|
||||
}>();
|
||||
|
||||
// TODO @puhui999:这个也要调整,和 antd 保持统一。
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { default as WxAccountSelect } from './wx-account-select/wx-account-select.vue';
|
||||
export { default as WxLocation } from './wx-location/wx-location.vue';
|
||||
export { default as WxMaterialSelect } from './wx-material-select/wx-material-select.vue';
|
||||
export { default as WxMsg } from './wx-msg/msg.vue'; // TODO @hw、@dylan:貌似和 antd 不同。antd 这里是 export { default as WxMsg } from './wx-msg/wx-msg.vue'; 看看哪个是对的
|
||||
export { default as WxMsg } from './wx-msg/wx-msg.vue';
|
||||
export { default as WxMusic } from './wx-music/wx-music.vue';
|
||||
export { default as WxNews } from './wx-news/wx-news.vue';
|
||||
export { default as WxReply } from './wx-reply/wx-reply.vue';
|
||||
|
||||
76
apps/web-ele/src/views/mp/hooks/useUpload.ts
Normal file
76
apps/web-ele/src/views/mp/hooks/useUpload.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
export enum UploadType {
|
||||
Image = 'image',
|
||||
Video = 'video',
|
||||
Voice = 'voice',
|
||||
}
|
||||
|
||||
interface UploadTypeConfig {
|
||||
allowTypes: string[];
|
||||
maxSizeMB: number;
|
||||
i18nKey: string;
|
||||
}
|
||||
|
||||
export interface UploadRawFile {
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const UPLOAD_CONFIGS: Record<UploadType, UploadTypeConfig> = {
|
||||
[UploadType.Image]: {
|
||||
allowTypes: [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
'image/jpg',
|
||||
],
|
||||
maxSizeMB: 2,
|
||||
i18nKey: 'mp.upload.image',
|
||||
},
|
||||
[UploadType.Video]: {
|
||||
allowTypes: ['video/mp4'],
|
||||
maxSizeMB: 10,
|
||||
i18nKey: 'mp.upload.video',
|
||||
},
|
||||
[UploadType.Voice]: {
|
||||
allowTypes: [
|
||||
'audio/mp3',
|
||||
'audio/mpeg',
|
||||
'audio/wma',
|
||||
'audio/wav',
|
||||
'audio/amr',
|
||||
],
|
||||
maxSizeMB: 2,
|
||||
i18nKey: 'mp.upload.voice',
|
||||
},
|
||||
};
|
||||
|
||||
export const useBeforeUpload = (type: UploadType, maxSizeMB?: number) => {
|
||||
const fn = (rawFile: UploadRawFile): boolean => {
|
||||
const config = UPLOAD_CONFIGS[type];
|
||||
const finalMaxSize = maxSizeMB ?? config.maxSizeMB;
|
||||
|
||||
// 格式不正确
|
||||
if (!config.allowTypes.includes(rawFile.type)) {
|
||||
const typeName = $t(config.i18nKey);
|
||||
ElMessage.error($t('mp.upload.invalidFormat', [typeName]));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 大小不正确
|
||||
if (rawFile.size / 1024 / 1024 > finalMaxSize) {
|
||||
const typeName = $t(config.i18nKey);
|
||||
ElMessage.error($t('mp.upload.maxSize', [typeName, finalMaxSize]));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
return fn;
|
||||
};
|
||||
239
apps/web-ele/src/views/mp/material/index.vue
Normal file
239
apps/web-ele/src/views/mp/material/index.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<script lang="ts" setup>
|
||||
import { provide, reactive, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { confirm, DocAlert, Page } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElLoading,
|
||||
ElMessage,
|
||||
ElPagination,
|
||||
ElTabPane,
|
||||
ElTabs,
|
||||
} from 'element-plus';
|
||||
|
||||
import { deletePermanentMaterial, getMaterialPage } from '#/api/mp/material';
|
||||
import { WxAccountSelect } from '#/views/mp/components';
|
||||
|
||||
import ImageTable from './modules/image-table.vue';
|
||||
import { UploadType } from './modules/upload';
|
||||
import UploadFile from './modules/UploadFile.vue';
|
||||
import UploadVideo from './modules/UploadVideo.vue';
|
||||
import VideoTable from './modules/video-table.vue';
|
||||
import VoiceTable from './modules/voice-table.vue';
|
||||
|
||||
defineOptions({ name: 'MpMaterial' });
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
const type = ref<UploadType>(UploadType.Image); // 素材类型
|
||||
const loading = ref(false); // 遮罩层
|
||||
const list = ref<any[]>([]); // 数据列表
|
||||
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
|
||||
const queryParams = reactive({
|
||||
accountId,
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
permanent: true,
|
||||
}); // 查询参数
|
||||
const showCreateVideo = ref(false); // 是否新建视频的弹窗
|
||||
|
||||
/** 侦听公众号变化 */
|
||||
function onAccountChanged(id: number) {
|
||||
accountId.value = id;
|
||||
queryParams.accountId = id;
|
||||
queryParams.pageNo = 1;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 查询列表 */
|
||||
async function getList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getMaterialPage({
|
||||
...queryParams,
|
||||
type: type.value,
|
||||
});
|
||||
list.value = data.list;
|
||||
total.value = data.total;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
function handleQuery() {
|
||||
queryParams.pageNo = 1;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 处理 tab 切换 */
|
||||
function onTabChange() {
|
||||
// 提前清空数据,避免 tab 切换后显示垃圾数据
|
||||
list.value = [];
|
||||
total.value = 0;
|
||||
// 从第一页开始查询
|
||||
handleQuery();
|
||||
}
|
||||
|
||||
/** 处理删除操作 */
|
||||
async function handleDelete(id: number) {
|
||||
await confirm('此操作将永久删除该文件, 是否继续?');
|
||||
const loadingInstance = ElLoading.service({
|
||||
text: '正在删除...',
|
||||
lock: true,
|
||||
});
|
||||
try {
|
||||
await deletePermanentMaterial(id);
|
||||
ElMessage.success('删除成功');
|
||||
await getList();
|
||||
} finally {
|
||||
loadingInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 分页改变事件 */
|
||||
function handlePageChange(page: number) {
|
||||
queryParams.pageNo = page;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 每页条数改变事件 */
|
||||
function handleSizeChange(pageSize: number) {
|
||||
queryParams.pageSize = pageSize;
|
||||
queryParams.pageNo = 1;
|
||||
getList();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<template #doc>
|
||||
<DocAlert title="公众号素材" url="https://doc.iocoder.cn/mp/material/" />
|
||||
</template>
|
||||
<div class="h-full">
|
||||
<!-- 搜索工作栏 -->
|
||||
<ElCard class="h-[10%]" shadow="never">
|
||||
<ElForm :model="queryParams" :inline="true">
|
||||
<ElFormItem label="公众号" class="w-52">
|
||||
<WxAccountSelect @change="onAccountChanged" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
|
||||
<ElCard shadow="never" class="mt-4 h-auto">
|
||||
<ElTabs v-model="type" @tab-change="onTabChange">
|
||||
<!-- tab 1:图片 -->
|
||||
<ElTabPane :name="UploadType.Image">
|
||||
<template #label>
|
||||
<span class="flex items-center">
|
||||
<IconifyIcon icon="lucide:image" class="mr-1" />
|
||||
图片
|
||||
</span>
|
||||
</template>
|
||||
<!-- 列表 -->
|
||||
<ImageTable :list="list" :loading="loading" @delete="handleDelete">
|
||||
<template #toolbar-tools>
|
||||
<UploadFile
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
:type="UploadType.Image"
|
||||
@uploaded="getList"
|
||||
/>
|
||||
</template>
|
||||
</ImageTable>
|
||||
<!-- 分页组件 -->
|
||||
<div 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"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- tab 2:语音 -->
|
||||
<ElTabPane :name="UploadType.Voice">
|
||||
<template #label>
|
||||
<span class="flex items-center">
|
||||
<IconifyIcon icon="lucide:mic" class="mr-1" />
|
||||
语音
|
||||
</span>
|
||||
</template>
|
||||
<!-- 列表 -->
|
||||
<VoiceTable :list="list" :loading="loading" @delete="handleDelete">
|
||||
<template #toolbar-tools>
|
||||
<UploadFile
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
:type="UploadType.Voice"
|
||||
@uploaded="getList"
|
||||
/>
|
||||
</template>
|
||||
</VoiceTable>
|
||||
<!-- 分页组件 -->
|
||||
<div 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"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- tab 3:视频 -->
|
||||
<ElTabPane :name="UploadType.Video">
|
||||
<template #label>
|
||||
<span class="flex items-center">
|
||||
<IconifyIcon icon="lucide:video" class="mr-1" />
|
||||
视频
|
||||
</span>
|
||||
</template>
|
||||
<!-- 列表 -->
|
||||
<VideoTable :list="list" :loading="loading" @delete="handleDelete">
|
||||
<template #toolbar-tools>
|
||||
<ElButton
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
type="primary"
|
||||
@click="showCreateVideo = true"
|
||||
>
|
||||
新建视频
|
||||
</ElButton>
|
||||
</template>
|
||||
</VideoTable>
|
||||
<!-- 新建视频的弹窗 -->
|
||||
<UploadVideo v-model="showCreateVideo" @uploaded="getList" />
|
||||
<!-- 分页组件 -->
|
||||
<div 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"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</ElCard>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
108
apps/web-ele/src/views/mp/material/modules/UploadFile.vue
Normal file
108
apps/web-ele/src/views/mp/material/modules/UploadFile.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadProps } from 'element-plus';
|
||||
|
||||
import type { UploadData } from './upload';
|
||||
|
||||
import { inject, reactive, ref } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { ElButton, ElMessage, ElUpload } from 'element-plus';
|
||||
|
||||
import {
|
||||
beforeImageUpload,
|
||||
beforeVoiceUpload,
|
||||
HEADERS,
|
||||
UPLOAD_URL,
|
||||
UploadType,
|
||||
} from './upload';
|
||||
|
||||
const props = defineProps<{ type: UploadType }>();
|
||||
|
||||
// TODO @dylan:是不是要和 antd 的 props 定义相同哈?这样后续两侧维护方便点
|
||||
const emit = defineEmits<{
|
||||
uploaded: [v: void];
|
||||
}>();
|
||||
|
||||
const accountId = inject<number>('accountId');
|
||||
|
||||
const fileList = ref<any[]>([]);
|
||||
const uploadData: UploadData = reactive({
|
||||
accountId: accountId!,
|
||||
introduction: '',
|
||||
title: '',
|
||||
type: props.type,
|
||||
});
|
||||
|
||||
/** 上传前检查 */
|
||||
const onBeforeUpload =
|
||||
props.type === UploadType.Image ? beforeImageUpload : beforeVoiceUpload;
|
||||
|
||||
/** 自定义上传 */
|
||||
const customRequest: UploadProps['httpRequest'] = async function (options) {
|
||||
const { file, onError, onSuccess } = options;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file as File);
|
||||
formData.append('type', uploadData.type);
|
||||
formData.append('title', uploadData.title);
|
||||
formData.append('introduction', uploadData.introduction);
|
||||
formData.append('accountId', String(uploadData.accountId));
|
||||
|
||||
try {
|
||||
const response = await fetch(UPLOAD_URL, {
|
||||
body: formData,
|
||||
headers: HEADERS,
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const res = await response.json();
|
||||
|
||||
if (res.code !== 0) {
|
||||
ElMessage.error(`上传出错:${res.msg}`);
|
||||
// TODO @dylan:这里有个 linter 错误。
|
||||
onError?.(new Error(res.msg));
|
||||
return;
|
||||
}
|
||||
|
||||
// 清空上传时的各种数据
|
||||
fileList.value = [];
|
||||
uploadData.title = '';
|
||||
uploadData.introduction = '';
|
||||
|
||||
ElMessage.success('上传成功');
|
||||
onSuccess?.(res);
|
||||
emit('uploaded');
|
||||
} catch (error: any) {
|
||||
ElMessage.error(`上传失败: ${error.message}`);
|
||||
onError?.(error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElUpload
|
||||
:action="UPLOAD_URL"
|
||||
:before-upload="onBeforeUpload"
|
||||
:http-request="customRequest"
|
||||
:file-list="fileList"
|
||||
:headers="HEADERS"
|
||||
:multiple="true"
|
||||
>
|
||||
<ElButton type="primary">
|
||||
<IconifyIcon icon="lucide:upload" class="mr-1" />
|
||||
点击上传
|
||||
</ElButton>
|
||||
<template #file="{ file }">
|
||||
<div class="flex items-center">
|
||||
<span>{{ file.name }}</span>
|
||||
<ElButton type="primary" link size="small" @click="fileList = []">
|
||||
删除
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElUpload>
|
||||
<div v-if="$slots.default" class="ml-1 text-sm text-gray-500">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
166
apps/web-ele/src/views/mp/material/modules/UploadVideo.vue
Normal file
166
apps/web-ele/src/views/mp/material/modules/UploadVideo.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FormInstance, UploadProps } from 'element-plus';
|
||||
|
||||
import type { UploadData } from './upload';
|
||||
|
||||
import { inject, reactive, ref } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElDialog,
|
||||
ElDivider,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElUpload,
|
||||
} from 'element-plus';
|
||||
|
||||
import { beforeVideoUpload, HEADERS, UPLOAD_URL, UploadType } from './upload';
|
||||
|
||||
// TODO @dylan:是不是要和 antd 的 props 定义相同哈?这样后续两侧维护方便点
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean;
|
||||
}>(),
|
||||
{
|
||||
modelValue: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [v: boolean];
|
||||
uploaded: [v: void];
|
||||
}>();
|
||||
|
||||
const accountId = inject<number>('accountId');
|
||||
|
||||
const uploadRules = {
|
||||
introduction: [
|
||||
{ message: '请输入描述', required: true, trigger: 'blur' } as const,
|
||||
],
|
||||
title: [{ message: '请输入标题', required: true, trigger: 'blur' } as const],
|
||||
};
|
||||
|
||||
function handleCancel() {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
|
||||
const fileList = ref<any[]>([]);
|
||||
|
||||
const uploadData: UploadData = reactive({
|
||||
accountId: accountId!,
|
||||
introduction: '',
|
||||
title: '',
|
||||
type: UploadType.Video,
|
||||
});
|
||||
|
||||
const uploadFormRef = ref<FormInstance | null>(null);
|
||||
const uploadVideoRef = ref<any>(null);
|
||||
|
||||
async function submitVideo() {
|
||||
await uploadFormRef.value?.validate();
|
||||
uploadVideoRef.value?.submit();
|
||||
}
|
||||
|
||||
/** 自定义上传 */
|
||||
const customRequest: UploadProps['httpRequest'] = async function (options) {
|
||||
const { file, onError, onSuccess } = options;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file as File);
|
||||
formData.append('type', uploadData.type);
|
||||
formData.append('title', uploadData.title);
|
||||
formData.append('introduction', uploadData.introduction);
|
||||
formData.append('accountId', String(uploadData.accountId));
|
||||
|
||||
try {
|
||||
const response = await fetch(UPLOAD_URL, {
|
||||
body: formData,
|
||||
headers: HEADERS,
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const res = await response.json();
|
||||
|
||||
if (res.code !== 0) {
|
||||
ElMessage.error(`上传出错:${res.msg}`);
|
||||
onError?.(new Error(res.msg));
|
||||
return;
|
||||
}
|
||||
|
||||
// 清空上传时的各种数据
|
||||
fileList.value = [];
|
||||
uploadData.title = '';
|
||||
uploadData.introduction = '';
|
||||
|
||||
emit('update:modelValue', false);
|
||||
ElMessage.success('上传成功');
|
||||
onSuccess?.(res);
|
||||
emit('uploaded');
|
||||
} catch (error: any) {
|
||||
ElMessage.error(`上传失败: ${error.message}`);
|
||||
onError?.(error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="modelValue"
|
||||
title="新建视频"
|
||||
width="600px"
|
||||
@close="handleCancel"
|
||||
>
|
||||
<template #footer>
|
||||
<ElButton @click="handleCancel">取消</ElButton>
|
||||
<ElButton type="primary" @click="submitVideo">确定</ElButton>
|
||||
</template>
|
||||
<ElUpload
|
||||
ref="uploadVideoRef"
|
||||
:action="UPLOAD_URL"
|
||||
:auto-upload="false"
|
||||
:before-upload="beforeVideoUpload"
|
||||
:http-request="customRequest"
|
||||
:file-list="fileList"
|
||||
:headers="HEADERS"
|
||||
:limit="1"
|
||||
:multiple="true"
|
||||
class="mb-4"
|
||||
>
|
||||
<ElButton type="primary">
|
||||
<IconifyIcon icon="lucide:video" class="mr-1" />
|
||||
选择视频
|
||||
</ElButton>
|
||||
</ElUpload>
|
||||
<div class="mb-4 ml-1 text-sm text-gray-500">
|
||||
格式支持 MP4,文件大小不超过 10MB
|
||||
</div>
|
||||
|
||||
<ElDivider />
|
||||
|
||||
<ElForm
|
||||
ref="uploadFormRef"
|
||||
:model="uploadData"
|
||||
:rules="uploadRules"
|
||||
label-position="top"
|
||||
>
|
||||
<ElFormItem label="标题" prop="title">
|
||||
<ElInput
|
||||
v-model="uploadData.title"
|
||||
placeholder="标题将展示在相关播放页面,建议填写清晰、准确、生动的标题"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="描述" prop="introduction">
|
||||
<ElInput
|
||||
v-model="uploadData.introduction"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="介绍语将展示在相关播放页面,建议填写简洁明确、有信息量的内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</ElDialog>
|
||||
</template>
|
||||
133
apps/web-ele/src/views/mp/material/modules/data.ts
Normal file
133
apps/web-ele/src/views/mp/material/modules/data.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
/** 视频表格列配置 */
|
||||
export function useVideoGridColumns(): VxeTableGridOptions<MpMaterialApi.Material>['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'mediaId',
|
||||
title: '编号',
|
||||
align: 'center',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '文件名',
|
||||
align: 'center',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'title',
|
||||
title: '标题',
|
||||
align: 'center',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'introduction',
|
||||
title: '介绍',
|
||||
align: 'center',
|
||||
minWidth: 220,
|
||||
},
|
||||
{
|
||||
field: 'video',
|
||||
title: '视频',
|
||||
align: 'center',
|
||||
width: 220,
|
||||
slots: { default: 'video' },
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '上传时间',
|
||||
align: 'center',
|
||||
width: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
title: '操作',
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
width: 180,
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 语音表格列配置 */
|
||||
export function useVoiceGridColumns(): VxeTableGridOptions<MpMaterialApi.Material>['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'mediaId',
|
||||
title: '编号',
|
||||
align: 'center',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '文件名',
|
||||
align: 'center',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'voice',
|
||||
title: '语音',
|
||||
align: 'center',
|
||||
width: 220,
|
||||
slots: { default: 'voice' },
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '上传时间',
|
||||
align: 'center',
|
||||
width: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
title: '操作',
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
width: 160,
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 图片表格列配置 */
|
||||
export function useImageGridColumns(): VxeTableGridOptions<MpMaterialApi.Material>['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'mediaId',
|
||||
title: '编号',
|
||||
align: 'center',
|
||||
width: 400,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '文件名',
|
||||
align: 'center',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
field: 'url',
|
||||
title: '图片',
|
||||
align: 'center',
|
||||
width: 200,
|
||||
slots: { default: 'image' },
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '上传时间',
|
||||
align: 'center',
|
||||
width: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
title: '操作',
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
108
apps/web-ele/src/views/mp/material/modules/image-table.vue
Normal file
108
apps/web-ele/src/views/mp/material/modules/image-table.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
import { nextTick, onMounted, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
|
||||
import { useImageGridColumns } from './data';
|
||||
|
||||
const props = defineProps<{
|
||||
list: MpMaterialApi.Material[];
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [v: number];
|
||||
}>();
|
||||
|
||||
const columns = useImageGridColumns();
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid<MpMaterialApi.Material>({
|
||||
gridOptions: {
|
||||
border: true,
|
||||
columns,
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
height: 220,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
});
|
||||
|
||||
function updateGridData(data: MpMaterialApi.Material[]) {
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
async (list: MpMaterialApi.Material[]) => {
|
||||
const data = Array.isArray(list) ? list : [];
|
||||
await nextTick();
|
||||
updateGridData(data);
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading: boolean) => {
|
||||
gridApi.setLoading(loading);
|
||||
},
|
||||
);
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
updateGridData(Array.isArray(props.list) ? props.list : []);
|
||||
gridApi.setLoading(props.loading);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Grid class="image-table-grid mt-4 pb-0">
|
||||
<template #toolbar-tools>
|
||||
<slot name="toolbar-tools"></slot>
|
||||
</template>
|
||||
<template #image="{ row }">
|
||||
<div class="flex items-center justify-center" style="height: 192px">
|
||||
<img
|
||||
:src="row.url"
|
||||
class="object-contain"
|
||||
style="display: block; max-width: 100%; max-height: 192px"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'primary',
|
||||
link: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['mp:material:delete'],
|
||||
popConfirm: {
|
||||
title: '确定要删除该图片吗?',
|
||||
confirm: () => emit('delete', row.id!),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</template>
|
||||
46
apps/web-ele/src/views/mp/material/modules/upload.ts
Normal file
46
apps/web-ele/src/views/mp/material/modules/upload.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { UploadProps } from 'element-plus';
|
||||
|
||||
import type { UploadRawFile } from '#/views/mp/hooks/useUpload';
|
||||
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
|
||||
import { UploadType, useBeforeUpload } from '#/views/mp/hooks/useUpload';
|
||||
|
||||
const accessStore = useAccessStore();
|
||||
const HEADERS = { Authorization: `Bearer ${accessStore.accessToken}` }; // 请求头
|
||||
const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-permanent`; // 上传地址
|
||||
|
||||
interface UploadData {
|
||||
accountId: number;
|
||||
introduction: string;
|
||||
title: string;
|
||||
type: UploadType;
|
||||
}
|
||||
|
||||
const beforeImageUpload: UploadProps['beforeUpload'] = function (
|
||||
rawFile: UploadRawFile,
|
||||
) {
|
||||
return useBeforeUpload(UploadType.Image, 2)(rawFile);
|
||||
};
|
||||
|
||||
const beforeVoiceUpload: UploadProps['beforeUpload'] = function (
|
||||
rawFile: UploadRawFile,
|
||||
) {
|
||||
return useBeforeUpload(UploadType.Voice, 2)(rawFile);
|
||||
};
|
||||
|
||||
const beforeVideoUpload: UploadProps['beforeUpload'] = function (
|
||||
rawFile: UploadRawFile,
|
||||
) {
|
||||
return useBeforeUpload(UploadType.Video, 10)(rawFile);
|
||||
};
|
||||
|
||||
export {
|
||||
beforeImageUpload,
|
||||
beforeVideoUpload,
|
||||
beforeVoiceUpload,
|
||||
HEADERS,
|
||||
UPLOAD_URL,
|
||||
type UploadData,
|
||||
UploadType,
|
||||
};
|
||||
99
apps/web-ele/src/views/mp/material/modules/video-table.vue
Normal file
99
apps/web-ele/src/views/mp/material/modules/video-table.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
import { watch } from 'vue';
|
||||
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { WxVideoPlayer } from '#/views/mp/components';
|
||||
|
||||
import { useVideoGridColumns } from './data';
|
||||
|
||||
const props = defineProps<{
|
||||
list: MpMaterialApi.Material[];
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [v: number];
|
||||
}>();
|
||||
|
||||
const columns = useVideoGridColumns();
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
border: true,
|
||||
columns,
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
} as VxeTableGridOptions<MpMaterialApi.Material>,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
(list: MpMaterialApi.Material[]) => {
|
||||
const data = Array.isArray(list) ? list : [];
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading: boolean) => {
|
||||
gridApi.setLoading(loading);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Grid class="mt-4">
|
||||
<template #toolbar-tools>
|
||||
<slot name="toolbar-tools"></slot>
|
||||
</template>
|
||||
<template #video="{ row }">
|
||||
<WxVideoPlayer v-if="row.url" :url="row.url" />
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '下载',
|
||||
type: 'primary',
|
||||
link: true,
|
||||
icon: ACTION_ICON.DOWNLOAD,
|
||||
onClick: () => openWindow(row.url),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'danger',
|
||||
link: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['mp:material:delete'],
|
||||
popConfirm: {
|
||||
title: '确定要删除该视频吗?',
|
||||
confirm: () => emit('delete', row.id!),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</template>
|
||||
100
apps/web-ele/src/views/mp/material/modules/voice-table.vue
Normal file
100
apps/web-ele/src/views/mp/material/modules/voice-table.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
import { watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { WxVoicePlayer } from '#/views/mp/components';
|
||||
|
||||
import { useVoiceGridColumns } from './data';
|
||||
|
||||
const props = defineProps<{
|
||||
list: MpMaterialApi.Material[];
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [v: number];
|
||||
}>();
|
||||
|
||||
const columns = useVoiceGridColumns();
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
border: true,
|
||||
columns,
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
} as VxeTableGridOptions<MpMaterialApi.Material>,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
(list: MpMaterialApi.Material[]) => {
|
||||
const data = Array.isArray(list) ? list : [];
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading: boolean) => {
|
||||
gridApi.setLoading(loading);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Grid class="mt-4">
|
||||
<template #toolbar-tools>
|
||||
<slot name="toolbar-tools"></slot>
|
||||
</template>
|
||||
<template #voice="{ row }">
|
||||
<WxVoicePlayer v-if="row.url" :url="row.url" />
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '下载',
|
||||
type: 'primary',
|
||||
link: true,
|
||||
icon: ACTION_ICON.DOWNLOAD,
|
||||
onClick: () => openWindow(row.url),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'danger',
|
||||
link: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['mp:material:delete'],
|
||||
popConfirm: {
|
||||
title: '确定要删除该语音吗?',
|
||||
confirm: () => emit('delete', row.id!),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</template>
|
||||
211
apps/web-ele/src/views/mp/message/index.vue
Normal file
211
apps/web-ele/src/views/mp/message/index.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { reactive, 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 {
|
||||
ElButton,
|
||||
ElDatePicker,
|
||||
ElDialog,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElOption,
|
||||
ElPagination,
|
||||
ElSelect,
|
||||
} from 'element-plus';
|
||||
|
||||
import { getMessagePage } from '#/api/mp/message';
|
||||
import { WxAccountSelect, WxMsg } from '#/views/mp/components';
|
||||
|
||||
import MessageTable from './message-table.vue';
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/** 打开消息发送窗口 */
|
||||
async 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 哈?
|
||||
</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>
|
||||
|
||||
<!-- 列表 -->
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发送消息的弹窗 -->
|
||||
<ElDialog
|
||||
v-model="messageBoxVisible"
|
||||
title="粉丝消息列表"
|
||||
width="800"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<WxMsg :user-id="messageBoxUserId" />
|
||||
</ElDialog>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-form :deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
263
apps/web-ele/src/views/mp/message/message-table.vue
Normal file
263
apps/web-ele/src/views/mp/message/message-table.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<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>
|
||||
@@ -80,6 +80,18 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
values.socialType === SystemUserSocialTypeEnum.WECHAT_ENTERPRISE.type,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'publicKey',
|
||||
label: 'publicKey',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入 publicKey 公钥',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['socialType'],
|
||||
show: (values) => values.socialType === 40,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
|
||||
@@ -17,6 +17,7 @@ export namespace InfraFileConfigApi {
|
||||
accessSecret?: string;
|
||||
pathStyle?: boolean;
|
||||
enablePublicAccess?: boolean;
|
||||
region?: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export namespace SystemSocialClientApi {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
agentId?: string;
|
||||
publicKey?: string;
|
||||
status: number;
|
||||
createTime?: Date;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,8 @@ function handleRowCheckboxChange({
|
||||
}: {
|
||||
records: InfraDataSourceConfigApi.DataSourceConfig[];
|
||||
}) {
|
||||
checkedIds.value = records.map((item) => item.id!);
|
||||
// 过滤掉id为 0 的主数据源
|
||||
checkedIds.value = records.map((item) => item.id!).filter((id) => id !== 0);
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
@@ -138,6 +139,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
text: true,
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['infra:data-source-config:update'],
|
||||
disabled: row.id === 0,
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
@@ -146,6 +148,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
text: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['infra:data-source-config:delete'],
|
||||
disabled: row.id === 0,
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
|
||||
@@ -224,6 +224,18 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
},
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
fieldName: 'config.region',
|
||||
label: '区域',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请填写区域,一般仅 AWS 需要填写',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['storage'],
|
||||
show: (formValues) => formValues.storage === 20,
|
||||
},
|
||||
},
|
||||
// 通用
|
||||
{
|
||||
fieldName: 'config.domain',
|
||||
|
||||
@@ -79,6 +79,18 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
values.socialType === SystemUserSocialTypeEnum.WECHAT_ENTERPRISE.type,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'publicKey',
|
||||
label: 'publicKey',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入 publicKey 公钥',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['socialType'],
|
||||
show: (values) => values.socialType === 40,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
|
||||
@@ -17,6 +17,7 @@ export namespace InfraFileConfigApi {
|
||||
accessSecret?: string;
|
||||
pathStyle?: boolean;
|
||||
enablePublicAccess?: boolean;
|
||||
region?: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export namespace SystemSocialClientApi {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
agentId?: string;
|
||||
publicKey?: string;
|
||||
status: number;
|
||||
createTime?: Date;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,8 @@ function handleRowCheckboxChange({
|
||||
}: {
|
||||
records: InfraDataSourceConfigApi.DataSourceConfig[];
|
||||
}) {
|
||||
checkedIds.value = records.map((item) => item.id!);
|
||||
// 过滤掉id为 0 的主数据源
|
||||
checkedIds.value = records.map((item) => item.id!).filter((id) => id !== 0);
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
@@ -138,6 +139,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
variant: 'text',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['infra:data-source-config:update'],
|
||||
disabled: row.id === 0,
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
@@ -146,6 +148,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
type: 'danger',
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['infra:data-source-config:delete'],
|
||||
disabled: row.id === 0,
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
|
||||
@@ -229,6 +229,18 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
},
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
fieldName: 'config.region',
|
||||
label: '区域',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请填写区域,一般仅 AWS 需要填写',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['storage'],
|
||||
show: (formValues) => formValues.storage === 20,
|
||||
},
|
||||
},
|
||||
// 通用
|
||||
{
|
||||
fieldName: 'config.domain',
|
||||
|
||||
@@ -81,6 +81,18 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
values.socialType === SystemUserSocialTypeEnum.WECHAT_ENTERPRISE.type,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'publicKey',
|
||||
label: 'publicKey',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入 publicKey 公钥',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['socialType'],
|
||||
show: (values) => values.socialType === 40,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
|
||||
Reference in New Issue
Block a user