Merge pull request !268 from hw/reform-mp
This commit is contained in:
芋道源码
2025-11-23 10:53:33 +00:00
committed by Gitee
86 changed files with 963 additions and 1195 deletions

View File

@@ -3,27 +3,22 @@ import type { VxeGridPropTypes } from '#/adapter/vxe-table';
import { markRaw } from 'vue'; import { markRaw } from 'vue';
import { DICT_TYPE, AutoReplyMsgType as MsgType } from '@vben/constants'; import {
AutoReplyMsgType,
DICT_TYPE,
RequestMessageTypes,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks'; import { getDictOptions } from '@vben/hooks';
import { WxReply } from '#/views/mp/components'; import { WxReply } from '#/views/mp/components';
// TODO @hw要不要使用统一枚举
const RequestMessageTypes = new Set([
'image',
'link',
'location',
'shortvideo',
'text',
'video',
'voice',
]); // 允许选择的请求消息类型
/** 获取表格列配置 */ /** 获取表格列配置 */
export function useGridColumns(msgType: MsgType): VxeGridPropTypes.Columns { export function useGridColumns(
msgType: AutoReplyMsgType,
): VxeGridPropTypes.Columns {
const columns: VxeGridPropTypes.Columns = []; const columns: VxeGridPropTypes.Columns = [];
// 请求消息类型列(仅消息回复显示) // 请求消息类型列(仅消息回复显示)
if (msgType === MsgType.Message) { if (msgType === AutoReplyMsgType.Message) {
columns.push({ columns.push({
field: 'requestMessageType', field: 'requestMessageType',
title: '请求消息类型', title: '请求消息类型',
@@ -32,7 +27,7 @@ export function useGridColumns(msgType: MsgType): VxeGridPropTypes.Columns {
} }
// 关键词列(仅关键词回复显示) // 关键词列(仅关键词回复显示)
if (msgType === MsgType.Keyword) { if (msgType === AutoReplyMsgType.Keyword) {
columns.push({ columns.push({
field: 'requestKeyword', field: 'requestKeyword',
title: '关键词', title: '关键词',
@@ -41,7 +36,7 @@ export function useGridColumns(msgType: MsgType): VxeGridPropTypes.Columns {
} }
// 匹配类型列(仅关键词回复显示) // 匹配类型列(仅关键词回复显示)
if (msgType === MsgType.Keyword) { if (msgType === AutoReplyMsgType.Keyword) {
columns.push({ columns.push({
field: 'requestMatch', field: 'requestMatch',
title: '匹配类型', title: '匹配类型',
@@ -87,11 +82,11 @@ export function useGridColumns(msgType: MsgType): VxeGridPropTypes.Columns {
} }
/** 新增/修改的表单 */ /** 新增/修改的表单 */
export function useFormSchema(msgType: MsgType): VbenFormSchema[] { export function useFormSchema(msgType: AutoReplyMsgType): VbenFormSchema[] {
const schema: VbenFormSchema[] = []; const schema: VbenFormSchema[] = [];
// 消息类型(仅消息回复显示) // 消息类型(仅消息回复显示)
if (msgType === MsgType.Message) { if (msgType === AutoReplyMsgType.Message) {
schema.push({ schema.push({
fieldName: 'requestMessageType', fieldName: 'requestMessageType',
label: '消息类型', label: '消息类型',
@@ -106,7 +101,7 @@ export function useFormSchema(msgType: MsgType): VbenFormSchema[] {
} }
// 匹配类型(仅关键词回复显示) // 匹配类型(仅关键词回复显示)
if (msgType === MsgType.Keyword) { if (msgType === AutoReplyMsgType.Keyword) {
schema.push({ schema.push({
fieldName: 'requestMatch', fieldName: 'requestMatch',
label: '匹配类型', label: '匹配类型',
@@ -124,7 +119,7 @@ export function useFormSchema(msgType: MsgType): VbenFormSchema[] {
} }
// 关键词(仅关键词回复显示) // 关键词(仅关键词回复显示)
if (msgType === MsgType.Keyword) { if (msgType === AutoReplyMsgType.Keyword) {
schema.push({ schema.push({
fieldName: 'requestKeyword', fieldName: 'requestKeyword',
label: '关键词', label: '关键词',

View File

@@ -5,8 +5,7 @@ import type { MpAutoReplyApi } from '#/api/mp/autoReply';
import { computed, nextTick, ref } from 'vue'; import { computed, nextTick, ref } from 'vue';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui'; import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
// TODO @hw直接使用 AutoReplyMsgType不用 as import { AutoReplyMsgType } from '@vben/constants';
import { AutoReplyMsgType as MsgType } from '@vben/constants';
import { IconifyIcon } from '@vben/icons'; import { IconifyIcon } from '@vben/icons';
import { message, Row, Tabs } from 'ant-design-vue'; import { message, Row, Tabs } from 'ant-design-vue';
@@ -26,10 +25,10 @@ import Form from './modules/form.vue';
defineOptions({ name: 'MpAutoReply' }); defineOptions({ name: 'MpAutoReply' });
const msgType = ref<string>(String(MsgType.Keyword)); // 消息类型 const msgType = ref<string>(String(AutoReplyMsgType.Keyword)); // 消息类型
const showCreateButton = computed(() => { const showCreateButton = computed(() => {
if (Number(msgType.value) !== MsgType.Follow) { if (Number(msgType.value) !== AutoReplyMsgType.Follow) {
return true; return true;
} }
try { try {
@@ -56,7 +55,7 @@ async function onTabChange(tabName: string) {
msgType.value = tabName; msgType.value = tabName;
await nextTick(); await nextTick();
// 更新 columns // 更新 columns
const columns = useGridColumns(Number(msgType.value) as MsgType); const columns = useGridColumns(Number(msgType.value) as AutoReplyMsgType);
if (columns) { if (columns) {
// 使用 setGridOptions 更新列配置 // 使用 setGridOptions 更新列配置
gridApi.setGridOptions({ columns }); gridApi.setGridOptions({ columns });
@@ -72,7 +71,7 @@ async function handleCreate() {
const formValues = await gridApi.formApi.getValues(); const formValues = await gridApi.formApi.getValues();
formModalApi formModalApi
.setData({ .setData({
msgType: Number(msgType.value) as MsgType, msgType: Number(msgType.value) as AutoReplyMsgType,
accountId: formValues.accountId, accountId: formValues.accountId,
}) })
.open(); .open();
@@ -83,7 +82,7 @@ async function handleEdit(row: any) {
const data = (await getAutoReply(row.id)) as any; const data = (await getAutoReply(row.id)) as any;
formModalApi formModalApi
.setData({ .setData({
msgType: Number(msgType.value) as MsgType, msgType: Number(msgType.value) as AutoReplyMsgType,
accountId: row.accountId, accountId: row.accountId,
row: data, row: data,
}) })
@@ -116,7 +115,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
schema: useGridFormSchema(), schema: useGridFormSchema(),
}, },
gridOptions: { gridOptions: {
columns: useGridColumns(Number(msgType.value) as MsgType), columns: useGridColumns(Number(msgType.value) as AutoReplyMsgType),
height: 'auto', height: 'auto',
keepSource: true, keepSource: true,
proxyConfig: { proxyConfig: {
@@ -125,7 +124,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
return await getAutoReplyPage({ return await getAutoReplyPage({
pageNo: page.currentPage, pageNo: page.currentPage,
pageSize: page.pageSize, pageSize: page.pageSize,
type: Number(msgType.value) as MsgType, type: Number(msgType.value) as AutoReplyMsgType,
...formValues, ...formValues,
}); });
}, },
@@ -161,14 +160,14 @@ const [Grid, gridApi] = useVbenVxeGrid({
class="w-full" class="w-full"
@change="(activeKey) => onTabChange(activeKey as string)" @change="(activeKey) => onTabChange(activeKey as string)"
> >
<Tabs.TabPane :key="String(MsgType.Follow)"> <Tabs.TabPane :key="String(AutoReplyMsgType.Follow)">
<template #tab> <template #tab>
<Row align="middle"> <Row align="middle">
<IconifyIcon icon="ep:star" class="mr-2px" /> 关注时回复 <IconifyIcon icon="ep:star" class="mr-2px" /> 关注时回复
</Row> </Row>
</template> </template>
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane :key="String(MsgType.Message)"> <Tabs.TabPane :key="String(AutoReplyMsgType.Message)">
<template #tab> <template #tab>
<Row align="middle"> <Row align="middle">
<IconifyIcon icon="ep:chat-line-round" class="mr-2px" /> <IconifyIcon icon="ep:chat-line-round" class="mr-2px" />
@@ -176,7 +175,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
</Row> </Row>
</template> </template>
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane :key="String(MsgType.Keyword)"> <Tabs.TabPane :key="String(AutoReplyMsgType.Keyword)">
<template #tab> <template #tab>
<Row align="middle"> <Row align="middle">
<IconifyIcon icon="fa:newspaper-o" class="mr-2px" /> 关键词回复 <IconifyIcon icon="fa:newspaper-o" class="mr-2px" /> 关键词回复

View File

@@ -1,6 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
// TODO @hw这里 Reply 貌似不存在 import type { Reply } from '#/views/mp/components/wx-reply/types';
import type { Reply } from '#/views/mp/components';
import { computed, nextTick, ref } from 'vue'; import { computed, nextTick, ref } from 'vue';

View File

@@ -1,3 +0,0 @@
export { default as WxAccountSelect } from './wx-account-select.vue';
// TODO @hw每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?

View File

@@ -1,4 +0,0 @@
export * from './types';
export { default as WxLocation } from './wx-location.vue';
// TODO @hw每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?

View File

@@ -1,4 +1,3 @@
// TODO @hwele 没这个文件,是不是也要搞个?
export interface WxLocationProps { export interface WxLocationProps {
label: string; label: string;
locationX: number; locationX: number;

View File

@@ -9,7 +9,7 @@ import { Col, Row } from 'ant-design-vue';
defineOptions({ name: 'WxLocation' }); defineOptions({ name: 'WxLocation' });
// TODO @dylan@hwapps/web-antd/src/views/mall/trade/delivery/pickUpStore/modules/form.vue 参考这个,从后端拿 key 哈 // TODO @dylanapps/web-antd/src/views/mall/trade/delivery/pickUpStore/modules/form.vue 参考这个,从后端拿 key 哈
const props = withDefaults(defineProps<WxLocationProps>(), { const props = withDefaults(defineProps<WxLocationProps>(), {
qqMapKey: 'TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E', // QQ 地图的密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc qqMapKey: 'TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E', // QQ 地图的密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc
}); });

View File

@@ -1,3 +0,0 @@
export { default as WxMaterialSelect } from './wx-material-select.vue';
// TODO @hw每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?

View File

@@ -43,7 +43,7 @@ const queryParams = reactive({
}); // 查询参数 }); // 查询参数
const voiceGridColumns: VxeTableGridOptions<any>['columns'] = [ const voiceGridColumns: VxeTableGridOptions<any>['columns'] = [
// TODO @hw@dylanany 有 linter 告警;看看别的模块哈 // TODO @dylanany 有 linter 告警;看看别的模块哈
{ {
field: 'mediaId', field: 'mediaId',
title: '编号', title: '编号',
@@ -78,7 +78,7 @@ const voiceGridColumns: VxeTableGridOptions<any>['columns'] = [
]; ];
const videoGridColumns: VxeTableGridOptions<any>['columns'] = [ const videoGridColumns: VxeTableGridOptions<any>['columns'] = [
// TODO @hw@dylanany 有 linter 告警;看看别的模块哈 // TODO @dylanany 有 linter 告警;看看别的模块哈
{ {
field: 'mediaId', field: 'mediaId',
title: '编号', title: '编号',
@@ -382,7 +382,7 @@ watch(
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
/** TODO @dylan@hw看看有没适合 tindwind 的哈。 */ /** TODO @dylan看看有没适合 tindwind 的哈。 */
@media (width >= 992px) and (width <= 1300px) { @media (width >= 992px) and (width <= 1300px) {
.waterfall { .waterfall {
column-count: 3; column-count: 3;

View File

@@ -1,5 +0,0 @@
export * from './types';
export { default as WxMsg } from './wx-msg.vue';
// TODO @hw每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { User } from './types'; import type { MpUserApi } from '#/api/mp/user/index';
import { preferences } from '@vben/preferences'; import { preferences } from '@vben/preferences';
import { formatDateTime } from '@vben/utils'; import { formatDateTime } from '@vben/utils';
@@ -11,7 +11,7 @@ defineOptions({ name: 'MsgList' });
const props = defineProps<{ const props = defineProps<{
accountId: number; accountId: number;
list: any[]; list: any[];
user: User; user: Partial<MpUserApi.User>;
}>(); }>();
const SendFrom = { const SendFrom = {
@@ -63,7 +63,7 @@ function getNickname(sendFrom: number) {
<style lang="scss" scoped> <style lang="scss" scoped>
/* 因为 joolun 实现依赖 avue 组件,该页面使用了 comment.scss、card.scc */ /* 因为 joolun 实现依赖 avue 组件,该页面使用了 comment.scss、card.scc */
/** TODO @dylan@hw 看看有没适合 tindwind 的哈。 */ /** TODO @dylan 看看有没适合 tindwind 的哈。 */
@import url('./comment.scss'); @import url('./comment.scss');
@import url('./card.scss'); @import url('./card.scss');

View File

@@ -2,7 +2,6 @@
import { MpMsgType } from '@vben/constants'; import { MpMsgType } from '@vben/constants';
import { IconifyIcon } from '@vben/icons'; import { IconifyIcon } from '@vben/icons';
// TODO @hw貌似这个 antd 才有ele 需要有么?
import { import {
WxLocation, WxLocation,
WxMusic, WxMusic,

View File

@@ -1,7 +0,0 @@
// TODO @hw用 MpUserApi 里的 user 可以么?
export interface User {
accountId: number;
avatar: string;
nickname: string;
}

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { User } from './types'; import type { MpUserApi } from '#/api/mp/user/index';
import { nextTick, onMounted, reactive, ref, unref } from 'vue'; import { nextTick, onMounted, reactive, ref, unref } from 'vue';
@@ -29,7 +29,7 @@ const queryParams = reactive({
pageSize: 14, // 每页显示多少条 pageSize: 14, // 每页显示多少条
}); });
const user: User = reactive({ const user: Partial<MpUserApi.User> = reactive({
accountId, // 公众号账号编号 accountId, // 公众号账号编号
avatar: preferences.app.defaultAvatar, avatar: preferences.app.defaultAvatar,
nickname: '用户', // 由于微信不再提供昵称,直接使用"用户"展示 nickname: '用户', // 由于微信不再提供昵称,直接使用"用户"展示

View File

@@ -1,3 +0,0 @@
export { default as WxMusic } from './wx-music.vue';
// TODO @hw每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?

View File

@@ -3,6 +3,8 @@ import type { WxMusicProps } from './types';
import { computed } from 'vue'; import { computed } from 'vue';
import { Typography } from 'ant-design-vue';
/** 微信消息 - 音乐 */ /** 微信消息 - 音乐 */
defineOptions({ name: 'WxMusic' }); defineOptions({ name: 'WxMusic' });
@@ -14,6 +16,8 @@ const props = withDefaults(defineProps<WxMusicProps>(), {
thumbMediaUrl: '', thumbMediaUrl: '',
}); });
const { Link } = Typography;
const href = computed(() => props.hqMusicUrl || props.musicUrl); const href = computed(() => props.hqMusicUrl || props.musicUrl);
defineExpose({ defineExpose({
@@ -23,8 +27,7 @@ defineExpose({
<template> <template>
<div> <div>
<!-- TODO @hw是不是用 antd link 更好 --> <Link :href="href" target="_blank" class="text-success no-underline">
<a :href="href" target="_blank" class="text-success no-underline">
<div class="music-card"> <div class="music-card">
<div class="music-avatar"> <div class="music-avatar">
<img :src="thumbMediaUrl" alt="音乐封面" /> <img :src="thumbMediaUrl" alt="音乐封面" />
@@ -34,12 +37,12 @@ defineExpose({
<div class="music-description">{{ description }}</div> <div class="music-description">{{ description }}</div>
</div> </div>
</div> </div>
</a> </Link>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
/** TODO @dylan@hw看看有没适合 tindwind 的哈。 */ /** TODO @dylan看看有没适合 tindwind 的哈。 */
.music-card { .music-card {
display: flex; display: flex;

View File

@@ -1,3 +0,0 @@
export { default as WxNews } from './wx-news.vue';
// TODO @hw每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?

View File

@@ -52,7 +52,7 @@ defineExpose({
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
/** TODO @dylan@hw看看有没适合 tindwind 的哈。 */ /** TODO @dylan看看有没适合 tindwind 的哈。 */
.news-home { .news-home {
width: 100%; width: 100%;

View File

@@ -1,5 +0,0 @@
export * from './types';
export { default as WxReply } from './wx-reply.vue';
// TODO @hw每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?

View File

@@ -65,21 +65,20 @@ async function customRequest(options: any) {
const result = await response.json(); const result = await response.json();
// TODO @hwif return 风格简化掉。if (result.code !=== 0) { ... } if (result.code !== 0) {
if (result.code === 0) {
// 清空上传时的各种数据
fileList.value = [];
uploadData.title = '';
uploadData.introduction = '';
// 上传好的文件,本质是个素材,所以可以进行选中
selectMaterial(result.data);
message.success('上传成功');
onSuccess(result, file);
} else {
message.error(result.msg || '上传出错'); message.error(result.msg || '上传出错');
onError(new Error(result.msg || '上传失败')); onError(new Error(result.msg || '上传失败'));
return;
} }
// 清空上传时的各种数据
fileList.value = [];
uploadData.title = '';
uploadData.introduction = '';
// 上传好的文件,本质是个素材,所以可以进行选中
selectMaterial(result.data);
message.success('上传成功');
onSuccess(result, file);
} catch (error) { } catch (error) {
message.error('上传失败,请重试'); message.error('上传失败,请重试');
onError(error); onError(error);

View File

@@ -21,8 +21,6 @@ import {
import { WxMaterialSelect } from '#/views/mp/components'; import { WxMaterialSelect } from '#/views/mp/components';
import { UploadType, useBeforeUpload } from '#/views/mp/hooks/useUpload'; import { UploadType, useBeforeUpload } from '#/views/mp/hooks/useUpload';
// TODO @hw类似 tab-image.vue 的建议
defineOptions({ name: 'TabMusic' }); defineOptions({ name: 'TabMusic' });
const props = defineProps<{ const props = defineProps<{
@@ -76,20 +74,20 @@ async function customRequest(options: any) {
const result = await response.json(); const result = await response.json();
if (result.code === 0) { if (result.code !== 0) {
// 清空上传时的各种数据
fileList.value = [];
uploadData.title = '';
uploadData.introduction = '';
// 上传好的文件,本质是个素材,所以可以进行选中
selectMaterial(result.data);
message.success('上传成功');
onSuccess(result, file);
} else {
message.error(result.msg || '上传出错'); message.error(result.msg || '上传出错');
onError(new Error(result.msg || '上传失败')); onError(new Error(result.msg || '上传失败'));
return;
} }
// 清空上传时的各种数据
fileList.value = [];
uploadData.title = '';
uploadData.introduction = '';
// 上传好的文件,本质是个素材,所以可以进行选中
selectMaterial(result.data);
message.success('上传成功');
onSuccess(result, file);
} catch (error) { } catch (error) {
message.error('上传失败,请重试'); message.error('上传失败,请重试');
onError(error); onError(error);

View File

@@ -93,7 +93,7 @@ function onDelete() {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
/** TODO @dylan@hw看看有没适合 tindwind 的哈。 */ /** TODO @dylan看看有没适合 tindwind 的哈。 */
.select-item { .select-item {
width: 280px; width: 280px;
padding: 10px; padding: 10px;

View File

@@ -74,21 +74,20 @@ async function customRequest(options: any) {
const result = await response.json(); const result = await response.json();
// TODO @hw也采用类似 ele 的 if return(res.code !== 0) return 写法; if (result.code !== 0) {
if (result.code === 0) {
// 清空上传时的各种数据
fileList.value = [];
uploadData.title = '';
uploadData.introduction = '';
// 选择素材
selectMaterial(result.data);
message.success('上传成功');
onSuccess(result, file);
} else {
message.error(result.msg || '上传出错'); message.error(result.msg || '上传出错');
onError(new Error(result.msg || '上传失败')); onError(new Error(result.msg || '上传失败'));
return;
} }
// 清空上传时的各种数据
fileList.value = [];
uploadData.title = '';
uploadData.introduction = '';
// 选择素材
selectMaterial(result.data);
message.success('上传成功');
onSuccess(result, file);
} catch (error) { } catch (error) {
message.error('上传失败,请重试'); message.error('上传失败,请重试');
onError(error); onError(error);

View File

@@ -66,21 +66,21 @@ async function customRequest(options: any) {
const result = await response.json(); const result = await response.json();
// TODO @hwif result.code !== 0 return代码简洁一点。 if (result.code !== 0) {
if (result.code === 0) {
// 清空上传时的各种数据
fileList.value = [];
uploadData.title = '';
uploadData.introduction = '';
// 上传好的文件,本质是个素材,所以可以进行选中
selectMaterial(result.data);
message.success('上传成功');
onSuccess(result, file);
} else {
message.error(result.msg || '上传出错'); message.error(result.msg || '上传出错');
onError(new Error(result.msg || '上传失败')); onError(new Error(result.msg || '上传失败'));
return;
} }
// 清空上传时的各种数据
fileList.value = [];
uploadData.title = '';
uploadData.introduction = '';
// 上传好的文件,本质是个素材,所以可以进行选中
selectMaterial(result.data);
message.success('上传成功');
onSuccess(result, file);
} catch (error) { } catch (error) {
message.error('上传失败,请重试'); message.error('上传失败,请重试');
onError(error); onError(error);
@@ -169,7 +169,7 @@ function selectMaterial(item: Reply) {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
/** TODO @dylan@hw看看有没适合 tindwind 的哈。 */ /** TODO @dylan看看有没适合 tindwind 的哈。 */
.select-item { .select-item {
padding: 10px; padding: 10px;
margin: 0 auto 10px; margin: 0 auto 10px;

View File

@@ -26,7 +26,6 @@ const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void; (e: 'update:modelValue', v: Reply): void;
}>(); }>();
// TODO @hwantd 和 ele 风格不同,需要统一;
interface Props { interface Props {
modelValue: Reply | undefined; modelValue: Reply | undefined;
newsType?: NewsType; newsType?: NewsType;
@@ -45,7 +44,6 @@ const reply = computed<Reply>({
const tabCache = new Map<ReplyType, Reply>(); // 作为多个标签保存各自 Reply 的缓存 const tabCache = new Map<ReplyType, Reply>(); // 作为多个标签保存各自 Reply 的缓存
const currentTab = ref<ReplyType>(props.modelValue?.type || ReplyType.Text); // 采用独立的 ref 来保存当前 tab避免在 watch 标签变化,对 reply 进行赋值会产生了循环调用 const currentTab = ref<ReplyType>(props.modelValue?.type || ReplyType.Text); // 采用独立的 ref 来保存当前 tab避免在 watch 标签变化,对 reply 进行赋值会产生了循环调用
// TODO @hwantd 和 ele 风格不同,需要统一;
// 监听 modelValue 变化,同步更新 currentTab 和缓存 // 监听 modelValue 变化,同步更新 currentTab 和缓存
watch( watch(
() => props.modelValue, () => props.modelValue,
@@ -64,7 +62,6 @@ watch(
{ immediate: true, deep: true }, { immediate: true, deep: true },
); );
// TODO @hwantd 和 ele 风格不同,需要统一;
watch( watch(
currentTab, currentTab,
(newTab, oldTab) => { (newTab, oldTab) => {

View File

@@ -1,3 +0,0 @@
export { default as WxVideoPlayer } from './wx-video-play.vue';
// TODO @hw每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?

View File

@@ -1,3 +0,0 @@
export { default as WxVoicePlayer } from './wx-voice-play.vue';
// TODO @hw每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?

View File

@@ -1,14 +1,28 @@
import type { VbenFormSchema } from '#/adapter/form'; import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table'; import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { formatDateTime } from '@vben/utils';
/** 获取表格列配置 */ /** 获取表格列配置 */
export function useGridColumns(): VxeTableGridOptions['columns'] { export function useGridColumns(): VxeTableGridOptions['columns'] {
return [ return [
{ {
field: 'content', field: 'cover',
title: '图文内容', title: '图',
minWidth: 300, width: 360,
slots: { default: 'content' }, slots: { default: 'cover' },
},
{
field: 'title',
title: '标题',
slots: { default: 'title' },
},
{
field: 'updateTime',
title: '修改时间',
formatter: ({ row }) => {
return formatDateTime(row.updateTime * 1000);
},
}, },
{ {
title: '操作', title: '操作',

View File

@@ -9,11 +9,11 @@ import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'; import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { createEmptyNewsItem, deleteDraft, getDraftPage } from '#/api/mp/draft'; import { createEmptyNewsItem, deleteDraft, getDraftPage } from '#/api/mp/draft';
// import { getDraftPage } from '#/api/mp/draft'; // 调试时注释掉
import { submitFreePublish } from '#/api/mp/freePublish'; import { submitFreePublish } from '#/api/mp/freePublish';
import { WxAccountSelect } from '#/views/mp/components'; import { WxAccountSelect } from '#/views/mp/components';
import { useGridColumns, useGridFormSchema } from './data'; import { useGridColumns, useGridFormSchema } from './data';
import DraftTableCell from './modules/draft-table.vue';
import Form from './modules/form.vue'; import Form from './modules/form.vue';
defineOptions({ name: 'MpDraft' }); defineOptions({ name: 'MpDraft' });
@@ -128,6 +128,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
proxyConfig: { proxyConfig: {
ajax: { ajax: {
query: async ({ page }, formValues) => { query: async ({ page }, formValues) => {
// 调试用:跳过请求,直接返回模拟数据
const drafts = await getDraftPage({ const drafts = await getDraftPage({
pageNo: page.currentPage, pageNo: page.currentPage,
pageSize: page.pageSize, pageSize: page.pageSize,
@@ -142,9 +143,10 @@ const [Grid, gridApi] = useVbenVxeGrid({
}); });
} }
}); });
return { return {
list: drafts.list as unknown as MpDraftApi.DraftArticle[], list: drafts.list,
total: drafts.total, total: drafts.total, // 模拟总数
}; };
}, },
}, },
@@ -187,10 +189,40 @@ const [Grid, gridApi] = useVbenVxeGrid({
]" ]"
/> />
</template> </template>
<!-- TODO @hw按照微信群沟通的换下卡片的样式 --> <template #cover="{ row }">
<template #content="{ row }"> <div
<DraftTableCell :row="row" /> v-if="row.content?.newsItem && row.content.newsItem.length > 0"
<!-- TODO @hw增加一列更新时间 --> class="flex flex-col items-center justify-center gap-1"
>
<a
v-for="(item, index) in row.content.newsItem"
:key="index"
:href="(item as any).url"
target="_blank"
>
<img
:src="item.picUrl || item.thumbUrl"
class="h-36 w-[50px] rounded object-cover"
:alt="`文章${index + 1}封面图`"
/>
</a>
</div>
<span v-else class="text-gray-400">-</span>
</template>
<template #title="{ row }">
<div
v-if="row.content?.newsItem && row.content.newsItem.length > 0"
class="space-y-1"
>
<div
v-for="(item, index) in row.content.newsItem"
:key="index"
class="flex h-36 items-center justify-center"
>
{{ item.title }}
</div>
</div>
<span v-else class="text-gray-400">-</span>
</template> </template>
<template #actions="{ row }"> <template #actions="{ row }">
<TableAction <TableAction

View File

@@ -11,6 +11,7 @@ import { useAccessStore } from '@vben/stores';
import { Button, Image, message, Modal, Upload } from 'ant-design-vue'; import { Button, Image, message, Modal, Upload } from 'ant-design-vue';
import { UploadType, useBeforeUpload } from '#/utils/useUpload'; import { UploadType, useBeforeUpload } from '#/utils/useUpload';
import WxMaterialSelect from '#/views/mp/components/wx-material-select/wx-material-select.vue';
const props = defineProps<{ const props = defineProps<{
isFirst: boolean; isFirst: boolean;
@@ -133,14 +134,13 @@ function onUploadError(err: Error) {
支持 bmp/png/jpeg/jpg/gif 格式大小不超过 2M 支持 bmp/png/jpeg/jpg/gif 格式大小不超过 2M
</div> </div>
</div> </div>
<!-- TODO @hw这个貌似不行ele 我试了下可以的 -->
<Modal <Modal
v-model:open="dialogVisible" v-model:open="dialogVisible"
title="图片选择" title="图片选择"
width="65%" width="65%"
:footer="null" :footer="null"
> >
<MaterialSelect <WxMaterialSelect
type="image" type="image"
:account-id="accountId!" :account-id="accountId!"
@select-material="onMaterialSelected" @select-material="onMaterialSelected"

View File

@@ -1,20 +0,0 @@
<script lang="ts" setup>
import type { MpDraftApi } from '#/api/mp/draft';
import { WxNews } from '#/views/mp/components';
// TODO @hw按照微信里说的感觉这个可以干掉。少点组件哈。= = mp 模块,小组件可太多了。。。
defineOptions({ name: 'DraftTableCell' });
const props = defineProps<{
row: MpDraftApi.DraftArticle;
}>();
</script>
<template>
<div class="p-2.5">
<div v-if="props.row.content && props.row.content.newsItem">
<WxNews :articles="props.row.content.newsItem" />
</div>
</div>
</template>

View File

@@ -88,23 +88,25 @@ function plusNews() {
<div class="mx-auto mb-[10px] w-[60%] border border-[#eaeaea] p-[10px]"> <div class="mx-auto mb-[10px] w-[60%] border border-[#eaeaea] p-[10px]">
<div v-for="(news, index) in newsList" :key="index"> <div v-for="(news, index) in newsList" :key="index">
<div <div
class="group relative mx-auto h-[120px] w-full cursor-pointer bg-white" class="group relative mx-auto mb-[10px] w-full cursor-pointer border-[2px] bg-white"
v-if="index === 0" v-if="index === 0"
:class="{ :class="
'border-[5px] border-[#2bb673]': activeNewsIndex === index, activeNewsIndex === index
}" ? 'border-green-500'
: 'border-transparent'
"
@click="activeNewsIndex = index" @click="activeNewsIndex = index"
> >
<div class="relative h-[120px] w-full bg-[#acadae]"> <div class="relative w-full bg-[#acadae]">
<img class="h-full w-full" :src="news.thumbUrl" /> <img class="h-full w-full" :src="news.thumbUrl" />
<div <div
class="absolute bottom-0 left-0 inline-block h-[25px] w-[98%] overflow-hidden text-ellipsis whitespace-nowrap bg-black p-[1%] text-[15px] text-white opacity-65" class="absolute bottom-0 left-0 inline-block h-[25px] w-[100%] overflow-hidden text-ellipsis whitespace-nowrap bg-black p-[1%] text-center text-[15px] text-white opacity-65"
> >
{{ news.title }} {{ news.title }}
</div> </div>
</div> </div>
<div <div
class="relative -bottom-[25px] hidden text-center group-hover:block" class="relative flex justify-center gap-[10px] py-[5px] text-center"
v-if="newsList.length > 1" v-if="newsList.length > 1"
> >
<Button <Button
@@ -127,25 +129,28 @@ function plusNews() {
</Button> </Button>
</div> </div>
</div> </div>
<!-- TODO @hw1每个文章的选中框太粗了2没完全覆盖住文章最好首个文章和第个文章的情况都看看 -->
<div <div
class="group relative mx-auto w-full cursor-pointer border-t border-[#eaeaea] bg-white py-[5px]" class="group relative mx-auto mb-[10px] cursor-pointer border-[2px] bg-white"
v-if="index > 0" v-if="index > 0"
:class="{ :class="
'border-[5px] border-[#2bb673]': activeNewsIndex === index, activeNewsIndex === index
}" ? 'border-green-500'
: 'border-transparent'
"
@click="activeNewsIndex = index" @click="activeNewsIndex = index"
> >
<div class="relative -ml-[3px]"> <div class="relative">
<div class="inline-block w-[70%] text-xs">{{ news.title }}</div> <div class="bg-[#acadae]">
<div class="inline-block w-[25%] bg-[#acadae]"> <img class="block h-full w-full" :src="news.thumbUrl" />
<img class="h-full w-full" :src="news.thumbUrl" /> <div
class="absolute bottom-0 left-0 inline-block h-[25px] w-[100%] overflow-hidden text-ellipsis whitespace-nowrap bg-black p-[1%] text-center text-[15px] text-white opacity-65"
>
{{ news.title }}
</div>
</div> </div>
</div> </div>
<!-- TODO @hw这里的按钮交互不太对应该在每个卡片的里面或者类似公众号现在的交互放到右侧复现本周如果有 2 个文章的时候 -->
<!-- TODO @hw当有 2 个文章的时候挪到第二个文章的时候卡片会变大期望不变大 -->
<div <div
class="relative -bottom-[25px] hidden text-center group-hover:block" class="relative flex justify-center gap-[10px] py-[5px] text-center"
> >
<Button <Button
v-if="newsList.length > index + 1" v-if="newsList.length > index + 1"

View File

@@ -12,7 +12,7 @@ import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table'; import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { WxVideoPlayer } from '#/views/mp/components'; import { WxVideoPlayer } from '#/views/mp/components';
// TODO @dylan@hwvue 组件名小写 + 中划线 // TODO @dylanvue 组件名小写 + 中划线
const props = defineProps<{ const props = defineProps<{
list: any[]; list: any[];

View File

@@ -5,7 +5,7 @@ import { useAccess } from '@vben/access';
import { DocAlert, Page } from '@vben/common-ui'; import { DocAlert, Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons'; import { IconifyIcon } from '@vben/icons';
// TODO @dlyan、@hw可以先 antd 迁移完,在搞 ele避免搞两遍 // TODO @dlyan、可以先 antd 迁移完,在搞 ele避免搞两遍
import { import {
Button, Button,
Card, Card,
@@ -117,8 +117,8 @@ async function handleDelete(id: number) {
<Card :bordered="false" class="mt-4 h-[88%]"> <Card :bordered="false" class="mt-4 h-[88%]">
<Tabs v-model:active-key="type" @change="onTabChange"> <Tabs v-model:active-key="type" @change="onTabChange">
<!-- tab 1图片 --> <!-- tab 1图片 -->
<!-- TODO @hw要不这里也改成 grid 视图然后操作按钮都改成右上角 --> <!-- TODO @dylan要不这里也改成 grid 视图然后操作按钮都改成右上角 -->
<!-- TODO @hw图片展示时就编号文件名图片上传时间操作 --> <!-- TODO @dylan图片展示时就编号文件名图片上传时间操作 -->
<Tabs.TabPane :key="UploadType.Image"> <Tabs.TabPane :key="UploadType.Image">
<template #tab> <template #tab>
<span class="flex items-center"> <span class="flex items-center">

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -16,11 +16,12 @@ import {
MENU_NOT_SELECTED, MENU_NOT_SELECTED,
useGridFormSchema, useGridFormSchema,
} from '#/views/mp/menu/data'; } from '#/views/mp/menu/data';
import { MenuEditor, MenuPreviewer } from '#/views/mp/menu/modules'; import Editor from '#/views/mp/menu/modules/editor.vue';
import Previewer from '#/views/mp/menu/modules/previewer.vue';
import iphoneBackImg from './modules/assets/iphone_backImg.png'; import iphoneBackImg from './assets/iphone_backImg.png';
import menuFootImg from './modules/assets/menu_foot.png'; import menuFootImg from './assets/menu_foot.png';
import menuHeadImg from './modules/assets/menu_head.png'; import menuHeadImg from './assets/menu_head.png';
defineOptions({ name: 'MpMenu' }); defineOptions({ name: 'MpMenu' });
@@ -64,8 +65,8 @@ const parentIndex = ref(-1);
// ======================== 菜单编辑 ======================== // ======================== 菜单编辑 ========================
const showRightPanel = ref(false); // 右边配置显示默认详情还是配置详情 const showRightPanel = ref(false); // 右边配置显示默认详情还是配置详情
const isParent = ref<boolean>(true); // 是否一级菜单,控制MenuEditor中name字段长度 const isParent = ref<boolean>(true); // 是否一级菜单控制Editor中name字段长度
const activeMenu = ref<Menu>({}); // 选中菜单,MenuEditor的modelValue const activeMenu = ref<Menu>({}); // 选中菜单Editor的modelValue
// 一些临时值放在这里进行判断,如果放在 activeMenu由于引用关系menu 也会多了多余的参数 // 一些临时值放在这里进行判断,如果放在 activeMenu由于引用关系menu 也会多了多余的参数
const tempSelfObj = ref<{ const tempSelfObj = ref<{
@@ -327,7 +328,7 @@ function menuToBackend(menu: any) {
class="bg-[position:0_0] bg-no-repeat pl-[43px] text-xs after:clear-both after:table after:content-['']" class="bg-[position:0_0] bg-no-repeat pl-[43px] text-xs after:clear-both after:table after:content-['']"
:style="{ backgroundImage: `url(${menuFootImg})` }" :style="{ backgroundImage: `url(${menuFootImg})` }"
> >
<MenuPreviewer <Previewer
v-model="menuList" v-model="menuList"
:account-id="accountId" :account-id="accountId"
:active-index="activeIndex" :active-index="activeIndex"
@@ -354,7 +355,7 @@ function menuToBackend(menu: any) {
class="float-left ml-5 box-border w-[63%] bg-[#e8e7e7] p-5" class="float-left ml-5 box-border w-[63%] bg-[#e8e7e7] p-5"
v-if="showRightPanel" v-if="showRightPanel"
> >
<MenuEditor <Editor
:account-id="accountId" :account-id="accountId"
:is-parent="isParent" :is-parent="isParent"
v-model="activeMenu" v-model="activeMenu"

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, ref, watch } from 'vue'; import { computed, ref } from 'vue';
import { IconifyIcon } from '@vben/icons'; import { IconifyIcon } from '@vben/icons';
@@ -37,17 +37,8 @@ const menu = computed({
}, },
}); });
const showNewsDialog = ref(false); const showNewsDialog = ref(false);
// TODO @hw这个 reset 还有用么?
const hackResetReplySelect = ref(false);
const isLeave = computed<boolean>(() => !(menu.value.children?.length > 0)); const isLeave = computed<boolean>(() => !(menu.value.children?.length > 0));
watch(menu, () => {
hackResetReplySelect.value = false; // 销毁组件
nextTick(() => {
hackResetReplySelect.value = true; // 重建组件
});
});
// ======================== 菜单编辑(素材选择) ======================== // ======================== 菜单编辑(素材选择) ========================
/** 选择素材 */ /** 选择素材 */

View File

@@ -1,5 +0,0 @@
export { default as MenuEditor } from './editor.vue';
export { default as MenuPreviewer } from './previewer.vue';
export type * from './types';
export * from './types';
// TODO @hw这个貌似没用可以考虑删除哈。modules 里,直接用就完事啦!

View File

@@ -193,8 +193,6 @@ function onChildDragEnd({ newIndex }: { newIndex: number }) {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
/** todo @hwantd 和 ele 这里的写法,看看能不能统一; */
.draggable-ghost { .draggable-ghost {
background: #f7fafc; background: #f7fafc;
border: 1px solid #4299e1; border: 1px solid #4299e1;

View File

@@ -189,7 +189,7 @@ function showTotal(total: number) {
:footer="null" :footer="null"
destroy-on-close destroy-on-close
> >
<!-- TODO @hw@dlayn这里有告警 --> <!-- TODO @dlayn这里有告警 -->
<WxMsg :user-id="messageBoxUserId" /> <WxMsg :user-id="messageBoxUserId" />
</Modal> </Modal>
</Page> </Page>

View File

@@ -1,14 +1,8 @@
import type { PageParam, PageResult } from '@vben/request'; import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request'; import { MaterialType } from '@vben/constants';
/** 素材类型枚举 */ import { requestClient } from '#/api/request';
export enum MaterialType {
IMAGE = 1, // 图片
THUMB = 4, // 缩略图
VIDEO = 3, // 视频
VOICE = 2, // 语音
}
export namespace MpMaterialApi { export namespace MpMaterialApi {
/** 素材信息 */ /** 素材信息 */

View File

@@ -1,37 +1,24 @@
import type { VbenFormSchema } from '#/adapter/form'; import type { VbenFormSchema } from '#/adapter/form';
import type { VxeGridPropTypes } from '#/adapter/vxe-table'; import type { VxeGridPropTypes } from '#/adapter/vxe-table';
import type { MpAccountApi } from '#/api/mp/account';
import { markRaw } from 'vue'; import { markRaw } from 'vue';
import { DICT_TYPE } from '@vben/constants'; import {
import { getDictObj, getDictOptions } from '@vben/hooks'; AutoReplyMsgType,
DICT_TYPE,
RequestMessageTypes,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getSimpleAccountList } from '#/api/mp/account'; import { WxReply } from '#/views/mp/components';
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',
]); // 允许选择的请求消息类型
/** 获取表格列配置 */ /** 获取表格列配置 */
export function useGridColumns(msgType: MsgType): VxeGridPropTypes.Columns { export function useGridColumns(
msgType: AutoReplyMsgType,
): VxeGridPropTypes.Columns {
const columns: VxeGridPropTypes.Columns = []; const columns: VxeGridPropTypes.Columns = [];
// 请求消息类型列(仅消息回复显示) // 请求消息类型列(仅消息回复显示)
if (msgType === MsgType.Message) { if (msgType === AutoReplyMsgType.Message) {
columns.push({ columns.push({
field: 'requestMessageType', field: 'requestMessageType',
title: '请求消息类型', title: '请求消息类型',
@@ -40,7 +27,7 @@ export function useGridColumns(msgType: MsgType): VxeGridPropTypes.Columns {
} }
// 关键词列(仅关键词回复显示) // 关键词列(仅关键词回复显示)
if (msgType === MsgType.Keyword) { if (msgType === AutoReplyMsgType.Keyword) {
columns.push({ columns.push({
field: 'requestKeyword', field: 'requestKeyword',
title: '关键词', title: '关键词',
@@ -49,7 +36,7 @@ export function useGridColumns(msgType: MsgType): VxeGridPropTypes.Columns {
} }
// 匹配类型列(仅关键词回复显示) // 匹配类型列(仅关键词回复显示)
if (msgType === MsgType.Keyword) { if (msgType === AutoReplyMsgType.Keyword) {
columns.push({ columns.push({
field: 'requestMatch', field: 'requestMatch',
title: '匹配类型', title: '匹配类型',
@@ -67,9 +54,10 @@ export function useGridColumns(msgType: MsgType): VxeGridPropTypes.Columns {
field: 'responseMessageType', field: 'responseMessageType',
title: '回复消息类型', title: '回复消息类型',
minWidth: 120, minWidth: 120,
// TODO @hw这里和 antd 有差别。两侧尽量统一; cellRender: {
formatter: ({ cellValue }) => name: 'CellDict',
getDictObj(DICT_TYPE.MP_MESSAGE_TYPE, String(cellValue))?.label ?? '', props: { type: DICT_TYPE.MP_MESSAGE_TYPE },
},
}, },
{ {
field: 'responseContent', field: 'responseContent',
@@ -94,12 +82,11 @@ export function useGridColumns(msgType: MsgType): VxeGridPropTypes.Columns {
} }
/** 新增/修改的表单 */ /** 新增/修改的表单 */
export function useFormSchema(msgType: MsgType): VbenFormSchema[] { export function useFormSchema(msgType: AutoReplyMsgType): VbenFormSchema[] {
const schema: VbenFormSchema[] = []; const schema: VbenFormSchema[] = [];
// 消息类型(仅消息回复显示) // 消息类型(仅消息回复显示)
// TODO @hw这里和 antd 有差别。两侧尽量统一; if (msgType === AutoReplyMsgType.Message) {
if (Number(msgType) === MsgType.Message) {
schema.push({ schema.push({
fieldName: 'requestMessageType', fieldName: 'requestMessageType',
label: '消息类型', label: '消息类型',
@@ -114,8 +101,7 @@ export function useFormSchema(msgType: MsgType): VbenFormSchema[] {
} }
// 匹配类型(仅关键词回复显示) // 匹配类型(仅关键词回复显示)
// TODO @hw这里和 antd 有差别。两侧尽量统一; if (msgType === AutoReplyMsgType.Keyword) {
if (Number(msgType) === MsgType.Keyword) {
schema.push({ schema.push({
fieldName: 'requestMatch', fieldName: 'requestMatch',
label: '匹配类型', label: '匹配类型',
@@ -133,8 +119,7 @@ export function useFormSchema(msgType: MsgType): VbenFormSchema[] {
} }
// 关键词(仅关键词回复显示) // 关键词(仅关键词回复显示)
// TODO @hw这里和 antd 有差别。两侧尽量统一; if (msgType === AutoReplyMsgType.Keyword) {
if (Number(msgType) === MsgType.Keyword) {
schema.push({ schema.push({
fieldName: 'requestKeyword', fieldName: 'requestKeyword',
label: '关键词', label: '关键词',
@@ -147,31 +132,22 @@ export function useFormSchema(msgType: MsgType): VbenFormSchema[] {
}); });
} }
// 回复消息 // 回复消息
// TODO @hw这里和 antd 有差别。两侧尽量统一;
schema.push({ schema.push({
fieldName: 'reply', fieldName: 'reply',
label: '回复消息', label: '回复消息',
component: markRaw(WxReplySelect), component: markRaw(WxReply),
modelPropName: 'modelValue',
}); });
return schema; return schema;
} }
/** 列表的搜索表单 */ /** 列表的搜索表单 */
// TODO @hw是不是用 wxselect 组件哈?
export function useGridFormSchema(): VbenFormSchema[] { export function useGridFormSchema(): VbenFormSchema[] {
return [ return [
{ {
fieldName: 'accountId', fieldName: 'accountId',
label: '公众号', label: '公众号',
component: 'ApiSelect', component: 'Input',
componentProps: {
options: accountList.map((item) => ({
label: item.name,
value: item.id,
})),
placeholder: '请选择公众号',
},
defaultValue: accountList[0]?.id,
}, },
]; ];
} }

View File

@@ -1,13 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table'; import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MpAutoReplyApi } from '#/api/mp/autoReply';
import { computed, nextTick, ref } from 'vue'; import { computed, nextTick, ref } from 'vue';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui'; import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { AutoReplyMsgType } from '@vben/constants';
import { IconifyIcon } from '@vben/icons'; import { IconifyIcon } from '@vben/icons';
import { import {
ElLoading,
ElMessage, ElMessage,
ElMessageBox, ElMessageBox,
ElRow, ElRow,
@@ -27,14 +28,13 @@ import { WxAccountSelect } from '#/views/mp/components';
import { useGridColumns, useGridFormSchema } from './data'; import { useGridColumns, useGridFormSchema } from './data';
import Content from './modules/content.vue'; import Content from './modules/content.vue';
import Form from './modules/form.vue'; import Form from './modules/form.vue';
import { MsgType } from './modules/types';
defineOptions({ name: 'MpAutoReply' }); defineOptions({ name: 'MpAutoReply' });
const msgType = ref<string>(String(MsgType.Keyword)); // 消息类型 const msgType = ref<string>(String(AutoReplyMsgType.Keyword)); // 消息类型
const showCreateButton = computed(() => { const showCreateButton = computed(() => {
if (Number(msgType.value) !== MsgType.Follow) { if (Number(msgType.value) !== AutoReplyMsgType.Follow) {
return true; return true;
} }
try { try {
@@ -61,7 +61,7 @@ async function onTabChange(tabName: string) {
msgType.value = tabName; msgType.value = tabName;
await nextTick(); await nextTick();
// 更新 columns // 更新 columns
const columns = useGridColumns(Number(msgType.value) as MsgType); const columns = useGridColumns(Number(msgType.value) as AutoReplyMsgType);
if (columns) { if (columns) {
// 使用 setGridOptions 更新列配置 // 使用 setGridOptions 更新列配置
gridApi.setGridOptions({ columns }); gridApi.setGridOptions({ columns });
@@ -77,8 +77,7 @@ async function handleCreate() {
const formValues = await gridApi.formApi.getValues(); const formValues = await gridApi.formApi.getValues();
formModalApi formModalApi
.setData({ .setData({
// TODO @hw这里和 antd 不同,需要 number 下么? msgType: Number(msgType.value) as AutoReplyMsgType,
msgType: msgType.value,
accountId: formValues.accountId, accountId: formValues.accountId,
}) })
.open(); .open();
@@ -87,14 +86,11 @@ async function handleCreate() {
/** 修改自动回复 */ /** 修改自动回复 */
async function handleEdit(row: any) { async function handleEdit(row: any) {
const data = (await getAutoReply(row.id)) as any; const data = (await getAutoReply(row.id)) as any;
// TODO @hw这里使用 formValues还是使用 row
const formValues = await gridApi.formApi.getValues();
formModalApi formModalApi
.setData({ .setData({
// TODO @hw这里和 antd 不同,需要 number 下么? msgType: Number(msgType.value) as AutoReplyMsgType,
msgType: msgType.value, accountId: row.accountId,
row: data, row: data,
accountId: formValues.accountId,
}) })
.open(); .open();
} }
@@ -102,16 +98,10 @@ async function handleEdit(row: any) {
/** 删除自动回复 */ /** 删除自动回复 */
async function handleDelete(row: any) { async function handleDelete(row: any) {
await ElMessageBox.confirm('是否确认删除此数据?'); await ElMessageBox.confirm('是否确认删除此数据?');
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', ['自动回复']), await deleteAutoReply(row.id);
}); ElMessage.success('删除成功');
try { handleRefresh();
await deleteAutoReply(row.id);
ElMessage.success('删除成功');
handleRefresh();
} finally {
loadingInstance.close();
}
} }
const [FormModal, formModalApi] = useVbenModal({ const [FormModal, formModalApi] = useVbenModal({
@@ -124,7 +114,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
schema: useGridFormSchema(), schema: useGridFormSchema(),
}, },
gridOptions: { gridOptions: {
columns: useGridColumns(Number(msgType.value) as MsgType), columns: useGridColumns(Number(msgType.value) as AutoReplyMsgType),
height: 'auto', height: 'auto',
keepSource: true, keepSource: true,
proxyConfig: { proxyConfig: {
@@ -133,7 +123,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
return await getAutoReplyPage({ return await getAutoReplyPage({
pageNo: page.currentPage, pageNo: page.currentPage,
pageSize: page.pageSize, pageSize: page.pageSize,
type: Number(msgType.value) as MsgType, type: Number(msgType.value) as AutoReplyMsgType,
...formValues, ...formValues,
}); });
}, },
@@ -148,8 +138,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
refresh: true, refresh: true,
search: true, search: true,
}, },
// TODO @hw这里要调整下linter 报错; } as VxeTableGridOptions<MpAutoReplyApi.AutoReply>,
} as VxeTableGridOptions<any>,
}); });
</script> </script>
@@ -170,14 +159,14 @@ const [Grid, gridApi] = useVbenVxeGrid({
class="w-full" class="w-full"
@tab-change="(activeName) => onTabChange(activeName as string)" @tab-change="(activeName) => onTabChange(activeName as string)"
> >
<ElTabPane :name="String(MsgType.Follow)"> <ElTabPane :name="String(AutoReplyMsgType.Follow)">
<template #label> <template #label>
<ElRow align="middle"> <ElRow align="middle">
<IconifyIcon icon="ep:star" class="mr-[2px]" /> 关注时回复 <IconifyIcon icon="ep:star" class="mr-[2px]" /> 关注时回复
</ElRow> </ElRow>
</template> </template>
</ElTabPane> </ElTabPane>
<ElTabPane :name="String(MsgType.Message)"> <ElTabPane :name="String(AutoReplyMsgType.Message)">
<template #label> <template #label>
<ElRow align="middle"> <ElRow align="middle">
<IconifyIcon icon="ep:chat-line-round" class="mr-[2px]" /> <IconifyIcon icon="ep:chat-line-round" class="mr-[2px]" />
@@ -185,7 +174,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
</ElRow> </ElRow>
</template> </template>
</ElTabPane> </ElTabPane>
<ElTabPane :name="String(MsgType.Keyword)"> <ElTabPane :name="String(AutoReplyMsgType.Keyword)">
<template #label> <template #label>
<ElRow align="middle"> <ElRow align="middle">
<IconifyIcon icon="fa:newspaper-o" class="mr-[2px]" /> <IconifyIcon icon="fa:newspaper-o" class="mr-[2px]" />

View File

@@ -4,22 +4,21 @@ import type { Reply } from '#/views/mp/components/wx-reply/types';
import { computed, nextTick, ref } from 'vue'; import { computed, nextTick, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui'; import { useVbenModal } from '@vben/common-ui';
import { AutoReplyMsgType, ReplyType } from '@vben/constants';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form'; import { useVbenForm } from '#/adapter/form';
import { createAutoReply, updateAutoReply } from '#/api/mp/autoReply'; import { createAutoReply, updateAutoReply } from '#/api/mp/autoReply';
import { $t } from '#/locales'; import { $t } from '#/locales';
import { ReplyType } from '#/views/mp/components/wx-reply/types';
import { useFormSchema } from '../data'; import { useFormSchema } from '../data';
import { MsgType } from './types';
const emit = defineEmits(['success']); const emit = defineEmits(['success']);
const formData = ref<{ const formData = ref<{
accountId?: number; accountId?: number;
msgType: MsgType; msgType: AutoReplyMsgType;
row?: any; row?: any;
}>(); }>();
const getTitle = computed(() => { const getTitle = computed(() => {
@@ -37,8 +36,7 @@ const [Form, formApi] = useVbenForm({
labelWidth: 100, labelWidth: 100,
}, },
layout: 'horizontal', layout: 'horizontal',
// TODO @hwantd 和 ele 存在差异 schema: useFormSchema(AutoReplyMsgType.Keyword),
schema: useFormSchema(Number(formData.value?.msgType) as MsgType),
showDefaultActions: false, showDefaultActions: false,
}); });
@@ -59,8 +57,7 @@ const [Modal, modalApi] = useVbenModal({
if (formData.value?.row?.id && !submitForm.id) { if (formData.value?.row?.id && !submitForm.id) {
submitForm.id = formData.value.row.id; submitForm.id = formData.value.row.id;
} }
// TODO @hwantd 和 ele 存在差异 const reply = submitForm.reply as Reply;
const reply = submitForm.reply as Reply | undefined;
if (reply) { if (reply) {
submitForm.responseMessageType = reply.type; submitForm.responseMessageType = reply.type;
submitForm.responseContent = reply.content; submitForm.responseContent = reply.content;
@@ -99,8 +96,7 @@ const [Modal, modalApi] = useVbenModal({
// 加载数据 // 加载数据
const data = modalApi.getData<{ const data = modalApi.getData<{
accountId?: number; accountId?: number;
// TODO @hwantd 和 ele 存在差异 msgType: AutoReplyMsgType;
msgType: MsgType;
row?: any; row?: any;
}>(); }>();
if (!data) { if (!data) {
@@ -138,8 +134,7 @@ const [Modal, modalApi] = useVbenModal({
accountId: data.accountId || -1, accountId: data.accountId || -1,
type: data.msgType, type: data.msgType,
requestKeyword: undefined, requestKeyword: undefined,
// TODO @hwantd 和 ele 存在差异 requestMatch: data.msgType === AutoReplyMsgType.Keyword ? 1 : undefined,
requestMatch: data.msgType === MsgType.Keyword ? 1 : undefined,
requestMessageType: undefined, requestMessageType: undefined,
reply: { reply: {
type: ReplyType.Text, type: ReplyType.Text,

View File

@@ -1,8 +0,0 @@
// 消息类型Follow: 关注时回复Message: 消息回复Keyword: 关键词回复)
// 作为 tab.nameenum 的数字不能随意修改,与 api 参数相关
// TODO @hwele 相比 antd 多了,看看要不要统一下;
export enum MsgType {
Follow = 1,
Keyword = 3,
Message = 2,
}

View File

@@ -1,22 +1,9 @@
export { default as WxAccountSelect } from './wx-account-select/wx-account-select.vue'; export { default as WxAccountSelect } from './wx-account-select/wx-account-select.vue';
export { default as WxLocation } from './wx-location/wx-location.vue'; export { default as 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 { default as WxMaterialSelect } from './wx-material-select/wx-material-select.vue';
export { default as WxMsg } from './wx-msg/msg.vue';
export * from './wx-msg/types';
export { default as WxMusic } from './wx-music/wx-music.vue'; export { default as WxMusic } from './wx-music/wx-music.vue';
export { default as WxNews } from './wx-news/wx-news.vue'; export { default as WxNews } from './wx-news/wx-news.vue';
export { default as WxReply } from './wx-reply/wx-reply.vue';
export * from './wx-reply/types';
export { default as WxReplySelect } from './wx-reply/wx-reply.vue';
export { default as WxVideoPlayer } from './wx-video-play/wx-video-play.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'; export { default as WxVoicePlayer } from './wx-voice-play/wx-voice-play.vue';
// TODO @hw是不是要和 antd 保持一致哈?

View File

@@ -1,3 +0,0 @@
export { default } from './wx-account-select.vue';
// TODO @hw每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?

View File

@@ -1,129 +1,63 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { MpAccountApi } from '#/api/mp/account'; 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 { useRouter } from 'vue-router';
import { useTabs } from '@vben/hooks';
import { ElMessage, ElOption, ElSelect } from 'element-plus'; import { ElMessage, ElOption, ElSelect } from 'element-plus';
import { getSimpleAccountList } from '#/api/mp/account'; import { getSimpleAccountList } from '#/api/mp/account';
// TODO @hw调整下代码和 antd 代码风格,尽量保持一致; defineOptions({ name: 'WxAccountSelect' });
defineOptions({ name: 'AccountSelect' });
const props = defineProps<{
modelValue?: number;
}>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'change', id: number, name: string): void; (e: 'change', id: number, name: string): void;
(e: 'update:modelValue', id: number): void;
}>(); }>();
const message = ElMessage; // 消息弹窗
const { closeCurrentTab } = useTabs(); // 视图操作
const { push } = useRouter(); const { push } = useRouter();
const account: MpAccountApi.AccountSimple = reactive({ const account = ref<MpAccountApi.Account>({
id: -1, id: -1,
name: '', name: '',
}); }); // 当前选中的公众号
const accountList = ref<MpAccountApi.Account[]>([]); // 公众号列表
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;
}
}
},
);
/** 查询公众号列表 */ /** 查询公众号列表 */
async function handleQuery() { async function handleQuery() {
accountList.value = await getSimpleAccountList(); accountList.value = await getSimpleAccountList();
if (accountList.value.length === 0) { if (accountList.value.length === 0) {
message.error('未配置公众号,请在【公众号管理 -> 账号管理】菜单,进行配置'); ElMessage.error(
await closeCurrentTab(); '未配置公众号,请在【公众号管理 -> 账号管理】菜单,进行配置',
);
await push({ name: 'MpAccount' }); await push({ name: 'MpAccount' });
return; return;
} }
// 如果外部没有传入值modelValue 为空),默认选中第一个 // 默认选中第一个,如无数据则不执行
if (props.modelValue === undefined || props.modelValue === null) { const first = accountList.value[0];
const firstAccount = accountList.value[0]; if (first) {
if (firstAccount) { account.value.id = first.id;
currentId.value = firstAccount.id; account.value.name = first.name;
account.name = firstAccount.name; emit('change', account.value.id, account.value.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;
}
} }
} }
/** 公众号变化 */ /** 切换选中公众号 */
function onChanged(id?: number) { function onChanged(id: number) {
if (id) { const found = accountList.value.find((v) => v.id === id);
currentId.value = id; if (found) {
account.value.name = found.name;
emit('change', account.value.id, account.value.name);
} }
} }
/** 初始化 */ /** 初始化 */
onMounted(() => { onMounted(handleQuery);
handleQuery();
});
</script> </script>
<template> <template>
<ElSelect <ElSelect
v-model="currentId" v-model="account.id"
placeholder="请选择公众号" placeholder="请选择公众号"
class="!w-full" class="!w-full"
@change="onChanged" @change="onChanged"

View File

@@ -1,3 +0,0 @@
export { default as WxLocation } from './wx-location.vue';
// TODO @hw每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?

View File

@@ -0,0 +1,6 @@
export interface WxLocationProps {
label: string;
locationX: number;
locationY: number;
qqMapKey?: string;
}

View File

@@ -2,6 +2,10 @@
微信消息 - 定位TODO @Dhb52 目前未启用@hw看看目前是不是没用起来哈 微信消息 - 定位TODO @Dhb52 目前未启用@hw看看目前是不是没用起来哈
--> -->
<script lang="ts" setup> <script lang="ts" setup>
import type { WxLocationProps } from './types';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons'; import { IconifyIcon } from '@vben/icons';
// TODO @dylan@hwapps/web-antd/src/views/mall/trade/delivery/pickUpStore/modules/form.vue 参考这个,从后端拿 key 哈 // TODO @dylan@hwapps/web-antd/src/views/mall/trade/delivery/pickUpStore/modules/form.vue 参考这个,从后端拿 key 哈
@@ -9,27 +13,16 @@ import { ElCol, ElLink, ElRow } from 'element-plus';
defineOptions({ name: 'Location' }); defineOptions({ name: 'Location' });
// TODO @hwantd 和 ele 这里的风格,看看怎么统一! const props = withDefaults(defineProps<WxLocationProps>(), {
const props = defineProps({ qqMapKey: 'TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E', // QQ 地图的密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc
locationX: { });
required: true,
type: Number, 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`;
locationY: { });
required: true,
type: Number, const mapImageUrl = computed(() => {
}, return `https://apis.map.qq.com/ws/staticmap/v2/?zoom=10&markers=color:blue|label:A|${props.locationX},${props.locationY}&key=${props.qqMapKey}&size=250*180`;
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', // 需要自定义
},
}); });
defineExpose({ defineExpose({
@@ -43,20 +36,10 @@ defineExpose({
<template> <template>
<!-- 微信消息 - 定位 --> <!-- 微信消息 - 定位 -->
<div> <div>
<ElLink <ElLink type="primary" target="_blank" :href="mapUrl">
type="primary"
target="_blank"
:href="`https://map.qq.com/?type=marker&isopeninfowin=1&markertype=1&pointx=${
locationY
}&pointy=${locationX}&name=${label}&ref=yudao`"
>
<ElCol> <ElCol>
<ElRow> <ElRow>
<img <img :src="mapImageUrl" alt="地图位置" />
:src="`https://apis.map.qq.com/ws/staticmap/v2/?zoom=10&markers=color:blue|label:A|${
locationX
},${locationY}&key=${qqMapKey}&size=250*180`"
/>
</ElRow> </ElRow>
<ElRow> <ElRow>
<IconifyIcon icon="ep:location" /> <IconifyIcon icon="ep:location" />

View File

@@ -1,5 +0,0 @@
export { MaterialType, NewsType } from './types';
export { default } from './wx-material-select.vue';
// TODO @hw每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?

View File

@@ -1,12 +0,0 @@
// TODO @hw这里的枚举看看要不要统一
export enum NewsType {
Draft = '2',
Published = '1',
}
export enum MaterialType {
Image = 'image',
News = 'news',
Video = 'video',
Voice = 'voice',
}

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { NewsType } from '@vben/constants';
import { IconifyIcon } from '@vben/icons'; import { IconifyIcon } from '@vben/icons';
import { formatTime } from '@vben/utils'; import { formatTime } from '@vben/utils';
@@ -19,9 +20,7 @@ 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 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 VoicePlayer from '#/views/mp/components/wx-voice-play/wx-voice-play.vue';
import { NewsType } from './types'; // TODO @hw代码风格看看 antd 和 ele 是不是统一下; 等antd此组件修改完再调整
// TODO @hw代码风格看看 antd 和 ele 是不是统一下;
/** 微信素材选择 */ /** 微信素材选择 */
defineOptions({ name: 'MaterialSelect' }); defineOptions({ name: 'MaterialSelect' });

View File

@@ -1,5 +0,0 @@
export { MsgType } from './types';
export { default } from './wx-msg.vue';
// TODO @hw每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?

View File

@@ -8,6 +8,7 @@ const props = defineProps<{
}>(); }>();
// TODO @hw看看用 antd 的风格,还是 ele 的风格,就是下面的 item。 // TODO @hw看看用 antd 的风格,还是 ele 的风格,就是下面的 item。
// 等antd修改完再调整
const item = ref(props.item); const item = ref(props.item);
</script> </script>

View File

@@ -1,22 +1,17 @@
<script lang="ts" setup> <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 { formatDateTime } from '@vben/utils';
import avatarWechat from '#/assets/imgs/wechat.png'; import Msg from './msg.vue';
import Msg from './wx-msg.vue';
// 确保 User 类型被识别为已使用
// TODO @hw是不是不用 PropsUser 哈?
type PropsUser = User;
defineOptions({ name: 'MsgList' }); defineOptions({ name: 'MsgList' });
const props = defineProps<{ const props = defineProps<{
accountId: number; accountId: number;
list: any[]; list: any[];
user: PropsUser; user: Partial<MpUserApi.User>;
}>(); }>();
const SendFrom = { const SendFrom = {
@@ -24,46 +19,39 @@ const SendFrom = {
User: 1, User: 1,
} as const; // 发送来源 } as const; // 发送来源
// TODO @hw是不是用 SendFrom ,或者 number function getAvatar(sendFrom: number) {
type SendFromType = (typeof SendFrom)[keyof typeof SendFrom]; return sendFrom === SendFrom.User
? props.user.avatar
: preferences.app.defaultAvatar;
}
// 显式引用枚举成员供模板使用 function getNickname(sendFrom: number) {
// TODO @hw是不是用 SendFrom 就好啦? return sendFrom === SendFrom.User ? props.user.nickname : '公众号';
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 : '公众号';
</script> </script>
<template> <template>
<div v-for="item in props.list" :key="item.id"> <div class="execution" v-for="item in props.list" :key="item.id">
<div <div
class="mb-[30px] flex items-start" class="mp-comment"
:class="{ 'flex-row-reverse': item.sendFrom === MpBotValue }" :class="{ 'mp-comment--reverse': item.sendFrom === SendFrom.MpBot }"
> >
<div class="w-20 text-center"> <div class="avatar-div">
<img <img :src="getAvatar(item.sendFrom)" class="mp-comment__avatar" />
:src="getAvatar(item.sendFrom)" <div class="mp-comment__author">
class="box-border h-12 w-12 rounded-full border border-transparent align-middle"
/>
<div class="text-sm font-bold text-[#999]">
{{ getNickname(item.sendFrom) }} {{ getNickname(item.sendFrom) }}
</div> </div>
</div> </div>
<div class="relative mx-5 flex-1 rounded-[5px] border border-[#dedede]"> <div class="mp-comment__main">
<div <div class="mp-comment__header">
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="mp-comment__create_time">
{{ formatDateTime(item.createTime) }} {{ formatDateTime(item.createTime) }}
</div> </div>
</div> </div>
<div <div
class="overflow-hidden rounded-b-[5px] bg-white px-[15px] py-[15px] text-sm text-[#333]" class="mp-comment__body"
:style="item.sendFrom === MpBotValue ? 'background: #6BED72;' : ''" :style="
item.sendFrom === SendFrom.MpBot ? 'background: #6BED72;' : ''
"
> >
<Msg :item="item" /> <Msg :item="item" />
</div> </div>
@@ -74,8 +62,14 @@ const getNickname = (sendFrom: SendFromType) =>
<style lang="scss" scoped> <style lang="scss" scoped>
/* 因为 joolun 实现依赖 avue 组件,该页面使用了 comment.scss、card.scc */ /* 因为 joolun 实现依赖 avue 组件,该页面使用了 comment.scss、card.scc */
/** TODO @dylan@hw 看看有没适合 tindwind 的哈。 */
@import url('../comment.scss'); /** TODO @hw 看看有没适合 tindwind 的哈。 */
@import url('../card.scss');
@import url('./comment.scss');
@import url('./card.scss');
.avatar-div {
width: 80px;
text-align: center;
}
</style> </style>

View File

@@ -0,0 +1,89 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { MpMsgType } from '@vben/constants';
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 MsgEvent from './msg-event.vue';
defineOptions({ name: 'Msg' });
const props = defineProps<{
item: any;
}>();
const item = ref<any>(props.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">
<VoicePlayer :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"
>
<VideoPlayer :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">
<Location
:label="item.label"
:location-y="item.locationY"
:location-x="item.locationX"
/>
</div>
<div v-else-if="item.type === MpMsgType.News" class="w-[300px]">
<News :articles="item.articles" />
</div>
<div v-else-if="item.type === MpMsgType.Music">
<Music
:title="item.title"
:description="item.description"
:thumb-media-url="item.thumbMediaUrl"
:music-url="item.musicUrl"
:hq-music-url="item.hqMusicUrl"
/>
</div>
</div>
</template>

View File

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

View File

@@ -1,90 +1,176 @@
<script lang="ts" setup> <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 { nextTick, onMounted, reactive, ref, unref } from '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 { MsgType } from '../types'; import { preferences } from '@vben/preferences';
import MsgEvent from './msg-event.vue';
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<{ 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); // WxReplySelect组件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.reverse();
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> </script>
<template> <template>
<div> <div class="flex h-full flex-col">
<MsgEvent v-if="item.type === MsgType.Event" :item="item" /> <div ref="msgDivRef" class="mx-2.5 flex-1 overflow-auto bg-[#eaeaea]">
<!-- 加载更多 -->
<div v-else-if="item.type === MsgType.Text">{{ item.content }}</div> <div v-loading="loading"></div>
<div v-if="!loading">
<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 <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> </div>
</el-link>
<div
class="h-auto overflow-hidden text-[rgba(0,0,0,0.45)]"
style="height: unset"
>
{{ item.description }}
</div> </div>
<!-- 消息列表 -->
<MsgList :list="list" :account-id="accountId" :user="user" />
</div> </div>
<div v-else-if="item.type === MsgType.Location"> <div class="p-2.5" v-loading="sendLoading">
<Location <WxReply ref="replySelectRef" v-model="reply" />
:label="item.label" <ElButton type="primary" class="float-right mb-2 mt-2" @click="sendMsg">
:location-y="item.locationY" 发送(S)
:location-x="item.locationX" </ElButton>
/>
</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> </div>
</div> </div>
</template> </template>

View File

@@ -1,3 +0,0 @@
export { default } from './wx-music.vue';
// TODO @hw每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ElLink } from 'element-plus';
/** 微信消息 - 音乐 */ /** 微信消息 - 音乐 */
defineOptions({ name: 'Music' }); defineOptions({ name: 'Music' });
@@ -36,8 +37,7 @@ defineExpose({
<template> <template>
<div> <div>
<!-- TODO @hwElLink --> <ElLink
<el-link
type="success" type="success"
:underline="false" :underline="false"
target="_blank" target="_blank"
@@ -56,7 +56,7 @@ defineExpose({
</div> </div>
</div> </div>
</div> </div>
</el-link> </ElLink>
</div> </div>
</template> </template>

View File

@@ -1,3 +0,0 @@
export { default as WxNews } from './wx-news.vue';
// TODO @hw每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?

View File

@@ -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 暴露就好了?

View File

@@ -28,12 +28,9 @@ const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void; (e: 'update:modelValue', v: Reply): void;
}>(); }>();
// TODO @hw直接用 ElMessage const accessStore = useAccessStore();
const message = ElMessage;
const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`; const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`;
// TODO @hw看看要不要和 antd 保持一致的风格; const HEADERS = { Authorization: `Bearer ${accessStore.accessToken}` };
const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` };
const reply = computed<Reply>({ const reply = computed<Reply>({
get: () => props.modelValue, get: () => props.modelValue,
set: (val) => emit('update:modelValue', val), set: (val) => emit('update:modelValue', val),
@@ -56,7 +53,7 @@ function beforeImageUpload(rawFile: UploadRawFile) {
/** 上传成功 */ /** 上传成功 */
function onUploadSuccess(res: any) { function onUploadSuccess(res: any) {
if (res.code !== 0) { if (res.code !== 0) {
message.error(`上传出错:${res.msg}`); ElMessage.error(`上传出错:${res.msg}`);
return false; return false;
} }
@@ -136,6 +133,7 @@ function selectMaterial(item: any) {
:span="12" :span="12"
class="float-right h-[160px] w-[49.5%] border border-[rgb(234,234,234)] py-[50px]" class="float-right h-[160px] w-[49.5%] border border-[rgb(234,234,234)] py-[50px]"
> >
{{ uploadData }}
<ElUpload <ElUpload
:action="UPLOAD_URL" :action="UPLOAD_URL"
:headers="HEADERS" :headers="HEADERS"
@@ -158,4 +156,4 @@ function selectMaterial(item: any) {
</ElCol> </ElCol>
</ElRow> </ElRow>
</div> </div>
</template> </template>

View File

@@ -1,7 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { UploadRawFile } from 'element-plus'; import type { UploadRawFile } from 'element-plus';
// TODO @hw类似 tab-image.vue 的建议
import type { Reply } from './types'; import type { Reply } from './types';
import { computed, reactive, ref } from 'vue'; import { computed, reactive, ref } from 'vue';
@@ -30,8 +29,6 @@ const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void; (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 UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`;
const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` }; const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` };
const reply = computed<Reply>({ const reply = computed<Reply>({
@@ -56,7 +53,7 @@ function beforeImageUpload(rawFile: UploadRawFile) {
/** 上传成功 */ /** 上传成功 */
function onUploadSuccess(res: any) { function onUploadSuccess(res: any) {
if (res.code !== 0) { if (res.code !== 0) {
message.error(`上传出错:${res.msg}`); ElMessage.error(`上传出错:${res.msg}`);
return false; return false;
} }

View File

@@ -3,6 +3,7 @@ import type { Reply } from './types';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { NewsType } from '@vben/constants';
import { IconifyIcon } from '@vben/icons'; import { IconifyIcon } from '@vben/icons';
import { ElButton, ElCol, ElDialog, ElRow } from 'element-plus'; 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 MaterialSelect from '#/views/mp/components/wx-material-select/wx-material-select.vue';
import News from '#/views/mp/components/wx-news/wx-news.vue'; import News from '#/views/mp/components/wx-news/wx-news.vue';
import { NewsType } from '../wx-material-select/types';
defineOptions({ name: 'TabNews' }); defineOptions({ name: 'TabNews' });
const props = defineProps<{ const props = defineProps<{

View File

@@ -31,18 +31,13 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void; (e: 'update:modelValue', v: Reply): void;
}>(); }>();
const accessStore = useAccessStore();
// TODO @hw还是用 ElMessage
const message = ElMessage;
const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`; const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`;
// TODO @hw这里 antd 和 ele 有差异,要统一么? const HEADERS = { Authorization: `Bearer ${accessStore.accessToken}` };
const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` };
const reply = computed<Reply>({ const reply = computed<Reply>({
get: () => props.modelValue, get: () => props.modelValue,
// TODO @hw这里 antd 和 ele 有差异,要统一么? set: (val) => emit('update:modelValue', val),
set: (val: Reply) => emit('update:modelValue', val),
}); });
const showDialog = ref(false); const showDialog = ref(false);
@@ -62,7 +57,7 @@ function beforeVideoUpload(rawFile: UploadRawFile) {
/** 上传成功 */ /** 上传成功 */
function onUploadSuccess(res: any) { function onUploadSuccess(res: any) {
if (res.code !== 0) { if (res.code !== 0) {
message.error(`上传出错:${res.msg}`); ElMessage.error(`上传出错:${res.msg}`);
return false; return false;
} }

View File

@@ -31,16 +31,13 @@ const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void; (e: 'update:modelValue', v: Reply): void;
}>(); }>();
// TODO @hw用 ElMessage const accessStore = useAccessStore();
const message = ElMessage;
const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`; const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`;
// TODO @hwantd 和 ele 写法的统一; const HEADERS = { Authorization: `Bearer ${accessStore.accessToken}` };
const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` };
const reply = computed<Reply>({ const reply = computed<Reply>({
get: () => props.modelValue, get: () => props.modelValue,
// TODO @hw这里要和 antd 统一么?还是 ele 和它统一 set: (val) => emit('update:modelValue', val),
set: (val: Reply) => emit('update:modelValue', val),
}); });
const showDialog = ref(false); const showDialog = ref(false);
@@ -60,7 +57,7 @@ function beforeVoiceUpload(rawFile: UploadRawFile) {
/** 上传成功 */ /** 上传成功 */
function onUploadSuccess(res: any) { function onUploadSuccess(res: any) {
if (res.code !== 0) { if (res.code !== 0) {
message.error(`上传出错:${res.msg}`); ElMessage.error(`上传出错:${res.msg}`);
return false; return false;
} }
@@ -158,4 +155,4 @@ function selectMaterial(item: Reply) {
</ElCol> </ElCol>
</ElRow> </ElRow>
</div> </div>
</template> </template>

View File

@@ -1,38 +1,28 @@
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import type { ReplyType } from '@vben/constants';
import { unref } from 'vue'; import { unref } from 'vue';
// TODO @hw和 antd 风格,保持一致; export interface Reply {
enum ReplyType {
Image = 'image',
Music = 'music',
News = 'news',
Text = 'text',
Video = 'video',
Voice = 'voice',
}
interface _Reply {
accountId: number; accountId: number;
type: ReplyType; articles?: any[];
name?: null | string;
content?: null | string; content?: null | string;
mediaId?: null | string;
url?: null | string;
title?: null | string;
description?: null | string; description?: null | string;
thumbMediaId?: null | string;
thumbMediaUrl?: null | string;
musicUrl?: null | string;
hqMusicUrl?: null | string; hqMusicUrl?: null | string;
introduction?: 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 */ /** 利用旧的 reply[accountId, type] 初始化新的 Reply */
const createEmptyReply = (old: Ref<Reply> | Reply): Reply => { export const createEmptyReply = (old: Ref<Reply> | Reply): Reply => {
return { return {
accountId: unref(old).accountId, accountId: unref(old).accountId,
type: unref(old).type, type: unref(old).type,
@@ -50,5 +40,3 @@ const createEmptyReply = (old: Ref<Reply> | Reply): Reply => {
articles: [], articles: [],
}; };
}; };
export { createEmptyReply, type Reply, ReplyType };

View File

@@ -1,38 +1,36 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Reply } from './types'; 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 { IconifyIcon } from '@vben/icons';
import { ElRow, ElTabPane, ElTabs } from 'element-plus'; import { ElRow, ElTabPane, ElTabs } from 'element-plus';
import { NewsType } from '../wx-material-select/types';
import TabImage from './tab-image.vue'; import TabImage from './tab-image.vue';
import TabMusic from './tab-music.vue'; import TabMusic from './tab-music.vue';
import TabNews from './tab-news.vue'; import TabNews from './tab-news.vue';
import TabText from './tab-text.vue'; import TabText from './tab-text.vue';
import TabVideo from './tab-video.vue'; import TabVideo from './tab-video.vue';
import TabVoice from './tab-voice.vue'; import TabVoice from './tab-voice.vue';
import { createEmptyReply, ReplyType } from './types'; import { createEmptyReply } from './types';
/** 消息回复选择 */ /** 消息回复选择 */
defineOptions({ name: 'WxReplySelect' }); defineOptions({ name: 'WxReplySelect' });
const props = withDefaults( const props = withDefaults(defineProps<Props>(), {
defineProps<{ newsType: undefined,
modelValue: Reply | undefined; });
newsType?: NewsType;
}>(),
{
newsType: () => NewsType.Published,
},
);
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void; (e: 'update:modelValue', v: Reply): void;
}>(); }>();
interface Props {
modelValue: Reply | undefined;
newsType?: NewsType;
}
const defaultReply: Reply = { const defaultReply: Reply = {
accountId: -1, accountId: -1,
type: ReplyType.Text, type: ReplyType.Text,
@@ -43,6 +41,67 @@ const reply = computed<Reply>({
set: (val) => emit('update:modelValue', val), 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`的字段 */ /** 清除除了`type`, `accountId`的字段 */
function clear() { function clear() {
reply.value = createEmptyReply(reply); reply.value = createEmptyReply(reply);
@@ -105,4 +164,4 @@ defineExpose({
<TabMusic v-model="reply" /> <TabMusic v-model="reply" />
</ElTabPane> </ElTabPane>
</ElTabs> </ElTabs>
</template> </template>

View File

@@ -1,3 +0,0 @@
export { default } from './wx-video-play.vue';
// TODO @hw每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?

View File

@@ -11,13 +11,9 @@ import 'video.js/dist/video-js.css';
/** 微信消息 - 视频 */ /** 微信消息 - 视频 */
defineOptions({ name: 'WxVideoPlayer' }); defineOptions({ name: 'WxVideoPlayer' });
// TODO @hwantd 或者 eleprops 保持一致; const props = defineProps<{
const props = defineProps({ url: string;
url: { }>();
type: String,
required: true,
},
});
const dialogVideo = ref(false); const dialogVideo = ref(false);
@@ -47,19 +43,6 @@ const playVideo = () => {
:width="800" :width="800"
:playback-rates="[0.7, 1.0, 1.5, 2.0]" :playback-rates="[0.7, 1.0, 1.5, 2.0]"
/> />
<!-- TODO @hw删除掉 -->
<!-- 事件暫時沒用
@mounted="handleMounted"-->
<!-- @ready="handleEvent($event)"-->
<!-- @play="handleEvent($event)"-->
<!-- @pause="handleEvent($event)"-->
<!-- @ended="handleEvent($event)"-->
<!-- @loadeddata="handleEvent($event)"-->
<!-- @waiting="handleEvent($event)"-->
<!-- @playing="handleEvent($event)"-->
<!-- @canplay="handleEvent($event)"-->
<!-- @canplaythrough="handleEvent($event)"-->
<!-- @timeupdate="handleEvent(player?.currentTime())"-->
</ElDialog> </ElDialog>
</div> </div>
</template> </template>

View File

@@ -1,3 +0,0 @@
export { default } from './wx-voice-play.vue';
// TODO @hw每个组件下的 index.ts 要不都删除,统一在 mp/components/index.ts 暴露就好了?

View File

@@ -1,7 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Article } from './modules/types';
import type { VxeTableGridOptions } from '#/adapter/vxe-table'; import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MpDraftApi } from '#/api/mp/draft';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui'; import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
@@ -9,7 +8,7 @@ import { ElLoading, ElMessage, ElMessageBox } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'; import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteDraft, getDraftPage } from '#/api/mp/draft'; import { deleteDraft, getDraftPage } from '#/api/mp/draft';
import * as MpFreePublishApi from '#/api/mp/freePublish'; import { submitFreePublish } from '#/api/mp/freePublish';
import { WxAccountSelect } from '#/views/mp/components'; import { WxAccountSelect } from '#/views/mp/components';
import { createEmptyNewsItem } from '#/views/mp/draft/modules/types'; import { createEmptyNewsItem } from '#/views/mp/draft/modules/types';
@@ -19,11 +18,6 @@ import Form from './modules/form.vue';
defineOptions({ name: 'MpDraft' }); defineOptions({ name: 'MpDraft' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */ /** 刷新表格 */
function handleRefresh() { function handleRefresh() {
gridApi.query(); gridApi.query();
@@ -35,7 +29,91 @@ function handleAccountChange(accountId: number) {
gridApi.formApi.submitForm(); gridApi.formApi.submitForm();
} }
// TODO @hw代码风格要和对应的 antd index.vue 一致,类似方法的顺序,注释等。原因是,这样后续两端迭代,会方便很多。 /** 新增草稿 */
async function handleCreate() {
const formValues = await gridApi.formApi.getValues();
const accountId = formValues.accountId;
if (!accountId) {
ElMessage.warning('请先选择公众号');
return;
}
formModalApi
.setData({
isCreating: true,
accountId,
newsList: [createEmptyNewsItem()],
})
.open();
}
/** 修改草稿 */
async function handleEdit(row: MpDraftApi.DraftArticle) {
const formValues = await gridApi.formApi.getValues();
const accountId = formValues.accountId;
if (!accountId) {
ElMessage.warning('请先选择公众号');
return;
}
formModalApi
.setData({
isCreating: false,
accountId,
mediaId: row.mediaId,
newsList: row.content.newsItem,
})
.open();
}
/** 删除草稿 */
async function handleDelete(row: MpDraftApi.DraftArticle) {
const formValues = await gridApi.formApi.getValues();
const accountId = formValues.accountId;
if (!accountId) {
ElMessage.warning('请先选择公众号');
return;
}
await ElMessageBox.confirm('此操作将永久删除该草稿, 是否继续?');
const hideLoading = ElLoading.service({
text: '删除中...',
});
try {
await deleteDraft(accountId, row.mediaId);
ElMessage.success('删除成功');
handleRefresh();
} finally {
hideLoading.close();
}
}
/** 发布草稿 */
async function handlePublish(row: MpDraftApi.DraftArticle) {
const formValues = await gridApi.formApi.getValues();
const accountId = formValues.accountId;
if (!accountId) {
ElMessage.warning('请先选择公众号');
return;
}
await ElMessageBox.confirm(
'你正在通过发布的方式发表内容。 发布不占用群发次数,一天可多次发布。' +
'已发布内容不会推送给用户,也不会展示在公众号主页中。 ' +
'发布后,你可以前往发表记录获取链接,也可以将发布内容添加到自定义菜单、自动回复、话题和页面模板中。',
);
const hideLoading = ElLoading.service({
text: '发布中...',
});
try {
await submitFreePublish(accountId, row.mediaId);
ElMessage.success('发布成功');
handleRefresh();
} finally {
hideLoading.close();
}
}
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [Grid, gridApi] = useVbenVxeGrid({ const [Grid, gridApi] = useVbenVxeGrid({
formOptions: { formOptions: {
@@ -48,6 +126,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
proxyConfig: { proxyConfig: {
ajax: { ajax: {
query: async ({ page }, formValues) => { query: async ({ page }, formValues) => {
// 调试用:跳过请求,直接返回模拟数据
const drafts = await getDraftPage({ const drafts = await getDraftPage({
pageNo: page.currentPage, pageNo: page.currentPage,
pageSize: page.pageSize, pageSize: page.pageSize,
@@ -62,9 +141,13 @@ const [Grid, gridApi] = useVbenVxeGrid({
}); });
} }
}); });
// 模拟数据
// const mockList = [
return { return {
list: drafts.list as unknown as Article[], list: drafts.list,
total: drafts.total, total: drafts.total, // 模拟总数
}; };
}, },
}, },
@@ -78,101 +161,8 @@ const [Grid, gridApi] = useVbenVxeGrid({
refresh: true, refresh: true,
search: true, search: true,
}, },
} as VxeTableGridOptions<Article>, } as VxeTableGridOptions<MpDraftApi.DraftArticle>,
}); });
/** 新增按钮操作 */
async function handleCreate() {
const formValues = await gridApi.formApi.getValues();
const accountId = formValues.accountId;
if (!accountId || accountId === -1) {
ElMessage.warning('请先选择公众号');
return;
}
formModalApi
.setData({
isCreating: true,
accountId,
newsList: [createEmptyNewsItem()],
})
.open();
}
/** 修改按钮操作 */
async function handleEdit(row: Article) {
const formValues = await gridApi.formApi.getValues();
const accountId = formValues.accountId;
if (!accountId || accountId === -1) {
ElMessage.warning('请先选择公众号');
return;
}
formModalApi
.setData({
isCreating: false,
accountId,
mediaId: row.mediaId,
newsList: row.content.newsItem,
})
.open();
}
/** 发布按钮操作 */
async function handlePublish(row: Article) {
const formValues = await gridApi.formApi.getValues();
const accountId = formValues.accountId;
if (!accountId || accountId === -1) {
ElMessage.warning('请先选择公众号');
return;
}
const content =
'你正在通过发布的方式发表内容。 发布不占用群发次数,一天可多次发布。' +
'已发布内容不会推送给用户,也不会展示在公众号主页中。 ' +
'发布后,你可以前往发表记录获取链接,也可以将发布内容添加到自定义菜单、自动回复、话题和页面模板中。';
try {
await ElMessageBox.confirm(content);
const loadingInstance = ElLoading.service({
text: '发布中...',
});
try {
await MpFreePublishApi.submitFreePublish(accountId, row.mediaId);
ElMessage.success('发布成功');
await gridApi.query();
} finally {
loadingInstance.close();
}
} catch {
//
}
}
/** 删除按钮操作 */
async function handleDelete(row: Article) {
const formValues = await gridApi.formApi.getValues();
const accountId = formValues.accountId;
if (!accountId) {
ElMessage.warning('请先选择公众号');
return;
}
try {
await ElMessageBox.confirm('此操作将永久删除该草稿, 是否继续?');
const loadingInstance = ElLoading.service({
text: '删除中...',
});
try {
await deleteDraft(accountId, row.mediaId);
ElMessage.success('删除成功');
handleRefresh();
} finally {
loadingInstance.close();
}
} catch {
//
}
}
</script> </script>
<template> <template>

View File

@@ -1,165 +0,0 @@
// TODO @hw要不要删除
export default {
list: [
{
mediaId:
'r6ryvl6LrxBU0miaST4Y-q-G9pdsmZw0OYG4FzHQkKfpLfEwIH51wy2bxisx8PvW',
content: {
newsItem: [
{
title: '我是标题OOO',
author: '我是作者',
digest: '我是摘要',
content: '我是内容',
contentSourceUrl: 'https://www.iocoder.cn',
thumbMediaId:
'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn',
showCoverPic: 0,
needOpenComment: 0,
onlyFansCanComment: 0,
url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9XaFphcmtJVFh3VEc4Q1MxQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuN2QxTE56SFBCYXc2RE9NcUxIeS1CQjJuUHhTWjBlN2VOeGRpRi1fZUhwN1FNQjdrQV9yRU9EU0hibHREZmZoVW5acnZrN3ZjaWsxejR3RGpKczBzTHFIM0dFNFZWVkpBc0dWWlAzUEhlVmpnfn4%3D&chksm=1f6354802814dd969ef83c0f3babe555c614270b30bc383beaf7ffd13b0257f0fe5ced9af694#rd',
thumbUrl:
'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png',
},
{
title: '我是标题XXX',
author: '我是作者',
digest: '我是摘要',
content: '我是内容',
contentSourceUrl: 'https://www.iocoder.cn',
thumbMediaId:
'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn',
showCoverPic: 0,
needOpenComment: 0,
onlyFansCanComment: 0,
url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9yTlYwOEs1clpwcE5OUEhCQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuN0NSMjFqN3N1aUZMbFNVLTZHN2ZDME9qOGp2THk2RFNlSTlKZ3Y1czFVZDdQQm5IeUg3dEppSUtpQUh5SExOOTRkT3dHNUdBdHdWSWlOendlREV3dS1jUEVQbFpiVTZmVW5iRWhZcGdkNTFRfn4%3D&chksm=1f6354802814dd96a403151cd44c7da4eecf0e475d25423e46ecd795b513bafd829a75daef9b#rd',
thumbUrl:
'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png',
},
],
},
updateTime: 1_673_655_730,
},
{
mediaId:
'r6ryvl6LrxBU0miaST4Y-jGpXnO73ihN0lsNXknCRQHapp2xgHMRxHKG50LituFe',
content: {
newsItem: [
{
title: '我是标题(修改)',
author: '我是作者',
digest: '我是摘要',
content: '我是内容',
contentSourceUrl: 'https://www.iocoder.cn',
thumbMediaId:
'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn',
showCoverPic: 0,
needOpenComment: 0,
onlyFansCanComment: 0,
url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl95WVFXYndIZnZJd0t5cjgvQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuN1dlNURPbWswbEF4RDd5dVJTdjQ4cm9Cc0Q1TWhpMUh6SE1hVEE3ZHljaHhlZjZYSGF5N2JNSHpDTlh6ajNZbkpGTGpTcUQ4M3NMdW41ZUpXNFZZQ1VKbVlaMVp5ekxEV1czREdsY1dOYTZnfn4%3D&chksm=1f6354be2814dda8e6238037c2ebd52b1c8e80e93249a861ad80e4d40e5ca7207233475ca689#rd',
thumbUrl:
'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png',
},
],
},
updateTime: 1_673_655_584,
},
{
mediaId:
'r6ryvl6LrxBU0miaST4Y-v5SrbNCPpD6M_p3TmSrYwTjKogs-0DMJgmjMyNZPeMO',
content: {
newsItem: [
{
title: '1321',
author: '3232',
digest: '1333',
content: '<p>444</p>',
contentSourceUrl: 'http://www.iocoder.cn',
thumbMediaId:
'r6ryvl6LrxBU0miaST4Y-tlQmcl3RdC-Jcgns6IQtf7zenGy3b86WLT7GzUcrb1T',
showCoverPic: 0,
needOpenComment: 0,
onlyFansCanComment: 0,
url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9jelJiaDAzbmdpSkJOZ2M2QWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNDNXVVc2ZDRYeTY0Zm1weXR6dE9vQWh1TzEwbEpUVnRfVzJyaGFDNXBkZ0ZXM2JFOTNaRHNhOHRUeFdEanhMeS01X01kMUNWQ1BpRER3cjYwTl9pMnpFLUJhZXFucVVfM1pDUXlTUEl1S25nfn4%3D&chksm=1f6354bc2814ddaa56a90ad5bc3d078601c8d1589ba01827a8170587bc830ff9747b5f59c3a0#rd',
thumbUrl:
'http://mmbiz.qpic.cn/mmbiz_png/btUmCVHwbJUoicwBiacjVeQbu6QxgBVrukfSJXz509boa21SpH8OVHAqXCJiaiaAaHQJNxwwsa0gHRXVr0G5EZYamw/0?wx_fmt=png',
},
],
},
updateTime: 1_673_628_969,
},
{
mediaId:
'r6ryvl6LrxBU0miaST4Y-vdWrisK5EZbk4Y3tzh8P0PG0eEUbnQrh0BcsEb3WNP0',
content: {
newsItem: [
{
title: 'tudou',
author: 'haha',
digest: '312',
content: '<p>132312</p>',
contentSourceUrl: 'http://www.iocoder.cn',
thumbMediaId:
'r6ryvl6LrxBU0miaST4Y-pgFtUNLu1foMSAMkoOsrQrTZ8EtTMssBLfTtzP0dfjG',
showCoverPic: 0,
needOpenComment: 0,
onlyFansCanComment: 0,
url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9qdkJ1ZjBoUmg2Uk9TS3RlQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNVg2aTJsaC1fMkU2eXNacUplN3VDTTZFZkhtMjhuTUZvWkxsNDBRSXExY2tiVXRHb09TaHgtREhzY3doZ0JYeC1TSTZ5eWZldXJsOWtfbV8yMi1aYkcyZ2pOY0haM0Ntb3VSWEtxUGVFRlNBfn4%3D&chksm=1f6354ba2814ddacf0184b24d310483641ef190b1faac098c285eb416c70017e2f54decfa1af#rd',
thumbUrl:
'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pgFtUNLu1foMSAMkoOsrQrTZ8EtTMssBLfTtzP0dfjG.png',
},
],
},
updateTime: 1_673_628_760,
},
{
mediaId:
'r6ryvl6LrxBU0miaST4Y-u9kTIm1DhWZDdXyxsxUVv2Z5DAB99IPxkIRTUUD206k',
content: {
newsItem: [
{
title: '12',
author: '333',
digest: '123',
content: '123',
contentSourceUrl: 'https://www.iocoder.cn',
thumbMediaId:
'r6ryvl6LrxBU0miaST4Y-jVixJGgnBnkBPRbuVptOW0CHYuQFyiOVNtamctS8xU8',
showCoverPic: 0,
needOpenComment: 0,
onlyFansCanComment: 0,
url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9qVVhpSDZUaFJWTzBBWWRVQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNWRnTDJWYmF2NER0clV1bThmQ0xUR3hqQnJkZ3BJSUNmNDJmc0lCZ1dadkVnZ3Z5bkN4YWtVUjhoaWZWYzZURUR4NnpMd0Y4Z3U5aUdib0lkMzI4Rjg3SG9JX2FycTMxbUctOHplaTlQVVhnfn4%3D&chksm=1f6354b62814dda076c778af33f06580165d8aa81f7798d55cfabb1886b5c74d9b2124a3535c#rd',
thumbUrl:
'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-jVixJGgnBnkBPRbuVptOW0CHYuQFyiOVNtamctS8xU8.jpg',
},
],
},
updateTime: 1_673_626_494,
},
{
mediaId:
'r6ryvl6LrxBU0miaST4Y-sO24upobaENDmeByfBTfaozB3aOqSMAV0lGy-UkHXE7',
content: {
newsItem: [
{
title: '我是标题',
author: '我是作者',
digest: '我是摘要',
content: '我是内容',
contentSourceUrl: 'https://www.iocoder.cn',
thumbMediaId:
'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn',
showCoverPic: 0,
needOpenComment: 0,
onlyFansCanComment: 0,
url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9LT2dqRnpMNUpsR0hjYWtBQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNGNmazZTdlE5WkxvU0tfX2V5cjV2WjJiR0xjQUhyREFSZWo2eWNrUW9EYVh6ZkpWRXBLR3FmTEV6YldBMno3Q2ZvVXBSdzlaVDc3aFhndEpQWUwzWmFMUWt0YVVURE1VZ1FsQTdPMlRtc3JBfn4%3D&chksm=1f6354aa2814ddbcc2637382f963a8742993ac38ebcebe6e3411df5ac82ac7bbdb391be6494a#rd',
thumbUrl:
'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png',
},
],
},
updateTime: 1_673_534_279,
},
],
total: 6,
};

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { UploadFiles, UploadProps, UploadRawFile } from 'element-plus'; import type { UploadFiles, UploadProps, UploadRawFile } from 'element-plus';
import type { NewsItem } from './types'; import type { MpDraftApi } from '#/api/mp/draft';
import { computed, inject, reactive, ref } from 'vue'; import { computed, inject, reactive, ref } from 'vue';
@@ -13,20 +13,18 @@ import { ElButton, ElDialog, ElImage, ElMessage, ElUpload } from 'element-plus';
import { UploadType, useBeforeUpload } from '#/utils/useUpload'; import { UploadType, useBeforeUpload } from '#/utils/useUpload';
import MaterialSelect from '#/views/mp/components/wx-material-select/wx-material-select.vue'; import MaterialSelect from '#/views/mp/components/wx-material-select/wx-material-select.vue';
// TODO @hw代码风格要和对应的 antd index.vue 一致,类似方法的顺序,注释等。原因是,这样后续两端迭代,会方便很多。
const props = defineProps<{ const props = defineProps<{
isFirst: boolean; isFirst: boolean;
modelValue: NewsItem; modelValue: MpDraftApi.NewsItem;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', v: NewsItem): void; (e: 'update:modelValue', v: MpDraftApi.NewsItem): void;
}>(); }>();
const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-permanent`; // 上传永久素材的地址 const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-permanent`; // 上传永久素材的地址
const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` }; const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` };
const newsItem = computed<NewsItem>({ const newsItem = computed<MpDraftApi.NewsItem>({
get() { get() {
return props.modelValue; return props.modelValue;
}, },
@@ -36,7 +34,7 @@ const newsItem = computed<NewsItem>({
}); });
const accountId = inject<number>('accountId'); const accountId = inject<number>('accountId');
const showImageDialog = ref(false); const dialogVisible = ref(false);
const fileList = ref<UploadFiles>([]); const fileList = ref<UploadFiles>([]);
interface UploadData { interface UploadData {
@@ -48,15 +46,19 @@ const uploadData: UploadData = reactive({
accountId: accountId!, accountId: accountId!,
}); });
function handleOpenDialog() {
dialogVisible.value = true;
}
/** 素材选择完成事件*/ /** 素材选择完成事件*/
function onMaterialSelected(item: any) { function onMaterialSelected(item: any) {
showImageDialog.value = false; dialogVisible.value = false;
newsItem.value.thumbMediaId = item.mediaId; newsItem.value.thumbMediaId = item.mediaId;
newsItem.value.thumbUrl = item.url; newsItem.value.thumbUrl = item.url;
} }
const onBeforeUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) => const onBeforeUpload: UploadProps['beforeUpload'] = (file: UploadRawFile) =>
useBeforeUpload(UploadType.Image, 2)(rawFile); useBeforeUpload(UploadType.Image, 2)(file as any);
function onUploadSuccess(res: any) { function onUploadSuccess(res: any) {
if (res.code !== 0) { if (res.code !== 0) {
@@ -113,7 +115,7 @@ function onUploadError(err: Error) {
<ElButton <ElButton
size="small" size="small"
type="primary" type="primary"
@click="showImageDialog = true" @click="handleOpenDialog"
class="ml-1.5" class="ml-1.5"
> >
素材库选择 素材库选择
@@ -127,7 +129,7 @@ function onUploadError(err: Error) {
</div> </div>
<ElDialog <ElDialog
title="选择图片" title="选择图片"
v-model="showImageDialog" v-model="dialogVisible"
width="80%" width="80%"
append-to-body append-to-body
destroy-on-close destroy-on-close

View File

@@ -93,27 +93,29 @@ function plusNews() {
<template> <template>
<ElContainer> <ElContainer>
<ElAside width="40%"> <ElAside width="40%">
<div class="mx-auto mb-2.5 w-3/5 border border-gray-200 p-2.5"> <div class="mx-auto mb-[10px] w-[60%] border border-[#eaeaea] p-[10px]">
<div v-for="(news, index) in newsList" :key="index"> <div v-for="(news, index) in newsList" :key="index">
<div <div
class="group mx-auto h-[120px] w-full cursor-pointer bg-white" class="group relative mx-auto mb-[10px] w-full cursor-pointer border-[2px] bg-white"
v-if="index === 0" v-if="index === 0"
:class="{ :class="
'border-[5px] border-[#2bb673]': activeNewsIndex === index, activeNewsIndex === index
}" ? 'border-green-500'
: 'border-transparent'
"
@click="activeNewsIndex = index" @click="activeNewsIndex = index"
> >
<div class="relative h-[120px] w-full bg-[#acadae]"> <div class="relative w-full bg-[#acadae]">
<img class="h-full w-full" :src="news.thumbUrl" /> <img class="h-full w-full" :src="news.thumbUrl" />
<div <div
class="absolute bottom-0 left-0 inline-block h-[25px] w-[98%] overflow-hidden text-ellipsis whitespace-nowrap bg-black p-[1%] text-[15px] text-white opacity-65" class="absolute bottom-0 left-0 inline-block h-[25px] w-[100%] overflow-hidden text-ellipsis whitespace-nowrap bg-black p-[1%] text-center text-[15px] text-white opacity-65"
> >
{{ news.title }} {{ news.title }}
</div> </div>
</div> </div>
<div <div
class="relative flex justify-center gap-[10px] py-[5px] text-center"
v-if="newsList.length > 1" v-if="newsList.length > 1"
class="relative -bottom-6 hidden text-center group-hover:block"
> >
<ElButton <ElButton
type="info" type="info"
@@ -134,25 +136,29 @@ function plusNews() {
</ElButton> </ElButton>
</div> </div>
</div> </div>
<!-- TODO @hw1每个文章的选中框太粗了2没完全覆盖住文章最好首个文章和第个文章的情况都看看 -->
<div <div
class="group mx-auto w-full cursor-pointer border-t border-gray-200 bg-white py-1.5" class="group relative mx-auto mb-[10px] cursor-pointer border-[2px] bg-white"
v-if="index > 0" v-if="index > 0"
:class="{ :class="
'border-[5px] border-[#2bb673]': activeNewsIndex === index, activeNewsIndex === index
}" ? 'border-green-500'
: 'border-transparent'
"
@click="activeNewsIndex = index" @click="activeNewsIndex = index"
> >
<div class="relative -ml-0.5"> <div class="relative">
<div class="inline-block w-[70%] text-xs">{{ news.title }}</div> <div class="bg-[#acadae]">
<div class="inline-block w-1/4 bg-[#acadae]"> <img class="block h-full w-full" :src="news.thumbUrl" />
<img class="h-full w-full" :src="news.thumbUrl" width="100%" /> <div
class="absolute bottom-0 left-0 inline-block h-[25px] w-[100%] overflow-hidden text-ellipsis whitespace-nowrap bg-black p-[1%] text-center text-[15px] text-white opacity-65"
>
{{ news.title }}
</div>
</div> </div>
</div> </div>
<!-- TODO @hw这里的按钮交互不太对应该在每个卡片的里面或者类似公众号现在的交互放到右侧复现本周如果有 2 个文章的时候 -->
<!-- TODO @hw当有 2 个文章的时候挪到第二个文章的时候卡片会变大期望不变大 -->
<div <div
class="relative -bottom-6 hidden text-center group-hover:block" class="relative flex justify-center gap-[10px] py-[5px] text-center"
> >
<ElButton <ElButton
v-if="newsList.length > index + 1" v-if="newsList.length > index + 1"

View File

@@ -4,22 +4,21 @@ import type { Menu, RawMenu } from './modules/types';
import { nextTick, onMounted, ref } from 'vue'; import { nextTick, onMounted, ref } from 'vue';
import { confirm, ContentWrap, DocAlert, Page } from '@vben/common-ui'; import { confirm, ContentWrap, DocAlert, Page } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import { ElButton, ElLoading, ElMessage } from 'element-plus'; import { ElButton, ElLoading, ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form'; import { useVbenForm } from '#/adapter/form';
import { getSimpleAccountList } from '#/api/mp/account'; import { getSimpleAccountList } from '#/api/mp/account';
import * as MpMenuApi from '#/api/mp/menu'; import { deleteMenu, getMenuList, saveMenu } from '#/api/mp/menu';
import * as UtilsTree from '#/utils/tree';
import { import {
Level, Level,
MENU_NOT_SELECTED, MENU_NOT_SELECTED,
useGridFormSchema, useGridFormSchema,
} from '#/views/mp/menu/data'; } from '#/views/mp/menu/data';
import MenuEditor from '#/views/mp/menu/modules/menu-editor.vue'; import Editor from '#/views/mp/menu/modules/editor.vue';
import MenuPreviewer from '#/views/mp/menu/modules/menu-previewer.vue'; import Previewer from '#/views/mp/menu/modules/previewer.vue';
// TODO @hw是不是资源的地址统一下antd 和 ele目录不同。建议按照 ele 的方法先;
import iphoneBackImg from './assets/iphone_backImg.png'; import iphoneBackImg from './assets/iphone_backImg.png';
import menuFootImg from './assets/menu_foot.png'; import menuFootImg from './assets/menu_foot.png';
import menuHeadImg from './assets/menu_head.png'; import menuHeadImg from './assets/menu_head.png';
@@ -33,36 +32,6 @@ const accountId = ref(-1);
const accountName = ref<string>(''); const accountName = ref<string>('');
const menuList = ref<Menu[]>([]); const menuList = ref<Menu[]>([]);
// ======================== 菜单操作 ========================
// 当前选中菜单编码:
// * 一级('x'
// * 二级('x-y'
// * 未选中MENU_NOT_SELECTED
const activeIndex = ref<string>(MENU_NOT_SELECTED);
// 二级菜单显示标志: 归属的一级菜单index
// * 未初始化:-1
// * 初始化x
const parentIndex = ref(-1);
// ======================== 菜单编辑 ========================
const showRightPanel = ref(false); // 右边配置显示默认详情还是配置详情
const isParent = ref<boolean>(true); // 是否一级菜单控制MenuEditor中name字段长度
const activeMenu = ref<Menu>({}); // 选中菜单MenuEditor的modelValue
// 一些临时值放在这里进行判断,如果放在 activeMenu由于引用关系menu 也会多了多余的参数
const tempSelfObj = ref<{
grand: Level;
x: number;
y: number;
}>({
grand: Level.Undefined,
x: 0,
y: 0,
});
const dialogNewsVisible = ref(false); // 跳转图文时的素材选择弹窗
const [AccountForm, accountFormApi] = useVbenForm({ const [AccountForm, accountFormApi] = useVbenForm({
commonConfig: { commonConfig: {
componentProps: { componentProps: {
@@ -81,6 +50,36 @@ const [AccountForm, accountFormApi] = useVbenForm({
}, },
}); });
// ======================== 菜单操作 ========================
// 当前选中菜单编码:
// * 一级('x'
// * 二级('x-y'
// * 未选中MENU_NOT_SELECTED
const activeIndex = ref<string>(MENU_NOT_SELECTED);
// 二级菜单显示标志: 归属的一级菜单index
// * 未初始化:-1
// * 初始化x
const parentIndex = ref(-1);
// ======================== 菜单编辑 ========================
const showRightPanel = ref(false); // 右边配置显示默认详情还是配置详情
const isParent = ref<boolean>(true); // 是否一级菜单控制Editor中name字段长度
const activeMenu = ref<Menu>({}); // 选中菜单Editor的modelValue
// 一些临时值放在这里进行判断,如果放在 activeMenu由于引用关系menu 也会多了多余的参数
const tempSelfObj = ref<{
grand: Level;
x: number;
y: number;
}>({
grand: Level.Undefined,
x: 0,
y: 0,
});
const dialogNewsVisible = ref(false); // 跳转图文时的素材选择弹窗
/** 侦听公众号变化 */ /** 侦听公众号变化 */
async function onAccountChanged(values: Record<string, any>) { async function onAccountChanged(values: Record<string, any>) {
accountId.value = values.accountId; accountId.value = values.accountId;
@@ -88,16 +87,32 @@ async function onAccountChanged(values: Record<string, any>) {
const accountList = await getSimpleAccountList(); const accountList = await getSimpleAccountList();
const account = accountList.find((item) => item.id === values.accountId); const account = accountList.find((item) => item.id === values.accountId);
accountName.value = account?.name || ''; accountName.value = account?.name || '';
getList(); await getList();
} }
/** 初始化账号ID - 作为备用方案,防止 handleValuesChange 未触发 */
async function initAccountId() {
await nextTick(); // 等待表单初始化完成
const values = await accountFormApi.getValues();
if (values?.accountId && accountId.value === -1) {
// 如果表单有值但 accountId 还是初始值,则手动触发一次
await onAccountChanged(values);
}
}
/** 组件挂载时初始化账号 ID */
onMounted(async () => {
await nextTick();
await initAccountId();
});
/** 查询并转换菜单 */ /** 查询并转换菜单 */
async function getList() { async function getList() {
loading.value = true; loading.value = true;
try { try {
const data = await MpMenuApi.getMenuList(accountId.value); const data = await getMenuList(accountId.value);
const menuData = menuListToFrontend(data); const menuData = menuListToFrontend(data);
menuList.value = UtilsTree.handleTree(menuData, 'id'); menuList.value = handleTree(menuData, 'id') as Menu[];
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -111,7 +126,9 @@ function handleQuery() {
/** 将后端返回的 menuList转换成前端的 menuList */ /** 将后端返回的 menuList转换成前端的 menuList */
function menuListToFrontend(list: any[]) { function menuListToFrontend(list: any[]) {
if (!list) return []; if (!list) {
return [];
}
const result: RawMenu[] = []; const result: RawMenu[] = [];
list.forEach((item: RawMenu) => { list.forEach((item: RawMenu) => {
@@ -151,6 +168,7 @@ function resetForm() {
} }
// ======================== 菜单操作 ======================== // ======================== 菜单操作 ========================
/** 一级菜单点击事件 */ /** 一级菜单点击事件 */
function menuClicked(parent: Menu, x: number) { function menuClicked(parent: Menu, x: number) {
// 右侧的表单相关 // 右侧的表单相关
@@ -181,66 +199,55 @@ function subMenuClicked(child: Menu, x: number, y: number) {
/** 删除当前菜单 */ /** 删除当前菜单 */
async function onDeleteMenu() { async function onDeleteMenu() {
try { await confirm('确定要删除吗?');
await confirm('确定要删除吗?'); if (tempSelfObj.value.grand === Level.Parent) {
if (tempSelfObj.value.grand === Level.Parent) { // 一级菜单的删除方法
// 一级菜单的删除方法 menuList.value.splice(tempSelfObj.value.x, 1);
menuList.value.splice(tempSelfObj.value.x, 1); } else if (tempSelfObj.value.grand === Level.Child) {
} else if (tempSelfObj.value.grand === Level.Child) { // 二级菜单的删除方法
// 二级菜单的删除方法 menuList.value[tempSelfObj.value.x]?.children?.splice(
menuList.value[tempSelfObj.value.x]?.children?.splice( tempSelfObj.value.y,
tempSelfObj.value.y, 1,
1, );
);
}
// 提示
ElMessage.success('删除成功');
// 处理菜单的选中
activeMenu.value = {};
showRightPanel.value = false;
activeIndex.value = MENU_NOT_SELECTED;
} catch {
//
} }
// 提示
ElMessage.success('删除成功');
// 处理菜单的选中
activeMenu.value = {};
showRightPanel.value = false;
activeIndex.value = MENU_NOT_SELECTED;
} }
// ======================== 菜单编辑 ======================== // ======================== 菜单编辑 ========================
/** 保存菜单 */ /** 保存菜单 */
async function onSave() { async function onSave() {
await confirm('确定要保存吗?');
const hideLoading = ElLoading.service({
text: '保存中...',
});
try { try {
await confirm('确定要保存吗?'); await saveMenu(accountId.value, menuListToBackend());
const loadingInstance = ElLoading.service({ await getList();
text: '保存中...', ElMessage.success('发布成功');
}); } finally {
try { hideLoading.close();
await MpMenuApi.saveMenu(accountId.value, menuListToBackend());
getList();
ElMessage.success('发布成功');
} finally {
loadingInstance.close();
}
} catch {
//
} }
} }
/** 清空菜单 */ /** 清空菜单 */
async function onClear() { async function onClear() {
await confirm('确定要删除吗?');
const hideLoading = ElLoading.service({
text: '删除中...',
});
try { try {
await confirm('确定要删除吗?'); await deleteMenu(accountId.value);
const loadingInstance = ElLoading.service({ handleQuery();
text: '删除中...', ElMessage.success('清空成功');
}); } finally {
try { hideLoading.close();
await MpMenuApi.deleteMenu(accountId.value);
handleQuery();
ElMessage.success('清空成功');
} finally {
loadingInstance.close();
}
} catch {
//
} }
} }
@@ -264,7 +271,7 @@ function menuListToBackend() {
} }
/** 将前端的 menu转换成后端接收的 menu */ /** 将前端的 menu转换成后端接收的 menu */
// TODO: @芋艿需要根据后台API删除不需要的字段 // TODO: @芋艿,需要根据后台 API 删除不需要的字段
function menuToBackend(menu: any) { function menuToBackend(menu: any) {
const result = { const result = {
...menu, ...menu,
@@ -282,31 +289,8 @@ function menuToBackend(menu: any) {
result.replyArticles = menu.reply.articles; result.replyArticles = menu.reply.articles;
result.replyMusicUrl = menu.reply.musicUrl; result.replyMusicUrl = menu.reply.musicUrl;
result.replyHqMusicUrl = menu.reply.hqMusicUrl; result.replyHqMusicUrl = menu.reply.hqMusicUrl;
return result; return result;
} }
/** 初始化账号ID - 作为备用方案,防止 handleValuesChange 未触发 */
async function initAccountId() {
// 等待表单初始化完成
await nextTick();
try {
const values = await accountFormApi.getValues();
if (values?.accountId && accountId.value === -1) {
// 如果表单有值但 accountId 还是初始值,则手动触发一次
await onAccountChanged(values);
}
} catch {
// 忽略错误
}
}
// 组件挂载时初始化账号ID
onMounted(async () => {
await nextTick();
await initAccountId();
});
// TODO @hw这个界面整理下代码整体的风格、方法的顺序、注释参考 antd 哈;
</script> </script>
<template> <template>
@@ -345,7 +329,7 @@ onMounted(async () => {
class="weixin-menu h-[46px] bg-no-repeat pl-[43px] text-[12px]" class="weixin-menu h-[46px] bg-no-repeat pl-[43px] text-[12px]"
:style="{ backgroundImage: `url(${menuFootImg})` }" :style="{ backgroundImage: `url(${menuFootImg})` }"
> >
<MenuPreviewer <Previewer
v-model="menuList" v-model="menuList"
:account-id="accountId" :account-id="accountId"
:active-index="activeIndex" :active-index="activeIndex"
@@ -378,7 +362,7 @@ onMounted(async () => {
class="right box-border flex-1 basis-[63%] bg-[#e8e7e7] p-[20px]" class="right box-border flex-1 basis-[63%] bg-[#e8e7e7] p-[20px]"
v-if="showRightPanel" v-if="showRightPanel"
> >
<MenuEditor <Editor
:account-id="accountId" :account-id="accountId"
:is-parent="isParent" :is-parent="isParent"
v-model="activeMenu" v-model="activeMenu"

View File

@@ -18,9 +18,8 @@ import MaterialSelect from '#/views/mp/components/wx-material-select/wx-material
import News from '#/views/mp/components/wx-news/wx-news.vue'; import News from '#/views/mp/components/wx-news/wx-news.vue';
import ReplySelect from '#/views/mp/components/wx-reply/wx-reply.vue'; import ReplySelect from '#/views/mp/components/wx-reply/wx-reply.vue';
import menuOptions from './menuOptions'; import { menuOptions } from './types';
// TODO @hw menu-
const props = defineProps<{ const props = defineProps<{
accountId: number; accountId: number;
isParent: boolean; isParent: boolean;

View File

@@ -1,43 +0,0 @@
// TODO @hw合并到 types
export default [
{
value: 'view',
label: '跳转网页',
},
{
value: 'miniprogram',
label: '跳转小程序',
},
{
value: 'click',
label: '点击回复',
},
{
value: 'article_view_limited',
label: '跳转图文消息',
},
{
value: 'scancode_push',
label: '扫码直接返回结果',
},
{
value: 'scancode_waitmsg',
label: '扫码回复',
},
{
value: 'pic_sysphoto',
label: '系统拍照发图',
},
{
value: 'pic_photo_or_album',
label: '拍照或者相册',
},
{
value: 'pic_weixin',
label: '微信相册',
},
{
value: 'location_select',
label: '选择地理位置',
},
];

View File

@@ -7,8 +7,6 @@ import { IconifyIcon } from '@vben/icons';
import draggable from 'vuedraggable'; import draggable from 'vuedraggable';
// TODO @hw menu-
const props = defineProps<{ const props = defineProps<{
accountId: number; accountId: number;
activeIndex: string; activeIndex: string;
@@ -199,6 +197,8 @@ function onChildDragEnd({ newIndex }: { newIndex: number }) {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.draggable-ghost { .draggable-ghost {
@apply border border-[#4299e1] bg-[#f7fafc] opacity-50; background: #f7fafc;
border: 1px solid #4299e1;
opacity: 0.5;
} }
</style> </style>

View File

@@ -71,3 +71,46 @@ interface _Menu extends RawMenu {
} }
export type Menu = Partial<_Menu>; export type Menu = Partial<_Menu>;
export const menuOptions = [
{
value: 'view',
label: '跳转网页',
},
{
value: 'miniprogram',
label: '跳转小程序',
},
{
value: 'click',
label: '点击回复',
},
{
value: 'article_view_limited',
label: '跳转图文消息',
},
{
value: 'scancode_push',
label: '扫码直接返回结果',
},
{
value: 'scancode_waitmsg',
label: '扫码回复',
},
{
value: 'pic_sysphoto',
label: '系统拍照发图',
},
{
value: 'pic_photo_or_album',
label: '拍照或者相册',
},
{
value: 'pic_weixin',
label: '微信相册',
},
{
value: 'location_select',
label: '选择地理位置',
},
];

View File

@@ -77,3 +77,14 @@ export enum MenuType {
VIEW = 'view', // 跳转 URL VIEW = 'view', // 跳转 URL
VIEW_LIMITED = 'view_limited', // 跳转图文消息URL VIEW_LIMITED = 'view_limited', // 跳转图文消息URL
} }
/** 允许选择的请求消息类型 */
export const RequestMessageTypes = new Set([
'image',
'link',
'location',
'shortvideo',
'text',
'video',
'voice',
]);