Merge remote-tracking branch 'yudao/dev' into dev
This commit is contained in:
@@ -68,5 +68,5 @@ export function generateAccountQrCode(id: number) {
|
||||
|
||||
/** 清空公众号账号 API 配额 */
|
||||
export function clearAccountQuota(id: number) {
|
||||
return requestClient.post(`/mp/account/clear-quota?id=${id}`);
|
||||
return requestClient.put(`/mp/account/clear-quota?id=${id}`);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
import { MaterialType } from '@vben/constants';
|
||||
|
||||
/** 素材类型枚举 */
|
||||
export enum MaterialType {
|
||||
IMAGE = 1, // 图片
|
||||
THUMB = 4, // 缩略图
|
||||
VIDEO = 3, // 视频
|
||||
VOICE = 2, // 语音
|
||||
}
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace MpMaterialApi {
|
||||
/** 素材信息 */
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
import { MenuType } from '@vben/constants';
|
||||
|
||||
/** 菜单类型枚举 */
|
||||
export enum MenuType {
|
||||
CLICK = 'click', // 点击推事件
|
||||
LOCATION_SELECT = 'location_select', // 发送位置
|
||||
MEDIA_ID = 'media_id', // 下发消息
|
||||
MINIPROGRAM = 'miniprogram', // 小程序
|
||||
PIC_PHOTO_OR_ALBUM = 'pic_photo_or_album', // 拍照或者相册发图
|
||||
PIC_SYSPHOTO = 'pic_sysphoto', // 系统拍照发图
|
||||
PIC_WEIXIN = 'pic_weixin', // 微信相册发图
|
||||
SCANCODE_PUSH = 'scancode_push', // 扫码推事件
|
||||
SCANCODE_WAITMSG = 'scancode_waitmsg', // 扫码带提示
|
||||
VIEW = 'view', // 跳转URL
|
||||
VIEW_LIMITED = 'view_limited', // 跳转图文消息URL
|
||||
}
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace MpMenuApi {
|
||||
/** 菜单按钮信息 */
|
||||
|
||||
57
apps/web-ele/src/api/mp/messageTemplate/index.ts
Normal file
57
apps/web-ele/src/api/mp/messageTemplate/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace MpMessageTemplateApi {
|
||||
/** 消息模板信息 */
|
||||
export interface MessageTemplate {
|
||||
id: number;
|
||||
accountId: number;
|
||||
appId: string;
|
||||
templateId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
example: string;
|
||||
primaryIndustry: string;
|
||||
deputyIndustry: string;
|
||||
createTime?: Date;
|
||||
}
|
||||
|
||||
/** 发送消息模板请求 */
|
||||
export interface MessageTemplateSendVO {
|
||||
id: number;
|
||||
userId: number;
|
||||
data?: Record<string, string>;
|
||||
url?: string;
|
||||
miniProgramAppId?: string;
|
||||
miniProgramPagePath?: string;
|
||||
miniprogram?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询消息模板列表 */
|
||||
export function getMessageTemplateList(params: { accountId: number }) {
|
||||
return requestClient.get<MpMessageTemplateApi.MessageTemplate[]>(
|
||||
'/mp/message-template/list',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 删除消息模板 */
|
||||
export function deleteMessageTemplate(id: number) {
|
||||
return requestClient.delete('/mp/message-template/delete', {
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/** 同步公众号模板 */
|
||||
export function syncMessageTemplate(accountId: number) {
|
||||
return requestClient.post('/mp/message-template/sync', null, {
|
||||
params: { accountId },
|
||||
});
|
||||
}
|
||||
|
||||
/** 发送消息模板 */
|
||||
export function sendMessageTemplate(
|
||||
data: MpMessageTemplateApi.MessageTemplateSendVO,
|
||||
) {
|
||||
return requestClient.post('/mp/message-template/send', data);
|
||||
}
|
||||
@@ -69,6 +69,11 @@ export function useApiSelect(option: ApiSelectProps) {
|
||||
type: String,
|
||||
default: 'label',
|
||||
},
|
||||
// 返回值类型(用于部门选择器等):id 返回 ID,name 返回名称
|
||||
returnType: {
|
||||
type: String,
|
||||
default: 'id',
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const attrs = useAttrs();
|
||||
@@ -129,10 +134,21 @@ export function useApiSelect(option: ApiSelectProps) {
|
||||
|
||||
function parseOptions0(data: any[]) {
|
||||
if (Array.isArray(data)) {
|
||||
options.value = data.map((item: any) => ({
|
||||
label: parseExpression(item, props.labelField),
|
||||
value: parseExpression(item, props.valueField),
|
||||
}));
|
||||
options.value = data.map((item: any) => {
|
||||
const label = parseExpression(item, props.labelField);
|
||||
let value = parseExpression(item, props.valueField);
|
||||
|
||||
// 根据 returnType 决定返回值
|
||||
// 如果设置了 returnType 为 'name',则返回 label 作为 value
|
||||
if (props.returnType === 'name') {
|
||||
value = label;
|
||||
}
|
||||
|
||||
return {
|
||||
label: label,
|
||||
value: value,
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
console.warn(`接口[${props.url}] 返回结果不是一个数组`);
|
||||
|
||||
@@ -194,6 +194,18 @@ export async function useFormCreateDesigner(designer: Ref) {
|
||||
name: 'DeptSelect',
|
||||
label: '部门选择器',
|
||||
icon: 'icon-tree',
|
||||
props: [
|
||||
{
|
||||
type: 'select',
|
||||
field: 'returnType',
|
||||
title: '返回值类型',
|
||||
value: 'id',
|
||||
options: [
|
||||
{ label: '部门编号', value: 'id' },
|
||||
{ label: '部门名称', value: 'name' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
const dictSelectRule = useDictSelectRule();
|
||||
const apiSelectRule0 = useSelectRule({
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"register": "Register",
|
||||
"codeLogin": "Code Login",
|
||||
"qrcodeLogin": "Qr Code Login",
|
||||
"forgetPassword": "Forget Password"
|
||||
"forgetPassword": "Forget Password",
|
||||
"profile": "Profile"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"register": "注册",
|
||||
"codeLogin": "验证码登录",
|
||||
"qrcodeLogin": "二维码登录",
|
||||
"forgetPassword": "忘记密码"
|
||||
"forgetPassword": "忘记密码",
|
||||
"profile": "个人中心"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "概览",
|
||||
|
||||
@@ -90,8 +90,7 @@ const routes: RouteRecordRaw[] = [
|
||||
title: '客户统计',
|
||||
activePath: '/crm/statistics/customer',
|
||||
},
|
||||
component: () =>
|
||||
import('#/views/crm/statistics/customer/index.vue'),
|
||||
component: () => import('#/views/crm/statistics/customer/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'statistics/funnel',
|
||||
@@ -109,8 +108,7 @@ const routes: RouteRecordRaw[] = [
|
||||
title: '员工业绩',
|
||||
activePath: '/crm/statistics/performance',
|
||||
},
|
||||
component: () =>
|
||||
import('#/views/crm/statistics/performance/index.vue'),
|
||||
component: () => import('#/views/crm/statistics/performance/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'statistics/portrait',
|
||||
@@ -119,8 +117,7 @@ const routes: RouteRecordRaw[] = [
|
||||
title: '客户画像',
|
||||
activePath: '/crm/statistics/portrait',
|
||||
},
|
||||
component: () =>
|
||||
import('#/views/crm/statistics/portrait/index.vue'),
|
||||
component: () => import('#/views/crm/statistics/portrait/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'statistics/rank',
|
||||
|
||||
@@ -80,7 +80,7 @@ export const useMallKefuStore = defineStore('mall-kefu', {
|
||||
},
|
||||
conversationSort() {
|
||||
// 按置顶属性和最后消息时间排序
|
||||
this.conversationList.sort((a, b) => {
|
||||
this.conversationList.toSorted((a, b) => {
|
||||
// 按照置顶排序,置顶的会在前面
|
||||
if (a.adminPinned !== b.adminPinned) {
|
||||
return a.adminPinned ? -1 : 1;
|
||||
|
||||
65
apps/web-ele/src/views/_core/profile/base-setting.vue
Normal file
65
apps/web-ele/src/views/_core/profile/base-setting.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import type { BasicOption } from '@vben/types';
|
||||
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { ProfileBaseSetting } from '@vben/common-ui';
|
||||
|
||||
import { getUserInfoApi } from '#/api';
|
||||
|
||||
const profileBaseSettingRef = ref();
|
||||
|
||||
const MOCK_ROLES_OPTIONS: BasicOption[] = [
|
||||
{
|
||||
label: '管理员',
|
||||
value: 'super',
|
||||
},
|
||||
{
|
||||
label: '用户',
|
||||
value: 'user',
|
||||
},
|
||||
{
|
||||
label: '测试',
|
||||
value: 'test',
|
||||
},
|
||||
];
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
fieldName: 'realName',
|
||||
component: 'Input',
|
||||
label: '姓名',
|
||||
},
|
||||
{
|
||||
fieldName: 'username',
|
||||
component: 'Input',
|
||||
label: '用户名',
|
||||
},
|
||||
{
|
||||
fieldName: 'roles',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
mode: 'tags',
|
||||
options: MOCK_ROLES_OPTIONS,
|
||||
},
|
||||
label: '角色',
|
||||
},
|
||||
{
|
||||
fieldName: 'introduction',
|
||||
component: 'Textarea',
|
||||
label: '个人简介',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const data = await getUserInfoApi();
|
||||
profileBaseSettingRef.value.getFormApi().setValues(data);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<ProfileBaseSetting ref="profileBaseSettingRef" :form-schema="formSchema" />
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ProfileNotificationSetting } from '@vben/common-ui';
|
||||
|
||||
const formSchema = computed(() => {
|
||||
return [
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'accountPassword',
|
||||
label: '账户密码',
|
||||
description: '其他用户的消息将以站内信的形式通知',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'systemMessage',
|
||||
label: '系统消息',
|
||||
description: '系统消息将以站内信的形式通知',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'todoTask',
|
||||
label: '待办任务',
|
||||
description: '待办任务将以站内信的形式通知',
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<ProfileNotificationSetting :form-schema="formSchema" />
|
||||
</template>
|
||||
66
apps/web-ele/src/views/_core/profile/password-setting.vue
Normal file
66
apps/web-ele/src/views/_core/profile/password-setting.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { ProfilePasswordSetting, z } from '@vben/common-ui';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const profilePasswordSettingRef = ref();
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
fieldName: 'oldPassword',
|
||||
label: '旧密码',
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
placeholder: '请输入旧密码',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'newPassword',
|
||||
label: '新密码',
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: '请输入新密码',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'confirmPassword',
|
||||
label: '确认密码',
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: '请再次输入新密码',
|
||||
},
|
||||
dependencies: {
|
||||
rules(values) {
|
||||
const { newPassword } = values;
|
||||
return z
|
||||
.string({ required_error: '请再次输入新密码' })
|
||||
.min(1, { message: '请再次输入新密码' })
|
||||
.refine((value) => value === newPassword, {
|
||||
message: '两次输入的密码不一致',
|
||||
});
|
||||
},
|
||||
triggerFields: ['newPassword'],
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
ElMessage.success('密码修改成功');
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ProfilePasswordSetting
|
||||
ref="profilePasswordSettingRef"
|
||||
class="w-1/3"
|
||||
:form-schema="formSchema"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
43
apps/web-ele/src/views/_core/profile/security-setting.vue
Normal file
43
apps/web-ele/src/views/_core/profile/security-setting.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ProfileSecuritySetting } from '@vben/common-ui';
|
||||
|
||||
const formSchema = computed(() => {
|
||||
return [
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'accountPassword',
|
||||
label: '账户密码',
|
||||
description: '当前密码强度:强',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'securityPhone',
|
||||
label: '密保手机',
|
||||
description: '已绑定手机:138****8293',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'securityQuestion',
|
||||
label: '密保问题',
|
||||
description: '未设置密保问题,密保问题可有效保护账户安全',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'securityEmail',
|
||||
label: '备用邮箱',
|
||||
description: '已绑定邮箱:ant***sign.com',
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
fieldName: 'securityMfa',
|
||||
label: 'MFA 设备',
|
||||
description: '未绑定 MFA 设备,绑定后,可以进行二次确认',
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<ProfileSecuritySetting :form-schema="formSchema" />
|
||||
</template>
|
||||
@@ -411,8 +411,8 @@ async function doSendMessageStream(userMessage: AiChatMessageApi.ChatMessage) {
|
||||
const lastMessage =
|
||||
activeMessageList.value[activeMessageList.value.length - 1];
|
||||
// 累加推理内容
|
||||
lastMessage.reasoningContent =
|
||||
(lastMessage.reasoningContent || '') +
|
||||
lastMessage!.reasoningContent =
|
||||
(lastMessage!.reasoningContent || '') +
|
||||
data.receive.reasoningContent;
|
||||
}
|
||||
|
||||
@@ -563,9 +563,9 @@ onMounted(async () => {
|
||||
/>
|
||||
|
||||
<!-- 右侧:详情部分 -->
|
||||
<ElContainer direction="vertical" class="bg-card mx-4 flex-1">
|
||||
<ElContainer direction="vertical" class="mx-4 flex-1 bg-card">
|
||||
<ElHeader
|
||||
class="!bg-card border-border flex !h-12 items-center justify-between border-b !px-4"
|
||||
class="flex !h-12 items-center justify-between border-b border-border !bg-card !px-4"
|
||||
>
|
||||
<div class="text-lg font-bold">
|
||||
{{ activeConversation?.title ? activeConversation?.title : '对话' }}
|
||||
@@ -632,9 +632,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
</ElMain>
|
||||
|
||||
<ElFooter height="auto" class="!bg-card flex flex-col !p-0">
|
||||
<ElFooter height="auto" class="flex flex-col !bg-card !p-0">
|
||||
<form
|
||||
class="border-border mx-4 mb-8 mt-2 flex flex-col rounded-xl border p-2"
|
||||
class="mx-4 mb-8 mt-2 flex flex-col rounded-xl border border-border p-2"
|
||||
>
|
||||
<textarea
|
||||
class="box-border h-24 resize-none overflow-auto rounded-md p-2 focus:outline-none"
|
||||
|
||||
@@ -97,7 +97,7 @@ async function getChatConversationList() {
|
||||
// 1.1 获取 对话数据
|
||||
conversationList.value = await getChatConversationMyList();
|
||||
// 1.2 排序
|
||||
conversationList.value.sort((a, b) => {
|
||||
conversationList.value.toSorted((a, b) => {
|
||||
return Number(b.createTime) - Number(a.createTime);
|
||||
});
|
||||
// 1.3 没有任何对话情况
|
||||
@@ -421,7 +421,7 @@ onMounted(async () => {
|
||||
|
||||
<!-- 左底部:工具栏 -->
|
||||
<div
|
||||
class="bg-card absolute bottom-1 left-0 right-0 mb-4 flex items-center justify-between px-5 leading-9 text-gray-400 shadow-sm"
|
||||
class="absolute bottom-1 left-0 right-0 mb-4 flex items-center justify-between bg-card px-5 leading-9 text-gray-400 shadow-sm"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-center text-gray-400"
|
||||
|
||||
@@ -138,14 +138,14 @@ async function uploadFile(fileItem: FileItem) {
|
||||
fileItem.progress = 100;
|
||||
|
||||
// 调试日志
|
||||
console.log('上传响应:', response);
|
||||
console.warn('上传响应:', response);
|
||||
|
||||
// 兼容不同的返回格式:{ url: '...' } 或 { data: '...' } 或直接是字符串
|
||||
const fileUrl =
|
||||
(response as any)?.url || (response as any)?.data || response;
|
||||
fileItem.url = fileUrl;
|
||||
|
||||
console.log('提取的文件 URL:', fileUrl);
|
||||
console.warn('提取的文件 URL:', fileUrl);
|
||||
|
||||
// 只有当 URL 有效时才添加到列表
|
||||
if (fileUrl && typeof fileUrl === 'string') {
|
||||
@@ -242,7 +242,7 @@ onUnmounted(() => {
|
||||
<!-- Hover 显示的文件列表 -->
|
||||
<div
|
||||
v-if="hasFiles && showTooltip"
|
||||
class="animate-in fade-in slide-in-from-bottom-1 absolute bottom-[calc(100%+8px)] left-1/2 z-[1000] min-w-[240px] max-w-[320px] -translate-x-1/2 rounded-lg border border-gray-200 bg-white p-2 shadow-lg duration-200"
|
||||
class="absolute bottom-[calc(100%+8px)] left-1/2 z-[1000] min-w-[240px] max-w-[320px] -translate-x-1/2 rounded-lg border border-gray-200 bg-white p-2 shadow-lg duration-200 animate-in fade-in slide-in-from-bottom-1"
|
||||
@mouseenter="showTooltipHandler"
|
||||
@mouseleave="hideTooltipHandler"
|
||||
>
|
||||
|
||||
@@ -66,7 +66,7 @@ function handleClick(doc: any) {
|
||||
<div
|
||||
v-for="(doc, index) in documentList"
|
||||
:key="index"
|
||||
class="bg-card cursor-pointer rounded-lg p-2 px-3 transition-all hover:bg-blue-50"
|
||||
class="cursor-pointer rounded-lg bg-card p-2 px-3 transition-all hover:bg-blue-50"
|
||||
@click="handleClick(doc)"
|
||||
>
|
||||
<div class="mb-1 text-sm text-gray-600">
|
||||
|
||||
@@ -233,7 +233,7 @@ onMounted(async () => {
|
||||
<!-- 回到底部按钮 -->
|
||||
<div
|
||||
v-if="isScrolling"
|
||||
class="z-1000 absolute bottom-0 right-1/2"
|
||||
class="absolute bottom-0 right-1/2 z-1000"
|
||||
@click="handleGoBottom"
|
||||
>
|
||||
<ElButton circle>
|
||||
|
||||
@@ -107,7 +107,7 @@ async function handleTabsScroll() {
|
||||
<ElDropdownItem @click="handleMoreClick('edit', role)">
|
||||
<div class="flex items-center">
|
||||
<IconifyIcon icon="lucide:edit" color="#787878" />
|
||||
<span class="text-primary ml-2">编辑</span>
|
||||
<span class="ml-2 text-primary">编辑</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
|
||||
@@ -183,12 +183,12 @@ onMounted(async () => {
|
||||
<template>
|
||||
<Drawer>
|
||||
<ElContainer
|
||||
class="bg-card absolute inset-0 flex h-full w-full flex-col overflow-hidden"
|
||||
class="absolute inset-0 flex h-full w-full flex-col overflow-hidden bg-card"
|
||||
>
|
||||
<FormModal @success="handlerAddRoleSuccess" />
|
||||
|
||||
<ElMain class="relative m-0 flex-1 overflow-hidden p-0">
|
||||
<div class="z-100 absolute right-5 top-5 flex items-center">
|
||||
<div class="absolute right-5 top-5 z-100 flex items-center">
|
||||
<!-- 搜索输入框 -->
|
||||
<ElInput
|
||||
v-model="search"
|
||||
|
||||
@@ -89,7 +89,7 @@ onMounted(async () => {
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="absolute inset-0 m-4 flex h-full w-full flex-row">
|
||||
<div class="bg-card left-0 mr-4 flex w-96 flex-col rounded-lg p-4">
|
||||
<div class="left-0 mr-4 flex w-96 flex-col rounded-lg bg-card p-4">
|
||||
<div class="flex justify-center">
|
||||
<ElSegmented v-model="selectPlatform" :options="platformOptions" />
|
||||
</div>
|
||||
@@ -120,7 +120,7 @@ onMounted(async () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-card flex-1">
|
||||
<div class="flex-1 bg-card">
|
||||
<ImageList ref="imageListRef" @on-regeneration="handleRegeneration" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -231,7 +231,7 @@ defineExpose({ settingValues });
|
||||
@click="handleSizeClick(imageSize)"
|
||||
>
|
||||
<div
|
||||
class="bg-card flex h-12 w-12 flex-col items-center justify-center rounded-lg border p-0"
|
||||
class="flex h-12 w-12 flex-col items-center justify-center rounded-lg border bg-card p-0"
|
||||
:class="[
|
||||
selectSize === imageSize.key ? 'border-blue-500' : 'border-white',
|
||||
]"
|
||||
|
||||
@@ -144,7 +144,7 @@ async function handleImageMidjourneyButtonClick(
|
||||
const data = {
|
||||
id: imageDetail.id,
|
||||
customId: button.customId,
|
||||
} as AiImageApi.ImageMidjourneyActionVO;
|
||||
} as AiImageApi.ImageMidjourneyAction;
|
||||
// 2. 发送 action
|
||||
await midjourneyAction(data);
|
||||
// 3. 刷新列表
|
||||
@@ -206,7 +206,7 @@ onUnmounted(async () => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-card sticky bottom-0 z-50 flex h-16 items-center justify-center shadow-sm"
|
||||
class="sticky bottom-0 z-50 flex h-16 items-center justify-center bg-card shadow-sm"
|
||||
>
|
||||
<ElPagination
|
||||
:total="pageTotal"
|
||||
|
||||
@@ -179,7 +179,7 @@ defineExpose({ settingValues });
|
||||
@click="handleSizeClick(imageSize)"
|
||||
>
|
||||
<div
|
||||
class="bg-card flex h-12 w-12 items-center justify-center rounded-lg border p-0"
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg border bg-card p-0"
|
||||
:class="[
|
||||
selectSize === imageSize.key ? 'border-blue-500' : 'border-white',
|
||||
]"
|
||||
|
||||
@@ -61,12 +61,12 @@ onMounted(async () => {
|
||||
</template>
|
||||
</ElInput>
|
||||
<div
|
||||
class="bg-card grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2.5 shadow-sm"
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2.5 bg-card shadow-sm"
|
||||
>
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="bg-card relative cursor-pointer overflow-hidden transition-transform duration-300 hover:scale-105"
|
||||
class="relative cursor-pointer overflow-hidden bg-card transition-transform duration-300 hover:scale-105"
|
||||
>
|
||||
<ElImage
|
||||
:src="item.picUrl"
|
||||
|
||||
@@ -132,7 +132,7 @@ onMounted(async () => {
|
||||
<div class="mx-auto">
|
||||
<!-- 头部导航栏 -->
|
||||
<div
|
||||
class="bg-card absolute left-0 right-0 top-0 z-10 flex h-12 items-center border-b px-4"
|
||||
class="absolute left-0 right-0 top-0 z-10 flex h-12 items-center border-b bg-card px-4"
|
||||
>
|
||||
<!-- 左侧标题 -->
|
||||
<div class="flex w-48 items-center overflow-hidden">
|
||||
|
||||
@@ -263,7 +263,7 @@ onMounted(async () => {
|
||||
分片-{{ index + 1 }} · {{ segment.contentLength || 0 }} 字符数 ·
|
||||
{{ segment.tokens || 0 }} Token
|
||||
</div>
|
||||
<div class="bg-card rounded-md p-2">
|
||||
<div class="rounded-md bg-card p-2">
|
||||
{{ segment.content }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,8 +25,8 @@ defineExpose({
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="bg-card flex w-80 flex-col rounded-lg p-5">
|
||||
<h3 class="text-primary h-7 w-full text-center text-xl leading-7">
|
||||
<div class="flex w-80 flex-col rounded-lg bg-card p-5">
|
||||
<h3 class="h-7 w-full text-center text-xl leading-7 text-primary">
|
||||
思维导图创作中心
|
||||
</h3>
|
||||
<div class="mt-4 flex-grow overflow-y-auto">
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
getChatRole,
|
||||
updateChatRole,
|
||||
} from '#/api/ai/model/chatRole';
|
||||
import {} from '#/api/bpm/model';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ElMessage } from 'element-plus';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { createModel, getModel, updateModel } from '#/api/ai/model/model';
|
||||
import {} from '#/api/bpm/model';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
@@ -39,7 +39,7 @@ function audioTimeUpdate(args: any) {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="b-1 b-l-none h-18 bg-card flex items-center justify-between border border-solid border-rose-100 px-2"
|
||||
class="b-1 b-l-none h-18 flex items-center justify-between border border-solid border-rose-100 bg-card px-2"
|
||||
>
|
||||
<!-- 歌曲信息 -->
|
||||
<div class="flex gap-2.5">
|
||||
|
||||
@@ -17,7 +17,7 @@ const currentSong = ref({}); // 当前音乐
|
||||
const mySongList = ref<Recordable<any>[]>([]);
|
||||
const squareSongList = ref<Recordable<any>[]>([]);
|
||||
|
||||
function generateMusic(formData: Recordable<any>) {
|
||||
function generateMusic(_formData: Recordable<any>) {
|
||||
loading.value = true;
|
||||
setTimeout(() => {
|
||||
mySongList.value = Array.from({ length: 20 }, (_, index) => {
|
||||
|
||||
@@ -204,7 +204,7 @@ onBeforeUnmount(() => {
|
||||
<div class="mx-auto">
|
||||
<!-- 头部导航栏 -->
|
||||
<div
|
||||
class="bg-card absolute inset-x-0 top-0 z-10 flex h-12 items-center border-b px-5"
|
||||
class="absolute inset-x-0 top-0 z-10 flex h-12 items-center border-b bg-card px-5"
|
||||
>
|
||||
<!-- 左侧标题 -->
|
||||
<div class="flex w-48 items-center overflow-hidden">
|
||||
|
||||
@@ -256,7 +256,7 @@ defineExpose({ validate });
|
||||
</fieldset>
|
||||
|
||||
<fieldset
|
||||
class="bg-card m-0 mt-10 rounded-lg border border-gray-200 px-3 py-4"
|
||||
class="m-0 mt-10 rounded-lg border border-gray-200 bg-card px-3 py-4"
|
||||
>
|
||||
<legend class="ml-2 px-2.5 text-base font-semibold text-gray-600">
|
||||
<h3>运行结果</h3>
|
||||
|
||||
@@ -136,7 +136,7 @@ function handleSubmit() {
|
||||
<span>{{ label }}</span>
|
||||
<span
|
||||
v-if="hint"
|
||||
class="text-primary-500 flex cursor-pointer select-none items-center text-xs"
|
||||
class="flex cursor-pointer select-none items-center text-xs text-primary-500"
|
||||
@click="hintClick"
|
||||
>
|
||||
<IconifyIcon icon="lucide:circle-help" />
|
||||
@@ -145,14 +145,14 @@ function handleSubmit() {
|
||||
</h3>
|
||||
</DefineLabel>
|
||||
<div class="flex flex-col" v-bind="$attrs">
|
||||
<div class="bg-card flex w-full justify-center pt-2">
|
||||
<div class="bg-card z-10 w-72 rounded-full p-1">
|
||||
<div class="flex w-full justify-center bg-card pt-2">
|
||||
<div class="z-10 w-72 rounded-full bg-card p-1">
|
||||
<div
|
||||
:class="
|
||||
selectedTab === AiWriteTypeEnum.REPLY &&
|
||||
'after:translate-x-[100%] after:transform'
|
||||
"
|
||||
class="after:bg-card relative flex items-center after:absolute after:left-0 after:top-0 after:block after:h-7 after:w-1/2 after:rounded-full after:transition-transform after:content-['']"
|
||||
class="relative flex items-center after:absolute after:left-0 after:top-0 after:block after:h-7 after:w-1/2 after:rounded-full after:bg-card after:transition-transform after:content-['']"
|
||||
>
|
||||
<ReuseTab
|
||||
v-for="tab in tabs"
|
||||
@@ -166,7 +166,7 @@ function handleSubmit() {
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-card box-border h-full w-96 flex-grow overflow-y-auto px-7 pb-2 lg:block"
|
||||
class="box-border h-full w-96 flex-grow overflow-y-auto bg-card px-7 pb-2 lg:block"
|
||||
>
|
||||
<div>
|
||||
<template v-if="selectedTab === AiWriteTypeEnum.WRITING">
|
||||
|
||||
@@ -72,7 +72,7 @@ watch(copied, (val) => {
|
||||
class="hide-scroll-bar box-border h-full overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="bg-card relative box-border min-h-full w-full flex-grow p-2 sm:p-5"
|
||||
class="relative box-border min-h-full w-full flex-grow bg-card p-2 sm:p-5"
|
||||
>
|
||||
<ElButton
|
||||
v-show="isWriting"
|
||||
|
||||
@@ -21,7 +21,7 @@ const emits = defineEmits<{
|
||||
<span
|
||||
v-for="tag in props.tags"
|
||||
:key="tag.value"
|
||||
class="bg-card border-card-100 mb-2 cursor-pointer rounded border-2 border-solid px-1 text-xs leading-6"
|
||||
class="border-card-100 mb-2 cursor-pointer rounded border-2 border-solid bg-card px-1 text-xs leading-6"
|
||||
:class="
|
||||
modelValue === tag.value && '!border-primary-500 !text-primary-500'
|
||||
"
|
||||
|
||||
@@ -200,7 +200,7 @@ onMounted(() => {
|
||||
</script>
|
||||
<template>
|
||||
<div class="simple-process-model-container">
|
||||
<div class="bg-card absolute right-0 top-0">
|
||||
<div class="absolute right-0 top-0 bg-card">
|
||||
<ElRow type="flex" justify="end">
|
||||
<ElButtonGroup key="scale-control">
|
||||
<ElButton v-if="!readonly" @click="exportJson">
|
||||
|
||||
@@ -395,7 +395,7 @@ onBeforeUnmount(() => {
|
||||
<div class="mx-auto">
|
||||
<!-- 头部导航栏 -->
|
||||
<div
|
||||
class="bg-card absolute inset-x-0 top-0 z-10 flex h-12 items-center border-b px-5"
|
||||
class="absolute inset-x-0 top-0 z-10 flex h-12 items-center border-b bg-card px-5"
|
||||
>
|
||||
<!-- 左侧标题 -->
|
||||
<div class="flex w-48 items-center overflow-hidden">
|
||||
|
||||
@@ -665,7 +665,7 @@ function handleRenameSuccess() {
|
||||
link
|
||||
size="small"
|
||||
@click="modelOperation('update', row.id)"
|
||||
:disabled="!isManagerUser(row) || !hasPermiUpdate"
|
||||
:disabled="!isManagerUser(row) && !hasPermiUpdate"
|
||||
>
|
||||
修改
|
||||
</ElButton>
|
||||
@@ -673,7 +673,7 @@ function handleRenameSuccess() {
|
||||
link
|
||||
size="small"
|
||||
@click="handleDeploy(row)"
|
||||
:disabled="!isManagerUser(row) || !hasPermiDeploy"
|
||||
:disabled="!isManagerUser(row) && !hasPermiDeploy"
|
||||
>
|
||||
发布
|
||||
</ElButton>
|
||||
@@ -717,7 +717,7 @@ function handleRenameSuccess() {
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
@click="handleModelCommand('handleDelete', row)"
|
||||
:disabled="!isManagerUser(row) || !hasPermiDelete"
|
||||
:disabled="!isManagerUser(row) && !hasPermiDelete"
|
||||
>
|
||||
删除
|
||||
</el-dropdown-item>
|
||||
|
||||
@@ -138,7 +138,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
<template
|
||||
v-if="
|
||||
row.status === BpmProcessInstanceStatus.RUNNING &&
|
||||
row.tasks!.length > 0
|
||||
row.tasks?.length! > 0
|
||||
"
|
||||
>
|
||||
<!-- 单人审批 -->
|
||||
|
||||
@@ -6,7 +6,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
|
||||
return {
|
||||
dataset: {
|
||||
dimensions: ['nickname', 'count'],
|
||||
source: cloneDeep(res).reverse(),
|
||||
source: cloneDeep(res).toReversed(),
|
||||
},
|
||||
grid: {
|
||||
left: 20,
|
||||
@@ -54,7 +54,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
|
||||
return {
|
||||
dataset: {
|
||||
dimensions: ['nickname', 'count'],
|
||||
source: cloneDeep(res).reverse(),
|
||||
source: cloneDeep(res).toReversed(),
|
||||
},
|
||||
grid: {
|
||||
left: 20,
|
||||
@@ -102,7 +102,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
|
||||
return {
|
||||
dataset: {
|
||||
dimensions: ['nickname', 'count'],
|
||||
source: cloneDeep(res).reverse(),
|
||||
source: cloneDeep(res).toReversed(),
|
||||
},
|
||||
grid: {
|
||||
left: 20,
|
||||
@@ -150,7 +150,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
|
||||
return {
|
||||
dataset: {
|
||||
dimensions: ['nickname', 'count'],
|
||||
source: cloneDeep(res).reverse(),
|
||||
source: cloneDeep(res).toReversed(),
|
||||
},
|
||||
grid: {
|
||||
left: 20,
|
||||
@@ -198,7 +198,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
|
||||
return {
|
||||
dataset: {
|
||||
dimensions: ['nickname', 'count'],
|
||||
source: cloneDeep(res).reverse(),
|
||||
source: cloneDeep(res).toReversed(),
|
||||
},
|
||||
grid: {
|
||||
left: 20,
|
||||
@@ -246,7 +246,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
|
||||
return {
|
||||
dataset: {
|
||||
dimensions: ['nickname', 'count'],
|
||||
source: cloneDeep(res).reverse(),
|
||||
source: cloneDeep(res).toReversed(),
|
||||
},
|
||||
grid: {
|
||||
left: 20,
|
||||
@@ -294,7 +294,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
|
||||
return {
|
||||
dataset: {
|
||||
dimensions: ['nickname', 'count'],
|
||||
source: cloneDeep(res).reverse(),
|
||||
source: cloneDeep(res).toReversed(),
|
||||
},
|
||||
grid: {
|
||||
left: 20,
|
||||
@@ -342,7 +342,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
|
||||
return {
|
||||
dataset: {
|
||||
dimensions: ['nickname', 'count'],
|
||||
source: cloneDeep(res).reverse(),
|
||||
source: cloneDeep(res).toReversed(),
|
||||
},
|
||||
grid: {
|
||||
left: 20,
|
||||
|
||||
@@ -24,7 +24,7 @@ onMounted(() => {
|
||||
{ name: '定制', value: 310 },
|
||||
{ name: '技术支持', value: 274 },
|
||||
{ name: '远程', value: 400 },
|
||||
].sort((a, b) => {
|
||||
].toSorted((a, b) => {
|
||||
return a.value - b.value;
|
||||
}),
|
||||
name: '商业占比',
|
||||
|
||||
@@ -128,6 +128,7 @@ const handleOpenPurchaseIn = () => {
|
||||
|
||||
const handleAddPurchaseIn = (rows: ErpPurchaseInApi.PurchaseIn[]) => {
|
||||
rows.forEach((row) => {
|
||||
// TODO @芋艿
|
||||
const newItem: ErpFinancePaymentApi.FinancePaymentItem = {
|
||||
bizId: row.id,
|
||||
bizType: ErpBizType.PURCHASE_IN,
|
||||
@@ -251,9 +252,9 @@ defineExpose({ validate });
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<div class="border-border bg-muted mt-2 rounded border p-2">
|
||||
<div class="text-muted-foreground flex justify-between text-sm">
|
||||
<span class="text-foreground font-medium">合计:</span>
|
||||
<div class="mt-2 rounded border border-border bg-muted p-2">
|
||||
<div class="flex justify-between text-sm text-muted-foreground">
|
||||
<span class="font-medium text-foreground">合计:</span>
|
||||
<div class="flex space-x-4">
|
||||
<span>
|
||||
合计付款:{{ erpPriceInputFormatter(summaries.totalPrice) }}
|
||||
|
||||
@@ -127,6 +127,7 @@ function handleOpenSaleOut() {
|
||||
}
|
||||
|
||||
function handleAddSaleOut(rows: ErpSaleOutApi.SaleOut[]) {
|
||||
// TODO @芋艿
|
||||
rows.forEach((row) => {
|
||||
const newItem: ErpFinanceReceiptApi.FinanceReceiptItem = {
|
||||
bizId: row.id,
|
||||
@@ -251,9 +252,9 @@ defineExpose({ validate });
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<div class="border-border bg-muted mt-2 rounded border p-2">
|
||||
<div class="text-muted-foreground flex justify-between text-sm">
|
||||
<span class="text-foreground font-medium">合计:</span>
|
||||
<div class="mt-2 rounded border border-border bg-muted p-2">
|
||||
<div class="flex justify-between text-sm text-muted-foreground">
|
||||
<span class="font-medium text-foreground">合计:</span>
|
||||
<div class="flex space-x-4">
|
||||
<span>
|
||||
合计收款:{{ erpPriceInputFormatter(summaries.totalPrice) }}
|
||||
|
||||
@@ -131,6 +131,7 @@ watch(
|
||||
|
||||
/** 处理删除 */
|
||||
function handleDelete(row: ErpPurchaseInApi.PurchaseInItem) {
|
||||
// TODO @芋艿
|
||||
const index = tableData.value.findIndex((item) => item.seq === row.seq);
|
||||
if (index !== -1) {
|
||||
tableData.value.splice(index, 1);
|
||||
@@ -289,9 +290,9 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<div class="border-border bg-muted mt-2 rounded border p-2">
|
||||
<div class="text-muted-foreground flex justify-between text-sm">
|
||||
<span class="text-foreground font-medium">合计:</span>
|
||||
<div class="mt-2 rounded border border-border bg-muted p-2">
|
||||
<div class="flex justify-between text-sm text-muted-foreground">
|
||||
<span class="font-medium text-foreground">合计:</span>
|
||||
<div class="flex space-x-4">
|
||||
<span>数量:{{ erpCountInputFormatter(summaries.count) }}</span>
|
||||
<span>
|
||||
|
||||
@@ -142,6 +142,7 @@ function handleAdd() {
|
||||
|
||||
/** 处理删除 */
|
||||
function handleDelete(row: ErpPurchaseOrderApi.PurchaseOrderItem) {
|
||||
// TODO @芋艿
|
||||
const index = tableData.value.findIndex((item) => item.seq === row.seq);
|
||||
if (index !== -1) {
|
||||
tableData.value.splice(index, 1);
|
||||
@@ -169,6 +170,7 @@ async function handleProductChange(productId: any, row: any) {
|
||||
|
||||
/** 处理行数据变更 */
|
||||
function handleRowChange(row: any) {
|
||||
// TODO @芋艿
|
||||
const index = tableData.value.findIndex((item) => item.seq === row.seq);
|
||||
if (index === -1) {
|
||||
tableData.value.push(row);
|
||||
@@ -296,9 +298,9 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<div class="border-border bg-muted mt-2 rounded border p-2">
|
||||
<div class="text-muted-foreground flex justify-between text-sm">
|
||||
<span class="text-foreground font-medium">合计:</span>
|
||||
<div class="mt-2 rounded border border-border bg-muted p-2">
|
||||
<div class="flex justify-between text-sm text-muted-foreground">
|
||||
<span class="font-medium text-foreground">合计:</span>
|
||||
<div class="flex space-x-4">
|
||||
<span>数量:{{ erpCountInputFormatter(summaries.count) }}</span>
|
||||
<span>
|
||||
|
||||
@@ -131,6 +131,7 @@ watch(
|
||||
|
||||
/** 处理删除 */
|
||||
function handleDelete(row: ErpPurchaseReturnApi.PurchaseReturnItem) {
|
||||
// TODO @芋艿
|
||||
const index = tableData.value.findIndex((item) => item.seq === row.seq);
|
||||
if (index !== -1) {
|
||||
tableData.value.splice(index, 1);
|
||||
@@ -291,9 +292,9 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<div class="border-border bg-muted mt-2 rounded border p-2">
|
||||
<div class="text-muted-foreground flex justify-between text-sm">
|
||||
<span class="text-foreground font-medium">合计:</span>
|
||||
<div class="mt-2 rounded border border-border bg-muted p-2">
|
||||
<div class="flex justify-between text-sm text-muted-foreground">
|
||||
<span class="font-medium text-foreground">合计:</span>
|
||||
<div class="flex space-x-4">
|
||||
<span>数量:{{ erpCountInputFormatter(summaries.count) }}</span>
|
||||
<span>
|
||||
|
||||
@@ -142,6 +142,7 @@ function handleAdd() {
|
||||
|
||||
/** 处理删除 */
|
||||
function handleDelete(row: ErpSaleOrderApi.SaleOrderItem) {
|
||||
// TODO @芋艿
|
||||
const index = tableData.value.findIndex((item) => item.seq === row.seq);
|
||||
if (index !== -1) {
|
||||
tableData.value.splice(index, 1);
|
||||
@@ -296,9 +297,9 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<div class="border-border bg-muted mt-2 rounded border p-2">
|
||||
<div class="text-muted-foreground flex justify-between text-sm">
|
||||
<span class="text-foreground font-medium">合计:</span>
|
||||
<div class="mt-2 rounded border border-border bg-muted p-2">
|
||||
<div class="flex justify-between text-sm text-muted-foreground">
|
||||
<span class="font-medium text-foreground">合计:</span>
|
||||
<div class="flex space-x-4">
|
||||
<span>数量:{{ erpCountInputFormatter(summaries.count) }}</span>
|
||||
<span>
|
||||
|
||||
@@ -131,6 +131,7 @@ watch(
|
||||
|
||||
/** 处理删除 */
|
||||
function handleDelete(row: ErpSaleOutApi.SaleOutItem) {
|
||||
// TODO @芋艿
|
||||
const index = tableData.value.findIndex((item) => item.seq === row.seq);
|
||||
if (index !== -1) {
|
||||
tableData.value.splice(index, 1);
|
||||
@@ -289,9 +290,9 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<div class="border-border bg-muted mt-2 rounded border p-2">
|
||||
<div class="text-muted-foreground flex justify-between text-sm">
|
||||
<span class="text-foreground font-medium">合计:</span>
|
||||
<div class="mt-2 rounded border border-border bg-muted p-2">
|
||||
<div class="flex justify-between text-sm text-muted-foreground">
|
||||
<span class="font-medium text-foreground">合计:</span>
|
||||
<div class="flex space-x-4">
|
||||
<span>数量:{{ erpCountInputFormatter(summaries.count) }}</span>
|
||||
<span>
|
||||
|
||||
@@ -131,6 +131,7 @@ watch(
|
||||
|
||||
/** 处理删除 */
|
||||
function handleDelete(row: ErpSaleReturnApi.SaleReturnItem) {
|
||||
// TODO @芋艿
|
||||
const index = tableData.value.findIndex((item) => item.seq === row.seq);
|
||||
if (index !== -1) {
|
||||
tableData.value.splice(index, 1);
|
||||
@@ -289,9 +290,9 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<div class="border-border bg-muted mt-2 rounded border p-2">
|
||||
<div class="text-muted-foreground flex justify-between text-sm">
|
||||
<span class="text-foreground font-medium">合计:</span>
|
||||
<div class="mt-2 rounded border border-border bg-muted p-2">
|
||||
<div class="flex justify-between text-sm text-muted-foreground">
|
||||
<span class="font-medium text-foreground">合计:</span>
|
||||
<div class="flex space-x-4">
|
||||
<span>数量:{{ erpCountInputFormatter(summaries.count) }}</span>
|
||||
<span>
|
||||
|
||||
@@ -106,6 +106,7 @@ function handleAdd() {
|
||||
|
||||
/** 处理删除 */
|
||||
function handleDelete(row: ErpStockCheckApi.StockCheckItem) {
|
||||
// TODO @芋艿
|
||||
const index = tableData.value.findIndex((item) => item.seq === row.seq);
|
||||
if (index !== -1) {
|
||||
tableData.value.splice(index, 1);
|
||||
@@ -284,9 +285,9 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<div class="border-border bg-muted mt-2 rounded border p-2">
|
||||
<div class="text-muted-foreground flex justify-between text-sm">
|
||||
<span class="text-foreground font-medium">合计:</span>
|
||||
<div class="mt-2 rounded border border-border bg-muted p-2">
|
||||
<div class="flex justify-between text-sm text-muted-foreground">
|
||||
<span class="font-medium text-foreground">合计:</span>
|
||||
<div class="flex space-x-4">
|
||||
<span>数量:{{ erpCountInputFormatter(summaries.count) }}</span>
|
||||
<span>
|
||||
|
||||
@@ -86,6 +86,7 @@ watch(
|
||||
|
||||
/** 处理新增 */
|
||||
function handleAdd() {
|
||||
// TODO @芋艿
|
||||
const newRow = {
|
||||
id: undefined,
|
||||
warehouseId: undefined,
|
||||
|
||||
@@ -105,6 +105,7 @@ function handleAdd() {
|
||||
|
||||
/** 处理删除 */
|
||||
function handleDelete(row: ErpStockOutApi.StockOutItem) {
|
||||
// TODO @芋艿
|
||||
const index = tableData.value.findIndex((item) => item.seq === row.seq);
|
||||
if (index !== -1) {
|
||||
tableData.value.splice(index, 1);
|
||||
@@ -271,7 +272,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<div class="border-border bg-muted mt-2 rounded border p-2">
|
||||
<div class="mt-2 rounded border border-border bg-muted p-2">
|
||||
<div class="text-muted-foreground flex justify-between text-sm">
|
||||
<span class="text-foreground font-medium">合计:</span>
|
||||
<div class="flex space-x-4">
|
||||
|
||||
@@ -119,7 +119,7 @@ getDetail();
|
||||
|
||||
<template>
|
||||
<Page auto-content-height v-loading="loading">
|
||||
<div class="bg-card flex h-[95%] flex-col rounded-md p-4">
|
||||
<div class="flex h-[95%] flex-col rounded-md bg-card p-4">
|
||||
<ElSteps :active="currentStep" class="mb-8 rounded shadow-sm" simple>
|
||||
<ElStep
|
||||
v-for="(step, index) in steps"
|
||||
|
||||
@@ -47,7 +47,7 @@ const { status, data, send, close, open } = useWebSocket(server.value, {
|
||||
const messageList = ref(
|
||||
[] as { text: string; time: number; type?: string; userId?: string }[],
|
||||
); // 消息列表
|
||||
const messageReverseList = computed(() => [...messageList.value].reverse());
|
||||
const messageReverseList = computed(() => [...messageList.value].toReversed());
|
||||
watchEffect(() => {
|
||||
if (!data.value) {
|
||||
return;
|
||||
|
||||
@@ -125,7 +125,7 @@ function emitSpuChange() {
|
||||
<!-- 添加商品按钮 -->
|
||||
<ElTooltip v-if="canAdd" content="选择商品">
|
||||
<div
|
||||
class="hover:border-primary hover:bg-primary/5 flex h-[60px] w-[60px] cursor-pointer items-center justify-center rounded-lg border-2 border-dashed transition-colors"
|
||||
class="flex h-[60px] w-[60px] cursor-pointer items-center justify-center rounded-lg border-2 border-dashed transition-colors hover:border-primary hover:bg-primary/5"
|
||||
@click="handleOpenSpuSelect"
|
||||
>
|
||||
<IconifyIcon icon="ep:plus" class="text-xl text-gray-400" />
|
||||
|
||||
@@ -111,6 +111,7 @@ function emitActivityChange() {
|
||||
>
|
||||
<ElTooltip :content="activity.name">
|
||||
<div class="relative h-full w-full">
|
||||
<!-- TODO @芋艿 -->
|
||||
<ElImage
|
||||
:src="activity.picUrl"
|
||||
class="h-full w-full rounded-lg object-cover"
|
||||
@@ -131,7 +132,7 @@ function emitActivityChange() {
|
||||
<!-- 添加活动按钮 -->
|
||||
<ElTooltip v-if="canAdd" content="选择活动">
|
||||
<div
|
||||
class="hover:border-primary hover:bg-primary/5 flex h-[60px] w-[60px] cursor-pointer items-center justify-center rounded-lg border-2 border-dashed transition-colors"
|
||||
class="flex h-[60px] w-[60px] cursor-pointer items-center justify-center rounded-lg border-2 border-dashed transition-colors hover:border-primary hover:bg-primary/5"
|
||||
@click="handleOpenActivitySelect"
|
||||
>
|
||||
<IconifyIcon icon="ep:plus" class="text-xl text-gray-400" />
|
||||
|
||||
@@ -75,7 +75,7 @@ function handleHotAreaSelected(
|
||||
<template>
|
||||
<div class="h-40px flex items-center justify-center">
|
||||
<MagicCubeEditor
|
||||
v-model="cellList as any"
|
||||
v-model="cellList"
|
||||
:cols="cellCount"
|
||||
:cube-size="38"
|
||||
:rows="1"
|
||||
|
||||
@@ -57,10 +57,10 @@ export function isContains(hotArea: Rect, point: Point): boolean {
|
||||
*/
|
||||
export function createRect(a: Point, b: Point): Rect {
|
||||
// 计算矩形的范围
|
||||
let [left, left2] = [a.x, b.x].sort();
|
||||
let [left, left2] = [a.x, b.x].toSorted();
|
||||
left = left ?? 0;
|
||||
left2 = left2 ?? 0;
|
||||
let [top, top2] = [a.y, b.y].sort();
|
||||
let [top, top2] = [a.y, b.y].toSorted();
|
||||
top = top ?? 0;
|
||||
top2 = top2 ?? 0;
|
||||
const right = left2 + 1;
|
||||
|
||||
@@ -159,7 +159,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-background flex flex-shrink-0 flex-col border-r border-gray-200 p-4"
|
||||
class="flex flex-shrink-0 flex-col border-r border-gray-200 bg-background p-4"
|
||||
>
|
||||
<div class="flex h-12 w-full flex-row items-center justify-between">
|
||||
<span class="text-lg font-bold">会话记录</span>
|
||||
@@ -213,7 +213,7 @@ onBeforeUnmount(() => {
|
||||
<ul
|
||||
v-show="showRightMenu"
|
||||
:style="rightMenuStyle"
|
||||
class="bg-background absolute z-[9999] m-0 w-32 list-none rounded-xl p-1 shadow-md"
|
||||
class="absolute z-[9999] m-0 w-32 list-none rounded-xl bg-background p-1 shadow-md"
|
||||
>
|
||||
<li
|
||||
v-show="!rightClickConversation.adminPinned"
|
||||
|
||||
@@ -139,13 +139,13 @@ async function getUserData() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-background flex h-full flex-auto flex-col">
|
||||
<div class="flex h-full flex-auto flex-col bg-background">
|
||||
<div
|
||||
class="mt-4 flex h-12 items-center justify-around before:absolute before:bottom-0 before:left-0 before:h-1 before:w-full before:scale-y-[0.3] before:bg-gray-200 before:content-['']"
|
||||
>
|
||||
<div
|
||||
:class="{
|
||||
'before:border-primary before:border-b-2': tabActivation('会员信息'),
|
||||
'before:border-b-2 before:border-primary': tabActivation('会员信息'),
|
||||
}"
|
||||
class="relative flex w-full cursor-pointer items-center justify-center before:pointer-events-none before:absolute before:inset-0 before:content-[''] hover:before:border-b-2 hover:before:border-gray-500/50"
|
||||
@click="handleClick('会员信息')"
|
||||
@@ -154,7 +154,7 @@ async function getUserData() {
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'before:border-primary before:border-b-2': tabActivation('最近浏览'),
|
||||
'before:border-b-2 before:border-primary': tabActivation('最近浏览'),
|
||||
}"
|
||||
class="relative flex w-full cursor-pointer items-center justify-center before:pointer-events-none before:absolute before:inset-0 before:content-[''] hover:before:border-b-2 hover:before:border-gray-500/50"
|
||||
@click="handleClick('最近浏览')"
|
||||
@@ -163,7 +163,7 @@ async function getUserData() {
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'before:border-primary before:border-b-2': tabActivation('交易订单'),
|
||||
'before:border-b-2 before:border-primary': tabActivation('交易订单'),
|
||||
}"
|
||||
class="relative flex w-full cursor-pointer items-center justify-center before:pointer-events-none before:absolute before:inset-0 before:content-[''] hover:before:border-b-2 hover:before:border-gray-500/50"
|
||||
@click="handleClick('交易订单')"
|
||||
|
||||
@@ -97,7 +97,7 @@ function pushMessage(message: any) {
|
||||
/** 按照时间倒序,获取消息列表 */
|
||||
const getMessageList0 = computed(() => {
|
||||
// 使用展开运算符创建新数组,避免直接修改原数组
|
||||
return [...messageList.value].sort(
|
||||
return [...messageList.value].toSorted(
|
||||
(a: any, b: any) => a.createTime - b.createTime,
|
||||
);
|
||||
});
|
||||
@@ -267,7 +267,7 @@ function showTime(item: MallKefuMessageApi.Message, index: number) {
|
||||
<template>
|
||||
<div
|
||||
v-if="showMessageList()"
|
||||
class="bg-background flex h-full flex-auto flex-col p-4"
|
||||
class="flex h-full flex-auto flex-col bg-background p-4"
|
||||
>
|
||||
<div class="flex h-full flex-auto flex-shrink-0 flex-col">
|
||||
<div class="flex h-12 w-full flex-row items-center justify-between">
|
||||
@@ -423,7 +423,7 @@ function showTime(item: MallKefuMessageApi.Message, index: number) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="bg-background relative">
|
||||
<div v-else class="relative bg-background">
|
||||
<ElEmpty description="请选择左侧的一个会话后开始" class="mt-[20%]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -78,7 +78,7 @@ function formatOrderStatus(order: any) {
|
||||
<div class="flex flex-row text-sm">
|
||||
<div>订单号:</div>
|
||||
<span
|
||||
class="text-primary cursor-pointer hover:underline"
|
||||
class="cursor-pointer text-primary hover:underline"
|
||||
@click="openDetail(getMessageContent.id)"
|
||||
>
|
||||
{{ getMessageContent.no }}
|
||||
|
||||
@@ -132,7 +132,7 @@ function emitActivityChange() {
|
||||
<!-- 添加活动按钮 -->
|
||||
<ElTooltip v-if="canAdd" content="选择活动">
|
||||
<div
|
||||
class="hover:border-primary hover:bg-primary/5 flex h-[60px] w-[60px] cursor-pointer items-center justify-center rounded-lg border-2 border-dashed transition-colors"
|
||||
class="flex h-[60px] w-[60px] cursor-pointer items-center justify-center rounded-lg border-2 border-dashed transition-colors hover:border-primary hover:bg-primary/5"
|
||||
@click="handleOpenActivitySelect"
|
||||
>
|
||||
<IconifyIcon icon="ep:plus" class="text-xl text-gray-400" />
|
||||
|
||||
@@ -110,6 +110,7 @@ function emitActivityChange() {
|
||||
>
|
||||
<ElTooltip :content="activity.name">
|
||||
<div class="relative h-full w-full">
|
||||
<!-- TODO @芋艿 -->
|
||||
<ElImage
|
||||
:src="activity.picUrl"
|
||||
class="h-full w-full rounded-lg object-cover"
|
||||
@@ -130,7 +131,7 @@ function emitActivityChange() {
|
||||
<!-- 添加活动按钮 -->
|
||||
<ElTooltip v-if="canAdd" content="选择活动">
|
||||
<div
|
||||
class="hover:border-primary hover:bg-primary/5 flex h-[60px] w-[60px] cursor-pointer items-center justify-center rounded-lg border-2 border-dashed transition-colors"
|
||||
class="flex h-[60px] w-[60px] cursor-pointer items-center justify-center rounded-lg border-2 border-dashed transition-colors hover:border-primary hover:bg-primary/5"
|
||||
@click="handleOpenActivitySelect"
|
||||
>
|
||||
<IconifyIcon icon="lucide:plus" class="text-xl text-gray-400" />
|
||||
|
||||
@@ -1,55 +1,40 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeGridPropTypes } from '#/adapter/vxe-table';
|
||||
import type { MpAccountApi } from '#/api/mp/account';
|
||||
|
||||
import { markRaw } from 'vue';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictObj, getDictOptions } from '@vben/hooks';
|
||||
import {
|
||||
AutoReplyMsgType,
|
||||
DICT_TYPE,
|
||||
RequestMessageTypes,
|
||||
} from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { getSimpleAccountList } from '#/api/mp/account';
|
||||
import { WxReplySelect } from '#/views/mp/components';
|
||||
|
||||
import { MsgType } from './modules/types';
|
||||
|
||||
/** 关联数据 */
|
||||
let accountList: MpAccountApi.AccountSimple[] = [];
|
||||
getSimpleAccountList().then((data) => (accountList = data));
|
||||
|
||||
// TODO @hw:要不要使用统一枚举?
|
||||
const RequestMessageTypes = new Set([
|
||||
'image',
|
||||
'link',
|
||||
'location',
|
||||
'shortvideo',
|
||||
'text',
|
||||
'video',
|
||||
'voice',
|
||||
]); // 允许选择的请求消息类型
|
||||
import { WxReply } from '#/views/mp/components';
|
||||
|
||||
/** 获取表格列配置 */
|
||||
export function useGridColumns(msgType: MsgType): VxeGridPropTypes.Columns {
|
||||
export function useGridColumns(
|
||||
msgType: AutoReplyMsgType,
|
||||
): VxeGridPropTypes.Columns {
|
||||
const columns: VxeGridPropTypes.Columns = [];
|
||||
// 请求消息类型列(仅消息回复显示)
|
||||
if (msgType === MsgType.Message) {
|
||||
if (msgType === AutoReplyMsgType.Message) {
|
||||
columns.push({
|
||||
field: 'requestMessageType',
|
||||
title: '请求消息类型',
|
||||
minWidth: 120,
|
||||
});
|
||||
}
|
||||
|
||||
// 关键词列(仅关键词回复显示)
|
||||
if (msgType === MsgType.Keyword) {
|
||||
if (msgType === AutoReplyMsgType.Keyword) {
|
||||
columns.push({
|
||||
field: 'requestKeyword',
|
||||
title: '关键词',
|
||||
minWidth: 150,
|
||||
});
|
||||
}
|
||||
|
||||
// 匹配类型列(仅关键词回复显示)
|
||||
if (msgType === MsgType.Keyword) {
|
||||
if (msgType === AutoReplyMsgType.Keyword) {
|
||||
columns.push({
|
||||
field: 'requestMatch',
|
||||
title: '匹配类型',
|
||||
@@ -60,16 +45,16 @@ export function useGridColumns(msgType: MsgType): VxeGridPropTypes.Columns {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 回复消息类型列
|
||||
columns.push(
|
||||
{
|
||||
field: 'responseMessageType',
|
||||
title: '回复消息类型',
|
||||
minWidth: 120,
|
||||
// TODO @hw:这里和 antd 有差别。两侧尽量统一;
|
||||
formatter: ({ cellValue }) =>
|
||||
getDictObj(DICT_TYPE.MP_MESSAGE_TYPE, String(cellValue))?.label ?? '',
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.MP_MESSAGE_TYPE },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'responseContent',
|
||||
@@ -94,12 +79,10 @@ export function useGridColumns(msgType: MsgType): VxeGridPropTypes.Columns {
|
||||
}
|
||||
|
||||
/** 新增/修改的表单 */
|
||||
export function useFormSchema(msgType: MsgType): VbenFormSchema[] {
|
||||
export function useFormSchema(msgType: AutoReplyMsgType): VbenFormSchema[] {
|
||||
const schema: VbenFormSchema[] = [];
|
||||
|
||||
// 消息类型(仅消息回复显示)
|
||||
// TODO @hw:这里和 antd 有差别。两侧尽量统一;
|
||||
if (Number(msgType) === MsgType.Message) {
|
||||
if (msgType === AutoReplyMsgType.Message) {
|
||||
schema.push({
|
||||
fieldName: 'requestMessageType',
|
||||
label: '消息类型',
|
||||
@@ -112,10 +95,8 @@ export function useFormSchema(msgType: MsgType): VbenFormSchema[] {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 匹配类型(仅关键词回复显示)
|
||||
// TODO @hw:这里和 antd 有差别。两侧尽量统一;
|
||||
if (Number(msgType) === MsgType.Keyword) {
|
||||
if (msgType === AutoReplyMsgType.Keyword) {
|
||||
schema.push({
|
||||
fieldName: 'requestMatch',
|
||||
label: '匹配类型',
|
||||
@@ -131,10 +112,8 @@ export function useFormSchema(msgType: MsgType): VbenFormSchema[] {
|
||||
rules: 'required',
|
||||
});
|
||||
}
|
||||
|
||||
// 关键词(仅关键词回复显示)
|
||||
// TODO @hw:这里和 antd 有差别。两侧尽量统一;
|
||||
if (Number(msgType) === MsgType.Keyword) {
|
||||
if (msgType === AutoReplyMsgType.Keyword) {
|
||||
schema.push({
|
||||
fieldName: 'requestKeyword',
|
||||
label: '关键词',
|
||||
@@ -147,31 +126,22 @@ export function useFormSchema(msgType: MsgType): VbenFormSchema[] {
|
||||
});
|
||||
}
|
||||
// 回复消息
|
||||
// TODO @hw:这里和 antd 有差别。两侧尽量统一;
|
||||
schema.push({
|
||||
fieldName: 'reply',
|
||||
label: '回复消息',
|
||||
component: markRaw(WxReplySelect),
|
||||
component: markRaw(WxReply),
|
||||
modelPropName: 'modelValue',
|
||||
});
|
||||
return schema;
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
// TODO @hw:是不是用 wxselect 组件哈?
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'accountId',
|
||||
label: '公众号',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
options: accountList.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
placeholder: '请选择公众号',
|
||||
},
|
||||
defaultValue: accountList[0]?.id,
|
||||
component: 'Input',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpAutoReplyApi } from '#/api/mp/autoReply';
|
||||
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
|
||||
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
|
||||
import { AutoReplyMsgType } from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
ElLoading,
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
ElRow,
|
||||
ElTabPane,
|
||||
ElTabs,
|
||||
} from 'element-plus';
|
||||
import { ElLoading, ElMessage, ElRow, ElTabPane, ElTabs } from 'element-plus';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
@@ -25,16 +20,15 @@ import { $t } from '#/locales';
|
||||
import { WxAccountSelect } from '#/views/mp/components';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import Content from './modules/content.vue';
|
||||
import ReplyContent from './modules/content.vue';
|
||||
import Form from './modules/form.vue';
|
||||
import { MsgType } from './modules/types';
|
||||
|
||||
defineOptions({ name: 'MpAutoReply' });
|
||||
|
||||
const msgType = ref<string>(String(MsgType.Keyword)); // 消息类型
|
||||
const msgType = ref<string>(String(AutoReplyMsgType.Keyword)); // 消息类型
|
||||
|
||||
const showCreateButton = computed(() => {
|
||||
if (Number(msgType.value) !== MsgType.Follow) {
|
||||
if (Number(msgType.value) !== AutoReplyMsgType.Follow) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
@@ -61,7 +55,7 @@ async function onTabChange(tabName: string) {
|
||||
msgType.value = tabName;
|
||||
await nextTick();
|
||||
// 更新 columns
|
||||
const columns = useGridColumns(Number(msgType.value) as MsgType);
|
||||
const columns = useGridColumns(Number(msgType.value) as AutoReplyMsgType);
|
||||
if (columns) {
|
||||
// 使用 setGridOptions 更新列配置
|
||||
gridApi.setGridOptions({ columns });
|
||||
@@ -77,37 +71,32 @@ async function handleCreate() {
|
||||
const formValues = await gridApi.formApi.getValues();
|
||||
formModalApi
|
||||
.setData({
|
||||
// TODO @hw:这里和 antd 不同,需要 number 下么?
|
||||
msgType: msgType.value,
|
||||
msgType: Number(msgType.value) as AutoReplyMsgType,
|
||||
accountId: formValues.accountId,
|
||||
})
|
||||
.open();
|
||||
}
|
||||
|
||||
/** 修改自动回复 */
|
||||
async function handleEdit(row: any) {
|
||||
const data = (await getAutoReply(row.id)) as any;
|
||||
// TODO @hw:这里使用 formValues,还是使用 row?
|
||||
const formValues = await gridApi.formApi.getValues();
|
||||
async function handleEdit(row: MpAutoReplyApi.AutoReply) {
|
||||
const data = await getAutoReply(row.id!);
|
||||
formModalApi
|
||||
.setData({
|
||||
// TODO @hw:这里和 antd 不同,需要 number 下么?
|
||||
msgType: msgType.value,
|
||||
msgType: Number(msgType.value) as AutoReplyMsgType,
|
||||
accountId: row.accountId,
|
||||
row: data,
|
||||
accountId: formValues.accountId,
|
||||
})
|
||||
.open();
|
||||
}
|
||||
|
||||
/** 删除自动回复 */
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('是否确认删除此数据?');
|
||||
async function handleDelete(row: MpAutoReplyApi.AutoReply) {
|
||||
const loadingInstance = ElLoading.service({
|
||||
text: $t('ui.actionMessage.deleting', ['自动回复']),
|
||||
});
|
||||
try {
|
||||
await deleteAutoReply(row.id);
|
||||
ElMessage.success('删除成功');
|
||||
await deleteAutoReply(row.id!);
|
||||
ElMessage.success($t('ui.actionMessage.deleteSuccess'));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
loadingInstance.close();
|
||||
@@ -124,7 +113,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(Number(msgType.value) as MsgType),
|
||||
columns: useGridColumns(Number(msgType.value) as AutoReplyMsgType),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
@@ -133,7 +122,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
return await getAutoReplyPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
type: Number(msgType.value) as MsgType,
|
||||
type: Number(msgType.value) as AutoReplyMsgType,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
@@ -148,8 +137,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
// TODO @hw:这里要调整下,linter 报错;
|
||||
} as VxeTableGridOptions<any>,
|
||||
} as VxeTableGridOptions<MpAutoReplyApi.AutoReply>,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -170,32 +158,34 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
class="w-full"
|
||||
@tab-change="(activeName) => onTabChange(activeName as string)"
|
||||
>
|
||||
<ElTabPane :name="String(MsgType.Follow)">
|
||||
<ElTabPane :name="String(AutoReplyMsgType.Follow)">
|
||||
<template #label>
|
||||
<ElRow align="middle">
|
||||
<IconifyIcon icon="ep:star" class="mr-[2px]" /> 关注时回复
|
||||
<IconifyIcon icon="lucide:star" class="mr-[2px]" /> 关注时回复
|
||||
</ElRow>
|
||||
</template>
|
||||
</ElTabPane>
|
||||
<ElTabPane :name="String(MsgType.Message)">
|
||||
<ElTabPane :name="String(AutoReplyMsgType.Message)">
|
||||
<template #label>
|
||||
<ElRow align="middle">
|
||||
<IconifyIcon icon="ep:chat-line-round" class="mr-[2px]" />
|
||||
<IconifyIcon
|
||||
icon="lucide:message-circle-more"
|
||||
class="mr-[2px]"
|
||||
/>
|
||||
消息回复
|
||||
</ElRow>
|
||||
</template>
|
||||
</ElTabPane>
|
||||
<ElTabPane :name="String(MsgType.Keyword)">
|
||||
<ElTabPane :name="String(AutoReplyMsgType.Keyword)">
|
||||
<template #label>
|
||||
<ElRow align="middle">
|
||||
<IconifyIcon icon="fa:newspaper-o" class="mr-[2px]" />
|
||||
<IconifyIcon icon="lucide:newspaper" class="mr-[2px]" />
|
||||
关键词回复
|
||||
</ElRow>
|
||||
</template>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</template>
|
||||
<!-- 工具栏按钮 -->
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
v-if="showCreateButton"
|
||||
@@ -211,7 +201,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
/>
|
||||
</template>
|
||||
<template #replyContent="{ row }">
|
||||
<Content :row="row" />
|
||||
<ReplyContent :row="row" />
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
|
||||
@@ -4,22 +4,21 @@ import type { Reply } from '#/views/mp/components/wx-reply/types';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { AutoReplyMsgType, ReplyType } from '@vben/constants';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { createAutoReply, updateAutoReply } from '#/api/mp/autoReply';
|
||||
import { $t } from '#/locales';
|
||||
import { ReplyType } from '#/views/mp/components/wx-reply/types';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
import { MsgType } from './types';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const formData = ref<{
|
||||
accountId?: number;
|
||||
msgType: MsgType;
|
||||
msgType: AutoReplyMsgType;
|
||||
row?: any;
|
||||
}>();
|
||||
const getTitle = computed(() => {
|
||||
@@ -37,8 +36,7 @@ const [Form, formApi] = useVbenForm({
|
||||
labelWidth: 100,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
// TODO @hw:antd 和 ele 存在差异
|
||||
schema: useFormSchema(Number(formData.value?.msgType) as MsgType),
|
||||
schema: useFormSchema(AutoReplyMsgType.Keyword),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
@@ -59,8 +57,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
if (formData.value?.row?.id && !submitForm.id) {
|
||||
submitForm.id = formData.value.row.id;
|
||||
}
|
||||
// TODO @hw:antd 和 ele 存在差异
|
||||
const reply = submitForm.reply as Reply | undefined;
|
||||
const reply = submitForm.reply as Reply;
|
||||
if (reply) {
|
||||
submitForm.responseMessageType = reply.type;
|
||||
submitForm.responseContent = reply.content;
|
||||
@@ -99,8 +96,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
// 加载数据
|
||||
const data = modalApi.getData<{
|
||||
accountId?: number;
|
||||
// TODO @hw:antd 和 ele 存在差异
|
||||
msgType: MsgType;
|
||||
msgType: AutoReplyMsgType;
|
||||
row?: any;
|
||||
}>();
|
||||
if (!data) {
|
||||
@@ -115,20 +111,22 @@ const [Modal, modalApi] = useVbenModal({
|
||||
if (data.row?.id) {
|
||||
// 编辑:加载数据
|
||||
const rowData = data.row;
|
||||
const formValues: any = { ...rowData };
|
||||
formValues.reply = {
|
||||
type: rowData.responseMessageType,
|
||||
accountId: data.accountId || -1,
|
||||
content: rowData.responseContent,
|
||||
mediaId: rowData.responseMediaId,
|
||||
url: rowData.responseMediaUrl,
|
||||
title: rowData.responseTitle,
|
||||
description: rowData.responseDescription,
|
||||
thumbMediaId: rowData.responseThumbMediaId,
|
||||
thumbMediaUrl: rowData.responseThumbMediaUrl,
|
||||
articles: rowData.responseArticles,
|
||||
musicUrl: rowData.responseMusicUrl,
|
||||
hqMusicUrl: rowData.responseHqMusicUrl,
|
||||
const formValues: any = {
|
||||
...rowData,
|
||||
reply: {
|
||||
type: rowData.responseMessageType,
|
||||
accountId: data.accountId || -1,
|
||||
content: rowData.responseContent,
|
||||
mediaId: rowData.responseMediaId,
|
||||
url: rowData.responseMediaUrl,
|
||||
title: rowData.responseTitle,
|
||||
description: rowData.responseDescription,
|
||||
thumbMediaId: rowData.responseThumbMediaId,
|
||||
thumbMediaUrl: rowData.responseThumbMediaUrl,
|
||||
articles: rowData.responseArticles,
|
||||
musicUrl: rowData.responseMusicUrl,
|
||||
hqMusicUrl: rowData.responseHqMusicUrl,
|
||||
},
|
||||
};
|
||||
await formApi.setValues(formValues);
|
||||
} else {
|
||||
@@ -138,8 +136,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
accountId: data.accountId || -1,
|
||||
type: data.msgType,
|
||||
requestKeyword: undefined,
|
||||
// TODO @hw:antd 和 ele 存在差异
|
||||
requestMatch: data.msgType === MsgType.Keyword ? 1 : undefined,
|
||||
requestMatch: data.msgType === AutoReplyMsgType.Keyword ? 1 : undefined,
|
||||
requestMessageType: undefined,
|
||||
reply: {
|
||||
type: ReplyType.Text,
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// 消息类型(Follow: 关注时回复;Message: 消息回复;Keyword: 关键词回复)
|
||||
// 作为 tab.name,enum 的数字不能随意修改,与 api 参数相关
|
||||
// TODO @hw:ele 相比 antd 多了,看看要不要统一下;
|
||||
export enum MsgType {
|
||||
Follow = 1,
|
||||
Keyword = 3,
|
||||
Message = 2,
|
||||
}
|
||||
@@ -1,22 +1,9 @@
|
||||
export { default as WxAccountSelect } from './wx-account-select/wx-account-select.vue';
|
||||
|
||||
export { default as WxLocation } from './wx-location/wx-location.vue';
|
||||
export * from './wx-material-select/types';
|
||||
|
||||
export { default as WxMaterialSelect } from './wx-material-select/wx-material-select.vue';
|
||||
|
||||
export * from './wx-msg/types';
|
||||
|
||||
export { default as WxMsg } from './wx-msg/msg.vue';
|
||||
export { default as WxMusic } from './wx-music/wx-music.vue';
|
||||
|
||||
export { default as WxNews } from './wx-news/wx-news.vue';
|
||||
|
||||
export * from './wx-reply/types';
|
||||
|
||||
export { default as WxReplySelect } from './wx-reply/wx-reply.vue';
|
||||
|
||||
export { default as WxReply } from './wx-reply/wx-reply.vue';
|
||||
export { default as WxVideoPlayer } from './wx-video-play/wx-video-play.vue';
|
||||
|
||||
export { default as WxVoicePlayer } from './wx-voice-play/wx-voice-play.vue';
|
||||
|
||||
// TODO @hw:是不是要和 antd 保持一致哈?
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default } from './wx-account-select.vue';
|
||||
|
||||
// TODO @hw:每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?
|
||||
@@ -1,129 +1,63 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MpAccountApi } from '#/api/mp/account';
|
||||
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useTabs } from '@vben/hooks';
|
||||
|
||||
import { ElMessage, ElOption, ElSelect } from 'element-plus';
|
||||
|
||||
import { getSimpleAccountList } from '#/api/mp/account';
|
||||
|
||||
// TODO @hw:调整下代码,和 antd 代码风格,尽量保持一致;
|
||||
defineOptions({ name: 'AccountSelect' });
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: number;
|
||||
}>();
|
||||
defineOptions({ name: 'WxAccountSelect' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'change', id: number, name: string): void;
|
||||
(e: 'update:modelValue', id: number): void;
|
||||
}>();
|
||||
|
||||
const message = ElMessage; // 消息弹窗
|
||||
const { closeCurrentTab } = useTabs(); // 视图操作
|
||||
const { push } = useRouter();
|
||||
|
||||
const account: MpAccountApi.AccountSimple = reactive({
|
||||
const account = ref<MpAccountApi.Account>({
|
||||
id: -1,
|
||||
name: '',
|
||||
});
|
||||
|
||||
const accountList = ref<MpAccountApi.AccountSimple[]>([]);
|
||||
|
||||
// 计算当前选中的 ID,优先使用 modelValue(表单绑定),否则使用内部 account.id
|
||||
const currentId = computed({
|
||||
get: () => {
|
||||
// 如果外部传入了 modelValue,优先使用外部的值
|
||||
if (props.modelValue !== undefined && props.modelValue !== null) {
|
||||
return props.modelValue;
|
||||
}
|
||||
return account.id;
|
||||
},
|
||||
set: (value: number) => {
|
||||
// 更新内部状态
|
||||
account.id = value;
|
||||
// 同步到外部(表单系统)
|
||||
emit('update:modelValue', value);
|
||||
// 触发 change 事件(保持向后兼容)
|
||||
const found = accountList.value.find(
|
||||
(v: MpAccountApi.AccountSimple) => v.id === value,
|
||||
);
|
||||
if (found) {
|
||||
account.name = found.name;
|
||||
emit('change', value, found.name);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 监听外部 modelValue 变化,同步到内部状态
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (
|
||||
newValue !== undefined &&
|
||||
newValue !== null &&
|
||||
newValue !== account.id
|
||||
) {
|
||||
account.id = newValue;
|
||||
const found = accountList.value.find(
|
||||
(v: MpAccountApi.AccountSimple) => v.id === newValue,
|
||||
);
|
||||
if (found) {
|
||||
account.name = found.name;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}); // 当前选中的公众号
|
||||
const accountList = ref<MpAccountApi.Account[]>([]); // 公众号列表
|
||||
|
||||
/** 查询公众号列表 */
|
||||
async function handleQuery() {
|
||||
accountList.value = await getSimpleAccountList();
|
||||
if (accountList.value.length === 0) {
|
||||
message.error('未配置公众号,请在【公众号管理 -> 账号管理】菜单,进行配置');
|
||||
await closeCurrentTab();
|
||||
ElMessage.error(
|
||||
'未配置公众号,请在【公众号管理 -> 账号管理】菜单,进行配置',
|
||||
);
|
||||
await push({ name: 'MpAccount' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果外部没有传入值(modelValue 为空),默认选中第一个
|
||||
if (props.modelValue === undefined || props.modelValue === null) {
|
||||
const firstAccount = accountList.value[0];
|
||||
if (firstAccount) {
|
||||
currentId.value = firstAccount.id;
|
||||
account.name = firstAccount.name;
|
||||
emit('change', firstAccount.id, firstAccount.name);
|
||||
}
|
||||
} else {
|
||||
// 如果外部有值,同步到内部状态
|
||||
const found = accountList.value.find(
|
||||
(v: MpAccountApi.AccountSimple) => v.id === props.modelValue,
|
||||
);
|
||||
if (found) {
|
||||
account.id = props.modelValue;
|
||||
account.name = found.name;
|
||||
}
|
||||
// 默认选中第一个,如无数据则不执行
|
||||
const first = accountList.value[0];
|
||||
if (first) {
|
||||
account.value.id = first.id;
|
||||
account.value.name = first.name;
|
||||
emit('change', account.value.id, account.value.name);
|
||||
}
|
||||
}
|
||||
|
||||
/** 公众号变化 */
|
||||
function onChanged(id?: number) {
|
||||
if (id) {
|
||||
currentId.value = id;
|
||||
/** 切换选中公众号 */
|
||||
function onChanged(id: number) {
|
||||
const found = accountList.value.find((v) => v.id === id);
|
||||
if (found) {
|
||||
account.value.name = found.name;
|
||||
emit('change', account.value.id, account.value.name);
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
handleQuery();
|
||||
});
|
||||
onMounted(handleQuery);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSelect
|
||||
v-model="currentId"
|
||||
v-model="account.id"
|
||||
placeholder="请选择公众号"
|
||||
class="!w-full"
|
||||
@change="onChanged"
|
||||
@@ -136,8 +70,3 @@ onMounted(() => {
|
||||
/>
|
||||
</ElSelect>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-select__wrapper) {
|
||||
width: 240px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default as WxLocation } from './wx-location.vue';
|
||||
|
||||
// TODO @hw:每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface WxLocationProps {
|
||||
label: string;
|
||||
locationX: number;
|
||||
locationY: number;
|
||||
qqMapKey?: string;
|
||||
}
|
||||
@@ -1,65 +1,67 @@
|
||||
<!--
|
||||
【微信消息 - 定位】TODO @Dhb52 目前未启用;;;;@hw:看看目前是不是没用起来哈?
|
||||
-->
|
||||
<script lang="ts" setup>
|
||||
import type { WxLocationProps } from './types';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
// TODO @dylan:@hw:apps/web-antd/src/views/mall/trade/delivery/pickUpStore/modules/form.vue 参考这个,从后端拿 key 哈
|
||||
import { ElCol, ElLink, ElRow } from 'element-plus';
|
||||
import { ElCol, ElLink, ElMessage, ElRow } from 'element-plus';
|
||||
|
||||
import { getTradeConfig } from '#/api/mall/trade/config';
|
||||
|
||||
/** 微信消息 - 定位 */
|
||||
defineOptions({ name: 'Location' });
|
||||
|
||||
// TODO @hw:antd 和 ele 这里的风格,看看怎么统一!
|
||||
const props = defineProps({
|
||||
locationX: {
|
||||
required: true,
|
||||
type: Number,
|
||||
},
|
||||
locationY: {
|
||||
required: true,
|
||||
type: Number,
|
||||
},
|
||||
label: {
|
||||
// 地名
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
qqMapKey: {
|
||||
// QQ 地图的密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc
|
||||
required: false,
|
||||
type: String,
|
||||
default: 'TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E', // 需要自定义
|
||||
},
|
||||
const props = defineProps<WxLocationProps>();
|
||||
|
||||
const fetchedQqMapKey = ref('');
|
||||
const resolvedQqMapKey = computed(
|
||||
() => props.qqMapKey || fetchedQqMapKey.value || '',
|
||||
);
|
||||
|
||||
const mapUrl = computed(() => {
|
||||
return `https://map.qq.com/?type=marker&isopeninfowin=1&markertype=1&pointx=${props.locationY}&pointy=${props.locationX}&name=${props.label}&ref=yudao`;
|
||||
});
|
||||
|
||||
const mapImageUrl = computed(() => {
|
||||
return `https://apis.map.qq.com/ws/staticmap/v2/?zoom=10&markers=color:blue|label:A|${props.locationX},${props.locationY}&key=${resolvedQqMapKey.value}&size=250*180`;
|
||||
});
|
||||
|
||||
async function fetchQqMapKey() {
|
||||
try {
|
||||
const data = await getTradeConfig();
|
||||
fetchedQqMapKey.value = data.tencentLbsKey ?? '';
|
||||
if (!fetchedQqMapKey.value) {
|
||||
ElMessage.warning('请先配置腾讯位置服务密钥');
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('获取腾讯位置服务密钥失败');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.qqMapKey) {
|
||||
await fetchQqMapKey();
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
locationX: props.locationX,
|
||||
locationY: props.locationY,
|
||||
label: props.label,
|
||||
qqMapKey: props.qqMapKey,
|
||||
qqMapKey: resolvedQqMapKey,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 微信消息 - 定位 -->
|
||||
<div>
|
||||
<ElLink
|
||||
type="primary"
|
||||
target="_blank"
|
||||
:href="`https://map.qq.com/?type=marker&isopeninfowin=1&markertype=1&pointx=${
|
||||
locationY
|
||||
}&pointy=${locationX}&name=${label}&ref=yudao`"
|
||||
>
|
||||
<ElLink type="primary" target="_blank" :href="mapUrl">
|
||||
<ElCol>
|
||||
<ElRow>
|
||||
<img
|
||||
:src="`https://apis.map.qq.com/ws/staticmap/v2/?zoom=10&markers=color:blue|label:A|${
|
||||
locationX
|
||||
},${locationY}&key=${qqMapKey}&size=250*180`"
|
||||
/>
|
||||
<img :src="mapImageUrl" alt="地图位置" />
|
||||
</ElRow>
|
||||
<ElRow>
|
||||
<IconifyIcon icon="ep:location" />
|
||||
<IconifyIcon icon="lucide:map-pin" />
|
||||
{{ label }}
|
||||
</ElRow>
|
||||
</ElCol>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export { MaterialType, NewsType } from './types';
|
||||
|
||||
export { default } from './wx-material-select.vue';
|
||||
|
||||
// TODO @hw:每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?
|
||||
@@ -1,12 +0,0 @@
|
||||
// TODO @hw:这里的枚举,看看要不要统一
|
||||
export enum NewsType {
|
||||
Draft = '2',
|
||||
Published = '1',
|
||||
}
|
||||
|
||||
export enum MaterialType {
|
||||
Image = 'image',
|
||||
News = 'news',
|
||||
Video = 'video',
|
||||
Voice = 'voice',
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { NewsType } from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { formatTime } from '@vben/utils';
|
||||
|
||||
@@ -19,12 +20,10 @@ import News from '#/views/mp/components/wx-news/wx-news.vue';
|
||||
import VideoPlayer from '#/views/mp/components/wx-video-play/wx-video-play.vue';
|
||||
import VoicePlayer from '#/views/mp/components/wx-voice-play/wx-voice-play.vue';
|
||||
|
||||
import { NewsType } from './types';
|
||||
|
||||
// TODO @hw:代码风格,看看 antd 和 ele 是不是统一下;
|
||||
// TODO @hw:代码风格,看看 antd 和 ele 是不是统一下; 等antd此组件修改完再调整
|
||||
|
||||
/** 微信素材选择 */
|
||||
defineOptions({ name: 'MaterialSelect' });
|
||||
defineOptions({ name: 'WxMaterialSelect' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -37,15 +36,17 @@ const props = withDefaults(
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits(['selectMaterial']);
|
||||
const emit = defineEmits<{
|
||||
(e: 'selectMaterial', item: any): void;
|
||||
}>();
|
||||
|
||||
const loading = ref(false); // 遮罩层
|
||||
const total = ref(0); // 总条数
|
||||
const list = ref<any[]>([]); // 数据列表
|
||||
const queryParams = reactive({
|
||||
accountId: props.accountId,
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
accountId: props.accountId,
|
||||
}); // 查询参数
|
||||
|
||||
/** 选择素材 */
|
||||
@@ -117,14 +118,23 @@ onMounted(async () => {
|
||||
<div class="pb-30px">
|
||||
<!-- 类型:image -->
|
||||
<div v-if="props.type === 'image'">
|
||||
<div class="waterfall" v-loading="loading">
|
||||
<div class="waterfall-item" v-for="item in list" :key="item.mediaId">
|
||||
<img class="material-img" :src="item.url" />
|
||||
<p class="item-name">{{ item.name }}</p>
|
||||
<ElRow class="ope-row">
|
||||
<div
|
||||
class="mx-auto w-full columns-1 [column-gap:10px] md:columns-2 lg:columns-3 xl:columns-4 2xl:columns-5"
|
||||
v-loading="loading"
|
||||
>
|
||||
<div
|
||||
class="mb-2.5 break-inside-avoid border border-[#eaeaea] p-2.5"
|
||||
v-for="item in list"
|
||||
:key="item.mediaId"
|
||||
>
|
||||
<img class="w-full" :src="item.url" />
|
||||
<p class="truncate text-center text-xs leading-[30px]">
|
||||
{{ item.name }}
|
||||
</p>
|
||||
<ElRow class="flex justify-center pt-2.5">
|
||||
<ElButton type="success" @click="selectMaterialFun(item)">
|
||||
选择
|
||||
<IconifyIcon icon="ep:circle-check" />
|
||||
<IconifyIcon icon="lucide:circle-check" />
|
||||
</ElButton>
|
||||
</ElRow>
|
||||
</div>
|
||||
@@ -164,7 +174,7 @@ onMounted(async () => {
|
||||
<template #default="scope">
|
||||
<ElButton type="primary" link @click="selectMaterialFun(scope.row)">
|
||||
选择
|
||||
<IconifyIcon icon="ep:plus" />
|
||||
<IconifyIcon icon="lucide:plus" />
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
@@ -211,7 +221,7 @@ onMounted(async () => {
|
||||
<template #default="scope">
|
||||
<ElButton type="primary" link @click="selectMaterialFun(scope.row)">
|
||||
选择
|
||||
<IconifyIcon icon="akar-icons:circle-plus" />
|
||||
<IconifyIcon icon="lucide:circle-plus" />
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
@@ -229,14 +239,21 @@ onMounted(async () => {
|
||||
</div>
|
||||
<!-- 类型:news -->
|
||||
<div v-else-if="props.type === 'news'">
|
||||
<div class="waterfall" v-loading="loading">
|
||||
<div class="waterfall-item" v-for="item in list" :key="item.mediaId">
|
||||
<div
|
||||
class="mx-auto w-full columns-1 [column-gap:10px] md:columns-2 lg:columns-3 xl:columns-4 2xl:columns-5"
|
||||
v-loading="loading"
|
||||
>
|
||||
<div
|
||||
class="mb-2.5 break-inside-avoid border border-[#eaeaea] p-2.5"
|
||||
v-for="item in list"
|
||||
:key="item.mediaId"
|
||||
>
|
||||
<div v-if="item.content && item.content.newsItem">
|
||||
<News :articles="item.content.newsItem" />
|
||||
<ElRow class="ope-row">
|
||||
<ElRow class="flex justify-center pt-2.5">
|
||||
<ElButton type="success" @click="selectMaterialFun(item)">
|
||||
选择
|
||||
<IconifyIcon icon="ep:circle-check" />
|
||||
<IconifyIcon icon="lucide:circle-check" />
|
||||
</ElButton>
|
||||
</ElRow>
|
||||
</div>
|
||||
@@ -255,54 +272,3 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
@media (width >= 992px) and (width <= 1300px) {
|
||||
.waterfall {
|
||||
column-count: 3;
|
||||
}
|
||||
|
||||
p {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 768px) and (width <= 991px) {
|
||||
.waterfall {
|
||||
column-count: 2;
|
||||
}
|
||||
|
||||
p {
|
||||
color: orange;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 767px) {
|
||||
.waterfall {
|
||||
column-count: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** TODO @dylan:@hw:看看有没适合 tindwind 的哈。 */
|
||||
|
||||
.waterfall {
|
||||
column-gap: 10px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
column-count: 5;
|
||||
}
|
||||
|
||||
.waterfall-item {
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #eaeaea;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.material-img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 30px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export { MsgType } from './types';
|
||||
|
||||
export { default } from './wx-msg.vue';
|
||||
|
||||
// TODO @hw:每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?
|
||||
@@ -1,14 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { ElTag } from 'element-plus';
|
||||
|
||||
const props = defineProps<{
|
||||
defineOptions({ name: 'MsgEvent' });
|
||||
|
||||
defineProps<{
|
||||
item: any;
|
||||
}>();
|
||||
|
||||
// TODO @hw:看看用 antd 的风格,还是 ele 的风格,就是下面的 item。
|
||||
const item = ref(props.item);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import type { User } from '../types';
|
||||
import type { MpUserApi } from '#/api/mp/user/index';
|
||||
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { formatDateTime } from '@vben/utils';
|
||||
|
||||
import avatarWechat from '#/assets/imgs/wechat.png';
|
||||
|
||||
import Msg from './wx-msg.vue';
|
||||
|
||||
// 确保 User 类型被识别为已使用
|
||||
// TODO @hw:是不是不用 PropsUser 哈?
|
||||
type PropsUser = User;
|
||||
import Msg from './msg.vue';
|
||||
|
||||
defineOptions({ name: 'MsgList' });
|
||||
|
||||
const props = defineProps<{
|
||||
accountId: number;
|
||||
list: any[];
|
||||
user: PropsUser;
|
||||
user: Partial<MpUserApi.User>;
|
||||
}>();
|
||||
|
||||
const SendFrom = {
|
||||
@@ -24,46 +19,58 @@ const SendFrom = {
|
||||
User: 1,
|
||||
} as const; // 发送来源
|
||||
|
||||
// TODO @hw:是不是用 SendFrom ,或者 number?
|
||||
type SendFromType = (typeof SendFrom)[keyof typeof SendFrom];
|
||||
function getAvatar(sendFrom: number) {
|
||||
return sendFrom === SendFrom.User
|
||||
? props.user.avatar
|
||||
: preferences.app.defaultAvatar;
|
||||
}
|
||||
|
||||
// 显式引用枚举成员供模板使用
|
||||
// TODO @hw:是不是用 SendFrom 就好啦?
|
||||
const MpBotValue = SendFrom.MpBot;
|
||||
const UserValue = SendFrom.User;
|
||||
|
||||
const getAvatar = (sendFrom: SendFromType) =>
|
||||
sendFrom === UserValue ? props.user.avatar : avatarWechat;
|
||||
|
||||
const getNickname = (sendFrom: SendFromType) =>
|
||||
sendFrom === UserValue ? props.user.nickname : '公众号';
|
||||
function getNickname(sendFrom: number) {
|
||||
return sendFrom === SendFrom.User ? props.user.nickname : '公众号';
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div v-for="item in props.list" :key="item.id">
|
||||
<div class="execution" v-for="item in props.list" :key="item.id">
|
||||
<div
|
||||
class="mb-[30px] flex items-start"
|
||||
:class="{ 'flex-row-reverse': item.sendFrom === MpBotValue }"
|
||||
:class="{ 'flex-row-reverse': item.sendFrom === SendFrom.MpBot }"
|
||||
>
|
||||
<div class="w-20 text-center">
|
||||
<div class="flex w-20 flex-col items-center text-center">
|
||||
<img
|
||||
:src="getAvatar(item.sendFrom)"
|
||||
class="box-border h-12 w-12 rounded-full border border-transparent align-middle"
|
||||
class="mb-2 h-12 w-12 rounded-full border border-transparent object-cover"
|
||||
/>
|
||||
<div class="text-sm font-bold text-[#999]">
|
||||
<div class="text-sm font-semibold text-[#999]">
|
||||
{{ getNickname(item.sendFrom) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative mx-5 flex-1 rounded-[5px] border border-[#dedede]">
|
||||
<div class="relative mx-2 flex-1 rounded-[5px] border border-[#dedede]">
|
||||
<span
|
||||
class="pointer-events-none absolute -left-2 top-[10px] h-0 w-0 border-y-[8px] border-r-[8px] border-y-transparent border-r-[#dedede]"
|
||||
:class="{
|
||||
'-right-2 left-auto border-l-[8px] border-r-0 border-l-[#dedede]':
|
||||
item.sendFrom === SendFrom.MpBot,
|
||||
}"
|
||||
></span>
|
||||
<span
|
||||
class="pointer-events-none absolute -left-[7px] top-[10px] h-0 w-0 border-y-[8px] border-r-[8px] border-y-transparent border-r-[#f8f8f8]"
|
||||
:class="{
|
||||
'-right-[7px] left-auto border-l-[8px] border-r-0 border-l-[#f8f8f8]':
|
||||
item.sendFrom === SendFrom.MpBot,
|
||||
}"
|
||||
></span>
|
||||
<div
|
||||
class="flex items-center justify-between rounded-t-[5px] border-b border-[#eee] bg-[#f8f8f8] px-[15px] py-[5px]"
|
||||
>
|
||||
<div class="mp-comment__create_time">
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ formatDateTime(item.createTime) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="overflow-hidden rounded-b-[5px] bg-white px-[15px] py-[15px] text-sm text-[#333]"
|
||||
:style="item.sendFrom === MpBotValue ? 'background: #6BED72;' : ''"
|
||||
class="overflow-hidden rounded-b-[5px] p-[15px] text-sm text-[#333]"
|
||||
:class="
|
||||
item.sendFrom === SendFrom.MpBot ? 'bg-[#6BED72]' : 'bg-white'
|
||||
"
|
||||
>
|
||||
<Msg :item="item" />
|
||||
</div>
|
||||
@@ -71,11 +78,3 @@ const getNickname = (sendFrom: SendFromType) =>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 因为 joolun 实现依赖 avue 组件,该页面使用了 comment.scss、card.scc */
|
||||
/** TODO @dylan:@hw 看看有没适合 tindwind 的哈。 */
|
||||
|
||||
@import url('../comment.scss');
|
||||
@import url('../card.scss');
|
||||
</style>
|
||||
|
||||
92
apps/web-ele/src/views/mp/components/wx-msg/msg.vue
Normal file
92
apps/web-ele/src/views/mp/components/wx-msg/msg.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts" setup>
|
||||
import { MpMsgType } from '@vben/constants';
|
||||
|
||||
import {
|
||||
WxLocation,
|
||||
WxMusic,
|
||||
WxNews,
|
||||
WxVideoPlayer,
|
||||
WxVoicePlayer,
|
||||
} from '#/views/mp/components';
|
||||
|
||||
import MsgEvent from './msg-event.vue';
|
||||
|
||||
defineOptions({ name: 'WxMsg' });
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
item?: any;
|
||||
}>(),
|
||||
{
|
||||
item: {},
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<MsgEvent v-if="item.type === MpMsgType.Event" :item="item" />
|
||||
|
||||
<div v-else-if="item.type === MpMsgType.Text">{{ item.content }}</div>
|
||||
|
||||
<div v-else-if="item.type === MpMsgType.Voice">
|
||||
<WxVideoPlayer :url="item.mediaUrl" :content="item.recognition" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="item.type === MpMsgType.Image">
|
||||
<a target="_blank" :href="item.mediaUrl">
|
||||
<img :src="item.mediaUrl" class="w-[100px]" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="item.type === MpMsgType.Video || item.type === 'shortvideo'"
|
||||
class="text-center"
|
||||
>
|
||||
<WxVoicePlayer :url="item.mediaUrl" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="item.type === MpMsgType.Link" class="flex-1">
|
||||
<el-link
|
||||
type="success"
|
||||
:underline="false"
|
||||
target="_blank"
|
||||
:href="item.url"
|
||||
>
|
||||
<div
|
||||
class="mb-3 text-base text-[rgba(0,0,0,0.85)] hover:text-[#1890ff]"
|
||||
>
|
||||
<i class="el-icon-link"></i>{{ item.title }}
|
||||
</div>
|
||||
</el-link>
|
||||
<div
|
||||
class="h-auto overflow-hidden text-[rgba(0,0,0,0.45)]"
|
||||
style="height: unset"
|
||||
>
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="item.type === MpMsgType.Location">
|
||||
<WxLocation
|
||||
:label="item.label"
|
||||
:location-y="item.locationY"
|
||||
:location-x="item.locationX"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="item.type === MpMsgType.News" class="w-[300px]">
|
||||
<WxNews :articles="item.articles" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="item.type === MpMsgType.Music">
|
||||
<WxMusic
|
||||
:title="item.title"
|
||||
:description="item.description"
|
||||
:thumb-media-url="item.thumbMediaUrl"
|
||||
:music-url="item.musicUrl"
|
||||
:hq-music-url="item.hqMusicUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,19 +0,0 @@
|
||||
// TODO @hw:是不是放枚举里?
|
||||
export enum MsgType {
|
||||
Event = 'event',
|
||||
Image = 'image',
|
||||
Link = 'link',
|
||||
Location = 'location',
|
||||
Music = 'music',
|
||||
News = 'news',
|
||||
Text = 'text',
|
||||
Video = 'video',
|
||||
Voice = 'voice',
|
||||
}
|
||||
|
||||
// TODO @hw:用 MpUserApi 里的 user 可以么?
|
||||
export interface User {
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
accountId: number;
|
||||
}
|
||||
@@ -1,90 +1,176 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import type { MpUserApi } from '#/api/mp/user/index';
|
||||
|
||||
import Location from '#/views/mp/components/wx-location/wx-location.vue';
|
||||
import Music from '#/views/mp/components/wx-music/wx-music.vue';
|
||||
import News from '#/views/mp/components/wx-news/wx-news.vue';
|
||||
import VideoPlayer from '#/views/mp/components/wx-video-play/wx-video-play.vue';
|
||||
import VoicePlayer from '#/views/mp/components/wx-voice-play/wx-voice-play.vue';
|
||||
import { nextTick, onMounted, reactive, ref, unref } from 'vue';
|
||||
|
||||
import { MsgType } from '../types';
|
||||
import MsgEvent from './msg-event.vue';
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
defineOptions({ name: 'Msg' });
|
||||
import { ElButton, ElMessage } from 'element-plus';
|
||||
|
||||
// TODO @hw:这个貌似和 antd 的差很多?
|
||||
import { getMessagePage, sendMessage } from '#/api/mp/message';
|
||||
import { getUser } from '#/api/mp/user';
|
||||
import { WxReply } from '#/views/mp/components';
|
||||
|
||||
import MsgList from './msg-list.vue';
|
||||
|
||||
defineOptions({ name: 'WxMsg' });
|
||||
|
||||
const props = defineProps<{
|
||||
item: any;
|
||||
userId: number;
|
||||
}>();
|
||||
|
||||
const item = ref<any>(props.item);
|
||||
const accountId = ref(-1); // 公众号ID,需要通过userId初始化
|
||||
const loading = ref(false); // 消息列表是否正在加载中
|
||||
const hasMore = ref(true); // 是否可以加载更多
|
||||
const list = ref<any[]>([]); // 消息列表
|
||||
const queryParams = reactive({
|
||||
accountId,
|
||||
pageNo: 1, // 当前页数
|
||||
pageSize: 14, // 每页显示多少条
|
||||
});
|
||||
|
||||
const user: Partial<MpUserApi.User> = reactive({
|
||||
accountId, // 公众号账号编号
|
||||
avatar: preferences.app.defaultAvatar,
|
||||
nickname: '用户', // 由于微信不再提供昵称,直接使用"用户"展示
|
||||
});
|
||||
|
||||
// ========= 消息发送 =========
|
||||
const sendLoading = ref(false); // 发送消息是否加载中
|
||||
const reply = ref<any>({
|
||||
accountId: -1,
|
||||
articles: [],
|
||||
type: 'text',
|
||||
}); // 微信发送消息
|
||||
|
||||
const replySelectRef = ref<InstanceType<typeof WxReply> | null>(null); // WxReply组件ref,用于消息发送成功后清除内容
|
||||
const msgDivRef = ref<HTMLDivElement | null>(null); // 消息显示窗口ref,用于滚动到底部
|
||||
|
||||
/** 完成加载 */
|
||||
onMounted(async () => {
|
||||
const data = await getUser(props.userId);
|
||||
user.nickname = data.nickname?.length > 0 ? data.nickname : user.nickname;
|
||||
user.avatar = data.avatar?.length > 0 ? data.avatar : user.avatar;
|
||||
accountId.value = data.accountId;
|
||||
reply.value.accountId = data.accountId;
|
||||
|
||||
refreshChange();
|
||||
});
|
||||
|
||||
/** 执行发送 */
|
||||
async function sendMsg() {
|
||||
if (!unref(reply)) {
|
||||
return;
|
||||
}
|
||||
// 公众号限制:客服消息,公众号只允许发送一条
|
||||
if (
|
||||
reply.value.type === 'news' &&
|
||||
reply.value.articles &&
|
||||
reply.value.articles.length > 1
|
||||
) {
|
||||
reply.value.articles = [reply.value.articles[0]];
|
||||
ElMessage.success('图文消息条数限制在 1 条以内,已默认发送第一条');
|
||||
}
|
||||
|
||||
const data = await sendMessage({
|
||||
...reply.value,
|
||||
userId: props.userId,
|
||||
} as any);
|
||||
sendLoading.value = false;
|
||||
|
||||
list.value = [...list.value, data];
|
||||
await scrollToBottom();
|
||||
|
||||
// 发送后清空数据
|
||||
replySelectRef.value?.clear();
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
queryParams.pageNo++;
|
||||
getPage(queryParams, null);
|
||||
}
|
||||
|
||||
async function getPage(page: any, params: any = null) {
|
||||
loading.value = true;
|
||||
const dataTemp = await getMessagePage(
|
||||
Object.assign(
|
||||
{
|
||||
accountId: page.accountId,
|
||||
pageNo: page.pageNo,
|
||||
pageSize: page.pageSize,
|
||||
userId: props.userId,
|
||||
},
|
||||
params,
|
||||
),
|
||||
);
|
||||
|
||||
const scrollHeight = msgDivRef.value?.scrollHeight ?? 0;
|
||||
// 处理数据
|
||||
const data = dataTemp.list.toReversed();
|
||||
list.value = [...data, ...list.value];
|
||||
loading.value = false;
|
||||
if (data.length < queryParams.pageSize || data.length === 0) {
|
||||
hasMore.value = false;
|
||||
}
|
||||
queryParams.pageNo = page.pageNo;
|
||||
queryParams.pageSize = page.pageSize;
|
||||
// 滚动到原来的位置
|
||||
if (queryParams.pageNo === 1) {
|
||||
// 定位到消息底部
|
||||
await scrollToBottom();
|
||||
} else if (data.length > 0) {
|
||||
// 定位滚动条
|
||||
await nextTick();
|
||||
if (scrollHeight !== 0 && msgDivRef.value) {
|
||||
msgDivRef.value.scrollTop =
|
||||
msgDivRef.value.scrollHeight - scrollHeight - 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refreshChange() {
|
||||
getPage(queryParams);
|
||||
}
|
||||
|
||||
/** 定位到消息底部 */
|
||||
async function scrollToBottom() {
|
||||
await nextTick();
|
||||
if (msgDivRef.value) {
|
||||
msgDivRef.value.scrollTop = msgDivRef.value.scrollHeight;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<MsgEvent v-if="item.type === MsgType.Event" :item="item" />
|
||||
|
||||
<div v-else-if="item.type === MsgType.Text">{{ item.content }}</div>
|
||||
|
||||
<div v-else-if="item.type === MsgType.Voice">
|
||||
<VoicePlayer :url="item.mediaUrl" :content="item.recognition" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="item.type === MsgType.Image">
|
||||
<a target="_blank" :href="item.mediaUrl">
|
||||
<img :src="item.mediaUrl" class="w-[100px]" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="item.type === MsgType.Video || item.type === 'shortvideo'"
|
||||
class="text-center"
|
||||
>
|
||||
<VideoPlayer :url="item.mediaUrl" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="item.type === MsgType.Link" class="flex-1">
|
||||
<el-link
|
||||
type="success"
|
||||
:underline="false"
|
||||
target="_blank"
|
||||
:href="item.url"
|
||||
>
|
||||
<div class="flex h-full flex-col">
|
||||
<div ref="msgDivRef" class="mx-2.5 flex-1 overflow-auto bg-[#eaeaea]">
|
||||
<!-- 加载更多 -->
|
||||
<div v-loading="loading"></div>
|
||||
<div v-if="!loading">
|
||||
<div
|
||||
class="mb-3 text-base text-[rgba(0,0,0,0.85)] hover:text-[#1890ff]"
|
||||
v-if="hasMore"
|
||||
class="cursor-pointer rounded p-3 text-center text-sm text-[#409eff] transition-colors duration-300 hover:bg-[#f5f7fa]"
|
||||
@click="loadMore"
|
||||
>
|
||||
<i class="el-icon-link"></i>{{ item.title }}
|
||||
<span>点击加载更多</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="cursor-not-allowed rounded p-3 text-center text-sm text-[#909399] hover:bg-transparent"
|
||||
>
|
||||
<span>没有更多了</span>
|
||||
</div>
|
||||
</el-link>
|
||||
<div
|
||||
class="h-auto overflow-hidden text-[rgba(0,0,0,0.45)]"
|
||||
style="height: unset"
|
||||
>
|
||||
{{ item.description }}
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<MsgList :list="list" :account-id="accountId" :user="user" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="item.type === MsgType.Location">
|
||||
<Location
|
||||
:label="item.label"
|
||||
:location-y="item.locationY"
|
||||
:location-x="item.locationX"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="item.type === MsgType.News" class="w-[300px]">
|
||||
<News :articles="item.articles" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="item.type === MsgType.Music">
|
||||
<Music
|
||||
:title="item.title"
|
||||
:description="item.description"
|
||||
:thumb-media-url="item.thumbMediaUrl"
|
||||
:music-url="item.musicUrl"
|
||||
:hq-music-url="item.hqMusicUrl"
|
||||
/>
|
||||
<div class="p-2.5" v-loading="sendLoading">
|
||||
<WxReply ref="replySelectRef" v-model="reply" />
|
||||
<ElButton type="primary" class="float-right mb-2 mt-2" @click="sendMsg">
|
||||
发送(S)
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default } from './wx-music.vue';
|
||||
|
||||
// TODO @hw:每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?
|
||||
7
apps/web-ele/src/views/mp/components/wx-music/types.ts
Normal file
7
apps/web-ele/src/views/mp/components/wx-music/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface WxMusicProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
musicUrl?: string;
|
||||
hqMusicUrl?: string;
|
||||
thumbMediaUrl: string;
|
||||
}
|
||||
@@ -1,34 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
/** 微信消息 - 音乐 */
|
||||
defineOptions({ name: 'Music' });
|
||||
import type { WxMusicProps } from './types';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
description: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
musicUrl: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hqMusicUrl: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
thumbMediaUrl: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ElLink } from 'element-plus';
|
||||
|
||||
/** 微信消息 - 音乐 */
|
||||
defineOptions({ name: 'WxMusic' });
|
||||
|
||||
const props = withDefaults(defineProps<WxMusicProps>(), {
|
||||
title: '',
|
||||
description: '',
|
||||
musicUrl: '',
|
||||
hqMusicUrl: '',
|
||||
thumbMediaUrl: '',
|
||||
});
|
||||
|
||||
const href = computed(() => props.hqMusicUrl || props.musicUrl);
|
||||
|
||||
defineExpose({
|
||||
musicUrl: props.musicUrl,
|
||||
});
|
||||
@@ -36,33 +25,30 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- TODO @hw:ElLink -->
|
||||
<el-link
|
||||
<ElLink
|
||||
type="success"
|
||||
:underline="false"
|
||||
target="_blank"
|
||||
:href="hqMusicUrl ? hqMusicUrl : musicUrl"
|
||||
:href="href"
|
||||
class="block"
|
||||
>
|
||||
<div class="mp-card__body bg-background rounded-sm p-2.5">
|
||||
<div class="mp-card__avatar">
|
||||
<img :src="thumbMediaUrl" alt="" />
|
||||
<div
|
||||
class="flex items-center rounded-sm border border-[#e8e8e8] bg-background p-4 transition hover:border-black/10 hover:shadow-sm"
|
||||
>
|
||||
<div
|
||||
class="mr-3 h-12 w-12 overflow-hidden rounded-full border border-transparent"
|
||||
>
|
||||
<img :src="thumbMediaUrl" alt="" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
<div class="mp-card__detail">
|
||||
<div class="mp-card__title" style="margin-bottom: unset">
|
||||
<div class="flex-1">
|
||||
<div class="mb-3 text-base font-medium text-[#000000d9]">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="mp-card__info" style="height: unset">
|
||||
<div class="line-clamp-3 h-16 overflow-hidden text-sm text-black/45">
|
||||
{{ description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-link>
|
||||
</ElLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/** TODO @dylan:@hw:看看有没适合 tindwind 的哈。 */
|
||||
|
||||
/* 因为 joolun 实现依赖 avue 组件,该页面使用了 card.scss */
|
||||
@import url('../wx-msg/card.scss');
|
||||
</style>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default as WxNews } from './wx-news.vue';
|
||||
|
||||
// TODO @hw:每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?
|
||||
@@ -1,13 +1,4 @@
|
||||
<!--
|
||||
- Copyright (C) 2018-2019
|
||||
- All rights reserved, Designed By www.joolun.com
|
||||
【微信消息 - 图文】
|
||||
芋道源码:
|
||||
① 代码优化,补充注释,提升阅读性
|
||||
-->
|
||||
<script lang="ts" setup>
|
||||
import { ElImage } from 'element-plus';
|
||||
|
||||
/** 微信消息 - 图文 */
|
||||
defineOptions({ name: 'WxNews' });
|
||||
|
||||
@@ -26,17 +17,21 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="news-home">
|
||||
<div class="mx-auto flex w-full flex-col gap-[10px] bg-white">
|
||||
<div v-for="(article, index) in articles" :key="index" class="news-div">
|
||||
<!-- 头条 -->
|
||||
<a v-if="index === 0" :href="article.url" target="_blank">
|
||||
<div class="news-main">
|
||||
<div class="news-content flex items-center justify-center">
|
||||
<ElImage
|
||||
:src="article.picUrl || article.thumbUrl"
|
||||
class="material-img"
|
||||
<div class="mx-auto w-full">
|
||||
<div class="relative w-full bg-[#acadae]">
|
||||
<img
|
||||
:src="article.picUrl"
|
||||
:preview="false"
|
||||
class="flex w-[100%] items-center justify-center object-cover"
|
||||
/>
|
||||
<div class="news-content-title">
|
||||
<div
|
||||
class="absolute bottom-0 left-0 ml-[10px] inline-block w-[98%] whitespace-normal p-[1%] text-base text-white"
|
||||
style="box-sizing: unset !important"
|
||||
>
|
||||
<span>{{ article.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,14 +39,15 @@ defineExpose({
|
||||
</a>
|
||||
<!-- 二条/三条等等 -->
|
||||
<a v-else :href="article.url" target="_blank">
|
||||
<div class="news-main-item">
|
||||
<div class="news-content-item">
|
||||
<div class="news-content-item-title">{{ article.title }}</div>
|
||||
<div class="news-content-item-img flex items-center justify-center">
|
||||
<div class="bg-white">
|
||||
<div class="relative box-border p-[10px]">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1 text-sm">{{ article.title }}</div>
|
||||
|
||||
<img
|
||||
:src="article.picUrl || article.thumbUrl"
|
||||
class="material-img"
|
||||
height="100%"
|
||||
:src="article.picUrl"
|
||||
class="h-[70px] w-[70px] object-cover"
|
||||
alt="文章图片"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,71 +56,3 @@ defineExpose({
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/** TODO @dylan:@hw:看看有没适合 tindwind 的哈。 */
|
||||
|
||||
.news-home {
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.news-main {
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.news-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background-color: #acadae;
|
||||
}
|
||||
|
||||
.news-content-title {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
box-sizing: unset !important;
|
||||
display: inline-block;
|
||||
width: 98%;
|
||||
padding: 1%;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
white-space: normal;
|
||||
background-color: black;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.news-main-item {
|
||||
padding: 5px 0;
|
||||
background-color: #fff;
|
||||
border-top: 1px solid #eaeaea;
|
||||
}
|
||||
|
||||
.news-content-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.news-content-item-title {
|
||||
display: inline-block;
|
||||
width: 70%;
|
||||
margin-left: 1%;
|
||||
font-size: 10px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.news-content-item-img {
|
||||
display: inline-block;
|
||||
width: 25%;
|
||||
margin-right: 1%;
|
||||
background-color: #acadae;
|
||||
}
|
||||
|
||||
.material-img {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export { createEmptyReply, type Reply, ReplyType } from './types';
|
||||
|
||||
export { default } from './wx-reply.vue';
|
||||
|
||||
// TODO @hw:每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
import { UploadType, useBeforeUpload } from '#/utils/useUpload';
|
||||
import MaterialSelect from '#/views/mp/components/wx-material-select/wx-material-select.vue';
|
||||
|
||||
defineOptions({ name: 'TabImage' });
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Reply;
|
||||
}>();
|
||||
@@ -28,12 +30,9 @@ const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: Reply): void;
|
||||
}>();
|
||||
|
||||
// TODO @hw:直接用 ElMessage
|
||||
const message = ElMessage;
|
||||
|
||||
const accessStore = useAccessStore();
|
||||
const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`;
|
||||
// TODO @hw:看看要不要和 antd 保持一致的风格;
|
||||
const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` };
|
||||
const HEADERS = { Authorization: `Bearer ${accessStore.accessToken}` };
|
||||
const reply = computed<Reply>({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
@@ -56,7 +55,7 @@ function beforeImageUpload(rawFile: UploadRawFile) {
|
||||
/** 上传成功 */
|
||||
function onUploadSuccess(res: any) {
|
||||
if (res.code !== 0) {
|
||||
message.error(`上传出错:${res.msg}`);
|
||||
ElMessage.error(`上传出错:${res.msg}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -103,7 +102,7 @@ function selectMaterial(item: any) {
|
||||
</p>
|
||||
<ElRow class="pt-[10px] text-center" justify="center">
|
||||
<ElButton type="danger" circle @click="onDelete">
|
||||
<IconifyIcon icon="ep:delete" />
|
||||
<IconifyIcon icon="lucide:trash-2" />
|
||||
</ElButton>
|
||||
</ElRow>
|
||||
</div>
|
||||
@@ -115,7 +114,7 @@ function selectMaterial(item: any) {
|
||||
class="h-[160px] w-[49.5%] border border-[rgb(234,234,234)] py-[50px]"
|
||||
>
|
||||
<ElButton type="success" @click="showDialog = true">
|
||||
素材库选择 <IconifyIcon icon="ep:circle-check" />
|
||||
素材库选择 <IconifyIcon icon="lucide:circle-check" />
|
||||
</ElButton>
|
||||
<ElDialog
|
||||
title="选择图片"
|
||||
@@ -158,4 +157,4 @@ function selectMaterial(item: any) {
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadRawFile } from 'element-plus';
|
||||
|
||||
// TODO @hw:类似 tab-image.vue 的建议
|
||||
import type { Reply } from './types';
|
||||
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
@@ -22,6 +21,8 @@ import {
|
||||
import { UploadType, useBeforeUpload } from '#/utils/useUpload';
|
||||
import MaterialSelect from '#/views/mp/components/wx-material-select/wx-material-select.vue';
|
||||
|
||||
defineOptions({ name: 'TabMusic' });
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Reply;
|
||||
}>();
|
||||
@@ -30,8 +31,6 @@ const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: Reply): void;
|
||||
}>();
|
||||
|
||||
const message = ElMessage;
|
||||
|
||||
const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`;
|
||||
const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` };
|
||||
const reply = computed<Reply>({
|
||||
@@ -56,7 +55,7 @@ function beforeImageUpload(rawFile: UploadRawFile) {
|
||||
/** 上传成功 */
|
||||
function onUploadSuccess(res: any) {
|
||||
if (res.code !== 0) {
|
||||
message.error(`上传出错:${res.msg}`);
|
||||
ElMessage.error(`上传出错:${res.msg}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -72,7 +71,6 @@ function onUploadSuccess(res: any) {
|
||||
/** 选择素材 */
|
||||
function selectMaterial(item: any) {
|
||||
showDialog.value = false;
|
||||
|
||||
reply.value.thumbMediaId = item.mediaId;
|
||||
reply.value.thumbMediaUrl = item.url;
|
||||
}
|
||||
@@ -90,7 +88,7 @@ function selectMaterial(item: any) {
|
||||
v-if="reply.thumbMediaUrl"
|
||||
:src="reply.thumbMediaUrl"
|
||||
/>
|
||||
<IconifyIcon v-else icon="ep:plus" />
|
||||
<IconifyIcon v-else icon="lucide:plus" />
|
||||
</ElRow>
|
||||
<ElRow align="middle" justify="center" class="mt-[2%]">
|
||||
<div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Reply } from './types';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { NewsType } from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { ElButton, ElCol, ElDialog, ElRow } from 'element-plus';
|
||||
@@ -10,8 +11,6 @@ import { ElButton, ElCol, ElDialog, ElRow } from 'element-plus';
|
||||
import MaterialSelect from '#/views/mp/components/wx-material-select/wx-material-select.vue';
|
||||
import News from '#/views/mp/components/wx-news/wx-news.vue';
|
||||
|
||||
import { NewsType } from '../wx-material-select/types';
|
||||
|
||||
defineOptions({ name: 'TabNews' });
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -52,10 +51,11 @@ function onDelete() {
|
||||
<News :articles="reply.articles" />
|
||||
<ElCol class="pt-[10px] text-center">
|
||||
<ElButton type="danger" circle @click="onDelete">
|
||||
<IconifyIcon icon="ep:delete" />
|
||||
<IconifyIcon icon="lucide:trash-2" />
|
||||
</ElButton>
|
||||
</ElCol>
|
||||
</div>
|
||||
|
||||
<!-- 选择素材 -->
|
||||
<ElCol :span="24" v-if="!reply.content">
|
||||
<ElRow class="text-center" align="middle">
|
||||
@@ -66,7 +66,7 @@ function onDelete() {
|
||||
? '选择已发布图文'
|
||||
: '选择草稿箱图文'
|
||||
}}
|
||||
<IconifyIcon icon="ep:circle-check" />
|
||||
<IconifyIcon icon="lucide:circle-check" />
|
||||
</ElButton>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
@@ -32,17 +32,13 @@ const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: Reply): void;
|
||||
}>();
|
||||
|
||||
// TODO @hw:还是用 ElMessage
|
||||
const message = ElMessage;
|
||||
|
||||
const accessStore = useAccessStore();
|
||||
const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`;
|
||||
// TODO @hw:这里 antd 和 ele 有差异,要统一么?
|
||||
const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` };
|
||||
const HEADERS = { Authorization: `Bearer ${accessStore.accessToken}` };
|
||||
|
||||
const reply = computed<Reply>({
|
||||
get: () => props.modelValue,
|
||||
// TODO @hw:这里 antd 和 ele 有差异,要统一么?
|
||||
set: (val: Reply) => emit('update:modelValue', val),
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const showDialog = ref(false);
|
||||
@@ -62,7 +58,7 @@ function beforeVideoUpload(rawFile: UploadRawFile) {
|
||||
/** 上传成功 */
|
||||
function onUploadSuccess(res: any) {
|
||||
if (res.code !== 0) {
|
||||
message.error(`上传出错:${res.msg}`);
|
||||
ElMessage.error(`上传出错:${res.msg}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -109,7 +105,7 @@ function selectMaterial(item: any) {
|
||||
<!-- 选择素材 -->
|
||||
<ElCol :span="12">
|
||||
<ElButton type="success" @click="showDialog = true">
|
||||
素材库选择 <IconifyIcon icon="ep:circle-check" />
|
||||
素材库选择 <IconifyIcon icon="lucide:circle-check" />
|
||||
</ElButton>
|
||||
<ElDialog
|
||||
title="选择视频"
|
||||
@@ -138,7 +134,7 @@ function selectMaterial(item: any) {
|
||||
:on-success="onUploadSuccess"
|
||||
>
|
||||
<ElButton type="primary">
|
||||
新建视频 <IconifyIcon icon="ep:upload" />
|
||||
新建视频 <IconifyIcon icon="lucide:upload" />
|
||||
</ElButton>
|
||||
</ElUpload>
|
||||
</ElCol>
|
||||
|
||||
@@ -31,16 +31,13 @@ const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: Reply): void;
|
||||
}>();
|
||||
|
||||
// TODO @hw:用 ElMessage
|
||||
const message = ElMessage;
|
||||
|
||||
const accessStore = useAccessStore();
|
||||
const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`;
|
||||
// TODO @hw:antd 和 ele 写法的统一;
|
||||
const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` };
|
||||
const HEADERS = { Authorization: `Bearer ${accessStore.accessToken}` };
|
||||
|
||||
const reply = computed<Reply>({
|
||||
get: () => props.modelValue,
|
||||
// TODO @hw:这里要和 antd 统一么?还是 ele 和它统一
|
||||
set: (val: Reply) => emit('update:modelValue', val),
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const showDialog = ref(false);
|
||||
@@ -60,7 +57,7 @@ function beforeVoiceUpload(rawFile: UploadRawFile) {
|
||||
/** 上传成功 */
|
||||
function onUploadSuccess(res: any) {
|
||||
if (res.code !== 0) {
|
||||
message.error(`上传出错:${res.msg}`);
|
||||
ElMessage.error(`上传出错:${res.msg}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -106,7 +103,7 @@ function selectMaterial(item: Reply) {
|
||||
</ElRow>
|
||||
<ElRow class="w-full pt-[10px] text-center" justify="center">
|
||||
<ElButton type="danger" circle @click="onDelete">
|
||||
<IconifyIcon icon="ep:delete" />
|
||||
<IconifyIcon icon="lucide:trash-2" />
|
||||
</ElButton>
|
||||
</ElRow>
|
||||
</div>
|
||||
@@ -117,7 +114,7 @@ function selectMaterial(item: Reply) {
|
||||
class="h-[160px] w-[49.5%] border border-[rgb(234,234,234)] py-[50px]"
|
||||
>
|
||||
<ElButton type="success" @click="showDialog = true">
|
||||
素材库选择<IconifyIcon icon="ep:circle-check" />
|
||||
素材库选择<IconifyIcon icon="lucide:circle-check" />
|
||||
</ElButton>
|
||||
<ElDialog
|
||||
title="选择语音"
|
||||
@@ -158,4 +155,4 @@ function selectMaterial(item: Reply) {
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,54 +1,42 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { ReplyType } from '@vben/constants';
|
||||
|
||||
import { unref } from 'vue';
|
||||
|
||||
// TODO @hw:和 antd 风格,保持一致;
|
||||
enum ReplyType {
|
||||
Image = 'image',
|
||||
Music = 'music',
|
||||
News = 'news',
|
||||
Text = 'text',
|
||||
Video = 'video',
|
||||
Voice = 'voice',
|
||||
}
|
||||
|
||||
interface _Reply {
|
||||
export interface Reply {
|
||||
accountId: number;
|
||||
type: ReplyType;
|
||||
name?: null | string;
|
||||
articles?: any[];
|
||||
content?: null | string;
|
||||
mediaId?: null | string;
|
||||
url?: null | string;
|
||||
title?: null | string;
|
||||
description?: null | string;
|
||||
thumbMediaId?: null | string;
|
||||
thumbMediaUrl?: null | string;
|
||||
musicUrl?: null | string;
|
||||
hqMusicUrl?: null | string;
|
||||
introduction?: null | string;
|
||||
articles?: any[];
|
||||
mediaId?: null | string;
|
||||
musicUrl?: null | string;
|
||||
name?: null | string;
|
||||
thumbMediaId?: null | string;
|
||||
thumbMediaUrl?: null | string;
|
||||
title?: null | string;
|
||||
type: ReplyType;
|
||||
url?: null | string;
|
||||
}
|
||||
|
||||
type Reply = _Reply; // Partial<_Reply>
|
||||
|
||||
/** 利用旧的 reply[accountId, type] 初始化新的 Reply */
|
||||
const createEmptyReply = (old: Ref<Reply> | Reply): Reply => {
|
||||
export function createEmptyReply(old: Ref<Reply> | Reply): Reply {
|
||||
return {
|
||||
accountId: unref(old).accountId,
|
||||
type: unref(old).type,
|
||||
name: null,
|
||||
articles: [],
|
||||
content: null,
|
||||
mediaId: null,
|
||||
url: null,
|
||||
title: null,
|
||||
description: null,
|
||||
thumbMediaId: null,
|
||||
thumbMediaUrl: null,
|
||||
musicUrl: null,
|
||||
hqMusicUrl: null,
|
||||
introduction: null,
|
||||
articles: [],
|
||||
mediaId: null,
|
||||
musicUrl: null,
|
||||
name: null,
|
||||
thumbMediaId: null,
|
||||
thumbMediaUrl: null,
|
||||
title: null,
|
||||
type: unref(old).type,
|
||||
url: null,
|
||||
};
|
||||
};
|
||||
|
||||
export { createEmptyReply, type Reply, ReplyType };
|
||||
}
|
||||
|
||||
@@ -1,38 +1,36 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Reply } from './types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref, unref, watch } from 'vue';
|
||||
|
||||
import { NewsType, ReplyType } from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { ElRow, ElTabPane, ElTabs } from 'element-plus';
|
||||
|
||||
import { NewsType } from '../wx-material-select/types';
|
||||
import TabImage from './tab-image.vue';
|
||||
import TabMusic from './tab-music.vue';
|
||||
import TabNews from './tab-news.vue';
|
||||
import TabText from './tab-text.vue';
|
||||
import TabVideo from './tab-video.vue';
|
||||
import TabVoice from './tab-voice.vue';
|
||||
import { createEmptyReply, ReplyType } from './types';
|
||||
import { createEmptyReply } from './types';
|
||||
|
||||
/** 消息回复选择 */
|
||||
defineOptions({ name: 'WxReplySelect' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: Reply | undefined;
|
||||
newsType?: NewsType;
|
||||
}>(),
|
||||
{
|
||||
newsType: () => NewsType.Published,
|
||||
},
|
||||
);
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
newsType: undefined,
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: Reply): void;
|
||||
}>();
|
||||
|
||||
interface Props {
|
||||
modelValue: Reply | undefined;
|
||||
newsType?: NewsType;
|
||||
}
|
||||
|
||||
const defaultReply: Reply = {
|
||||
accountId: -1,
|
||||
type: ReplyType.Text,
|
||||
@@ -43,6 +41,67 @@ const reply = computed<Reply>({
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const tabCache = new Map<ReplyType, Reply>(); // 作为多个标签保存各自 Reply 的缓存
|
||||
const currentTab = ref<ReplyType>(props.modelValue?.type || ReplyType.Text); // 采用独立的 ref 来保存当前 tab,避免在 watch 标签变化,对 reply 进行赋值会产生了循环调用
|
||||
|
||||
// 监听 modelValue 变化,同步更新 currentTab 和缓存
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue?.type) {
|
||||
// 如果类型变化,更新 currentTab
|
||||
if (newValue.type !== currentTab.value) {
|
||||
currentTab.value = newValue.type;
|
||||
}
|
||||
// 如果 modelValue 有数据,更新对应 tab 的缓存
|
||||
if (newValue.type) {
|
||||
tabCache.set(newValue.type, { ...newValue });
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
currentTab,
|
||||
(newTab, oldTab) => {
|
||||
// 第一次进入:oldTab 为 undefined
|
||||
// 判断 newTab 是因为 Reply 为 Partial
|
||||
if (oldTab === undefined || newTab === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存旧tab的数据到缓存
|
||||
const oldReply = unref(reply);
|
||||
// 只有当旧tab的reply有实际数据时才缓存(避免缓存空数据)
|
||||
if (oldReply && oldTab === oldReply.type) {
|
||||
tabCache.set(oldTab, oldReply);
|
||||
}
|
||||
|
||||
// 从缓存里面取出新tab内容,有则覆盖Reply,没有则创建空Reply
|
||||
const temp = tabCache.get(newTab);
|
||||
if (temp) {
|
||||
reply.value = temp;
|
||||
} else {
|
||||
// 如果当前reply的类型就是新tab的类型,说明这是从外部传入的数据,应该保留
|
||||
const currentReply = unref(reply);
|
||||
if (currentReply && currentReply.type === newTab) {
|
||||
// 这是从外部传入的数据,直接缓存并使用
|
||||
tabCache.set(newTab, currentReply);
|
||||
// 不需要修改reply.value,因为它已经是正确的了
|
||||
} else {
|
||||
// 创建新的空reply
|
||||
const newData = createEmptyReply(reply);
|
||||
newData.type = newTab;
|
||||
reply.value = newData;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
/** 清除除了`type`, `accountId`的字段 */
|
||||
function clear() {
|
||||
reply.value = createEmptyReply(reply);
|
||||
@@ -58,7 +117,9 @@ defineExpose({
|
||||
<!-- 类型 1:文本 -->
|
||||
<ElTabPane :name="ReplyType.Text">
|
||||
<template #label>
|
||||
<ElRow align="middle"><IconifyIcon icon="ep:document" /> 文本</ElRow>
|
||||
<ElRow align="middle">
|
||||
<IconifyIcon icon="lucide:file-text" /> 文本
|
||||
</ElRow>
|
||||
</template>
|
||||
<TabText v-model="reply.content" />
|
||||
</ElTabPane>
|
||||
@@ -67,7 +128,7 @@ defineExpose({
|
||||
<ElTabPane :name="ReplyType.Image">
|
||||
<template #label>
|
||||
<ElRow align="middle">
|
||||
<IconifyIcon icon="ep:picture" class="mr-5px" /> 图片
|
||||
<IconifyIcon icon="lucide:image" class="mr-5px" /> 图片
|
||||
</ElRow>
|
||||
</template>
|
||||
<TabImage v-model="reply" />
|
||||
@@ -76,7 +137,7 @@ defineExpose({
|
||||
<!-- 类型 3:语音 -->
|
||||
<ElTabPane :name="ReplyType.Voice">
|
||||
<template #label>
|
||||
<ElRow align="middle"><IconifyIcon icon="ep:phone" /> 语音</ElRow>
|
||||
<ElRow align="middle"> <IconifyIcon icon="lucide:mic" /> 语音 </ElRow>
|
||||
</template>
|
||||
<TabVoice v-model="reply" />
|
||||
</ElTabPane>
|
||||
@@ -84,7 +145,7 @@ defineExpose({
|
||||
<!-- 类型 4:视频 -->
|
||||
<ElTabPane :name="ReplyType.Video">
|
||||
<template #label>
|
||||
<ElRow align="middle"><IconifyIcon icon="ep:share" /> 视频</ElRow>
|
||||
<ElRow align="middle"><IconifyIcon icon="lucide:video" /> 视频</ElRow>
|
||||
</template>
|
||||
<TabVideo v-model="reply" />
|
||||
</ElTabPane>
|
||||
@@ -92,7 +153,9 @@ defineExpose({
|
||||
<!-- 类型 5:图文 -->
|
||||
<ElTabPane :name="ReplyType.News">
|
||||
<template #label>
|
||||
<ElRow align="middle"><IconifyIcon icon="ep:reading" /> 图文</ElRow>
|
||||
<ElRow align="middle">
|
||||
<IconifyIcon icon="lucide:newspaper" /> 图文
|
||||
</ElRow>
|
||||
</template>
|
||||
<TabNews v-model="reply" :news-type="newsType" />
|
||||
</ElTabPane>
|
||||
@@ -100,9 +163,9 @@ defineExpose({
|
||||
<!-- 类型 6:音乐 -->
|
||||
<ElTabPane :name="ReplyType.Music">
|
||||
<template #label>
|
||||
<ElRow align="middle"><IconifyIcon icon="ep:service" />音乐</ElRow>
|
||||
<ElRow align="middle"> <IconifyIcon icon="lucide:music" />音乐 </ElRow>
|
||||
</template>
|
||||
<TabMusic v-model="reply" />
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default } from './wx-video-play.vue';
|
||||
|
||||
// TODO @hw:每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user