chore: 合并远程 dev 分支代码

This commit is contained in:
YunaiV
2025-11-13 20:45:00 +08:00
108 changed files with 542 additions and 3074 deletions

View File

@@ -44,7 +44,6 @@
"@vben/types": "workspace:*",
"@vben/utils": "workspace:*",
"@videojs-player/vue": "catalog:",
"@vueuse/components": "catalog:",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"ant-design-vue": "catalog:",

View File

@@ -1,5 +1,3 @@
import type { PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MallKefuConversationApi {
@@ -28,7 +26,7 @@ export namespace MallKefuConversationApi {
/** 获得客服会话列表 */
export function getConversationList() {
return requestClient.get<PageResult<MallKefuConversationApi.Conversation>>(
return requestClient.get<MallKefuConversationApi.Conversation[]>(
'/promotion/kefu-conversation/list',
);
}

View File

@@ -96,7 +96,6 @@ export function setupFormCreate(app: App) {
components.forEach((component) => {
app.component(component.name as string, component);
});
// TODO @xingyu这里为啥 app.component('AMessage', message); 看官方是没有的; 需要额外引入
app.component('AMessage', message);
formCreate.use(install);
app.use(formCreate);

View File

@@ -5,7 +5,10 @@ import { isEmpty } from '@vben/utils';
import { acceptHMRUpdate, defineStore } from 'pinia';
import * as KeFuConversationApi from '#/api/mall/promotion/kefu/conversation';
import {
getConversation,
getConversationList,
} from '#/api/mall/promotion/kefu/conversation';
interface MallKefuInfoVO {
conversationList: MallKefuConversationApi.Conversation[]; // 会话列表
@@ -41,9 +44,7 @@ export const useMallKefuStore = defineStore('mall-kefu', {
// ======================= 会话相关 =======================
/** 加载会话缓存列表 */
async setConversationList() {
// TODO @javeidea linter 告警,修复下;
// TODO @jave不使用 KeFuConversationApi.,直接用 getConversationList
this.conversationList = await KeFuConversationApi.getConversationList();
this.conversationList = await getConversationList();
this.conversationSort();
},
/** 更新会话缓存已读 */
@@ -51,8 +52,11 @@ export const useMallKefuStore = defineStore('mall-kefu', {
if (isEmpty(this.conversationList)) {
return;
}
const conversation = this.conversationList.find(
(item) => item.id === conversationId,
const conversationList = this
.conversationList as MallKefuConversationApi.Conversation[];
const conversation = conversationList.find(
(item: MallKefuConversationApi.Conversation) =>
item.id === conversationId,
);
conversation && (conversation.adminUnreadMessageCount = 0);
},
@@ -62,10 +66,16 @@ export const useMallKefuStore = defineStore('mall-kefu', {
return;
}
const conversation =
await KeFuConversationApi.getConversation(conversationId);
const conversation = await getConversation(conversationId);
this.deleteConversation(conversationId);
conversation && this.conversationList.push(conversation);
if (conversation && this.conversationList) {
const conversationList = this
.conversationList as MallKefuConversationApi.Conversation[];
this.conversationList = [
...conversationList,
conversation as MallKefuConversationApi.Conversation,
];
}
this.conversationSort();
},
/** 删除会话缓存 */

View File

@@ -25,7 +25,7 @@ import {
Typography,
} from 'ant-design-vue';
import * as CouponTemplateApi from '#/api/mall/promotion/coupon/couponTemplate';
import { getCouponTemplateList } from '#/api/mall/promotion/coupon/couponTemplate';
import UploadImg from '#/components/upload/image-upload.vue';
import { ColorInput } from '#/views/mall/promotion/components';
import CouponSelect from '#/views/mall/promotion/coupon/components/select.vue';
@@ -66,9 +66,7 @@ watch(
() => formData.value.couponIds,
async () => {
if (formData.value.couponIds?.length > 0) {
couponList.value = await CouponTemplateApi.getCouponTemplateList(
formData.value.couponIds,
);
couponList.value = await getCouponTemplateList(formData.value.couponIds);
}
},
{

View File

@@ -82,10 +82,12 @@ const [Grid, gridApi] = useVbenVxeGrid({
<template>
<Page auto-content-height>
<DocAlert
title="会员等级、积分、签到"
url="https://doc.iocoder.cn/member/level/"
/>
<template #doc>
<DocAlert
title="会员等级、积分、签到"
url="https://doc.iocoder.cn/member/level/"
/>
</template>
<FormModal @success="handleRefresh" />
<Grid table-title="等级列表">

View File

@@ -42,10 +42,12 @@ const [Grid] = useVbenVxeGrid({
<template>
<Page auto-content-height>
<DocAlert
title="会员等级、积分、签到"
url="https://doc.iocoder.cn/member/level/"
/>
<template #doc>
<DocAlert
title="会员等级、积分、签到"
url="https://doc.iocoder.cn/member/level/"
/>
</template>
<Grid table-title="积分记录列表" />
</Page>

View File

@@ -5,7 +5,7 @@ import type { MallFavoriteApi } from '#/api/mall/product/favorite';
import { DICT_TYPE } from '@vben/constants';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import * as FavoriteApi from '#/api/mall/product/favorite';
import { getFavoritePage } from '#/api/mall/product/favorite';
const props = defineProps<{
userId: number;
@@ -72,7 +72,7 @@ const [Grid] = useVbenVxeGrid({
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await FavoriteApi.getFavoritePage({
return await getFavoritePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
userId: props.userId,

View File

@@ -71,7 +71,7 @@ async function handleGenerateQrCode(row: MpAccountApi.Account) {
/** 清空 API 配额 */
async function handleCleanQuota(row: MpAccountApi.Account) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.processing', ['清空 API 配额']),
content: '正在清空 API 配额',
duration: 0,
});
try {

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { Rule } from 'ant-design-vue/es/form';
import type { Reply } from '#/views/mp/modules/wx-reply';
import type { Reply } from '#/views/mp/components';
import { computed, ref } from 'vue';
@@ -10,7 +10,7 @@ import { getDictOptions } from '@vben/hooks';
import { Form, FormItem, Input, Select, SelectOption } from 'ant-design-vue';
import { WxReplySelect } from '#/views/mp/modules/wx-reply';
import { WxReply } from '#/views/mp/components';
import { MsgType } from './types';
@@ -131,7 +131,7 @@ defineExpose({
/>
</FormItem>
<FormItem label="回复消息">
<WxReplySelect v-model="reply" />
<WxReply v-model="reply" />
</FormItem>
</Form>
</div>

View File

@@ -1,8 +1,10 @@
<script lang="ts" setup>
import { WxMusic } from '#/views/mp/modules/wx-music';
import { WxNews } from '#/views/mp/modules/wx-news';
import { WxVideoPlayer } from '#/views/mp/modules/wx-video-play';
import { WxVoicePlayer } from '#/views/mp/modules/wx-voice-play';
import {
WxMusic,
WxNews,
WxVideoPlayer,
WxVoicePlayer,
} from '#/views/mp/components';
// TODO @hw迁移到 /Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/views/mp/autoReply/modules 里;组件名可能要换一个,= = 之前写的不太好;类似 content.vue ?本质是内容~
defineOptions({ name: 'ReplyContentCell' });

View File

@@ -5,7 +5,7 @@ import { markRaw } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { WxAccountSelect } from '#/views/mp/modules/wx-account-select';
import { WxAccountSelect } from '#/views/mp/components';
import { MsgType } from './components/types';

View File

@@ -3,19 +3,17 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { computed, nextTick, onMounted, ref } from 'vue';
import {
confirm,
ContentWrap,
DocAlert,
Page,
useVbenModal,
} from '@vben/common-ui';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { message, Row, Tabs } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import * as MpAutoReplyApi from '#/api/mp/autoReply';
import {
deleteAutoReply,
getAutoReply,
getAutoReplyPage,
} from '#/api/mp/autoReply';
import { $t } from '#/locales';
import ReplyContentCell from './components/ReplyTable.vue';
@@ -25,10 +23,17 @@ import Form from './modules/form.vue';
defineOptions({ name: 'MpAutoReply' });
/** 刷新表格 */
function handleRefresh() {
gridApi.query().then(() => {
updateTableDataLength();
});
}
const msgType = ref<string>(String(MsgType.Keyword)); // 消息类型
/** 切换回复类型 */
async function onTabChange(tabName: string) {
async function onTabChange(tabName: any) {
msgType.value = tabName;
await nextTick();
// 更新 columns
@@ -58,7 +63,7 @@ async function handleCreate() {
/** 修改按钮操作 */
async function handleEdit(row: any) {
const data = (await MpAutoReplyApi.getAutoReply(row.id)) as any;
const data = (await getAutoReply(row.id)) as any;
formModalApi
.setData({
isCreating: false,
@@ -76,7 +81,7 @@ async function handleDelete(row: any) {
duration: 0,
});
try {
await MpAutoReplyApi.deleteAutoReply(row.id);
await deleteAutoReply(row.id);
message.success('删除成功');
await gridApi.query();
// 查询完成后更新数据长度
@@ -98,12 +103,12 @@ const [Grid, gridApi] = useVbenVxeGrid({
},
gridOptions: {
columns: useGridColumns(Number(msgType.value) as MsgType),
height: 'calc(100vh - 300px)',
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await MpAutoReplyApi.getAutoReplyPage({
return await getAutoReplyPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
type: Number(msgType.value) as MsgType,
@@ -171,91 +176,83 @@ onMounted(async () => {
<template>
<Page auto-content-height>
<DocAlert title="自动回复" url="https://doc.iocoder.cn/mp/auto-reply/" />
<template #doc>
<DocAlert title="自动回复" url="https://doc.iocoder.cn/mp/auto-reply/" />
</template>
<!-- tab 切换 -->
<!-- TODO @hw貌似 tabs 里面套 table 的样式在 vben 里有点丑要不我们按照 /Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/views/mall/trade/afterSale/index.vue1第一层是公众号的选择2第二层是 tab3第三层是 table -->
<ContentWrap>
<Tabs
v-model:active-key="msgType"
@change="(activeKey) => onTabChange(activeKey as string)"
>
<!-- tab -->
<Tabs.TabPane :key="String(MsgType.Follow)">
<template #tab>
<Row align="middle">
<IconifyIcon icon="ep:star" class="mr-2px" /> 关注时回复
</Row>
</template>
</Tabs.TabPane>
<Tabs.TabPane :key="String(MsgType.Message)">
<template #tab>
<Row align="middle">
<IconifyIcon icon="ep:chat-line-round" class="mr-2px" /> 消息回复
</Row>
</template>
</Tabs.TabPane>
<Tabs.TabPane :key="String(MsgType.Keyword)">
<template #tab>
<Row align="middle">
<IconifyIcon icon="fa:newspaper-o" class="mr-2px" /> 关键词回复
</Row>
</template>
</Tabs.TabPane>
</Tabs>
<!-- 列表 -->
<FormModal
@success="
() => {
gridApi.query().then(() => {
updateTableDataLength();
});
}
"
/>
<Grid table-title="自动回复列表">
<template #toolbar-tools>
<TableAction
v-if="showCreateButton"
:actions="[
{
label: $t('ui.actionTitle.create', ['自动回复']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['mp:auto-reply:create'],
onClick: handleCreate,
<FormModal @success="handleRefresh" />
<Grid>
<template #toolbar-actions>
<!-- tab 切换 -->
<Tabs v-model:active-key="msgType" class="w-full" @change="onTabChange">
<!-- tab -->
<Tabs.TabPane :key="String(MsgType.Follow)">
<template #tab>
<Row align="middle">
<IconifyIcon icon="lucide:star" class="mr-2px" /> 关注时回复
</Row>
</template>
</Tabs.TabPane>
<Tabs.TabPane :key="String(MsgType.Message)">
<template #tab>
<Row align="middle">
<IconifyIcon icon="lucide:message-circle" class="mr-2px" />
消息回复
</Row>
</template>
</Tabs.TabPane>
<Tabs.TabPane :key="String(MsgType.Keyword)">
<template #tab>
<Row align="middle">
<IconifyIcon icon="lucide:newspaper" class="mr-2px" />
关键词回复
</Row>
</template>
</Tabs.TabPane>
</Tabs>
</template>
<template #toolbar-tools>
<TableAction
v-if="showCreateButton"
:actions="[
{
label: $t('ui.actionTitle.create', ['自动回复']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['mp:auto-reply:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #replyContent="{ row }">
<ReplyContentCell :row="row" />
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['mp:auto-reply:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['mp:auto-reply:delete'],
popConfirm: {
title: '是否确认删除此数据?',
confirm: handleDelete.bind(null, row),
},
]"
/>
</template>
<template #replyContent="{ row }">
<ReplyContentCell :row="row" />
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['mp:auto-reply:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['mp:auto-reply:delete'],
popConfirm: {
title: '是否确认删除此数据?',
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</ContentWrap>
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { Reply } from '#/views/mp/modules/wx-reply';
import type { Reply } from '#/views/mp/components';
import { computed, ref } from 'vue';
@@ -9,7 +9,7 @@ import { message } from 'ant-design-vue';
import { createAutoReply, updateAutoReply } from '#/api/mp/autoReply';
import { $t } from '#/locales';
import { ReplyType } from '#/views/mp/modules/wx-reply/types';
import { ReplyType } from '#/views/mp/components';
import ReplyForm from '../components/ReplyForm.vue';
import { MsgType } from '../components/types';

View File

@@ -0,0 +1,32 @@
export enum ReplyType {
Image = 'image',
Music = 'music',
News = 'news',
Text = 'text',
Video = 'video',
Voice = 'voice',
}
export enum NewsType {
Draft = '2',
Published = '1',
}
export enum MaterialType {
Image = 'image',
News = 'news',
Video = 'video',
Voice = 'voice',
}
export enum MsgType {
Event = 'event',
Image = 'image',
Link = 'link',
Location = 'location',
Music = 'music',
News = 'news',
Text = 'text',
Video = 'video',
Voice = 'voice',
}

View File

@@ -0,0 +1,11 @@
export * from './constants';
export * from './wx-account-select';
export * from './wx-location';
export * from './wx-material-select';
export * from './wx-msg';
export * from './wx-music';
export * from './wx-news';
export * from './wx-reply';
export * from './wx-video-play';
export * from './wx-voice-play';

View File

@@ -3,7 +3,7 @@ import type { SelectValue } from 'ant-design-vue/es/select';
import type { MpAccountApi } from '#/api/mp/account';
import { onMounted, reactive, ref } from 'vue';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message, Select } from 'ant-design-vue';
@@ -18,7 +18,7 @@ const emit = defineEmits<{
const { push } = useRouter();
const account: MpAccountApi.Account = reactive({
const account = ref<MpAccountApi.Account>({
id: -1,
name: '',
}); //
@@ -36,9 +36,9 @@ async function handleQuery() {
//
const first = accountList.value[0];
if (first) {
account.id = first.id;
account.name = first.name;
emit('change', account.id, account.name);
account.value.id = first.id;
account.value.name = first.name;
emit('change', account.value.id, account.value.name);
}
}
@@ -46,8 +46,8 @@ async function handleQuery() {
function onChanged(id: SelectValue) {
const found = accountList.value.find((v) => v.id === id);
if (found) {
account.name = found.name;
emit('change', account.id, account.name);
account.value.name = found.name;
emit('change', account.value.id, account.value.name);
}
}

View File

@@ -1,2 +1 @@
// TODO @dylanyudao-ui-admin-vben-v5/apps/web-antd/src/views/mp/components 要不加个 index.ts统一 export 所有的组件
export { default as WxAccountSelect } from './main.vue';
export { default as WxAccountSelect } from './account-select.vue';

View File

@@ -1 +1,2 @@
export { default as WxLocation } from './main.vue';
export * from './types';
export { default as WxLocation } from './wx-location.vue';

View File

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

View File

@@ -1,4 +1,6 @@
<script lang="ts" setup>
import type { WxLocationProps } from './types';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
@@ -8,17 +10,9 @@ import { Col, Row } from 'ant-design-vue';
defineOptions({ name: 'WxLocation' });
// TODO @dylanapps/web-antd/src/views/mall/trade/delivery/pickUpStore/modules/form.vue key
const props = withDefaults(
defineProps<{
label: string;
locationX: number;
locationY: number;
qqMapKey?: string;
}>(),
{
qqMapKey: 'TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E', // QQ https://lbs.qq.com/service/staticV2/staticGuide/staticDoc
},
);
const props = withDefaults(defineProps<WxLocationProps>(), {
qqMapKey: 'TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E', // QQ https://lbs.qq.com/service/staticV2/staticGuide/staticDoc
});
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`;
@@ -45,7 +39,7 @@ defineExpose({
<img :src="mapImageUrl" alt="地图位置" />
</Row>
<Row class="mt-2">
<IconifyIcon icon="mdi:map-marker" class="mr-1" />
<IconifyIcon icon="lucide:map-pin" class="mr-1" />
{{ label }}
</Row>
</Col>

View File

@@ -1,3 +1 @@
export { default as WxMaterialSelect } from './main.vue';
export { MaterialType, NewsType } from './types';
export { default as WxMaterialSelect } from './wx-material-select.vue';

View File

@@ -1,11 +0,0 @@
export enum NewsType {
Draft = '2',
Published = '1',
}
export enum MaterialType {
Image = 'image',
News = 'news',
Video = 'video',
Voice = 'voice',
}

View File

@@ -9,14 +9,12 @@ import { IconifyIcon } from '@vben/icons';
import { Button, Pagination, Row, Spin } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import * as MpDraftApi from '#/api/mp/draft';
import * as MpFreePublishApi from '#/api/mp/freePublish';
import * as MpMaterialApi from '#/api/mp/material';
import { WxNews } from '#/views/mp/components/wx-news';
import { WxVideoPlayer } from '#/views/mp/components/wx-video-play';
import { WxVoicePlayer } from '#/views/mp/components/wx-voice-play';
import { getDraftPage } from '#/api/mp/draft';
import { getFreePublishPage } from '#/api/mp/freePublish';
import { getMaterialPage } from '#/api/mp/material';
import { WxNews, WxVideoPlayer, WxVoicePlayer } from '#/views/mp/components';
import { NewsType } from './types';
import { NewsType } from '../constants';
defineOptions({ name: 'WxMaterialSelect' });
@@ -142,7 +140,7 @@ const [VoiceGrid, voiceGridApi] = useVbenVxeGrid({
return { list: [], total: 0 };
}
// TODO @dylan MpMaterialApi
return await MpMaterialApi.getMaterialPage({
return await getMaterialPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
accountId: finalAccountId,
@@ -178,7 +176,7 @@ const [VideoGrid, videoGridApi] = useVbenVxeGrid({
if (finalAccountId === undefined || finalAccountId === null) {
return { list: [], total: 0 };
}
return await MpMaterialApi.getMaterialPage({
return await getMaterialPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
accountId: finalAccountId,
@@ -202,7 +200,7 @@ function selectMaterialFun(item: any) {
}
async function getMaterialPageFun() {
const data = await MpMaterialApi.getMaterialPage({
const data = await getMaterialPage({
...queryParams,
type: props.type,
});
@@ -211,7 +209,7 @@ async function getMaterialPageFun() {
}
async function getFreePublishPageFun() {
const data = await MpFreePublishApi.getFreePublishPage(queryParams);
const data = await getFreePublishPage(queryParams);
data.list.forEach((item: any) => {
const articles = item.content.newsItem;
articles.forEach((article: any) => {
@@ -223,7 +221,7 @@ async function getFreePublishPageFun() {
}
async function getDraftPageFun() {
const data = await MpDraftApi.getDraftPage(queryParams);
const data = await getDraftPage(queryParams);
data.list.forEach((draft: any) => {
const articles = draft.content.newsItem;
articles.forEach((article: any) => {
@@ -301,7 +299,7 @@ watch(
<Button type="primary" @click="selectMaterialFun(item)">
选择
<template #icon>
<IconifyIcon icon="mdi:check-circle" />
<IconifyIcon icon="lucide:circle-check" />
</template>
</Button>
</Row>
@@ -328,7 +326,7 @@ watch(
<Button type="link" @click="selectMaterialFun(row)">
选择
<template #icon>
<IconifyIcon icon="mdi:plus" />
<IconifyIcon icon="lucide:plus" />
</template>
</Button>
</template>
@@ -345,7 +343,7 @@ watch(
<Button type="link" @click="selectMaterialFun(row)">
选择
<template #icon>
<IconifyIcon icon="mdi:plus-circle" />
<IconifyIcon icon="lucide:circle-plus" />
</template>
</Button>
</template>
@@ -363,7 +361,7 @@ watch(
<Button type="primary" @click="selectMaterialFun(item)">
选择
<template #icon>
<IconifyIcon icon="mdi:check-circle" />
<IconifyIcon icon="lucide:circle-check" />
</template>
</Button>
</Row>

View File

@@ -1,4 +1,4 @@
.avue-card {
.mp-card {
&__item {
box-sizing: border-box;
height: 200px;
@@ -99,18 +99,18 @@
}
/** joolun 额外加的 */
.avue-comment__main {
.mp-comment__main {
flex: unset !important;
margin: 0 8px !important;
border-radius: 5px !important;
}
.avue-comment__header {
.mp-comment__header {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
.avue-comment__body {
.mp-comment__body {
border-bottom-right-radius: 5px;
border-bottom-left-radius: 5px;
}

View File

@@ -1,5 +1,5 @@
/* 来自 https://github.com/nmxiaowei/avue/blob/master/styles/src/element-ui/comment.scss */
.avue-comment {
.mp-comment {
display: flex;
align-items: flex-start;
margin-bottom: 30px;
@@ -7,7 +7,7 @@
&--reverse {
flex-direction: row-reverse;
.avue-comment__main {
.mp-comment__main {
&::before,
&::after {
right: -8px;

View File

@@ -1,53 +0,0 @@
<script lang="ts" setup>
import { Tag } from 'ant-design-vue';
// TODO @dylanvue 组件名小写 + 中划线
defineOptions({ name: 'MsgEvent' });
defineProps<{
item: any;
}>();
</script>
<template>
<div>
<div v-if="item.event === 'subscribe'">
<Tag color="success">关注</Tag>
</div>
<div v-else-if="item.event === 'unsubscribe'">
<Tag color="error">取消关注</Tag>
</div>
<div v-else-if="item.event === 'CLICK'">
<Tag>点击菜单</Tag>
{{ item.eventKey }}
</div>
<div v-else-if="item.event === 'VIEW'">
<Tag>点击菜单链接</Tag>
{{ item.eventKey }}
</div>
<div v-else-if="item.event === 'scancode_waitmsg'">
<Tag>扫码结果</Tag>
{{ item.eventKey }}
</div>
<div v-else-if="item.event === 'scancode_push'">
<Tag>扫码结果</Tag>
{{ item.eventKey }}
</div>
<div v-else-if="item.event === 'pic_sysphoto'">
<Tag>系统拍照发图</Tag>
</div>
<div v-else-if="item.event === 'pic_photo_or_album'">
<Tag>拍照或者相册</Tag>
</div>
<div v-else-if="item.event === 'pic_weixin'">
<Tag>微信相册</Tag>
</div>
<div v-else-if="item.event === 'location_select'">
<Tag>选择地理位置</Tag>
</div>
<div v-else>
<Tag color="error">未知事件类型</Tag>
</div>
</div>
</template>

View File

@@ -1,3 +1,3 @@
export { default as WxMsg } from './main.vue';
export * from './types';
export { MsgType } from './types';
export { default as WxMsg } from './wx-msg.vue';

View File

@@ -1,13 +1,11 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Tag } from 'ant-design-vue';
const props = defineProps<{
defineOptions({ name: 'MsgEvent' });
defineProps<{
item: any;
}>();
const item = ref(props.item);
</script>
<template>
@@ -18,7 +16,6 @@ const item = ref(props.item);
<div v-else-if="item.event === 'unsubscribe'">
<Tag color="error">取消关注</Tag>
</div>
<!-- @hw看看能不能处理下 linter 报错哈 -->
<div v-else-if="item.event === 'CLICK'">
<Tag>点击菜单</Tag>
{{ item.eventKey }}
@@ -47,9 +44,6 @@ const item = ref(props.item);
<div v-else-if="item.event === 'location_select'">
<Tag>选择地理位置</Tag>
</div>
<div v-else-if="item.event === 'SCAN'">
<Tag>扫码</Tag>
</div>
<div v-else>
<Tag color="error">未知事件类型</Tag>
</div>

View File

@@ -1,12 +1,10 @@
<script lang="ts" setup>
import type { User } from '../types';
import type { User } from './types';
import { preferences } from '@vben/preferences';
import { formatDateTime } from '@vben/utils';
import Msg from './Msg.vue';
// TODO @dylanvue + 线
import Msg from './msg.vue';
defineOptions({ name: 'MsgList' });
@@ -15,6 +13,7 @@ const props = defineProps<{
list: any[];
user: User;
}>();
const SendFrom = {
MpBot: 2,
User: 1,
@@ -26,31 +25,30 @@ function getAvatar(sendFrom: number) {
: preferences.app.defaultAvatar;
}
// TODO @dylanSendFrom
function getNickname(sendFrom: SendFrom) {
function getNickname(sendFrom: number) {
return sendFrom === SendFrom.User ? props.user.nickname : '公众号';
}
</script>
<template>
<div class="execution" v-for="item in props.list" :key="item.id">
<div
class="avue-comment"
:class="{ 'avue-comment--reverse': item.sendFrom === SendFrom.MpBot }"
class="mp-comment"
:class="{ 'mp-comment--reverse': item.sendFrom === SendFrom.MpBot }"
>
<div class="avatar-div">
<img :src="getAvatar(item.sendFrom)" class="avue-comment__avatar" />
<div class="avue-comment__author">
<img :src="getAvatar(item.sendFrom)" class="mp-comment__avatar" />
<div class="mp-comment__author">
{{ getNickname(item.sendFrom) }}
</div>
</div>
<div class="avue-comment__main">
<div class="avue-comment__header">
<div class="avue-comment__create_time">
<div class="mp-comment__main">
<div class="mp-comment__header">
<div class="mp-comment__create_time">
{{ formatDateTime(item.createTime) }}
</div>
</div>
<div
class="avue-comment__body"
class="mp-comment__body"
:style="
item.sendFrom === SendFrom.MpBot ? 'background: #6BED72;' : ''
"
@@ -64,10 +62,11 @@ function getNickname(sendFrom: SendFrom) {
<style lang="scss" scoped>
/* 因为 joolun 实现依赖 avue 组件,该页面使用了 comment.scss、card.scc */
/** TODO @dylan看看有没适合 tindwind 的哈。 */
@import url('../comment.scss');
@import url('../card.scss');
@import url('./comment.scss');
@import url('./card.scss');
.avatar-div {
width: 80px;

View File

@@ -1,16 +1,16 @@
<script lang="ts" setup>
import { IconifyIcon } from '@vben/icons';
import { WxLocation } from '#/views/mp/components/wx-location';
import { WxMusic } from '#/views/mp/components/wx-music';
import { WxNews } from '#/views/mp/components/wx-news';
import { WxVideoPlayer } from '#/views/mp/components/wx-video-play';
import { WxVoicePlayer } from '#/views/mp/components/wx-voice-play';
import {
WxLocation,
WxMusic,
WxNews,
WxVideoPlayer,
WxVoicePlayer,
} from '#/views/mp/components';
import { MsgType } from '../types';
import MsgEvent from './MsgEvent.vue';
// TODO @dylanvue + 线
import { MsgType } from '../constants';
import MsgEvent from './msg-event.vue';
defineOptions({ name: 'Msg' });
@@ -45,7 +45,7 @@ defineProps<{
<div v-else-if="item.type === MsgType.Link" class="flex flex-col gap-2">
<a :href="item.url" target="_blank" class="text-success no-underline">
<div class="flex items-center text-sm font-medium text-[#52c41a]">
<IconifyIcon icon="mdi:link" class="mr-1" />
<IconifyIcon icon="lucide:link" class="mr-1" />
{{ item.title }}
</div>
</a>

View File

@@ -1,15 +1,3 @@
export enum MsgType {
Event = 'event',
Image = 'image',
Link = 'link',
Location = 'location',
Music = 'music',
News = 'news',
Text = 'text',
Video = 'video',
Voice = 'voice',
}
export interface User {
accountId: number;
avatar: string;

View File

@@ -9,9 +9,9 @@ import { Button, message, Spin } from 'ant-design-vue';
import { getMessagePage, sendMessage } from '#/api/mp/message';
import { getUser } from '#/api/mp/user';
import { WxReplySelect } from '#/views/mp/components/wx-reply';
import { WxReply } from '#/views/mp/components';
import MsgList from './components/MsgList.vue';
import MsgList from './msg-list.vue';
defineOptions({ name: 'WxMsg' });
@@ -43,7 +43,7 @@ const reply = ref<any>({
type: 'text',
}); //
const replySelectRef = ref<InstanceType<typeof WxReplySelect> | null>(null); // WxReplySelectref
const replySelectRef = ref<InstanceType<typeof WxReply> | null>(null); // WxReplyref
const msgDivRef = ref<HTMLDivElement | null>(null); // ref
/** 完成加载 */

View File

@@ -1 +1 @@
export { default as WxMusic } from './main.vue';
export { default as WxMusic } from './wx-music.vue';

View File

@@ -0,0 +1,7 @@
export interface WxMusicProps {
title?: string;
description?: string;
musicUrl?: string;
hqMusicUrl?: string;
thumbMediaUrl: string;
}

View File

@@ -1,23 +1,17 @@
<script lang="ts" setup>
import type { WxMusicProps } from './types';
import { computed } from 'vue';
defineOptions({ name: 'WxMusic' });
const props = withDefaults(
defineProps<{
description?: string;
hqMusicUrl?: string;
musicUrl?: string;
thumbMediaUrl: string;
title?: string;
}>(),
{
title: '',
description: '',
musicUrl: '',
hqMusicUrl: '',
},
);
const props = withDefaults(defineProps<WxMusicProps>(), {
title: '',
description: '',
musicUrl: '',
hqMusicUrl: '',
thumbMediaUrl: '',
});
const href = computed(() => props.hqMusicUrl || props.musicUrl);

View File

@@ -1 +1 @@
export { default as WxNews } from './main.vue';
export { default as WxNews } from './wx-news.vue';

View File

@@ -1,4 +1,3 @@
export type { NewsType, Reply, ReplyType } from './components/types';
export { createEmptyReply } from './components/types';
export * from './types';
export { default as WxReplySelect } from './main.vue';
export { default as WxReply } from './wx-reply.vue';

View File

@@ -10,11 +10,9 @@ import { useAccessStore } from '@vben/stores';
import { Button, Col, message, Modal, Row, Upload } from 'ant-design-vue';
import { WxMaterialSelect } from '#/views/mp/components/wx-material-select';
import { WxMaterialSelect } from '#/views/mp/components';
import { UploadType, useBeforeUpload } from '#/views/mp/hooks/useUpload';
// TODO @dylan
defineOptions({ name: 'TabImage' });
const props = defineProps<{
@@ -110,7 +108,7 @@ function selectMaterial(item: any) {
<Row class="ope-row" justify="center">
<Button danger shape="circle" @click="onDelete">
<template #icon>
<IconifyIcon icon="mdi:delete" />
<IconifyIcon icon="lucide:trash-2" />
</template>
</Button>
</Row>
@@ -123,7 +121,7 @@ function selectMaterial(item: any) {
<Button type="primary" @click="showDialog = true">
素材库选择
<template #icon>
<IconifyIcon icon="mdi:check-circle" />
<IconifyIcon icon="lucide:circle-check" />
</template>
</Button>
<Modal
@@ -154,7 +152,7 @@ function selectMaterial(item: any) {
<Button type="primary">
上传图片
<template #icon>
<IconifyIcon icon="mdi:upload" />
<IconifyIcon icon="lucide:upload" />
</template>
</Button>
</Upload>

View File

@@ -18,11 +18,9 @@ import {
Upload,
} from 'ant-design-vue';
import { WxMaterialSelect } from '#/views/mp/components/wx-material-select';
import { WxMaterialSelect } from '#/views/mp/components';
import { UploadType, useBeforeUpload } from '#/views/mp/hooks/useUpload';
// TODO @dylan
defineOptions({ name: 'TabMusic' });
const props = defineProps<{
@@ -116,7 +114,7 @@ function selectMaterial(item: any) {
/>
<IconifyIcon
v-else
icon="mdi:plus"
icon="lucide:plus"
:size="40"
class="text-gray-400"
/>

View File

@@ -7,12 +7,9 @@ import { IconifyIcon } from '@vben/icons';
import { Button, Col, Modal, Row } from 'ant-design-vue';
import { WxMaterialSelect } from '#/views/mp/components/wx-material-select';
import { WxNews } from '#/views/mp/components/wx-news';
import { WxMaterialSelect, WxNews } from '#/views/mp/components';
import { NewsType } from './types';
// TODO @dylan
import { NewsType } from '../constants';
defineOptions({ name: 'TabNews' });
@@ -53,7 +50,7 @@ function onDelete() {
<Col class="ope-row">
<Button danger shape="circle" @click="onDelete">
<template #icon>
<IconifyIcon icon="mdi:delete" />
<IconifyIcon icon="lucide:trash-2" />
</template>
</Button>
</Col>
@@ -70,7 +67,7 @@ function onDelete() {
: '选择草稿箱图文'
}}
<template #icon>
<IconifyIcon icon="mdi:check-circle" />
<IconifyIcon icon="lucide:circle-check" />
</template>
</Button>
</Col>

View File

@@ -3,8 +3,6 @@ import { computed } from 'vue';
import { Textarea } from 'ant-design-vue';
// TODO @dylan
const props = defineProps<{
modelValue?: null | string;
}>();

View File

@@ -18,12 +18,9 @@ import {
Upload,
} from 'ant-design-vue';
import { WxMaterialSelect } from '#/views/mp/components/wx-material-select';
import { WxVideoPlayer } from '#/views/mp/components/wx-video-play';
import { WxMaterialSelect, WxVideoPlayer } from '#/views/mp/components';
import { UploadType, useBeforeUpload } from '#/views/mp/hooks/useUpload';
// TODO @dylan
defineOptions({ name: 'TabVideo' });
const props = defineProps<{
@@ -143,7 +140,7 @@ function selectMaterial(item: any) {
<Button type="primary" @click="showDialog = true">
素材库选择
<template #icon>
<IconifyIcon icon="mdi:check-circle" />
<IconifyIcon icon="lucide:circle-check" />
</template>
</Button>
<Modal
@@ -174,7 +171,7 @@ function selectMaterial(item: any) {
<Button type="primary">
新建视频
<template #icon>
<IconifyIcon icon="mdi:upload" />
<IconifyIcon icon="lucide:upload" />
</template>
</Button>
</Upload>

View File

@@ -10,12 +10,9 @@ import { useAccessStore } from '@vben/stores';
import { Button, Col, message, Modal, Row, Upload } from 'ant-design-vue';
import { WxMaterialSelect } from '#/views/mp/components/wx-material-select';
import { WxVoicePlayer } from '#/views/mp/components/wx-voice-play';
import { WxMaterialSelect, WxVoicePlayer } from '#/views/mp/components';
import { UploadType, useBeforeUpload } from '#/views/mp/hooks/useUpload';
// TODO @dylan
defineOptions({ name: 'TabVoice' });
const props = defineProps<{
@@ -112,7 +109,7 @@ function selectMaterial(item: Reply) {
<Row class="ope-row" justify="center">
<Button danger shape="circle" @click="onDelete">
<template #icon>
<IconifyIcon icon="mdi:delete" />
<IconifyIcon icon="lucide:trash-2" />
</template>
</Button>
</Row>
@@ -124,7 +121,7 @@ function selectMaterial(item: Reply) {
<Button type="primary" @click="showDialog = true">
素材库选择
<template #icon>
<IconifyIcon icon="mdi:check-circle" />
<IconifyIcon icon="lucide:circle-check" />
</template>
</Button>
<Modal
@@ -155,7 +152,7 @@ function selectMaterial(item: Reply) {
<Button type="primary">
点击上传
<template #icon>
<IconifyIcon icon="mdi:upload" />
<IconifyIcon icon="lucide:upload" />
</template>
</Button>
</Upload>

View File

@@ -1,15 +1,8 @@
import type { Ref } from 'vue';
import { unref } from 'vue';
import type { ReplyType } from '../constants';
export enum ReplyType {
Image = 'image',
Music = 'music',
News = 'news',
Text = 'text',
Video = 'video',
Voice = 'voice',
}
import { unref } from 'vue';
export interface Reply {
accountId: number;
@@ -28,13 +21,8 @@ export interface Reply {
url?: null | string;
}
export enum NewsType {
Draft = '2',
Published = '1',
}
/** 利用旧的reply[accountId, type]初始化新的Reply */
export const createEmptyReply = (old: Ref<Reply> | Reply): Reply => {
export function createEmptyReply(old: Ref<Reply> | Reply): Reply {
return {
accountId: unref(old).accountId,
articles: [],
@@ -51,4 +39,4 @@ export const createEmptyReply = (old: Ref<Reply> | Reply): Reply => {
type: unref(old).type,
url: null,
};
};
}

View File

@@ -8,7 +8,7 @@
支持发送视频消息时支持新建视频
-->
<script lang="ts" setup>
import type { Reply } from './components/types';
import type { Reply } from './types';
import { computed, ref, unref, watch } from 'vue';
@@ -16,13 +16,14 @@ import { IconifyIcon } from '@vben/icons';
import { Row, Tabs } from 'ant-design-vue';
import TabImage from './components/TabImage.vue';
import TabMusic from './components/TabMusic.vue';
import TabNews from './components/TabNews.vue';
import TabText from './components/TabText.vue';
import TabVideo from './components/TabVideo.vue';
import TabVoice from './components/TabVoice.vue';
import { createEmptyReply, NewsType, ReplyType } from './components/types';
import { NewsType, ReplyType } from '../constants';
import TabImage from './tab-image.vue';
import TabMusic from './tab-music.vue';
import TabNews from './tab-news.vue';
import TabText from './tab-text.vue';
import TabVideo from './tab-video.vue';
import TabVoice from './tab-voice.vue';
import { createEmptyReply } from './types';
defineOptions({ name: 'WxReplySelect' });
@@ -88,7 +89,7 @@ defineExpose({
<Tabs.TabPane :key="ReplyType.Text">
<template #tab>
<Row align="middle">
<IconifyIcon icon="mdi:text" class="mr-1" />
<IconifyIcon icon="lucide:file-text" class="mr-1" />
文本
</Row>
</template>
@@ -99,7 +100,7 @@ defineExpose({
<Tabs.TabPane :key="ReplyType.Image">
<template #tab>
<Row align="middle">
<IconifyIcon icon="mdi:image" class="mr-1" />
<IconifyIcon icon="lucide:image" class="mr-1" />
图片
</Row>
</template>
@@ -110,7 +111,7 @@ defineExpose({
<Tabs.TabPane :key="ReplyType.Voice">
<template #tab>
<Row align="middle">
<IconifyIcon icon="mdi:microphone" class="mr-1" />
<IconifyIcon icon="lucide:mic" class="mr-1" />
语音
</Row>
</template>
@@ -121,7 +122,7 @@ defineExpose({
<Tabs.TabPane :key="ReplyType.Video">
<template #tab>
<Row align="middle">
<IconifyIcon icon="mdi:video" class="mr-1" />
<IconifyIcon icon="lucide:video" class="mr-1" />
视频
</Row>
</template>
@@ -132,7 +133,7 @@ defineExpose({
<Tabs.TabPane :key="ReplyType.News">
<template #tab>
<Row align="middle">
<IconifyIcon icon="mdi:newspaper" class="mr-1" />
<IconifyIcon icon="lucide:newspaper" class="mr-1" />
图文
</Row>
</template>
@@ -143,7 +144,7 @@ defineExpose({
<Tabs.TabPane :key="ReplyType.Music">
<template #tab>
<Row align="middle">
<IconifyIcon icon="mdi:music" class="mr-1" />
<IconifyIcon icon="lucide:music" class="mr-1" />
音乐
</Row>
</template>

View File

@@ -1 +1 @@
export { default as WxVideoPlayer } from './main.vue';
export { default as WxVideoPlayer } from './wx-video-play.vue';

View File

@@ -26,7 +26,7 @@ function playVideo() {
<div class="cursor-pointer" @click="playVideo()">
<!-- 提示 -->
<div class="flex items-center">
<IconifyIcon icon="mdi:play-circle" :size="32" class="mr-2" />
<IconifyIcon icon="lucide:circle-play" :size="32" class="mr-2" />
<p class="text-sm">点击播放视频</p>
</div>

View File

@@ -1 +1 @@
export { default as WxVoicePlayer } from './main.vue';
export { default as WxVoicePlayer } from './wx-voice-play.vue';

View File

@@ -68,8 +68,12 @@ function amrStop() {
<!-- 微信消息 - 语音播放 -->
<div class="wx-voice-div cursor-pointer" @click="playVoice">
<div class="flex items-center">
<IconifyIcon v-if="playing !== true" icon="mdi:play-circle" :size="32" />
<IconifyIcon v-else icon="mdi:pause-circle" :size="32" />
<IconifyIcon
v-if="playing !== true"
icon="lucide:circle-play"
:size="32"
/>
<IconifyIcon v-else icon="lucide:circle-pause" :size="32" />
<span v-if="duration" class="amr-duration">{{ duration }} </span>
</div>
<div v-if="content" class="mt-2">

View File

@@ -11,7 +11,7 @@ import { useAccessStore } from '@vben/stores';
import { Button, Image, message, Modal, Upload } from 'ant-design-vue';
import { UploadType, useBeforeUpload } from '#/utils/useUpload';
import { WxMaterialSelect } from '#/views/mp/modules/wx-material-select';
import { WxMaterialSelect } from '#/views/mp/components';
const props = defineProps<{
isFirst: boolean;
@@ -53,8 +53,9 @@ function onMaterialSelected(item: any) {
newsItem.value.thumbUrl = item.url;
}
// TODO @hw注释都补充下哈
const onBeforeUpload = (file: UploadFile) =>
useBeforeUpload(UploadType.Image, 2)(file as any);
function onBeforeUpload(file: UploadFile) {
return useBeforeUpload(UploadType.Image, 2)(file as any);
}
// TODO @hw注释都补充下哈
function onUploadChange(info: any) {

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { Article } from './types';
import { WxNews } from '#/views/mp/modules/wx-news';
import { WxNews } from '#/views/mp/components';
defineOptions({ name: 'DraftTableCell' });

View File

@@ -23,7 +23,7 @@ interface Article {
updateTime: number;
}
const createEmptyNewsItem = (): NewsItem => {
function createEmptyNewsItem(): NewsItem {
return {
title: '',
thumbMediaId: '',
@@ -36,7 +36,7 @@ const createEmptyNewsItem = (): NewsItem => {
onlyFansCanComment: 0,
thumbUrl: '',
};
};
}
export type { Article, NewsItem, NewsItemList };
export { createEmptyNewsItem };

View File

@@ -3,7 +3,7 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { markRaw } from 'vue';
import { WxAccountSelect } from '#/views/mp/modules/wx-account-select';
import { WxAccountSelect } from '#/views/mp/components';
/** 获取表格列配置 */
export function useGridColumns(): VxeTableGridOptions['columns'] {

View File

@@ -11,8 +11,8 @@ import { $t } from '@vben/locales';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import * as MpDraftApi from '#/api/mp/draft';
import * as MpFreePublishApi from '#/api/mp/freePublish';
import { deleteDraft, getDraftPage } from '#/api/mp/draft';
import { submitFreePublish } from '#/api/mp/freePublish';
import { createEmptyNewsItem } from '#/views/mp/draft/components/types';
import DraftTableCell from './components/draft-table.vue';
@@ -21,6 +21,11 @@ import Form from './modules/form.vue';
defineOptions({ name: 'MpDraft' });
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
@@ -43,7 +48,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
if (formValues?.accountId) {
accountId.value = formValues.accountId;
}
const drafts = await MpDraftApi.getDraftPage({
const drafts = await getDraftPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
@@ -190,7 +195,7 @@ async function handlePublish(row: Article) {
});
// TODO @hwMpFreePublishApi 去掉,直接 import参考别的模块哈
try {
await MpFreePublishApi.submitFreePublish(accountId, row.mediaId);
await submitFreePublish(accountId, row.mediaId);
message.success('发布成功');
await gridApi.query();
} finally {
@@ -212,7 +217,7 @@ async function handleDelete(row: Article) {
duration: 0,
});
try {
await MpDraftApi.deleteDraft(accountId, row.mediaId);
await deleteDraft(accountId, row.mediaId);
message.success('删除成功');
await gridApi.query();
} finally {
@@ -237,16 +242,11 @@ onMounted(async () => {
<template>
<Page auto-content-height>
<DocAlert title="公众号图文" url="https://doc.iocoder.cn/mp/article/" />
<template #doc>
<DocAlert title="公众号图文" url="https://doc.iocoder.cn/mp/article/" />
</template>
<!-- TODO @hw参考别的模块 @success 调用 refresh 方法 -->
<FormModal
@success="
() => {
gridApi.query();
}
"
/>
<FormModal @success="handleRefresh" />
<Grid table-title="草稿列表">
<template #toolbar-tools>

View File

@@ -36,7 +36,7 @@ const { hasAccessByCodes } = useAccess();
@click="emit('delete', item.id)"
>
<template #icon>
<IconifyIcon icon="mdi:delete" />
<IconifyIcon icon="lucide:trash-2" />
</template>
</Button>
</div>

View File

@@ -89,7 +89,7 @@ const customRequest: UploadProps['customRequest'] = async function (options) {
class="mb-4"
>
<Button type="primary">
<IconifyIcon icon="mdi:upload" class="mr-1" />
<IconifyIcon icon="lucide:upload" class="mr-1" />
点击上传
</Button>
<template #itemRender="{ file, actions }">

View File

@@ -126,7 +126,7 @@ const customRequest: UploadProps['customRequest'] = async function (options) {
class="mb-4"
>
<Button type="primary">
<IconifyIcon icon="mdi:video-plus" class="mr-1" />
<IconifyIcon icon="lucide:video" class="mr-1" />
选择视频
</Button>
</Upload>

View File

@@ -5,12 +5,12 @@ import { watch } from 'vue';
import { useAccess } from '@vben/access';
import { IconifyIcon } from '@vben/icons';
import { formatDate2 } from '@vben/utils';
import { formatDate2, openWindow } from '@vben/utils';
import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { WxVideoPlayer } from '#/views/mp/components/wx-video-play';
import { WxVideoPlayer } from '#/views/mp/components';
// TODO @dylanvue 组件名小写 + 中划线
@@ -93,10 +93,6 @@ const [Grid, gridApi] = useVbenVxeGrid({
} as VxeTableGridOptions<any>, // TODO @dylan这里有个告警哈
});
function handleDownload(url: string) {
window.open(url, '_blank');
}
watch(
() => props.list,
(list: any[]) => {
@@ -130,8 +126,8 @@ watch(
</template>
<!-- TODO @dylan tableaction yudao-ui-admin-vben-v5/apps/web-antd/src/views/system/user/index.vue -->
<template #actions="{ row }">
<Button type="link" @click="handleDownload(row.url)">
<IconifyIcon icon="mdi:download" />
<Button type="link" @click="openWindow(row.url)">
<IconifyIcon icon="lucide:download" />
下载
</Button>
<Button
@@ -140,7 +136,7 @@ watch(
type="link"
@click="emit('delete', row.id)"
>
<IconifyIcon icon="mdi:delete" />
<IconifyIcon icon="lucide:trash-2" />
删除
</Button>
</template>

View File

@@ -5,12 +5,12 @@ import { watch } from 'vue';
import { useAccess } from '@vben/access';
import { IconifyIcon } from '@vben/icons';
import { formatDate2 } from '@vben/utils';
import { formatDate2, openWindow } from '@vben/utils';
import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { WxVoicePlayer } from '#/views/mp/components/wx-voice-play';
import { WxVoicePlayer } from '#/views/mp/components';
// TODO @dylanvue 组件名小写 + 中划线
@@ -83,10 +83,6 @@ const [Grid, gridApi] = useVbenVxeGrid({
} as VxeTableGridOptions<any>, // TODO @dylan这里有个告警哈
});
function handleDownload(url: string) {
window.open(url, '_blank');
}
watch(
() => props.list,
(list: any[]) => {
@@ -119,9 +115,8 @@ watch(
{{ formatDate2(row.createTime) }}
</template>
<template #actions="{ row }">
<!-- TODO @dylan tableaction yudao-ui-admin-vben-v5/apps/web-antd/src/views/system/user/index.vue -->
<Button type="link" @click="handleDownload(row.url)">
<IconifyIcon icon="mdi:download" />
<Button type="link" @click="openWindow(row.url)">
<IconifyIcon icon="lucide:download" />
下载
</Button>
<Button
@@ -130,7 +125,7 @@ watch(
type="link"
@click="emit('delete', row.id)"
>
<IconifyIcon icon="mdi:delete" />
<IconifyIcon icon="lucide:trash-2" />
删除
</Button>
</template>

View File

@@ -2,7 +2,7 @@
import { provide, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { DocAlert, Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
@@ -15,8 +15,8 @@ import {
Tabs,
} from 'ant-design-vue';
import * as MpMaterialApi from '#/api/mp/material';
import { WxAccountSelect } from '#/views/mp/components/wx-account-select';
import { deletePermanentMaterial, getMaterialPage } from '#/api/mp/material';
import { WxAccountSelect } from '#/views/mp/components';
import ImageTable from './components/ImageTable.vue';
import { UploadType } from './components/upload';
@@ -57,7 +57,7 @@ function onAccountChanged(id: number) {
async function getList() {
loading.value = true;
try {
const data = await MpMaterialApi.getMaterialPage({
const data = await getMaterialPage({
...queryParams,
type: type.value,
});
@@ -90,7 +90,7 @@ async function handleDelete(id: number) {
content: '此操作将永久删除该文件, 是否继续?',
title: '提示',
async onOk() {
await MpMaterialApi.deletePermanentMaterial(id);
await deletePermanentMaterial(id);
message.success('删除成功');
await getList();
},
@@ -99,117 +99,127 @@ async function handleDelete(id: number) {
</script>
<template>
<!-- TODO @dylan这里不太对哈应该是 doc-alert 展示文档 -->
<Page
description="公众号素材"
doc-link="https://doc.iocoder.cn/mp/material/"
title="公众号素材"
>
<!-- 搜索工作栏 -->
<Card class="mb-4" :bordered="false">
<Form :model="queryParams" layout="inline">
<Form.Item label="公众号">
<WxAccountSelect @change="onAccountChanged" />
</Form.Item>
</Form>
</Card>
<Page auto-content-height>
<template #doc>
<DocAlert title="公众号素材" url="https://doc.iocoder.cn/mp/material/" />
</template>
<div class="h-full">
<!-- 搜索工作栏 -->
<Card class="h-[10%]" :bordered="false">
<Form :model="queryParams" layout="inline">
<Form.Item label="公众号">
<WxAccountSelect @change="onAccountChanged" />
</Form.Item>
</Form>
</Card>
<Card :bordered="false">
<Tabs v-model:active-key="type" @change="onTabChange">
<!-- tab 1图片 -->
<Tabs.TabPane :key="UploadType.Image">
<template #tab>
<span class="flex items-center">
<IconifyIcon icon="mdi:image" class="mr-1" />
图片
</span>
</template>
<UploadFile
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
:type="UploadType.Image"
@uploaded="getList"
>
支持 bmp/png/jpeg/jpg/gif 格式大小不超过 2M
</UploadFile>
<!-- 列表 -->
<ImageTable :list="list" :loading="loading" @delete="handleDelete" />
<!-- 分页组件 -->
<div class="mt-4 flex justify-end">
<Pagination
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:total="total"
show-size-changer
@change="getList"
@show-size-change="getList"
<Card :bordered="false" class="mt-4 h-[88%]">
<Tabs v-model:active-key="type" @change="onTabChange">
<!-- tab 1图片 -->
<Tabs.TabPane :key="UploadType.Image">
<template #tab>
<span class="flex items-center">
<IconifyIcon icon="lucide:image" class="mr-1" />
图片
</span>
</template>
<UploadFile
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
:type="UploadType.Image"
@uploaded="getList"
>
支持 bmp/png/jpeg/jpg/gif 格式大小不超过 2M
</UploadFile>
<!-- 列表 -->
<ImageTable
:list="list"
:loading="loading"
@delete="handleDelete"
/>
</div>
</Tabs.TabPane>
<!-- 分页组件 -->
<div class="mt-4 flex justify-end">
<Pagination
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:total="total"
show-size-changer
@change="getList"
@show-size-change="getList"
/>
</div>
</Tabs.TabPane>
<!-- TODO @dylan语音和视频的 tab 有了两个外框需要优化下 -->
<!-- tab 2语音 -->
<Tabs.TabPane :key="UploadType.Voice">
<template #tab>
<span class="flex items-center">
<IconifyIcon icon="mdi:microphone" class="mr-1" />
语音
</span>
</template>
<UploadFile
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
:type="UploadType.Voice"
@uploaded="getList"
>
格式支持 mp3/wma/wav/amr文件大小不超过 2M播放长度不超过 60s
</UploadFile>
<!-- 列表 -->
<VoiceTable :list="list" :loading="loading" @delete="handleDelete" />
<!-- 分页组件 -->
<div class="mt-4 flex justify-end">
<Pagination
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:total="total"
show-size-changer
@change="getList"
@show-size-change="getList"
<!-- tab 2语音 -->
<Tabs.TabPane :key="UploadType.Voice">
<template #tab>
<span class="flex items-center">
<IconifyIcon icon="lucide:mic" class="mr-1" />
语音
</span>
</template>
<UploadFile
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
:type="UploadType.Voice"
@uploaded="getList"
>
格式支持 mp3/wma/wav/amr文件大小不超过 2M播放长度不超过 60s
</UploadFile>
<!-- 列表 -->
<VoiceTable
:list="list"
:loading="loading"
@delete="handleDelete"
/>
</div>
</Tabs.TabPane>
<!-- 分页组件 -->
<div class="mt-4 flex justify-end">
<Pagination
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:total="total"
show-size-changer
@change="getList"
@show-size-change="getList"
/>
</div>
</Tabs.TabPane>
<!-- tab 3视频 -->
<Tabs.TabPane :key="UploadType.Video">
<template #tab>
<span class="flex items-center">
<IconifyIcon icon="mdi:video" class="mr-1" />
视频
</span>
</template>
<Button
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
type="primary"
@click="showCreateVideo = true"
>
新建视频
</Button>
<!-- 新建视频的弹窗 -->
<!-- TODO @dlyan是不是用 Modal 自带的 api 就好啦modal.open 哪个 -->
<UploadVideo v-model:open="showCreateVideo" @uploaded="getList" />
<!-- 列表 -->
<VideoTable :list="list" :loading="loading" @delete="handleDelete" />
<!-- 分页组件 -->
<div class="mt-4 flex justify-end">
<Pagination
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:total="total"
show-size-changer
@change="getList"
@show-size-change="getList"
<!-- tab 3视频 -->
<Tabs.TabPane :key="UploadType.Video">
<template #tab>
<span class="flex items-center">
<IconifyIcon icon="lucide:video" class="mr-1" />
视频
</span>
</template>
<Button
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
type="primary"
@click="showCreateVideo = true"
>
新建视频
</Button>
<!-- 新建视频的弹窗 -->
<UploadVideo v-model:open="showCreateVideo" @uploaded="getList" />
<!-- 列表 -->
<VideoTable
:list="list"
:loading="loading"
@delete="handleDelete"
/>
</div>
</Tabs.TabPane>
</Tabs>
</Card>
<!-- 分页组件 -->
<div class="mt-4 flex justify-end">
<Pagination
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:total="total"
show-size-changer
@change="getList"
@show-size-change="getList"
/>
</div>
</Tabs.TabPane>
</Tabs>
</Card>
</div>
</Page>
</template>

View File

@@ -14,9 +14,7 @@ import {
Select,
} from 'ant-design-vue';
import { WxMaterialSelect } from '#/views/mp/modules/wx-material-select';
import { WxNews } from '#/views/mp/modules/wx-news';
import { WxReplySelect } from '#/views/mp/modules/wx-reply';
import { WxMaterialSelect, WxNews, WxReply } from '#/views/mp/components';
import menuOptions from './menuOptions';
@@ -218,7 +216,7 @@ function deleteMaterial() {
class="configur-content"
v-if="menu.type === 'click' || menu.type === 'scancode_waitmsg'"
>
<WxReplySelect v-if="hackResetWxReplySelect" v-model="menu.reply" />
<WxReply v-if="hackResetWxReplySelect" v-model="menu.reply" />
</div>
<!-- TODO @hw扫码回复这个帮忙看看是不是有点问题= = 好像 vue3 + element-plus 就有点问题 -->
</div>

View File

@@ -9,9 +9,9 @@ import { handleTree } from '@vben/utils';
import { Button, Form, message } from 'ant-design-vue';
import { deleteMenu, getMenuList, saveMenu } from '#/api/mp/menu';
import { WxAccountSelect } from '#/views/mp/components';
import { MenuEditor, MenuPreviewer } from '#/views/mp/menu/components';
import { Level, MENU_NOT_SELECTED } from '#/views/mp/menu/data';
import { WxAccountSelect } from '#/views/mp/modules/wx-account-select';
defineOptions({ name: 'MpMenu' });
@@ -249,19 +249,15 @@ function menuToBackend(menu: any) {
<DocAlert title="公众号菜单" url="https://doc.iocoder.cn/mp/menu/" />
</template>
<!-- 搜索工作栏 -->
<!-- TODO @hw是不是少了一个框子哈 -->
<!-- <ContentWrap> -->
<Form layout="inline" class="-mb-15px w-240px">
<Form.Item label="公众号" prop="accountId" class="w-240px">
<WxAccountSelect @change="onAccountChanged" />
</Form.Item>
</Form>
<!-- </ContentWrap> -->
<!-- TODO @hw貌似高度高了点就是手机下面部分空了一大块 -->
<ContentWrap>
<div class="clearfix public-account-management" v-loading="loading">
<!-- 搜索工作栏 -->
<Form class="mb-10 w-full">
<Form.Item label="公众号" prop="accountId" class="w-60">
<WxAccountSelect @change="onAccountChanged" />
</Form.Item>
</Form>
<div class="clearfix public-account-management mt-10" v-loading="loading">
<!--左边配置菜单-->
<div class="left">
<div class="weixin-hd">

View File

@@ -8,12 +8,14 @@ import { formatDate2 } from '@vben/utils';
import { Button, Image, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { WxLocation } from '#/views/mp/components/wx-location';
import { MsgType } from '#/views/mp/components/wx-msg/types';
import { WxMusic } from '#/views/mp/components/wx-music';
import { WxNews } from '#/views/mp/components/wx-news';
import { WxVideoPlayer } from '#/views/mp/components/wx-video-play';
import { WxVoicePlayer } from '#/views/mp/components/wx-voice-play';
import {
MsgType,
WxLocation,
WxMusic,
WxNews,
WxVideoPlayer,
WxVoicePlayer,
} from '#/views/mp/components';
// TODO @dylanvue 组件名小写 + 中划线

View File

@@ -9,18 +9,18 @@ import { getDictOptions } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import {
Button,
DatePicker,
Form,
FormItem,
Input,
Modal,
Pagination,
Select,
} from 'ant-design-vue';
import { getMessagePage } from '#/api/mp/message';
import { WxAccountSelect } from '#/views/mp/components/wx-account-select';
import { WxMsg } from '#/views/mp/components/wx-msg';
import { MsgType } from '#/views/mp/components/wx-msg/types';
import { MsgType, WxAccountSelect, WxMsg } from '#/views/mp/components';
import MessageTable from './MessageTable.vue';
@@ -108,7 +108,7 @@ function showTotal(total: number) {
<template>
<Page auto-content-height class="flex flex-col">
<!-- 搜索工作栏 -->
<div class="mb-4 rounded-lg bg-white p-4">
<div class="bg-background mb-4 rounded-lg p-4">
<Form
ref="queryFormRef"
:model="queryParams"
@@ -149,28 +149,27 @@ function showTotal(total: number) {
/>
</FormItem>
<FormItem>
<a-button type="primary" @click="handleQuery">
<Button type="primary" @click="handleQuery">
<template #icon>
<IconifyIcon icon="mdi:magnify" />
</template>
搜索
</a-button>
<a-button class="ml-2" @click="resetQuery">
</Button>
<Button class="ml-2" @click="resetQuery">
<template #icon>
<IconifyIcon icon="mdi:refresh" />
</template>
重置
</a-button>
</Button>
</FormItem>
</Form>
</div>
<!-- 列表 -->
<div class="flex-1 rounded-lg bg-white p-4">
<!-- TODO @dylan Grid -->
<div class="bg-background flex-1 rounded-lg p-4">
<MessageTable :list="list" :loading="loading" @send="handleSend" />
<div v-show="total > 0" class="mt-4 flex justify-end">
<a-pagination
<Pagination
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:total="total"

View File

@@ -1,2 +0,0 @@
// TODO @hw1要不统一在 web-antd/src/views/mp/modules 下,搞个 index.ts 去 import 所有2这个包名需要改成 componentns不是 modules 哈3wx 前缀都可以去掉;例如说 account-select.vue
export { default as WxAccountSelect } from './wx-account-select.vue';

View File

@@ -1,123 +0,0 @@
<script lang="ts" setup>
import type { MpAccountApi } from '#/api/mp/account';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useTabs } from '@vben/hooks';
import { message, Select, SelectOption } from 'ant-design-vue';
import { getSimpleAccountList } from '#/api/mp/account';
// TODO @hw【可讨论】如果这个组件有办法调整下让接入的 yudao-ui-admin-vben-v5/apps/web-antd/src/views/mp/draft/index.vue 判断简单点,也可以。
defineOptions({ name: 'WxAccountSelect' });
const props = defineProps<{
modelValue?: number;
}>();
const emit = defineEmits<{
(e: 'change', id: number, name: string): void;
(e: 'update:modelValue', id: number): void;
}>();
const { closeCurrentTab } = useTabs(); // 视图操作
const { push } = useRouter();
const account: MpAccountApi.AccountSimple = reactive({
id: -1,
name: '',
});
const accountList = ref<MpAccountApi.AccountSimple[]>([]);
// 计算当前选中的 ID优先使用 modelValue表单绑定否则使用内部 account.id
const currentId = computed({
get: () => {
// 如果外部传入了 modelValue优先使用外部的值
if (props.modelValue !== undefined && props.modelValue !== null) {
return props.modelValue;
}
return account.id;
},
set: (value: number) => {
// 更新内部状态
account.id = value;
// 同步到外部(表单系统)
emit('update:modelValue', value);
// 触发 change 事件(保持向后兼容)
const found = accountList.value.find(
(v: MpAccountApi.AccountSimple) => v.id === value,
);
if (found) {
account.name = found.name;
emit('change', value, found.name);
}
},
});
// 监听外部 modelValue 变化,同步到内部状态
watch(
() => props.modelValue,
(newValue) => {
if (
newValue !== undefined &&
newValue !== null &&
newValue !== account.id
) {
account.id = newValue;
const found = accountList.value.find(
(v: MpAccountApi.AccountSimple) => v.id === newValue,
);
if (found) {
account.name = found.name;
}
}
},
);
/** 查询公众号列表 */
async function handleQuery() {
accountList.value = await getSimpleAccountList();
if (accountList.value.length === 0) {
message.error('未配置公众号,请在【公众号管理 -> 账号管理】菜单,进行配置');
await closeCurrentTab();
await push({ name: 'MpAccount' });
return;
}
// 如果外部没有传入值modelValue 为空),默认选中第一个
if (props.modelValue === undefined || props.modelValue === null) {
const firstAccount = accountList.value[0];
if (firstAccount) {
currentId.value = firstAccount.id;
account.name = firstAccount.name;
emit('change', firstAccount.id, firstAccount.name);
}
} else {
// 如果外部有值,同步到内部状态
const found = accountList.value.find(
(v: MpAccountApi.AccountSimple) => v.id === props.modelValue,
);
if (found) {
account.id = props.modelValue;
account.name = found.name;
}
}
}
/** 初始化 */
onMounted(() => {
handleQuery();
});
</script>
<template>
<Select v-model:value="currentId" placeholder="请选择公众号" class="w-full">
<SelectOption v-for="item in accountList" :key="item.id" :value="item.id">
{{ item.name }}
</SelectOption>
</Select>
</template>

View File

@@ -1 +0,0 @@
export { default as WxLocation } from './wx-location.vue';

View File

@@ -1,65 +0,0 @@
<!--
微信消息 - 定位TODO @Dhb52 目前未启用TODO @芋艿需要测试下
-->
<script lang="ts" setup>
import { IconifyIcon } from '@vben/icons';
import { Col, Row } from 'ant-design-vue';
defineOptions({ name: 'WxLocation' });
const props = defineProps({
locationX: {
required: true,
type: Number,
},
locationY: {
required: true,
type: Number,
},
label: {
// 地名
required: true,
type: String,
},
qqMapKey: {
// TODO @芋艿:是不是要换成全局的读取?
// QQ 地图的密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc
required: false,
type: String,
default: 'TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E', // 需要自定义
},
});
defineExpose({
locationX: props.locationX,
locationY: props.locationY,
label: props.label,
qqMapKey: props.qqMapKey,
});
</script>
<template>
<div>
<a
target="_blank"
:href="`https://map.qq.com/?type=marker&isopeninfowin=1&markertype=1&pointx=${
locationY
}&pointy=${locationX}&name=${label}&ref=yudao`"
>
<Col>
<Row>
<img
:src="`https://apis.map.qq.com/ws/staticmap/v2/?zoom=10&markers=color:blue|label:A|${
locationX
},${locationY}&key=${qqMapKey}&size=250*180`"
/>
</Row>
<Row>
<IconifyIcon icon="lucide:map-pin" />
{{ label }}
</Row>
</Col>
</a>
</div>
</template>

View File

@@ -1,3 +0,0 @@
export * from './types';
export { default as WxMaterialSelect } from './wx-material-select.vue';

View File

@@ -1,12 +0,0 @@
export enum NewsType {
Draft = '2',
Published = '1',
}
// TODO @hw应该要用到在 material-select.vue 里?
export enum MaterialType {
Image = 'image',
News = 'news',
Video = 'video',
Voice = 'voice',
}

View File

@@ -1,282 +0,0 @@
<script lang="ts" setup>
import { onMounted, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { formatTime } from '@vben/utils';
import { Button, Pagination, Row, Spin, Table } from 'ant-design-vue';
import { getDraftPage } from '#/api/mp/draft';
import { getFreePublishPage } from '#/api/mp/freePublish';
import { getMaterialPage } from '#/api/mp/material';
import { WxNews } from '#/views/mp/modules/wx-news';
import { WxVideoPlayer } from '#/views/mp/modules/wx-video-play';
import { WxVoicePlayer } from '#/views/mp/modules/wx-voice-play';
import { NewsType } from './types';
defineOptions({ name: 'WxMaterialSelect' });
const props = withDefaults(
defineProps<{
accountId: number;
newsType?: NewsType;
type: string;
}>(),
{
newsType: NewsType.Published,
},
);
const emit = defineEmits(['selectMaterial']);
const loading = ref(false); // 遮罩层
const total = ref(0); // 总条数
const list = ref<any[]>([]); // 数据列表
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
accountId: props.accountId,
}); // 查询参数
/** 选择素材 */
function selectMaterialFun(item: any) {
emit('selectMaterial', item);
}
/** 获取分页数据 */
async function getPage() {
loading.value = true;
try {
if (props.type === 'news' && props.newsType === NewsType.Published) {
// 【图文】+ 【已发布】
await getFreePublishPageFun();
} else if (props.type === 'news' && props.newsType === NewsType.Draft) {
// 【图文】+ 【草稿】
await getDraftPageFun();
} else {
// 【素材】
await getMaterialPageFun();
}
} finally {
loading.value = false;
}
}
/** 获取素材分页 */
async function getMaterialPageFun() {
const data = await getMaterialPage({
...queryParams,
type: props.type,
});
list.value = data.list;
total.value = data.total;
}
/** 获取已发布图文分页 */
async function getFreePublishPageFun() {
const data = await getFreePublishPage(queryParams);
data.list.forEach((item: any) => {
const articles = item.content.newsItem;
articles.forEach((article: any) => {
article.picUrl = article.thumbUrl;
});
});
list.value = data.list;
total.value = data.total;
}
/** 获取草稿图文分页 */
async function getDraftPageFun() {
const data = await getDraftPage(queryParams);
data.list.forEach((draft: any) => {
const articles = draft.content.newsItem;
articles.forEach((article: any) => {
article.picUrl = article.thumbUrl;
});
});
list.value = data.list;
total.value = data.total;
}
// TODO @hw改成 grid 风格;
onMounted(async () => {
getPage();
});
</script>
<template>
<div class="pb-30px">
<!-- 类型image -->
<div v-if="props.type === 'image'">
<Spin :spinning="loading">
<div class="waterfall">
<div class="waterfall-item" v-for="item in list" :key="item.mediaId">
<img class="material-img" :src="item.url" />
<p class="item-name">{{ item.name }}</p>
<Row class="ope-row">
<Button type="primary" @click="selectMaterialFun(item)">
选择
<IconifyIcon icon="lucide:circle-check" />
</Button>
</Row>
</div>
</div>
</Spin>
<!-- 分页组件 -->
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
@change="getMaterialPageFun"
/>
</div>
<!-- 类型voice -->
<div v-else-if="props.type === 'voice'">
<!-- 列表 -->
<Spin :spinning="loading">
<Table :data-source="list">
<Table.Column title="编号" data-index="mediaId" align="center" />
<Table.Column title="文件名" data-index="name" align="center" />
<Table.Column title="语音" align="center">
<template #default="{ record }">
<WxVoicePlayer :url="record.url" />
</template>
</Table.Column>
<Table.Column title="上传时间" align="center" width="180">
<template #default="{ record }">
{{ formatTime(record.createTime, 'YYYY-MM-DD HH:mm:ss') }}
</template>
</Table.Column>
<Table.Column title="操作" align="center" fixed="right">
<template #default="{ record }">
<Button type="link" @click="selectMaterialFun(record)">
选择
<IconifyIcon icon="lucide:plus" />
</Button>
</template>
</Table.Column>
</Table>
</Spin>
<!-- 分页组件 -->
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
@change="getPage"
/>
</div>
<!-- 类型video -->
<div v-else-if="props.type === 'video'">
<!-- 列表 -->
<Spin :spinning="loading">
<Table :data-source="list">
<Table.Column title="编号" data-index="mediaId" align="center" />
<Table.Column title="文件名" data-index="name" align="center" />
<Table.Column title="标题" data-index="title" align="center" />
<Table.Column title="介绍" data-index="introduction" align="center" />
<Table.Column title="视频" align="center">
<template #default="{ record }">
<WxVideoPlayer :url="record.url" />
</template>
</Table.Column>
<Table.Column title="上传时间" align="center" width="180">
<template #default="{ record }">
{{ formatTime(record.createTime, 'YYYY-MM-DD HH:mm:ss') }}
</template>
</Table.Column>
<Table.Column title="操作" align="center" fixed="right">
<template #default="{ record }">
<Button type="link" @click="selectMaterialFun(record)">
选择
<IconifyIcon icon="lucide:circle-plus" />
</Button>
</template>
</Table.Column>
</Table>
</Spin>
<!-- 分页组件 -->
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
@change="getMaterialPageFun"
/>
</div>
<!-- 类型news -->
<div v-else-if="props.type === 'news'">
<Spin :spinning="loading">
<div class="waterfall">
<div class="waterfall-item" v-for="item in list" :key="item.mediaId">
<div v-if="item.content && item.content.newsItem">
<WxNews :articles="item.content.newsItem" />
<Row class="ope-row">
<Button type="primary" @click="selectMaterialFun(item)">
选择
<IconifyIcon icon="lucide:circle-check" />
</Button>
</Row>
</div>
</div>
</div>
</Spin>
<!-- 分页组件 -->
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
@change="getMaterialPageFun"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
/** TODO @hwtindwind 风格 */
@media (width >= 992px) and (width <= 1300px) {
.waterfall {
column-count: 3;
}
p {
color: red;
}
}
@media (width >= 768px) and (width <= 991px) {
.waterfall {
column-count: 2;
}
p {
color: orange;
}
}
@media (width <= 767px) {
.waterfall {
column-count: 1;
}
}
.waterfall {
column-gap: 10px;
width: 100%;
margin: 0 auto;
column-count: 5;
}
.waterfall-item {
padding: 10px;
margin-bottom: 10px;
border: 1px solid #eaeaea;
break-inside: avoid;
}
.material-img {
width: 100%;
}
p {
line-height: 30px;
}
</style>

View File

@@ -1,116 +0,0 @@
.avue-card {
&__item {
box-sizing: border-box;
height: 200px;
margin-bottom: 16px;
font-size: 14px;
font-feature-settings: 'tnum';
font-variant: tabular-nums;
line-height: 1.5;
color: rgb(0 0 0 / 65%);
cursor: pointer;
list-style: none;
background-color: #fff;
border: 1px solid #e8e8e8;
&:hover {
border-color: rgb(0 0 0 / 9%);
box-shadow: 0 2px 8px rgb(0 0 0 / 9%);
}
&--add {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
font-size: 16px;
color: rgb(0 0 0 / 45%);
background-color: #fff;
border: 1px dashed #000;
border-color: #d9d9d9;
border-radius: 2px;
i {
margin-right: 10px;
}
&:hover {
color: #40a9ff;
background-color: #fff;
border-color: #40a9ff;
}
}
}
&__body {
display: flex;
padding: 24px;
}
&__detail {
flex: 1;
}
&__avatar {
width: 48px;
height: 48px;
margin-right: 12px;
overflow: hidden;
border-radius: 48px;
img {
width: 100%;
height: 100%;
}
}
&__title {
margin-bottom: 12px;
font-size: 16px;
color: rgb(0 0 0 / 85%);
&:hover {
color: #1890ff;
}
}
&__info {
display: -webkit-box;
height: 64px;
overflow: hidden;
-webkit-line-clamp: 3;
color: rgb(0 0 0 / 45%);
-webkit-box-orient: vertical;
}
&__menu {
display: flex;
justify-content: space-around;
height: 50px;
line-height: 50px;
color: rgb(0 0 0 / 45%);
text-align: center;
background: #f7f9fa;
&:hover {
color: #1890ff;
}
}
}
/** joolun 额外加的 */
.avue-comment__main {
flex: unset !important;
margin: 0 8px !important;
border-radius: 5px !important;
}
.avue-comment__header {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
.avue-comment__body {
border-bottom-right-radius: 5px;
border-bottom-left-radius: 5px;
}

View File

@@ -1,109 +0,0 @@
/* 来自 https://github.com/nmxiaowei/avue/blob/master/styles/src/element-ui/comment.scss */
.avue-comment {
display: flex;
align-items: flex-start;
margin-bottom: 30px;
&--reverse {
flex-direction: row-reverse;
.avue-comment__main {
&::before,
&::after {
right: -8px;
left: auto;
border-width: 8px 0 8px 8px;
}
&::before {
border-left-color: #dedede;
}
&::after {
margin-right: 1px;
margin-left: auto;
border-left-color: #f8f8f8;
}
}
}
&__avatar {
box-sizing: border-box;
width: 48px;
height: 48px;
vertical-align: middle;
border: 1px solid transparent;
border-radius: 50%;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 15px;
background: #f8f8f8;
border-bottom: 1px solid #eee;
}
&__author {
font-size: 14px;
font-weight: 700;
color: #999;
}
&__main {
position: relative;
flex: 1;
margin: 0 20px;
border: 1px solid #dedede;
border-radius: 2px;
&::before,
&::after {
position: absolute;
top: 10px;
right: 100%;
left: -8px;
display: block;
width: 0;
height: 0;
pointer-events: none;
content: ' ';
border-color: transparent;
border-style: solid solid outset;
border-width: 8px 8px 8px 0;
}
&::before {
z-index: 1;
border-right-color: #dedede;
}
&::after {
z-index: 2;
margin-left: 1px;
border-right-color: #f8f8f8;
}
}
&__body {
padding: 15px;
overflow: hidden;
font-family:
'Segoe UI', 'Lucida Grande', Helvetica, Arial, 'Microsoft YaHei',
FreeSans, Arimo, 'Droid Sans', 'wenquanyi micro hei', 'Hiragino Sans GB',
'Hiragino Sans GB W3', FontAwesome, sans-serif;
font-size: 14px;
color: #333;
background: #fff;
}
blockquote {
padding: 1px 0 1px 15px;
margin: 0;
font-family:
Georgia, 'Times New Roman', Times, Kai, 'Kaiti SC', KaiTi, BiauKai,
FontAwesome, serif;
border-left: 4px solid #ddd;
}
}

View File

@@ -1,3 +0,0 @@
export * from './types';
export { default as WxMsg } from './wx-msg.vue';

View File

@@ -1,78 +0,0 @@
<script lang="ts" setup>
import type { User } from './types';
import { formatDateTime } from '@vben/utils';
import avatarWechat from '#/assets/imgs/wechat.png';
import Msg from './msg.vue';
// 确保 User 类型被识别为已使用
type PropsUser = User;
defineOptions({ name: 'MsgList' });
const props = defineProps<{
accountId: number;
list: any[];
user: PropsUser;
}>();
const SendFrom = {
MpBot: 2,
User: 1,
} as const; // 使用常量对象替代枚举,避免 linter 误报
type SendFromType = (typeof SendFrom)[keyof typeof SendFrom];
// 显式引用枚举成员供模板使用
const MpBotValue = SendFrom.MpBot;
const UserValue = SendFrom.User;
const getAvatar = (sendFrom: SendFromType) =>
sendFrom === UserValue ? props.user.avatar : avatarWechat;
const getNickname = (sendFrom: SendFromType) =>
sendFrom === UserValue ? props.user.nickname : '公众号';
</script>
<template>
<div v-for="item in props.list" :key="item.id">
<div
class="mb-[30px] flex items-start"
:class="{ 'flex-row-reverse': item.sendFrom === MpBotValue }"
>
<div class="w-20 text-center">
<img
:src="getAvatar(item.sendFrom)"
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) }}
</div>
</div>
<div class="relative mx-5 flex-1 rounded-[5px] border border-[#dedede]">
<div
class="flex items-center justify-between rounded-t-[5px] border-b border-[#eee] bg-[#f8f8f8] px-[15px] py-[5px]"
>
<div class="avue-comment__create_time">
{{ formatDateTime(item.createTime) }}
</div>
</div>
<div
class="overflow-hidden rounded-b-[5px] bg-white px-[15px] py-[15px] text-sm text-[#333]"
:style="item.sendFrom === MpBotValue ? 'background: #6BED72;' : ''"
>
<Msg :item="item" />
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
/* 因为 joolun 实现依赖 avue 组件,该页面使用了 comment.scss、card.scc */
/** TODO @hw这里有没办法重构掉哈。辛苦~~~ */
@import url('../comment.scss');
@import url('../card.scss');
</style>

View File

@@ -1,85 +0,0 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { WxLocation } from '#/views/mp/modules/wx-location';
import { WxMusic } from '#/views/mp/modules/wx-music';
import { WxNews } from '#/views/mp/modules/wx-news';
import { WxVideoPlayer } from '#/views/mp/modules/wx-video-play';
import { WxVoicePlayer } from '#/views/mp/modules/wx-voice-play';
import MsgEvent from './msg-event.vue';
import { MsgType } from './types';
defineOptions({ name: 'Msg' });
const props = defineProps<{
item: any;
}>();
const item = ref<any>(props.item);
</script>
<template>
<div>
<MsgEvent v-if="item.type === MsgType.Event" :item="item" />
<div v-else-if="item.type === MsgType.Text">{{ item.content }}</div>
<div v-else-if="item.type === MsgType.Voice">
<WxVoicePlayer :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"
>
<WxVideoPlayer :url="item.mediaUrl" />
</div>
<div v-else-if="item.type === MsgType.Link" class="flex-1">
<a target="_blank" :href="item.url">
<div
class="mb-3 text-base text-[rgba(0,0,0,0.85)] hover:text-[#1890ff]"
>
<IconifyIcon icon="lucide:link" />{{ item.title }}
</div>
</a>
<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 === MsgType.Location">
<WxLocation
:label="item.label"
:location-y="item.locationY"
:location-x="item.locationX"
/>
</div>
<div v-else-if="item.type === MsgType.News" class="w-[300px]">
<WxNews :articles="item.articles" />
</div>
<div v-else-if="item.type === MsgType.Music">
<WxMusic
:title="item.title"
:description="item.description"
:thumb-media-url="item.thumbMediaUrl"
:music-url="item.musicUrl"
:hq-music-url="item.hqMusicUrl"
/>
</div>
</div>
</template>

View File

@@ -1,17 +0,0 @@
export enum MsgType {
Event = 'event',
Image = 'image',
Link = 'link',
Location = 'location',
Music = 'music',
News = 'news',
Text = 'text',
Video = 'video',
Voice = 'voice',
}
export interface User {
nickname: string;
avatar: string;
accountId: number;
}

View File

@@ -1,197 +0,0 @@
<!--
- Copyright (C) 2018-2019
- All rights reserved, Designed By www.joolun.com
芋道源码
移除暂时用不到的 websocket
代码优化补充注释提升阅读性
-->
<script lang="ts" setup>
import type { User } from './types';
import type { Reply } from '#/views/mp/modules/wx-reply';
import { nextTick, onMounted, reactive, ref, unref } from 'vue';
import { Button, message, Spin } from 'ant-design-vue';
import { getMessagePage, sendMessage } from '#/api/mp/message';
import { getUser } from '#/api/mp/user';
import profile from '#/assets/imgs/profile.jpg';
import { ReplyType, WxReplySelect } from '#/views/mp/modules/wx-reply';
import MsgList from './msg-list.vue';
defineOptions({ name: 'WxMsg' });
const props = defineProps({
userId: {
type: Number,
required: true,
},
});
const accountId = ref(-1); // 公众号ID需要通过userId初始化
const loading = ref(false); // 消息列表是否正在加载中
const hasMore = ref(true); // 是否可以加载更多
const list = ref<any[]>([]); // 消息列表
const queryParams = reactive({
pageNo: 1, // 当前页数
pageSize: 14, // 每页显示多少条
accountId,
});
const user: User = reactive({
nickname: '用户', // 由于微信不再提供昵称,直接使用"用户"展示
avatar: profile,
accountId, // 公众号账号编号
});
// ========= 消息发送 =========
const sendLoading = ref(false); // 发送消息是否加载中
const reply = ref<Reply>({
type: ReplyType.Text,
accountId: -1,
articles: [],
}); // 微信发送消息
const replySelectRef = ref<InstanceType<typeof WxReplySelect> | 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;
// API 返回的数据可能包含 headImageUrl但类型定义中没有使用类型断言
const userData = data as typeof data & { headImageUrl?: string };
user.avatar =
userData.headImageUrl && userData.headImageUrl.length > 0
? userData.headImageUrl
: user.avatar;
accountId.value = data.accountId;
reply.value.accountId = data.accountId;
refreshChange();
});
/** 执行发送 */
async function sendMsg() {
if (!unref(reply)) {
return;
}
// 公众号限制:客服消息,公众号只允许发送一条
if (
reply.value.type === ReplyType.News &&
reply.value.articles &&
reply.value.articles.length > 1
) {
reply.value.articles = [reply.value.articles[0]];
message.success('图文消息条数限制在 1 条以内,已默认发送第一条');
}
// 注意sendMessage API 需要 openid但这里传入的是 userId
// 这可能是后端 API 的特殊处理,使用类型断言绕过类型检查
const data = await sendMessage({
userId: props.userId,
...reply.value,
} 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(
{
pageNo: page.pageNo,
pageSize: page.pageSize,
userId: props.userId,
accountId: page.accountId,
},
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>
<template>
<ContentWrap>
<Spin :spinning="loading">
<div class="bg-background ml-2 mr-2 h-12 overflow-auto" ref="msgDivRef">
<!-- 加载更多 -->
<div v-if="!loading">
<div
class="cursor-pointer py-5 text-center"
v-if="hasMore"
@click="loadMore"
>
<span class="text-foreground">点击加载更多</span>
</div>
<div class="py-5 text-center" v-if="!hasMore">
<span class="text-foreground">没有更多了</span>
</div>
</div>
<!-- 消息列表 -->
<MsgList :list="list" :account-id="accountId" :user="user" />
</div>
</Spin>
<Spin :spinning="sendLoading">
<div class="p-[10px]">
<WxReplySelect ref="replySelectRef" v-model="reply" />
<Button type="primary" class="float-right mb-2 mt-2" @click="sendMsg">
发送(S)
</Button>
</div>
</Spin>
</ContentWrap>
</template>

View File

@@ -1 +0,0 @@
export { default as WxMusic } from './wx-music.vue';

View File

@@ -1,71 +0,0 @@
<!--
微信消息 - 音乐
-->
<script lang="ts" setup>
defineOptions({ name: 'WxMusic' });
const props = defineProps({
title: {
required: false,
type: String,
default: '',
},
description: {
required: false,
type: String,
default: '',
},
musicUrl: {
required: false,
type: String,
default: '',
},
hqMusicUrl: {
required: false,
type: String,
default: '',
},
thumbMediaUrl: {
required: true,
type: String,
},
});
defineExpose({
musicUrl: props.musicUrl,
});
</script>
<template>
<div>
<a
target="_blank"
:href="hqMusicUrl ? hqMusicUrl : musicUrl"
style="text-decoration: none"
>
<div
class="avue-card__body"
style="padding: 10px; background-color: #fff; border-radius: 5px"
>
<div class="avue-card__avatar">
<img :src="thumbMediaUrl" alt="" />
</div>
<div class="avue-card__detail">
<div class="avue-card__title" style="margin-bottom: unset">
{{ title }}
</div>
<div class="avue-card__info" style="height: unset">
{{ description }}
</div>
</div>
</div>
</a>
</div>
</template>
<style lang="scss" scoped>
/** TODO @hw这里有没办法重构掉哈。辛苦~~~ */
/* 因为 joolun 实现依赖 avue 组件,该页面使用了 card.scss */
@import url('../wx-msg/card.scss');
</style>

View File

@@ -1 +0,0 @@
export { default as WxNews } from './wx-news.vue';

View File

@@ -1,127 +0,0 @@
<!--
- Copyright (C) 2018-2019
- All rights reserved, Designed By www.joolun.com
微信消息 - 图文
芋道源码
代码优化补充注释提升阅读性
-->
<script lang="ts" setup>
import { Image } from 'ant-design-vue';
defineOptions({ name: 'WxNews' });
const props = withDefaults(
defineProps<{
articles?: any[] | null;
}>(),
{
articles: null,
},
);
defineExpose({
articles: props.articles,
});
</script>
<template>
<div class="news-home">
<div v-for="(article, index) in articles" :key="index" class="news-div">
<!-- 头条 -->
<a v-if="index === 0" :href="article.url" target="_blank">
<div class="news-main">
<div class="news-content">
<Image
:src="article.picUrl || article.thumbUrl"
class="material-img"
:preview="false"
style="width: 100%; height: 120px"
/>
<div class="news-content-title">
<span>{{ article.title }}</span>
</div>
</div>
</div>
</a>
<!-- 二条/三条等等 -->
<a v-else :href="article.url" target="_blank">
<div class="news-main-item">
<div class="news-content-item">
<div class="news-content-item-title">{{ article.title }}</div>
<div class="news-content-item-img">
<img
:src="article.picUrl || article.thumbUrl"
class="material-img"
height="100%"
/>
</div>
</div>
</div>
</a>
</div>
</div>
</template>
<style lang="scss" scoped>
/** TODO @hwtindwind 替代 */
.news-home {
width: 100%;
margin: auto;
background-color: #fff;
}
.news-main {
width: 100%;
margin: auto;
}
.news-content {
position: relative;
width: 100%;
background-color: #acadae;
}
.news-content-title {
position: absolute;
bottom: 0;
left: 0;
box-sizing: unset !important;
display: inline-block;
width: 98%;
padding: 1%;
font-size: 12px;
color: #fff;
white-space: normal;
background-color: black;
opacity: 0.65;
}
.news-main-item {
padding: 5px 0;
background-color: #fff;
border-top: 1px solid #eaeaea;
}
.news-content-item {
position: relative;
}
.news-content-item-title {
display: inline-block;
width: 70%;
margin-left: 1%;
font-size: 10px;
white-space: normal;
}
.news-content-item-img {
display: inline-block;
width: 25%;
margin-right: 1%;
background-color: #acadae;
}
.material-img {
width: 100%;
}
</style>

View File

@@ -1,2 +0,0 @@
export * from './types';
export { default as WxReplySelect } from './wx-reply.vue';

View File

@@ -1,154 +0,0 @@
<script lang="ts" setup>
import type { UploadFile } from 'ant-design-vue';
import type { Reply } from './types';
import { computed, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { useAccessStore } from '@vben/stores';
import { Button, Col, message, Modal, Row, Upload } from 'ant-design-vue';
import { UploadType, useBeforeUpload } from '#/utils/useUpload';
import { WxMaterialSelect } from '#/views/mp/modules/wx-material-select';
const props = defineProps<{
modelValue: Reply;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void;
}>();
const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`;
const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` };
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
const showDialog = ref(false);
const fileList = ref([]);
const uploadData = reactive({
accountId: reply.value.accountId,
type: 'image',
title: '',
introduction: '',
});
/** 图片上传前校验 */
function beforeImageUpload(file: UploadFile) {
return useBeforeUpload(UploadType.Image, 2)(file as any);
}
/** 上传成功 */
function onUploadSuccess(info: any) {
const res = info.response || info;
if (res.code !== 0) {
message.error(`上传出错:${res.msg}`);
return false;
}
// 清空上传时的各种数据
fileList.value = [];
uploadData.title = '';
uploadData.introduction = '';
// 上传好的文件,本质是个素材,所以可以进行选中
selectMaterial(res.data);
}
/** 删除图片 */
function onDelete() {
reply.value.mediaId = null;
reply.value.url = null;
reply.value.name = null;
}
/** 选择素材 */
function selectMaterial(item: any) {
showDialog.value = false;
// reply.value.type = 'image'
reply.value.mediaId = item.mediaId;
reply.value.url = item.url;
reply.value.name = item.name;
}
</script>
<template>
<div>
<!-- 情况一已经选择好素材或者上传好图片 -->
<div
class="mx-auto mb-[10px] w-[280px] border border-[#eaeaea] p-[10px]"
v-if="reply.url"
>
<img class="w-full" :src="reply.url" />
<p
class="overflow-hidden text-ellipsis whitespace-nowrap text-center text-xs"
v-if="reply.name"
>
{{ reply.name }}
</p>
<Row class="pt-[10px] text-center" justify="center">
<Button type="primary" danger shape="circle" @click="onDelete">
<IconifyIcon icon="ep:delete" />
</Button>
</Row>
</div>
<!-- 情况二未做完上述操作 -->
<Row v-else class="text-center" align="middle">
<!-- 选择素材 -->
<Col
:span="12"
class="h-[160px] w-[49.5%] border border-[rgb(234,234,234)] py-[50px]"
>
<Button type="primary" @click="showDialog = true">
素材库选择 <IconifyIcon icon="ep:circle-check" />
</Button>
<Modal
title="选择图片"
v-model:open="showDialog"
width="90%"
destroy-on-close
>
<WxMaterialSelect
type="image"
:account-id="reply.accountId"
@select-material="selectMaterial"
/>
</Modal>
</Col>
<!-- 文件上传 -->
<Col
:span="12"
class="float-right h-[160px] w-[49.5%] border border-[rgb(234,234,234)] py-[50px]"
>
<Upload
:action="UPLOAD_URL"
:headers="HEADERS"
:file-list="fileList"
:data="uploadData"
:before-upload="beforeImageUpload"
@change="
(info) => {
if (info.file.status === 'done') {
onUploadSuccess(info.file.response || info.file);
}
}
"
>
<Button type="primary">上传图片</Button>
<template #tip>
<span>
<div class="text-center leading-[18px]">
支持 bmp/png/jpeg/jpg/gif 格式大小不超过 2M
</div>
</span>
</template>
</Upload>
</Col>
</Row>
</div>
</template>

View File

@@ -1,153 +0,0 @@
<script lang="ts" setup>
import type { UploadFile } from 'ant-design-vue';
import type { Reply } from './types';
import { computed, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { useAccessStore } from '@vben/stores';
import {
Button,
Col,
Input,
message,
Modal,
Row,
Upload,
} from 'ant-design-vue';
import { UploadType, useBeforeUpload } from '#/utils/useUpload';
import { WxMaterialSelect } from '#/views/mp/modules/wx-material-select';
const props = defineProps<{
modelValue: Reply;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void;
}>();
const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`;
const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` };
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
const showDialog = ref(false);
const fileList = ref([]);
const uploadData = reactive({
accountId: reply.value.accountId,
type: 'thumb', // 音乐类型为thumb
title: '',
introduction: '',
});
/** 图片上传前校验 */
function beforeImageUpload(file: UploadFile) {
return useBeforeUpload(UploadType.Image, 2)(file as any);
}
/** 上传成功 */
function onUploadSuccess(info: any) {
const res = info.response || info;
if (res.code !== 0) {
message.error(`上传出错:${res.msg}`);
return false;
}
// 清空上传时的各种数据
fileList.value = [];
uploadData.title = '';
uploadData.introduction = '';
// 上传好的文件,本质是个素材,所以可以进行选中
selectMaterial(res.data);
}
/** 选择素材 */
function selectMaterial(item: any) {
showDialog.value = false;
reply.value.thumbMediaId = item.mediaId;
reply.value.thumbMediaUrl = item.url;
}
</script>
<template>
<div>
<Row align="middle" justify="center">
<Col :span="6">
<Row align="middle" justify="center" class="inline-block text-center">
<Col :span="24">
<Row align="middle" justify="center">
<img
class="w-[100px]"
v-if="reply.thumbMediaUrl"
:src="reply.thumbMediaUrl"
/>
<IconifyIcon v-else icon="ep:plus" />
</Row>
<Row align="middle" justify="center" class="mt-[2%]">
<div>
<Upload
:action="UPLOAD_URL"
:headers="HEADERS"
:file-list="fileList"
:data="uploadData"
:before-upload="beforeImageUpload"
@change="
(info) => {
if (info.file.status === 'done') {
onUploadSuccess(info.file.response || info.file);
}
}
"
>
<template #default>
<Button type="link">本地上传</Button>
</template>
</Upload>
<Button type="link" @click="showDialog = true" class="ml-[5px]">
素材库选择
</Button>
</div>
</Row>
</Col>
</Row>
<Modal
title="选择图片"
v-model:open="showDialog"
width="80%"
destroy-on-close
>
<WxMaterialSelect
type="image"
:account-id="reply.accountId"
@select-material="selectMaterial"
/>
</Modal>
</Col>
<Col :span="18">
<Input v-model:value="reply.title as string" placeholder="请输入标题" />
<div class="my-5"></div>
<Input
v-model:value="reply.description as string"
placeholder="请输入描述"
/>
</Col>
</Row>
<div class="my-5"></div>
<Input
v-model:value="reply.musicUrl as string"
placeholder="请输入音乐链接"
/>
<div class="my-5"></div>
<Input
v-model:value="reply.hqMusicUrl as string"
placeholder="请输入高质量音乐链接"
/>
</div>
</template>

View File

@@ -1,85 +0,0 @@
<script lang="ts" setup>
import type { Reply } from './types';
import { computed, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Button, Col, Modal, Row } from 'ant-design-vue';
import { WxMaterialSelect } from '#/views/mp/modules/wx-material-select';
import { WxNews } from '#/views/mp/modules/wx-news';
import { NewsType } from './types';
const props = defineProps<{
modelValue: Reply;
newsType: NewsType;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void;
}>();
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
const showDialog = ref(false);
/** 选择素材 */
function selectMaterial(item: any) {
showDialog.value = false;
reply.value.articles = item.content.newsItem;
}
/** 删除图文 */
function onDelete() {
reply.value.articles = [];
}
</script>
<template>
<div>
<Row>
<div
class="mx-auto mb-[10px] w-[280px] border border-[#eaeaea] p-[10px]"
v-if="reply.articles && reply.articles.length > 0"
>
<WxNews :articles="reply.articles" />
<Col class="pt-[10px] text-center">
<Button type="primary" danger shape="circle" @click="onDelete">
<IconifyIcon icon="ep:delete" />
</Button>
</Col>
</div>
<!-- 选择素材 -->
<Col :span="24" v-if="!reply.content">
<Row class="text-center" align="middle">
<Col :span="24">
<Button type="primary" @click="showDialog = true">
{{
newsType === NewsType.Published
? '选择已发布图文'
: '选择草稿箱图文'
}}
<IconifyIcon icon="ep:circle-check" />
</Button>
</Col>
</Row>
</Col>
<Modal
title="选择图文"
v-model:open="showDialog"
width="90%"
destroy-on-close
>
<WxMaterialSelect
type="news"
:account-id="reply.accountId"
:news-type="newsType"
@select-material="selectMaterial"
/>
</Modal>
</Row>
</div>
</template>

View File

@@ -1,31 +0,0 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { Input } from 'ant-design-vue';
const props = defineProps<{
modelValue?: null | string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: null | string): void;
(e: 'input', v: null | string): void;
}>();
const content = computed({
get: () => props.modelValue,
set: (val: null | string) => {
emit('update:modelValue', val);
emit('input', val);
},
});
</script>
<template>
<Input.TextArea
:rows="5"
placeholder="请输入内容"
v-model:value="content as string"
class="w-full"
/>
</template>

View File

@@ -1,199 +0,0 @@
<script lang="ts" setup>
import type { UploadFile } from 'ant-design-vue';
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
import type { Reply } from './types';
import { computed, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { useAccessStore } from '@vben/stores';
import {
Button,
Col,
Input,
message,
Modal,
Row,
Upload,
} from 'ant-design-vue';
import { UploadType, useBeforeUpload } from '#/utils/useUpload';
import { WxMaterialSelect } from '#/views/mp/modules/wx-material-select';
import { WxVideoPlayer } from '#/views/mp/modules/wx-video-play';
const props = defineProps<{
modelValue: Reply;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void;
}>();
const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`;
const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` };
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val: Reply) => emit('update:modelValue', val),
});
const showDialog = ref(false);
const fileList = ref([]);
const uploadData = reactive({
accountId: reply.value.accountId,
type: 'video',
title: '',
introduction: '',
});
/** 视频上传前校验 */
function beforeVideoUpload(file: UploadFile) {
return useBeforeUpload(UploadType.Video, 10)(file as any);
}
/** 自定义上传请求 */
async function customRequest(info: UploadRequestOption) {
const formData = new FormData();
formData.append('file', info.file as File);
formData.append('accountId', String(uploadData.accountId));
formData.append('type', uploadData.type);
if (uploadData.title) {
formData.append('title', uploadData.title);
}
if (uploadData.introduction) {
formData.append('introduction', uploadData.introduction);
}
try {
const xhr = new XMLHttpRequest();
// 监听上传进度
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
info.onProgress?.({ percent });
}
});
// 监听上传完成
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const res = JSON.parse(xhr.responseText);
onUploadSuccess(res);
info.onSuccess?.(res);
} catch {
info.onError?.(new Error('解析响应失败'));
message.error('上传失败:解析响应失败');
}
} else {
info.onError?.(new Error(`上传失败HTTP ${xhr.status}`));
message.error('上传失败,请重试');
}
});
// 监听上传错误
xhr.addEventListener('error', () => {
info.onError?.(new Error('上传请求失败'));
message.error('上传失败,请重试');
});
// 发送请求
xhr.open('POST', UPLOAD_URL);
xhr.setRequestHeader('Authorization', HEADERS.Authorization);
xhr.send(formData);
} catch (error: any) {
info.onError?.(error);
message.error('上传失败,请重试');
}
}
/** 上传成功 */
function onUploadSuccess(res: any) {
if (res.code !== 0) {
message.error(`上传出错:${res.msg}`);
return false;
}
// 清空上传时的各种数据
fileList.value = [];
uploadData.title = '';
uploadData.introduction = '';
selectMaterial(res.data);
}
/** 选择素材后设置 */
function selectMaterial(item: any) {
showDialog.value = false;
reply.value.mediaId = item.mediaId;
reply.value.url = item.url;
reply.value.name = item.name;
// title、introduction从 item 到 tempObjItem因为素材里有 title、introduction
if (item.title) {
reply.value.title = item.title || '';
}
if (item.introduction) {
reply.value.description = item.introduction || '';
}
}
</script>
<template>
<div>
<Row>
<Input
v-model:value="reply.title as string"
class="mb-[2%]"
placeholder="请输入标题"
/>
<Input
class="mb-[2%]"
v-model:value="reply.description as string"
placeholder="请输入描述"
/>
<Row class="w-full pt-[10px] text-center" justify="center">
<WxVideoPlayer v-if="reply.url" :url="reply.url" />
</Row>
<Col class="w-full">
<Row class="text-center" align="middle">
<!-- 选择素材 -->
<Col :span="12">
<Button type="primary" @click="showDialog = true">
<!-- TODO @dylanIconifyIcon 里的 icon 尽量用中立的例如说lucide 开头的这样 el 项目继续复用 -->
素材库选择 <IconifyIcon icon="ep:circle-check" />
</Button>
<!-- TODO @dylan貌似 modal 打开后列表长度无限延伸 -->
<Modal
title="选择视频"
v-model:open="showDialog"
width="90%"
destroy-on-close
>
<WxMaterialSelect
type="video"
:account-id="reply.accountId"
@select-material="selectMaterial"
/>
</Modal>
</Col>
<!-- 文件上传 -->
<Col :span="12">
<Upload
:file-list="fileList"
:before-upload="beforeVideoUpload"
:custom-request="customRequest"
>
<Button type="primary">
新建视频 <IconifyIcon icon="ep:upload" />
</Button>
</Upload>
</Col>
</Row>
</Col>
</Row>
</div>
</template>

View File

@@ -1,151 +0,0 @@
<script lang="ts" setup>
import type { UploadFile } from 'ant-design-vue';
import type { Reply } from './types';
import { computed, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { useAccessStore } from '@vben/stores';
import { Button, Col, message, Modal, Row, Upload } from 'ant-design-vue';
import { UploadType, useBeforeUpload } from '#/utils/useUpload';
import { WxMaterialSelect } from '#/views/mp/modules/wx-material-select';
import { WxVoicePlayer } from '#/views/mp/modules/wx-voice-play';
const props = defineProps<{
modelValue: Reply;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void;
}>();
const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`;
const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` };
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val: Reply) => emit('update:modelValue', val),
});
const showDialog = ref(false);
const fileList = ref([]);
const uploadData = reactive({
accountId: reply.value.accountId,
type: 'voice',
title: '',
introduction: '',
});
/** 语音上传前校验 */
function beforeVoiceUpload(file: UploadFile) {
return useBeforeUpload(UploadType.Voice, 10)(file as any);
}
/** 上传成功 */
function onUploadSuccess(info: any) {
const res = info.response || info;
if (res.code !== 0) {
message.error(`上传出错:${res.msg}`);
return false;
}
// 清空上传时的各种数据
fileList.value = [];
uploadData.title = '';
uploadData.introduction = '';
// 上传好的文件,本质是个素材,所以可以进行选中
selectMaterial(res.data);
}
/** 删除语音 */
function onDelete() {
reply.value.mediaId = null;
reply.value.url = null;
reply.value.name = null;
}
/** 选择素材 */
function selectMaterial(item: Reply) {
showDialog.value = false;
// reply.value.type = ReplyType.Voice
reply.value.mediaId = item.mediaId;
reply.value.url = item.url;
reply.value.name = item.name;
}
</script>
<template>
<div>
<div
class="mx-auto mb-[10px] border border-[#eaeaea] p-[10px]"
v-if="reply.url"
>
<p
class="overflow-hidden text-ellipsis whitespace-nowrap text-center text-xs"
>
{{ reply.name }}
</p>
<Row class="w-full pt-[10px] text-center" justify="center">
<WxVoicePlayer :url="reply.url" />
</Row>
<Row class="w-full pt-[10px] text-center" justify="center">
<Button type="primary" danger shape="circle" @click="onDelete">
<IconifyIcon icon="ep:delete" />
</Button>
</Row>
</div>
<Row v-else class="text-center">
<!-- 选择素材 -->
<Col
:span="12"
class="h-[160px] w-[49.5%] border border-[rgb(234,234,234)] py-[50px]"
>
<Button type="primary" @click="showDialog = true">
素材库选择<IconifyIcon icon="ep:circle-check" />
</Button>
<Modal
title="选择语音"
v-model:open="showDialog"
width="90%"
destroy-on-close
>
<WxMaterialSelect
type="voice"
:account-id="reply.accountId"
@select-material="selectMaterial"
/>
</Modal>
</Col>
<!-- 文件上传 -->
<Col
:span="12"
class="float-right h-[160px] w-[49.5%] border border-[rgb(234,234,234)] py-[50px]"
>
<Upload
:action="UPLOAD_URL"
:headers="HEADERS"
:file-list="fileList"
:data="uploadData"
:before-upload="beforeVoiceUpload"
@change="
(info) => {
if (info.file.status === 'done') {
onUploadSuccess(info.file.response || info.file);
}
}
"
>
<Button type="primary">点击上传</Button>
<template #tip>
<div class="text-center leading-[18px]">
格式支持 mp3/wma/wav/amr文件大小不超过 2M播放长度不超过 60s
</div>
</template>
</Upload>
</Col>
</Row>
</div>
</template>

View File

@@ -1,58 +0,0 @@
import type { Ref } from 'vue';
import { unref } from 'vue';
enum ReplyType {
Image = 'image',
Music = 'music',
News = 'news',
Text = 'text',
Video = 'video',
Voice = 'voice',
}
interface _Reply {
accountId: number;
type: ReplyType;
name?: null | string;
content?: null | string;
mediaId?: null | string;
url?: null | string;
title?: null | string;
description?: null | string;
thumbMediaId?: null | string;
thumbMediaUrl?: null | string;
musicUrl?: null | string;
hqMusicUrl?: null | string;
introduction?: null | string;
articles?: any[];
}
type Reply = _Reply; // Partial<_Reply>
enum NewsType {
Draft = '2',
Published = '1',
}
/** 利用旧的reply[accountId, type]初始化新的Reply */
const createEmptyReply = (old: Ref<Reply> | Reply): Reply => {
return {
accountId: unref(old).accountId,
type: unref(old).type,
name: null,
content: null,
mediaId: null,
url: null,
title: null,
description: null,
thumbMediaId: null,
thumbMediaUrl: null,
musicUrl: null,
hqMusicUrl: null,
introduction: null,
articles: [],
};
};
export { createEmptyReply, NewsType, type Reply, ReplyType };

View File

@@ -1,137 +0,0 @@
<!--
- Copyright (C) 2018-2019
- All rights reserved, Designed By www.joolun.com
芋道源码
移除多余的 rep 为前缀的变量 message 消息更简单
代码优化补充注释提升阅读性
优化消息的临时缓存策略发送消息时只清理被发送消息的 tab不会强制切回到 text 输入
支持发送视频消息时支持新建视频
-->
<script lang="ts" setup>
import type { Reply } from './types';
import { computed, ref, unref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Row, Tabs } from 'ant-design-vue';
import TabImage from './tab-image.vue';
import TabMusic from './tab-music.vue';
import TabNews from './tab-news.vue';
import TabText from './tab-text.vue';
import TabVideo from './tab-video.vue';
import TabVoice from './tab-voice.vue';
import { createEmptyReply, NewsType, ReplyType } from './types';
defineOptions({ name: 'WxReplySelect' });
const props = withDefaults(
defineProps<{
modelValue: Reply;
newsType?: NewsType;
}>(),
{
newsType: () => NewsType.Published,
},
);
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void;
}>();
const reply = computed<Reply>({
get: () => props.modelValue,
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进行赋值会产生了循环调用
watch(
currentTab,
(newTab, oldTab) => {
// 第一次进入oldTab 为 undefined
// 判断 newTab 是因为 Reply 为 Partial
if (oldTab === undefined || newTab === undefined) {
return;
}
tabCache.set(oldTab, unref(reply));
// 从缓存里面取出新tab内容有则覆盖Reply没有则创建空Reply
const temp = tabCache.get(newTab);
if (temp) {
reply.value = temp;
} else {
const newData = createEmptyReply(reply);
newData.type = newTab;
reply.value = newData;
}
},
{
immediate: true,
},
);
/** 清除除了`type`, `accountId`的字段 */
function clear() {
reply.value = createEmptyReply(reply);
}
defineExpose({
clear,
});
</script>
<template>
<Tabs v-model:active-key="currentTab" type="card">
<!-- 类型 1文本 -->
<Tabs.TabPane :key="ReplyType.Text">
<template #tab>
<Row align="middle"><IconifyIcon icon="ep:document" /> 文本</Row>
</template>
<TabText v-model="reply.content" />
</Tabs.TabPane>
<!-- 类型 2图片 -->
<Tabs.TabPane :key="ReplyType.Image">
<template #tab>
<Row align="middle">
<IconifyIcon icon="ep:picture" class="mr-5px" /> 图片
</Row>
</template>
<TabImage v-model="reply" />
</Tabs.TabPane>
<!-- 类型 3语音 -->
<Tabs.TabPane :key="ReplyType.Voice">
<template #tab>
<Row align="middle"><IconifyIcon icon="ep:phone" /> 语音</Row>
</template>
<TabVoice v-model="reply" />
</Tabs.TabPane>
<!-- 类型 4视频 -->
<Tabs.TabPane :key="ReplyType.Video">
<template #tab>
<Row align="middle"><IconifyIcon icon="ep:share" /> 视频</Row>
</template>
<TabVideo v-model="reply" />
</Tabs.TabPane>
<!-- 类型 5图文 -->
<Tabs.TabPane :key="ReplyType.News">
<template #tab>
<Row align="middle"><IconifyIcon icon="ep:reading" /> 图文</Row>
</template>
<TabNews v-model="reply" :news-type="newsType" />
</Tabs.TabPane>
<!-- 类型 6音乐 -->
<Tabs.TabPane :key="ReplyType.Music">
<template #tab>
<Row align="middle"><IconifyIcon icon="ep:service" />音乐</Row>
</template>
<TabMusic v-model="reply" />
</Tabs.TabPane>
</Tabs>
</template>

View File

@@ -1 +0,0 @@
export { default as WxVideoPlayer } from './wx-video-play.vue';

View File

@@ -1,80 +0,0 @@
<!--
- Copyright (C) 2018-2019
- All rights reserved, Designed By www.joolun.com
微信消息 - 视频
芋道源码
bug 修复
1joolun 的做法使用 mediaId 从微信公众号下载对应的 mp4 素材从而播放内容
存在的问题mediaId 有效期是 3 超过时间后无法播放
2重构后的做法后端接收到微信公众号的视频消息后将视频消息的 media_id 的文件内容保存到文件服务器中这样前端可以直接使用 URL 播放
体验优化弹窗关闭后自动暂停视频的播放
-->
<script lang="ts" setup>
import { ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { VideoPlayer } from '@videojs-player/vue';
import { Modal } from 'ant-design-vue';
import 'video.js/dist/video-js.css';
defineOptions({ name: 'WxVideoPlayer' });
const props = defineProps({
url: {
type: String,
required: true,
},
});
// TODO @hw是不是使用 vben 自带的 Modal 哈;这样 ele 通用性更好点。其它模块,涉及到 Modal 也按照这个调整噢
const dialogVideo = ref(false);
const playVideo = () => {
dialogVideo.value = true;
};
</script>
<template>
<div @click="playVideo()">
<!-- 提示 -->
<div class="flex cursor-pointer flex-col items-center">
<IconifyIcon icon="ep:video-play" class="size-5" />
<p class="text-sm">点击播放视频</p>
</div>
<!-- 弹窗播放 -->
<Modal
v-model:open="dialogVideo"
title="视频播放"
width="900px"
:footer="null"
>
<VideoPlayer
v-if="dialogVideo"
class="video-player vjs-big-play-centered"
:src="props.url"
poster=""
controls
playsinline
:volume="0.6"
:width="800"
:playback-rates="[0.7, 1.0, 1.5, 2.0]"
/>
<!-- 事件暫時沒用
@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())"-->
</Modal>
</div>
</template>

View File

@@ -1 +0,0 @@
export { default as WxVoicePlayer } from './wx-voice-play.vue';

View File

@@ -1,110 +0,0 @@
<!--
- Copyright (C) 2018-2019
- All rights reserved, Designed By www.joolun.com
微信消息 - 语音
芋道源码
bug 修复
1joolun 的做法使用 mediaId 从微信公众号下载对应的 mp4 素材从而播放内容
存在的问题mediaId 有效期是 3 超过时间后无法播放
2重构后的做法后端接收到微信公众号的视频消息后将视频消息的 media_id 的文件内容保存到文件服务器中这样前端可以直接使用 URL 播放
代码优化 props 中的 reply 调成为 data 中对应的属性并补充相关注释
-->
<script lang="ts" setup>
import { ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Tag } from 'ant-design-vue';
// 因为微信语音是 amr 格式,所以需要用到 amr 解码器https://www.npmjs.com/package/benz-amr-recorder
import BenzAMRRecorder from 'benz-amr-recorder';
defineOptions({ name: 'WxVoicePlayer' });
const props = defineProps({
url: {
type: String, // 语音地址例如说https://www.iocoder.cn/xxx.amr
required: true,
},
content: {
type: String, // 语音文本
required: false,
default: '',
},
});
const amr = ref();
const playing = ref(false);
const duration = ref();
/** 处理点击,播放或暂停 */
function playVoice() {
// 情况一:未初始化,则创建 BenzAMRRecorder
if (amr.value === undefined) {
amrInit();
return;
}
// 情况二:已经初始化,则根据情况播放或暂时
if (amr.value.isPlaying()) {
amrStop();
} else {
amrPlay();
}
}
/** 音频初始化 */
function amrInit() {
amr.value = new BenzAMRRecorder();
// 设置播放
amr.value.initWithUrl(props.url).then(() => {
amrPlay();
duration.value = amr.value.getDuration();
});
// 监听暂停
amr.value.onEnded(() => {
playing.value = false;
});
}
/** 音频播放 */
function amrPlay() {
playing.value = true;
amr.value.play();
}
/** 音频暂停 */
function amrStop() {
playing.value = false;
amr.value.stop();
}
// TODO 芋艿:下面样式有点问题
</script>
<template>
<div class="wx-voice-div" @click="playVoice">
<IconifyIcon v-if="playing !== true" icon="lucide:circle-play" :size="32" />
<IconifyIcon v-else icon="lucide:circle-pause" :size="32" />
<span class="amr-duration" v-if="duration">{{ duration }} </span>
<div v-if="content">
<Tag color="success" size="small">语音识别</Tag>
{{ content }}
</div>
</div>
</template>
<style lang="scss" scoped>
/** TODO @hwtindwind 替代 */
.wx-voice-div {
display: flex;
align-items: center;
justify-content: center;
width: 120px;
height: 50px;
padding: 5px;
background-color: #eaeaea;
border-radius: 10px;
}
.amr-duration {
margin-left: 5px;
font-size: 11px;
}
</style>

View File

@@ -81,11 +81,12 @@ const [Grid, gridApi] = useVbenVxeGrid({
<template>
<Page auto-content-height>
<DocAlert
title="会员等级、积分、签到"
url="https://doc.iocoder.cn/member/level/"
/>
<template #doc>
<DocAlert
title="会员等级、积分、签到"
url="https://doc.iocoder.cn/member/level/"
/>
</template>
<FormModal @success="handleRefresh" />
<Grid table-title="等级列表">
<template #toolbar-tools>

View File

@@ -42,10 +42,12 @@ const [Grid] = useVbenVxeGrid({
<template>
<Page auto-content-height>
<DocAlert
title="会员等级、积分、签到"
url="https://doc.iocoder.cn/member/level/"
/>
<template #doc>
<DocAlert
title="会员等级、积分、签到"
url="https://doc.iocoder.cn/member/level/"
/>
</template>
<Grid table-title="积分记录列表" />
</Page>

View File

@@ -166,7 +166,9 @@ onMounted(async () => {
<template>
<Page auto-content-height>
<DocAlert title="自动回复" url="https://doc.iocoder.cn/mp/auto-reply/" />
<template #doc>
<DocAlert title="自动回复" url="https://doc.iocoder.cn/mp/auto-reply/" />
</template>
<!-- tab 切换 -->
<ContentWrap>

Some files were not shown because too many files have changed in this diff Show More