Merge remote-tracking branch 'yudao/dev' into dev

This commit is contained in:
jason
2025-11-02 09:31:26 +08:00
95 changed files with 2520 additions and 315 deletions

View File

@@ -43,6 +43,7 @@
"@vben/styles": "workspace:*",
"@vben/types": "workspace:*",
"@vben/utils": "workspace:*",
"@vueuse/components": "^14.0.0",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"ant-design-vue": "catalog:",

View File

@@ -0,0 +1,38 @@
<script lang="ts" setup>
// TODO @jawe from xingyuhttps://gitee.com/yudaocode/yudao-ui-admin-vben/pulls/243/files#diff_note_47350213这个组件没有必要直接用antdv card 的slot去做就行了只有这一个地方用没有必要单独写一个组件
defineProps({
title: {
type: String,
required: true,
},
});
defineComponent({
name: 'CardTitle',
});
</script>
<template>
<span class="card-title">{{ title }}</span>
</template>
<style scoped lang="scss">
.card-title {
font-size: 14px;
font-weight: 600;
&::before {
position: relative;
top: 8px;
left: -5px;
display: inline-block;
width: 3px;
height: 14px;
content: '';
//background-color: #105cfb;
background: var(--el-color-primary);
border-radius: 5px;
transform: translateY(-50%);
}
}
</style>

View File

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

View File

@@ -0,0 +1,102 @@
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
import type { MallKefuMessageApi } from '#/api/mall/promotion/kefu/message';
import { isEmpty } from '@vben/utils';
import { acceptHMRUpdate, defineStore } from 'pinia';
import * as KeFuConversationApi from '#/api/mall/promotion/kefu/conversation';
interface MallKefuInfoVO {
conversationList: MallKefuConversationApi.Conversation[]; // 会话列表
conversationMessageList: Map<number, MallKefuMessageApi.Message[]>; // 会话消息
}
export const useMallKefuStore = defineStore('mall-kefu', {
state: (): MallKefuInfoVO => ({
conversationList: [],
conversationMessageList: new Map<number, MallKefuMessageApi.Message[]>(), // key 会话value 会话消息列表
}),
getters: {
getConversationList(): MallKefuConversationApi.Conversation[] {
return this.conversationList;
},
getConversationMessageList(): (
conversationId: number,
) => MallKefuMessageApi.Message[] | undefined {
return (conversationId: number) =>
this.conversationMessageList.get(conversationId);
},
},
actions: {
// ======================= 会话消息相关 =======================
/** 缓存历史消息 */
saveMessageList(
conversationId: number,
messageList: MallKefuMessageApi.Message[],
) {
this.conversationMessageList.set(conversationId, messageList);
},
// ======================= 会话相关 =======================
/** 加载会话缓存列表 */
async setConversationList() {
// TODO @javeidea linter 告警,修复下;
// TODO @jave不使用 KeFuConversationApi.,直接用 getConversationList
this.conversationList = await KeFuConversationApi.getConversationList();
this.conversationSort();
},
/** 更新会话缓存已读 */
async updateConversationStatus(conversationId: number) {
if (isEmpty(this.conversationList)) {
return;
}
const conversation = this.conversationList.find(
(item) => item.id === conversationId,
);
conversation && (conversation.adminUnreadMessageCount = 0);
},
/** 更新会话缓存 */
async updateConversation(conversationId: number) {
if (isEmpty(this.conversationList)) {
return;
}
const conversation =
await KeFuConversationApi.getConversation(conversationId);
this.deleteConversation(conversationId);
conversation && this.conversationList.push(conversation);
this.conversationSort();
},
/** 删除会话缓存 */
deleteConversation(conversationId: number) {
const index = this.conversationList.findIndex(
(item) => item.id === conversationId,
);
// 存在则删除
if (index !== -1) {
this.conversationList.splice(index, 1);
}
},
conversationSort() {
// 按置顶属性和最后消息时间排序
this.conversationList.sort((a, b) => {
// 按照置顶排序,置顶的会在前面
if (a.adminPinned !== b.adminPinned) {
return a.adminPinned ? -1 : 1;
}
// 按照最后消息时间排序,最近的会在前面
return (
(b.lastMessageTime as unknown as number) -
(a.lastMessageTime as unknown as number)
);
});
},
},
});
// 解决热更新问题
const hot = import.meta.hot;
if (hot) {
hot.accept(acceptHMRUpdate(useMallKefuStore, hot));
}

View File

@@ -183,9 +183,7 @@ function setFieldPermission(field: string, permission: string) {
}
}
/**
* 操作成功后刷新
*/
/** 操作成功后刷新 */
const refresh = () => {
// 重新获取详情
getDetail();

View File

@@ -239,7 +239,7 @@ export function useGridFormSchema(): VbenFormSchema[] {
label: '设备状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_DEVICE_STATUS, 'number'),
options: getDictOptions(DICT_TYPE.IOT_DEVICE_STATE, 'number'),
placeholder: '请选择设备状态',
allowClear: true,
},
@@ -295,12 +295,12 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
slots: { default: 'groups' },
},
{
field: 'status',
field: 'state',
title: '设备状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IOT_DEVICE_STATUS },
props: { type: DICT_TYPE.IOT_DEVICE_STATE },
},
},
{

View File

@@ -307,7 +307,7 @@ onMounted(async () => {
style="width: 200px"
>
<Select.Option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATUS)"
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATE)"
:key="dict.value"
:value="dict.value"
>

View File

@@ -294,7 +294,7 @@ onMounted(async () => {
style="width: 240px"
>
<Select.Option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATUS)"
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATE)"
:key="dict.value"
:value="dict.value"
>
@@ -373,7 +373,7 @@ onMounted(async () => {
</template>
<template v-else-if="column.key === 'status'">
<DictTag
:type="DICT_TYPE.IOT_DEVICE_STATUS"
:type="DICT_TYPE.IOT_DEVICE_STATE"
:value="record.status"
/>
</template>

View File

@@ -106,7 +106,7 @@ function handleAuthInfoDialogClose() {
</Descriptions.Item>
<Descriptions.Item label="当前状态">
<DictTag
:type="DICT_TYPE.IOT_DEVICE_STATUS"
:type="DICT_TYPE.IOT_DEVICE_STATE"
:value="device.state"
/>
</Descriptions.Item>

View File

@@ -7,7 +7,7 @@ import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改设备分组的表单 */
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{

View File

@@ -42,6 +42,7 @@ const [Form, formApi] = useVbenForm({
showDefaultActions: false,
});
// TODO @haohao参考别的 form1文件的命名可以简化2代码可以在简化下
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
@@ -71,7 +72,7 @@ const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
formApi.resetForm();
await formApi.resetForm();
return;
}

View File

@@ -7,7 +7,7 @@ import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改产品分类的表单 */
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{

View File

@@ -105,8 +105,6 @@ const [Grid, gridApi] = useVbenVxeGrid({
]"
/>
</template>
<!-- 操作列 -->
<template #actions="{ row }">
<TableAction
:actions="[

View File

@@ -38,6 +38,7 @@ const [Form, formApi] = useVbenForm({
showDefaultActions: false,
});
// TODO @haohao参考别的 form1文件的命名可以简化2代码可以在简化下
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();

View File

@@ -19,6 +19,7 @@ import {
import { getProductPage } from '#/api/iot/product/product';
// TODO @haohao命名不太对可以简化下
defineOptions({ name: 'ProductCardView' });
const props = defineProps<Props>();
@@ -195,16 +196,33 @@ defineExpose({
/>
物模型
</Button>
<Tooltip v-if="item.status === 1" title="启用状态的产品不能删除">
<Button
size="small"
danger
disabled
class="action-btn action-btn-delete !w-[32px]"
>
<IconifyIcon
icon="ant-design:delete-outlined"
class="text-[14px]"
/>
</Button>
</Tooltip>
<Popconfirm
v-else
:title="`确认删除产品 ${item.name} 吗?`"
@confirm="emit('delete', item)"
>
<Button
size="small"
danger
class="action-btn action-btn-delete"
class="action-btn action-btn-delete !w-[32px]"
>
<IconifyIcon icon="ant-design:delete-outlined" />
<IconifyIcon
icon="ant-design:delete-outlined"
class="text-[14px]"
/>
</Button>
</Popconfirm>
</div>

View File

@@ -1,117 +0,0 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCouponTemplatePage } from '#/api/mall/promotion/coupon/couponTemplate';
import { DictTag } from '#/components/dict-tag';
import { discountFormat } from '../formatter';
import { useCouponSelectFormSchema, useCouponSelectGridColumns } from './data';
defineOptions({ name: 'CouponSelect' });
const props = defineProps<{
takeType?: number; // 领取方式
}>();
const emit = defineEmits<{
change: [value: MallCouponTemplateApi.CouponTemplate[]];
}>();
const selectedCoupons = ref<MallCouponTemplateApi.CouponTemplate[]>([]);
/** Grid 配置 */
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useCouponSelectFormSchema(),
},
gridOptions: {
columns: useCouponSelectGridColumns(),
height: '500px',
keepSource: true,
proxyConfig: {
ajax: {
// TODO @芋艿:要不要 ele 和 antd 统一下;
query: async ({ page }, formValues) => {
const params: any = {
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
};
// 如果有 takeType 参数,添加到查询条件
if (props.takeType !== undefined) {
params.canTakeTypes = [props.takeType];
}
return await getCouponTemplatePage(params);
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<MallCouponTemplateApi.CouponTemplate>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
/** 复选框变化处理 */
function handleRowCheckboxChange({
records,
}: {
records: MallCouponTemplateApi.CouponTemplate[];
}) {
selectedCoupons.value = records;
}
/** Modal 配置 */
const [Modal, modalApi] = useVbenModal({
onConfirm() {
emit('change', selectedCoupons.value);
modalApi.close();
},
onOpenChange(isOpen: boolean) {
if (!isOpen) {
selectedCoupons.value = [];
return;
}
gridApi.query();
},
});
/** 打开弹窗 */
function open() {
modalApi.open();
}
defineExpose({ open });
</script>
<template>
<Modal title="选择优惠券" class="w-2/3">
<Grid table-title="优惠券列表">
<!-- 优惠列自定义渲染 -->
<template #discount="{ row }">
<DictTag
:type="DICT_TYPE.PROMOTION_DISCOUNT_TYPE"
:value="row.discountType"
/>
<span class="ml-1">{{ discountFormat(row) }}</span>
</template>
</Grid>
</Modal>
</template>

View File

@@ -1,3 +1,2 @@
export { default as CouponSelect } from './coupon-select.vue';
export * from './data';
export { default as CouponSelect } from './select.vue';
export { default as CouponSendForm } from './send-form.vue';

View File

@@ -1,5 +1,5 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
@@ -8,26 +8,25 @@ import {
discountFormat,
remainedCountFormat,
takeLimitCountFormat,
usePriceFormat,
validityTypeFormat,
} from '../formatter';
/** 优惠券选择弹窗的搜索表单 schema */
export function useCouponSelectFormSchema(): VbenFormSchema[] {
/** 优惠券选择的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'name',
label: '优惠券名称',
component: 'Input',
componentProps: {
placeholder: '请输入优惠券名称',
allowClear: true,
},
},
{
component: 'Select',
fieldName: 'discountType',
label: '优惠类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.PROMOTION_DISCOUNT_TYPE, 'number'),
placeholder: '请选择优惠类型',
@@ -37,33 +36,18 @@ export function useCouponSelectFormSchema(): VbenFormSchema[] {
];
}
/** 搜索表单的 schema */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'name',
label: '优惠券名称',
componentProps: {
placeholder: '请输入优惠券名称',
allowClear: true,
},
},
];
}
/** 优惠券选择弹窗的表格列配置 */
export function useCouponSelectGridColumns(): VxeGridProps['columns'] {
/** 优惠券选择的表格列 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 55 },
{
title: '优惠券名称',
field: 'name',
title: '优惠券名称',
minWidth: 140,
},
{
title: '类型',
field: 'productScope',
title: '类型',
minWidth: 80,
cellRender: {
name: 'CellDict',
@@ -71,14 +55,23 @@ export function useCouponSelectGridColumns(): VxeGridProps['columns'] {
},
},
{
title: '优惠',
field: 'discount',
field: 'discountType',
title: '优惠类型',
minWidth: 100,
slots: { default: 'discount' },
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.PROMOTION_DISCOUNT_TYPE },
},
},
{
field: 'discountPrice',
title: '优惠力度',
minWidth: 100,
formatter: ({ row }) => discountFormat(row),
},
{
title: '领取方式',
field: 'takeType',
title: '领取方式',
minWidth: 100,
cellRender: {
name: 'CellDict',
@@ -86,36 +79,37 @@ export function useCouponSelectGridColumns(): VxeGridProps['columns'] {
},
},
{
title: '使用时间',
field: 'validityType',
title: '使用时间',
minWidth: 185,
align: 'center',
formatter: ({ row }) => validityTypeFormat(row),
},
{
title: '发放数量',
field: 'totalCount',
align: 'center',
title: '发放数量',
minWidth: 100,
align: 'center',
},
{
field: 'remainedCount',
title: '剩余数量',
minWidth: 100,
align: 'center',
formatter: ({ row }) => remainedCountFormat(row),
},
{
title: '领取上限',
field: 'takeLimitCount',
title: '领取上限',
minWidth: 100,
align: 'center',
formatter: ({ row }) => takeLimitCountFormat(row),
},
{
title: '状态',
field: 'status',
align: 'center',
title: '状态',
minWidth: 80,
align: 'center',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
@@ -123,43 +117,3 @@ export function useCouponSelectGridColumns(): VxeGridProps['columns'] {
},
];
}
/** 表格列配置 */
export function useGridColumns(): VxeGridProps['columns'] {
return [
{
title: '优惠券名称',
field: 'name',
minWidth: 120,
},
{
title: '优惠金额 / 折扣',
field: 'discount',
minWidth: 120,
formatter: ({ row }) => discountFormat(row),
},
{
title: '最低消费',
field: 'usePrice',
minWidth: 100,
formatter: ({ row }) => usePriceFormat(row),
},
{
title: '有效期限',
field: 'validityType',
minWidth: 140,
formatter: ({ row }) => validityTypeFormat(row),
},
{
title: '剩余数量',
minWidth: 100,
formatter: ({ row }) => remainedCountFormat(row),
},
{
title: '操作',
width: 100,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,66 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
import { useVbenModal } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCouponTemplatePage } from '#/api/mall/promotion/coupon/couponTemplate';
import { useGridColumns, useGridFormSchema } from './select-data';
defineOptions({ name: 'CouponSelect' });
const props = defineProps<{
takeType?: number; // 领取方式
}>();
const emit = defineEmits(['success']);
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 500,
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCouponTemplatePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
canTakeTypes: props.takeType ? [props.takeType] : [],
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<MallCouponTemplateApi.CouponTemplate>,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
// 从 gridApi 获取选中的记录
const selectedRecords = (gridApi.grid?.getCheckboxRecords() ||
[]) as MallCouponTemplateApi.CouponTemplate[];
await modalApi.close();
emit('success', selectedRecords);
},
});
</script>
<template>
<Modal title="选择优惠券" class="w-2/3">
<Grid />
</Modal>
</template>

View File

@@ -0,0 +1,64 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import {
discountFormat,
remainedCountFormat,
usePriceFormat,
validityTypeFormat,
} from '../formatter';
/** 搜索表单的 schema */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'name',
label: '优惠券名称',
componentProps: {
placeholder: '请输入优惠券名称',
allowClear: true,
},
},
];
}
/** 表格列配置 */
export function useGridColumns(): VxeGridProps['columns'] {
return [
{
title: '优惠券名称',
field: 'name',
minWidth: 120,
},
{
title: '优惠金额 / 折扣',
field: 'discount',
minWidth: 120,
formatter: ({ row }) => discountFormat(row),
},
{
title: '最低消费',
field: 'usePrice',
minWidth: 100,
formatter: ({ row }) => usePriceFormat(row),
},
{
title: '有效期限',
field: 'validityType',
minWidth: 140,
formatter: ({ row }) => validityTypeFormat(row),
},
{
title: '剩余数量',
minWidth: 100,
formatter: ({ row }) => remainedCountFormat(row),
},
{
title: '操作',
width: 100,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -11,7 +11,7 @@ import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { sendCoupon } from '#/api/mall/promotion/coupon/coupon';
import { getCouponTemplatePage } from '#/api/mall/promotion/coupon/couponTemplate';
import { useFormSchema, useGridColumns } from './data';
import { useFormSchema, useGridColumns } from './send-form-data';
/** 发送优惠券 */
async function handleSendCoupon(row: MallCouponTemplateApi.CouponTemplate) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg t="1720063872285" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6895"
width="200" height="200">
<path d="M782.16 880.98c-179.31 23.91-361 23.91-540.32 0C138.89 867.25 62 779.43 62 675.57V348.43c0-103.86 76.89-191.69 179.84-205.41 179.31-23.91 361-23.91 540.31 0C885.11 156.75 962 244.57 962 348.43v327.13c0 103.87-76.89 191.69-179.84 205.42z"
fill="#FF554D" p-id="6896"></path>
<path d="M226.11 596.86c-9.74 47.83 17.26 95.6 63.48 111.3C333.49 723.08 394.55 737 469.53 737c59.25 0 105.46-8.69 140.23-19.7 51.59-16.34 79.94-71.16 63.37-122.68-24.47-76.11-65.57-180.7-106.68-180.7-64.62 0-64.62 96.92-64.62 96.92S437.22 317 372.61 317c-82.11 0-117.85 139.12-146.5 279.86z"
fill="#FFFFFF" p-id="6897"></path>
<path d="M782 347m-60 0a60 60 0 1 0 120 0 60 60 0 1 0-120 0Z" fill="#FFBC55" p-id="6898"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,290 @@
<script lang="ts" setup>
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { formatPast, jsonParse } from '@vben/utils';
import { Avatar, Badge, message } from 'ant-design-vue';
import * as KeFuConversationApi from '#/api/mall/promotion/kefu/conversation';
import { useMallKefuStore } from '#/store/mall/kefu';
import { KeFuMessageContentTypeEnum } from './tools/constants';
import { useEmoji } from './tools/emoji';
defineOptions({ name: 'KeFuConversationList' });
/** 打开右侧的消息列表 */
const emits = defineEmits<{
(e: 'change', v: MallKefuConversationApi.Conversation): void;
}>();
const kefuStore = useMallKefuStore(); // 客服缓存
const { replaceEmoji } = useEmoji();
const activeConversationId = ref(-1); // 选中的会话
const collapse = ref(false); // 折叠菜单
/** 计算消息最后发送时间距离现在过去了多久 */
const lastMessageTimeMap = ref<Map<number, string>>(new Map<number, string>());
function calculationLastMessageTime() {
kefuStore.getConversationList?.forEach((item) => {
lastMessageTimeMap.value.set(
item.id,
formatPast(item.lastMessageTime, 'YYYY-MM-DD'),
);
});
}
defineExpose({ calculationLastMessageTime });
function openRightMessage(item: MallKefuConversationApi.Conversation) {
// 同一个会话则不处理
if (activeConversationId.value === item.id) {
return;
}
activeConversationId.value = item.id;
emits('change', item);
}
/** 获得消息类型 */
const getConversationDisplayText = computed(
() => (lastMessageContentType: number, lastMessageContent: string) => {
switch (lastMessageContentType) {
case KeFuMessageContentTypeEnum.IMAGE: {
return '[图片消息]';
}
case KeFuMessageContentTypeEnum.ORDER: {
return '[订单消息]';
}
case KeFuMessageContentTypeEnum.PRODUCT: {
return '[商品消息]';
}
case KeFuMessageContentTypeEnum.SYSTEM: {
return '[系统消息]';
}
case KeFuMessageContentTypeEnum.TEXT: {
return replaceEmoji(
jsonParse(lastMessageContent).text || lastMessageContent,
);
}
case KeFuMessageContentTypeEnum.VIDEO: {
return '[视频消息]';
}
case KeFuMessageContentTypeEnum.VOICE: {
return '[语音消息]';
}
default: {
return '';
}
}
},
);
// ======================= 右键菜单 =======================
const showRightMenu = ref(false); // 显示右键菜单
const rightMenuStyle = ref<any>({}); // 右键菜单 Style
const rightClickConversation = ref<MallKefuConversationApi.Conversation>(
{} as MallKefuConversationApi.Conversation,
); // 右键选中的会话对象
/** 打开右键菜单 */
function rightClick(
mouseEvent: PointerEvent,
item: MallKefuConversationApi.Conversation,
) {
rightClickConversation.value = item;
// 显示右键菜单
showRightMenu.value = true;
rightMenuStyle.value = {
top: `${mouseEvent.clientY - 110}px`,
left: collapse.value
? `${mouseEvent.clientX - 80}px`
: `${mouseEvent.clientX - 210}px`,
};
}
/** 关闭右键菜单 */
function closeRightMenu() {
showRightMenu.value = false;
}
/** 置顶会话 */
async function updateConversationPinned(adminPinned: boolean) {
// 1. 会话置顶/取消置顶
await KeFuConversationApi.updateConversationPinned({
id: rightClickConversation.value.id,
adminPinned,
});
message.success(adminPinned ? '置顶成功' : '取消置顶成功');
// 2. 关闭右键菜单,更新会话列表
closeRightMenu();
await kefuStore.updateConversation(rightClickConversation.value.id);
}
/** 删除会话 */
async function deleteConversation() {
// 1. 删除会话
// TODO @jave使用全局的 confirm这样 ele 和 antd 可以相对复用;
await message.confirm('您确定要删除该会话吗?');
await KeFuConversationApi.deleteConversation(rightClickConversation.value.id);
// 2. 关闭右键菜单,更新会话列表
closeRightMenu();
kefuStore.deleteConversation(rightClickConversation.value.id);
}
/** 监听右键菜单的显示状态,添加点击事件监听器 */
watch(showRightMenu, (val) => {
if (val) {
document.body.addEventListener('click', closeRightMenu);
} else {
document.body.removeEventListener('click', closeRightMenu);
}
});
const timer = ref<any>();
/** 初始化 */
onMounted(() => {
timer.value = setInterval(calculationLastMessageTime, 1000 * 10); // 十秒计算一次
});
/** 组件卸载前 */
onBeforeUnmount(() => {
clearInterval(timer.value);
});
</script>
<template>
<a-layout-sider class="kefu h-full pt-[5px]" width="260px">
<div class="color-[#999] my-[10px] font-bold">
会话记录({{ kefuStore.getConversationList.length }})
</div>
<div
v-for="item in kefuStore.getConversationList"
:key="item.id"
:class="{
active: item.id === activeConversationId,
pinned: item.adminPinned,
}"
class="kefu-conversation flex items-center px-[10px]"
@click="openRightMessage(item)"
@contextmenu.prevent="rightClick($event as PointerEvent, item)"
>
<div class="!flex w-full items-center justify-center">
<div class="w-50[px] h-50[px] flex items-center justify-center">
<!-- 头像 + 未读 -->
<Badge
:hidden="item.adminUnreadMessageCount === 0"
:max="99"
:value="item.adminUnreadMessageCount"
>
<Avatar :src="item.userAvatar" alt="avatar" />
</Badge>
</div>
<div class="ml-[10px] w-full">
<div class="!flex w-full items-center justify-between">
<span class="username">{{ item.userNickname || 'null' }}</span>
<span class="color-[#999]" style="font-size: 13px">
{{ lastMessageTimeMap.get(item.id) ?? '计算中' }}
</span>
</div>
<!-- 最后聊天内容 -->
<div
v-dompurify-html="
getConversationDisplayText(
item.lastMessageContentType,
item.lastMessageContent,
)
"
class="last-message color-[#999] !flex items-center"
></div>
</div>
</div>
</div>
<!-- 右键,进行操作(类似微信) -->
<ul v-show="showRightMenu" :style="rightMenuStyle" class="right-menu-ul">
<li
v-show="!rightClickConversation.adminPinned"
class="flex items-center"
@click.stop="updateConversationPinned(true)"
>
<IconifyIcon class="mr-[5px]" icon="ep:top" />
置顶会话
</li>
<li
v-show="rightClickConversation.adminPinned"
class="flex items-center"
@click.stop="updateConversationPinned(false)"
>
<IconifyIcon class="mr-[5px]" icon="ep:bottom" />
取消置顶
</li>
<li class="flex items-center" @click.stop="deleteConversation">
<IconifyIcon class="mr-[5px]" color="red" icon="ep:delete" />
删除会话
</li>
<li class="flex items-center" @click.stop="closeRightMenu">
<IconifyIcon class="mr-[5px]" color="red" icon="ep:close" />
取消
</li>
</ul>
</a-layout-sider>
</template>
<style lang="scss" scoped>
/** TODO @jave看看哪些可以用 tailwind 简化掉 */
.kefu {
background-color: var(--app-content-bg-color);
&-conversation {
height: 60px;
//background-color: #fff;
//transition: border-left 0.05s ease-in-out; /* 设置过渡效果 */
.username {
min-width: 0;
max-width: 60%;
}
.last-message {
font-size: 13px;
}
.last-message,
.username {
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
}
.active {
background-color: rgb(128 128 128 / 50%); // 透明色,暗黑模式下也能体现
}
.right-menu-ul {
position: absolute;
width: 130px;
padding: 5px;
margin: 0;
list-style-type: none; /* 移除默认的项目符号 */
background-color: var(--app-content-bg-color);
border-radius: 12px;
box-shadow: 0 2px 4px rgb(0 0 0 / 10%); /* 阴影效果 */
li {
padding: 8px 16px;
cursor: pointer;
border-radius: 12px;
transition: background-color 0.3s; /* 平滑过渡 */
&:hover {
background-color: var(
--left-menu-bg-active-color
); /* 悬停时的背景颜色 */
}
}
}
}
</style>

View File

@@ -0,0 +1,563 @@
<script lang="ts" setup>
import type { UseScrollReturn } from '@vueuse/core';
import type { Emoji } from './tools/emoji';
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
import type { MallKefuMessageApi } from '#/api/mall/promotion/kefu/message';
import { computed, nextTick, reactive, ref, unref } from 'vue';
import { UserTypeEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDate, isEmpty, jsonParse } from '@vben/utils';
import { vScroll } from '@vueuse/components';
import { useDebounceFn, useScroll } from '@vueuse/core';
import { Avatar, Empty, Image, notification } from 'ant-design-vue'; // 添加 Empty 组件导入
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import * as KeFuMessageApi from '#/api/mall/promotion/kefu/message';
import { useMallKefuStore } from '#/store/mall/kefu';
import MessageItem from './message/MessageItem.vue';
import OrderItem from './message/OrderItem.vue';
import ProductItem from './message/ProductItem.vue';
import { KeFuMessageContentTypeEnum } from './tools/constants';
import { useEmoji } from './tools/emoji';
import EmojiSelectPopover from './tools/EmojiSelectPopover.vue';
import PictureSelectUpload from './tools/PictureSelectUpload.vue';
defineOptions({ name: 'KeFuMessageList' });
dayjs.extend(relativeTime);
const message = ref(''); // 消息弹窗
const { replaceEmoji } = useEmoji();
const messageList = ref<MallKefuMessageApi.Message[]>([]); // 消息列表
const conversation = ref<MallKefuConversationApi.Conversation>(
{} as MallKefuConversationApi.Conversation,
); // 用户会话
const showNewMessageTip = ref(false); // 显示有新消息提示
const queryParams = reactive({
conversationId: 0,
createTime: undefined,
});
const total = ref(0); // 消息总条数
const refreshContent = ref(false); // 内容刷新,主要解决会话消息页面高度不一致导致的滚动功能精度失效
const kefuStore = useMallKefuStore(); // 客服缓存
/** 获悉消息内容 */
const getMessageContent = computed(
() => (item: any) => jsonParse(item.content),
);
/** 获得消息列表 */
// TODO @javeidea 的 linter 报错,处理下;
async function getMessageList() {
const res = await KeFuMessageApi.getKeFuMessageList(queryParams);
if (isEmpty(res)) {
// 当返回的是空列表说明没有消息或者已经查询完了历史消息
skipGetMessageList.value = true;
return;
}
queryParams.createTime = formatDate(res.at(-1).createTime) as any;
// 情况一:加载最新消息
if (queryParams.createTime) {
// 情况二:加载历史消息
for (const item of res) {
pushMessage(item);
}
} else {
messageList.value = res;
}
refreshContent.value = true;
}
/** 添加消息 */
function pushMessage(message: any) {
if (messageList.value.some((val) => val.id === message.id)) {
return;
}
messageList.value.push(message);
}
/** 按照时间倒序,获取消息列表 */
const getMessageList0 = computed(() => {
// 使用展开运算符创建新数组,避免直接修改原数组
return [...messageList.value].sort(
(a: any, b: any) => a.createTime - b.createTime,
);
});
/** 刷新消息列表 */
async function refreshMessageList(message?: any) {
if (!conversation.value) {
return;
}
if (message === undefined) {
queryParams.createTime = undefined;
await getMessageList();
} else {
// 当前查询会话与消息所属会话不一致则不做处理
if (message.conversationId !== conversation.value.id) {
return;
}
pushMessage(message);
}
if (loadHistory.value) {
// 右下角显示有新消息提示
showNewMessageTip.value = true;
} else {
// 滚动到最新消息处
await handleToNewMessage();
}
}
/** 获得新会话的消息列表, 点击切换时读取缓存然后异步获取新消息merge 下; */
async function getNewMessageList(val: MallKefuMessageApi.Message) {
// 1. 缓存当前会话消息列表
kefuStore.saveMessageList(conversation.value.id, messageList.value);
// 2.1 会话切换,重置相关参数
messageList.value = kefuStore.getConversationMessageList(val.id) || [];
total.value = messageList.value.length || 0;
loadHistory.value = false;
refreshContent.value = false;
skipGetMessageList.value = false;
// 2.2 设置会话相关属性
conversation.value = val;
queryParams.conversationId = val.id;
queryParams.createTime = undefined;
// 3. 获取消息
await refreshMessageList();
}
defineExpose({ getNewMessageList, refreshMessageList });
/** 是否显示聊天区域 */
function showKeFuMessageList() {
return !isEmpty(conversation.value);
}
const skipGetMessageList = ref(false); // 跳过消息获取
/** 处理表情选择 */
function handleEmojiSelect(item: Emoji) {
message.value += item.name;
}
/** 处理图片发送 */
async function handleSendPicture(picUrl: string) {
// 组织发送消息
const msg = {
conversationId: conversation.value.id,
contentType: KeFuMessageContentTypeEnum.IMAGE,
content: JSON.stringify({ picUrl }),
};
await sendMessage(msg);
}
/** 发送文本消息 */
async function handleSendMessage(event: any) {
// shift 不发送
if (event.shiftKey) {
return;
}
// 1. 校验消息是否为空
if (isEmpty(unref(message.value)?.trim())) {
notification.warning({ message: '请输入消息后再发送哦!' });
message.value = '';
return;
}
// 2. 组织发送消息
const msg = {
conversationId: conversation.value.id,
contentType: KeFuMessageContentTypeEnum.TEXT,
content: JSON.stringify({ text: message.value }),
};
await sendMessage(msg);
}
/** 真正发送消息 【共用】*/
async function sendMessage(msg: MallKefuMessageApi.MessageSend) {
// 发送消息
await KeFuMessageApi.sendKeFuMessage(msg);
message.value = '';
// 加载消息列表
await refreshMessageList();
// 更新会话缓存
await kefuStore.updateConversation(conversation.value.id);
}
/** 滚动到底部 */
const innerRef = ref<HTMLDivElement>();
const scrollbarRef = ref<HTMLElement | null>(null);
const { y } = useScroll(scrollbarRef);
async function scrollToBottom() {
if (!scrollbarRef.value) return;
// 1. 首次加载时滚动到最新消息,如果加载的是历史消息则不滚动
if (loadHistory.value) {
return;
}
// 2.1 滚动到最新消息,关闭新消息提示
await nextTick();
// 使用 useScroll 监听滚动容器
y.value = scrollbarRef.value.scrollHeight - innerRef.value!.clientHeight;
// scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight)
showNewMessageTip.value = false;
// 2.2 消息已读
await KeFuMessageApi.updateKeFuMessageReadStatus(conversation.value.id);
}
/** 查看新消息 */
async function handleToNewMessage() {
loadHistory.value = false;
await scrollToBottom();
}
const loadHistory = ref(false); // 加载历史消息
/** 处理消息列表滚动事件(debounce 限流) */
const handleScroll = useDebounceFn((state: UseScrollReturn) => {
const { arrivedState } = state;
if (skipGetMessageList.value) {
return;
}
// 滚动到底部了
if (arrivedState.bottom) {
loadHistory.value = false;
refreshMessageList();
}
// 触顶自动加载下一页数据
if (arrivedState.top) {
handleOldMessage();
}
}, 200);
/** 加载历史消息 */
async function handleOldMessage() {
// 记录已有页面高度
const oldPageHeight = innerRef.value?.clientHeight;
if (!oldPageHeight) {
return;
}
loadHistory.value = true;
await getMessageList();
// 等页面加载完后,获得上一页最后一条消息的位置,控制滚动到它所在位置
// scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight - oldPageHeight)
y.value =
scrollbarRef.value.scrollHeight -
innerRef.value!.clientHeight -
oldPageHeight;
}
/** 是否显示时间 */
function showTime(item: MallKefuMessageApi.Message, index: number) {
if (unref(messageList.value)[index + 1]) {
const dateString = dayjs(
unref(messageList.value)[index + 1].createTime,
).fromNow();
return dateString !== dayjs(unref(item).createTime).fromNow();
}
return false;
}
</script>
<template>
<a-layout v-if="showKeFuMessageList" class="kefu">
<a-layout-header class="kefu-header">
<div class="kefu-title">{{ conversation.userNickname }}</div>
</a-layout-header>
<a-layout-content class="kefu-content">
<div
ref="scrollbarRef"
class="flex h-full overflow-y-auto"
v-scroll="handleScroll"
>
<div v-if="refreshContent" ref="innerRef" class="w-full px-[10px]">
<!-- 消息列表 -->
<div
v-for="(item, index) in getMessageList0"
:key="item.id"
class="w-full"
>
<div class="mb-[20px] flex items-center justify-center">
<!-- 日期 -->
<div
v-if="
item.contentType !== KeFuMessageContentTypeEnum.SYSTEM &&
showTime(item, index)
"
class="date-message"
>
{{ formatDate(item.createTime) }}
</div>
<!-- 系统消息 -->
<div
v-if="item.contentType === KeFuMessageContentTypeEnum.SYSTEM"
class="system-message"
>
{{ item.content }}
</div>
</div>
<div
:class="[
item.senderType === UserTypeEnum.MEMBER
? `ss-row-left`
: item.senderType === UserTypeEnum.ADMIN
? `ss-row-right`
: '',
]"
class="mb-[20px] flex w-full"
>
<Avatar
v-if="item.senderType === UserTypeEnum.MEMBER"
:src="conversation.userAvatar"
alt="avatar"
class="h-[60px] w-[60px]"
/>
<div
:class="{
'kefu-message':
KeFuMessageContentTypeEnum.TEXT === item.contentType,
}"
>
<!-- 文本消息 -->
<MessageItem :message="item">
<template
v-if="KeFuMessageContentTypeEnum.TEXT === item.contentType"
>
<div
v-dompurify-html="
replaceEmoji(
getMessageContent(item).text || item.content,
)
"
class="line-height-normal h-1/1 w-full text-justify"
></div>
</template>
</MessageItem>
<!-- 图片消息 -->
<MessageItem :message="item">
<Image
v-if="KeFuMessageContentTypeEnum.IMAGE === item.contentType"
:initial-index="0"
:preview-src-list="[
getMessageContent(item).picUrl || item.content,
]"
:src="getMessageContent(item).picUrl || item.content"
class="mx-[10px] w-[200px]"
fit="contain"
preview-teleported
/>
</MessageItem>
<!-- 商品消息 -->
<MessageItem :message="item">
<ProductItem
v-if="
KeFuMessageContentTypeEnum.PRODUCT === item.contentType
"
:pic-url="getMessageContent(item).picUrl"
:price="getMessageContent(item).price"
:sales-count="getMessageContent(item).salesCount"
:spu-id="getMessageContent(item).spuId"
:stock="getMessageContent(item).stock"
:title="getMessageContent(item).spuName"
class="mx-[10px] max-w-[300px]"
/>
</MessageItem>
<!-- 订单消息 -->
<MessageItem :message="item">
<OrderItem
v-if="KeFuMessageContentTypeEnum.ORDER === item.contentType"
:message="item"
class="mx-[10px] max-w-full"
/>
</MessageItem>
</div>
<Avatar
v-if="item.senderType === UserTypeEnum.ADMIN"
:src="item.senderAvatar"
alt="avatar"
/>
</div>
</div>
</div>
</div>
<div
v-show="showNewMessageTip"
class="newMessageTip flex cursor-pointer items-center"
@click="handleToNewMessage"
>
<span>有新消息</span>
<IconifyIcon class="ml-5px" icon="ep:bottom" />
</div>
</a-layout-content>
<a-layout-footer class="kefu-footer">
<div class="chat-tools flex items-center">
<EmojiSelectPopover @select-emoji="handleEmojiSelect" />
<PictureSelectUpload
class="ml-[15px] mt-[3px] cursor-pointer"
@send-picture="handleSendPicture"
/>
</div>
<a-textarea
v-model:value="message"
:rows="6"
placeholder="输入消息Enter发送Shift+Enter换行"
style="border-style: none"
@keyup.enter.prevent="handleSendMessage"
/>
</a-layout-footer>
</a-layout>
<a-layout v-else class="kefu">
<a-layout-content>
<Empty description="请选择左侧的一个会话后开始" class="mt-[50px]" />
</a-layout-content>
</a-layout>
</template>
<style lang="scss" scoped>
/** TODO @jave看看哪些可以用 tailwind 简化掉 */
.kefu {
position: relative;
width: calc(100% - 300px - 260px);
background-color: var(--app-content-bg-color);
&::after {
position: absolute;
top: 0;
left: 0;
width: 1px; /* 实际宽度 */
height: 100%;
content: '';
background-color: var(--border-color);
transform: scaleX(0.3); /* 缩小宽度 */
}
.kefu-header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--app-content-bg-color);
&::before {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 1px; /* 初始宽度 */
content: '';
background-color: var(-border-color);
transform: scaleY(0.3); /* 缩小视觉高度 */
}
&-title {
font-size: 18px;
font-weight: bold;
}
}
&-content {
position: relative;
width: 100%;
height: 100%;
padding: 10px;
margin: 0;
.newMessageTip {
position: absolute;
right: 35px;
bottom: 35px;
padding: 10px;
font-size: 12px;
background-color: var(--app-content-bg-color);
border-radius: 30px;
box-shadow: 0 2px 4px rgb(0 0 0 / 10%); /* 阴影效果 */
}
.ss-row-left {
justify-content: flex-start;
.kefu-message {
margin-top: 3px;
margin-left: 10px;
background-color: #fff;
border-top-right-radius: 10px;
border-bottom-right-radius: 10px;
border-bottom-left-radius: 10px;
}
}
.ss-row-right {
justify-content: flex-end;
.kefu-message {
margin-top: 3px;
margin-right: 10px;
background-color: rgb(206 223 255);
border-top-left-radius: 10px;
border-bottom-right-radius: 10px;
border-bottom-left-radius: 10px;
}
}
// 消息气泡
.kefu-message {
width: auto;
max-width: 50%;
padding: 5px 10px;
font-weight: 500;
color: #414141;
//text-align: left;
//display: inline-block !important;
//word-break: break-all;
transition: all 0.2s;
&:hover {
transform: scale(1.03);
}
}
.date-message,
.system-message {
width: fit-content;
padding: 0 5px;
font-size: 10px;
color: #fff;
background-color: rgb(0 0 0 / 10%);
border-radius: 8px;
}
}
.kefu-footer {
position: relative;
display: flex;
flex-direction: column;
height: auto;
padding: 0;
margin: 0;
&::before {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 1px; /* 初始宽度 */
content: '';
background-color: var(-border-color);
transform: scaleY(0.3); /* 缩小视觉高度 */
}
.chat-tools {
width: 100%;
height: 44px;
}
}
}
</style>

View File

@@ -0,0 +1,6 @@
export { default as KeFuConversationList } from './KeFuConversationList.vue';
export { default as KeFuMessageList } from './KeFuMessageList.vue';
export { default as MemberInfo } from './member/MemberInfo.vue';
// TODO @jawecomponents =》modules在 vben 里modules 是给自己用的,把一个大 vue 拆成 n 个小 vuecomponents 是给别的模块使用的;
// TODO @jawe1组件名小写类似 conversation-list.vue2KeFu 开头可以去掉,因为已经是当前模块下,不用重复拼写;

View File

@@ -0,0 +1,292 @@
<!-- 右侧信息会员信息 + 最近浏览 + 交易订单 -->
<script lang="ts" setup>
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
import { computed, nextTick, ref } from 'vue';
import { isEmpty } from '@vben/utils';
// TODO @jawedebounce 是不是还是需要的哈;应该有 2 处需要;可以微信沟通哈;
// import { debounce } from 'lodash-es'
import { useDebounceFn } from '@vueuse/core';
import { Card, Empty, message } from 'ant-design-vue';
import * as UserApi from '#/api/member/user';
import * as WalletApi from '#/api/pay/wallet/balance';
import { CardTitle } from '#/components/card-title';
import AccountInfo from '#/views/member/user/detail/modules/account-info.vue';
import BasicInfo from '#/views/member/user/detail/modules/basic-info.vue';
import OrderBrowsingHistory from './OrderBrowsingHistory.vue';
import ProductBrowsingHistory from './ProductBrowsingHistory.vue';
defineOptions({ name: 'MemberBrowsingHistory' });
const activeTab = ref('会员信息');
const tabActivation = computed(() => (tab: string) => activeTab.value === tab);
/** tab 切换 */
const productBrowsingHistoryRef =
ref<InstanceType<typeof ProductBrowsingHistory>>();
const orderBrowsingHistoryRef =
ref<InstanceType<typeof OrderBrowsingHistory>>();
async function handleClick(tab: string) {
activeTab.value = tab;
await nextTick();
await getHistoryList();
}
/** 获得历史数据 */
async function getHistoryList() {
switch (activeTab.value) {
case '交易订单': {
await orderBrowsingHistoryRef.value?.getHistoryList(conversation.value);
break;
}
case '会员信息': {
await getUserData();
await getUserWallet();
break;
}
case '最近浏览': {
await productBrowsingHistoryRef.value?.getHistoryList(conversation.value);
break;
}
default: {
break;
}
}
}
/** 加载下一页数据 */
async function loadMore() {
switch (activeTab.value) {
case '交易订单': {
await orderBrowsingHistoryRef.value?.loadMore();
break;
}
case '会员信息': {
break;
}
case '最近浏览': {
await productBrowsingHistoryRef.value?.loadMore();
break;
}
default: {
break;
}
}
}
/** 浏览历史初始化 */
const conversation = ref<MallKefuConversationApi.Conversation>(
{} as MallKefuConversationApi.Conversation,
); // 用户会话
async function initHistory(val: MallKefuConversationApi.Conversation) {
activeTab.value = '会员信息';
conversation.value = val;
await nextTick();
await getHistoryList();
}
defineExpose({ initHistory });
/** 处理消息列表滚动事件(debounce 限流) */
const scrollbarRef = ref<InstanceType>();
const handleScroll = useDebounceFn(() => {
const wrap = scrollbarRef.value?.wrapRef;
// 触底重置
if (Math.abs(wrap!.scrollHeight - wrap!.clientHeight - wrap!.scrollTop) < 1) {
loadMore();
}
}, 200);
/** 查询用户钱包信息 */
// TODO @jaweidea 的导入报错;需要看下;
const WALLET_INIT_DATA = {
balance: 0,
totalExpense: 0,
totalRecharge: 0,
} as WalletApi.WalletVO; // 钱包初始化数据
const wallet = ref<WalletApi.WalletVO>(WALLET_INIT_DATA); // 钱包信息
async function getUserWallet() {
if (!conversation.value.userId) {
wallet.value = WALLET_INIT_DATA;
return;
}
wallet.value =
(await WalletApi.getWallet({ userId: conversation.value.userId })) ||
WALLET_INIT_DATA;
}
/** 获得用户 */
const loading = ref(true); // 加载中
const user = ref<UserApi.UserVO>({} as UserApi.UserVO);
async function getUserData() {
loading.value = true;
try {
const res = await UserApi.getUser(conversation.value.userId);
if (res) {
user.value = res;
} else {
user.value = {} as UserApi.UserVO;
message.error('会员不存在!');
}
} finally {
loading.value = false;
}
}
</script>
<template>
<!-- TODO @javefrom xingyua- 换成大写的方式另外组件没有进行导入其他页面也有这个问题 -->
<a-layout class="kefu">
<a-layout-header class="kefu-header">
<div
:class="{ 'kefu-header-item-activation': tabActivation('会员信息') }"
class="kefu-header-item flex cursor-pointer items-center justify-center"
@click="handleClick('会员信息')"
>
会员信息
</div>
<div
:class="{ 'kefu-header-item-activation': tabActivation('最近浏览') }"
class="kefu-header-item flex cursor-pointer items-center justify-center"
@click="handleClick('最近浏览')"
>
最近浏览
</div>
<div
:class="{ 'kefu-header-item-activation': tabActivation('交易订单') }"
class="kefu-header-item flex cursor-pointer items-center justify-center"
@click="handleClick('交易订单')"
>
交易订单
</div>
</a-layout-header>
<a-layout-content class="kefu-content p-10px!">
<div v-if="!isEmpty(conversation)" v-loading="loading">
<!-- 基本信息 -->
<BasicInfo v-if="activeTab === '会员信息'" :user="user" mode="kefu">
<template #header>
<CardTitle title="基本信息" />
</template>
</BasicInfo>
<!-- 账户信息 -->
<Card
v-if="activeTab === '会员信息'"
class="mt-10px h-full"
shadow="never"
>
<template #header>
<CardTitle title="账户信息" />
</template>
<AccountInfo :column="1" :user="user" :wallet="wallet" />
</Card>
</div>
<div v-show="!isEmpty(conversation)">
<div ref="scrollbarRef" always @scroll="handleScroll">
<!-- 最近浏览 -->
<ProductBrowsingHistory
v-if="activeTab === '最近浏览'"
ref="productBrowsingHistoryRef"
/>
<!-- 交易订单 -->
<OrderBrowsingHistory
v-if="activeTab === '交易订单'"
ref="orderBrowsingHistoryRef"
/>
</div>
</div>
<Empty
v-show="isEmpty(conversation)"
description="请选择左侧的一个会话后开始"
class="mt-[50px]"
/>
</a-layout-content>
</a-layout>
</template>
<style lang="scss" scoped>
/** TODO @jave看看哪些可以用 tailwind 简化掉 */
.kefu {
position: relative;
width: 300px !important;
background-color: var(--app-content-bg-color);
&::after {
position: absolute;
top: 0;
left: 0;
width: 1px; /* 实际宽度 */
height: 100%;
content: '';
background-color: var(--el-border-color);
transform: scaleX(0.3); /* 缩小宽度 */
}
&-header {
position: relative;
display: flex;
align-items: center;
justify-content: space-around;
background-color: var(--app-content-bg-color);
&::before {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 1px; /* 初始宽度 */
content: '';
background-color: var(--el-border-color);
transform: scaleY(0.3); /* 缩小视觉高度 */
}
&-title {
font-size: 18px;
font-weight: bold;
}
&-item {
position: relative;
width: 100%;
height: 100%;
&-activation::before {
position: absolute; /* 绝对定位 */
inset: 0; /* 覆盖整个元素 */
pointer-events: none; /* 确保点击事件不会被伪元素拦截 */
content: '';
border-bottom: 2px solid rgb(128 128 128 / 50%); /* 边框样式 */
}
&:hover::before {
position: absolute; /* 绝对定位 */
inset: 0; /* 覆盖整个元素 */
pointer-events: none; /* 确保点击事件不会被伪元素拦截 */
content: '';
border-bottom: 2px solid rgb(128 128 128 / 50%); /* 边框样式 */
}
}
}
&-content {
position: relative;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
overflow: auto;
}
&-tabs {
width: 100%;
height: 100%;
}
}
.header-title {
border-bottom: #e4e0e0 solid 1px;
}
</style>

View File

@@ -0,0 +1,54 @@
<script lang="ts" setup>
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
import { computed, reactive, ref } from 'vue';
import { getOrderPage } from '#/api/mall/trade/order';
import OrderItem from '#/views/mall/promotion/kefu/components/message/OrderItem.vue';
defineOptions({ name: 'OrderBrowsingHistory' });
const list = ref<any>([]); // 列表
const total = ref(0); // 总数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
userId: 0,
});
const skipGetMessageList = computed(() => {
// 已加载到最后一页的话则不触发新的消息获取
return (
total.value > 0 &&
Math.ceil(total.value / queryParams.pageSize) === queryParams.pageNo
);
}); // 跳过消息获取
/** 获得浏览记录 */
async function getHistoryList(val: MallKefuConversationApi.Conversation) {
queryParams.userId = val.userId;
const res = await getOrderPage(queryParams);
total.value = res.total;
list.value = res.list;
}
/** 加载下一页数据 */
async function loadMore() {
if (skipGetMessageList.value) {
return;
}
queryParams.pageNo += 1;
const res = await getOrderPage(queryParams);
total.value = res.total;
list.value = [...list.value, ...res.list];
}
defineExpose({ getHistoryList, loadMore });
</script>
<template>
<OrderItem
v-for="item in list"
:key="item.id"
:order="item"
class="mb-[10px]"
/>
</template>

View File

@@ -0,0 +1,60 @@
<script lang="ts" setup>
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
import { computed, reactive, ref } from 'vue';
import { getBrowseHistoryPage } from '#/api/mall/product/history';
import ProductItem from '#/views/mall/promotion/kefu/components/message/ProductItem.vue';
defineOptions({ name: 'ProductBrowsingHistory' });
const list = ref<any>([]); // 列表
const total = ref(0); // 总数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
userId: 0,
userDeleted: false,
});
const skipGetMessageList = computed(() => {
// 已加载到最后一页的话则不触发新的消息获取
return (
total.value > 0 &&
Math.ceil(total.value / queryParams.pageSize) === queryParams.pageNo
);
}); // 跳过消息获取
/** 获得浏览记录 */
async function getHistoryList(val: MallKefuConversationApi.Conversation) {
queryParams.userId = val.userId;
const res = await getBrowseHistoryPage(queryParams);
total.value = res.total;
list.value = res.list;
}
/** 加载下一页数据 */
async function loadMore() {
if (skipGetMessageList.value) {
return;
}
queryParams.pageNo += 1;
const res = await getBrowseHistoryPage(queryParams);
total.value = res.total;
list.value = [...list.value, ...res.list];
}
defineExpose({ getHistoryList, loadMore });
</script>
<template>
<ProductItem
v-for="item in list"
:key="item.id"
:pic-url="item.picUrl"
:price="item.price"
:sales-count="item.salesCount"
:spu-id="item.spuId"
:stock="item.stock"
:title="item.spuName"
class="mb-10px"
/>
</template>

View File

@@ -0,0 +1,26 @@
<script lang="ts" setup>
import type { MallKefuMessageApi } from '#/api/mall/promotion/kefu/message';
import { UserTypeEnum } from '@vben/constants';
/** 消息组件 */
defineOptions({ name: 'MessageItem' });
defineProps<{
message: MallKefuMessageApi.Message;
}>();
</script>
<template>
<div
:class="[
message.senderType === UserTypeEnum.MEMBER
? `ml-[10px]`
: message.senderType === UserTypeEnum.ADMIN
? `mr-[10px]`
: '',
]"
>
<slot></slot>
</div>
</template>

View File

@@ -0,0 +1,206 @@
<script lang="ts" setup>
import type { MallKefuMessageApi } from '#/api/mall/promotion/kefu/message';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { fenToYuan, isObject, jsonParse } from '@vben/utils';
import ProductItem from '#/views/mall/promotion/kefu/components/message/ProductItem.vue';
defineOptions({ name: 'OrderItem' });
const props = defineProps<{
message?: MallKefuMessageApi.Message;
order?: any;
}>();
const { push } = useRouter();
const getMessageContent = computed(() =>
props.message === undefined
? props.order
: jsonParse(props!.message!.content),
);
/** 查看订单详情 */
function openDetail(id: number) {
push({ name: 'TradeOrderDetail', params: { id } });
}
/**
* 格式化订单状态的颜色
*
* @param order 订单
* @return {string} 颜色的 class 名称
*/
function formatOrderColor(order: any) {
if (order.status === 0) {
return 'info-color';
}
if (
order.status === 10 ||
order.status === 20 ||
(order.status === 30 && !order.commentStatus)
) {
return 'warning-color';
}
if (order.status === 30 && order.commentStatus) {
return 'success-color';
}
return 'danger-color';
}
/**
* 格式化订单状态
*
* @param order 订单
*/
function formatOrderStatus(order: any) {
if (order.status === 0) {
return '待付款';
}
if (order.status === 10 && order.deliveryType === 1) {
return '待发货';
}
if (order.status === 10 && order.deliveryType === 2) {
return '待核销';
}
if (order.status === 20) {
return '待收货';
}
if (order.status === 30 && !order.commentStatus) {
return '待评价';
}
if (order.status === 30 && order.commentStatus) {
return '已完成';
}
return '已关闭';
}
</script>
<template>
<div v-if="isObject(getMessageContent)">
<div :key="getMessageContent.id" class="order-list-card-box mt-[14px]">
<div
class="order-card-header p-x-[5px] flex items-center justify-between"
>
<div class="order-no">
订单号
<span
style="cursor: pointer"
@click="openDetail(getMessageContent.id)"
>
{{ getMessageContent.no }}
</span>
</div>
<div
:class="formatOrderColor(getMessageContent)"
class="order-state font-16"
>
{{ formatOrderStatus(getMessageContent) }}
</div>
</div>
<div
v-for="item in getMessageContent.items"
:key="item.id"
class="border-bottom"
>
<ProductItem
:num="item.count"
:pic-url="item.picUrl"
:price="item.price"
:sku-text="
item.properties.map((property: any) => property.valueName).join(' ')
"
:spu-id="item.spuId"
:title="item.spuName"
/>
</div>
<div class="pay-box flex justify-end pr-[5px]">
<div class="flex items-center">
<div class="discounts-title pay-color">
共 {{ getMessageContent?.productCount }} 件商品,总金额:
</div>
<div class="discounts-money pay-color">
¥{{ fenToYuan(getMessageContent?.payPrice) }}
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
/** TODO @jave看看哪些可以用 tailwind 简化掉 */
.order-list-card-box {
padding: 10px;
background-color: rgb(128 128 128 / 30%); // 透明色,暗黑模式下也能体现
border: 1px var(--el-border-color) solid;
border-radius: 10px;
.order-card-header {
height: 28px;
font-weight: bold;
.order-no {
font-size: 13px;
span {
&:hover {
color: var(--left-menu-bg-active-color);
text-decoration: underline;
}
}
}
.order-state {
font-size: 13px;
}
}
.pay-box {
padding-top: 10px;
font-weight: bold;
.discounts-title {
font-size: 16px;
line-height: normal;
}
.discounts-money {
font-family: OPPOSANS;
font-size: 16px;
line-height: normal;
}
.pay-color {
font-size: 13px;
}
}
}
.warning-color {
font-size: 11px;
font-weight: bold;
color: #faad14;
}
.danger-color {
font-size: 11px;
font-weight: bold;
color: #ff3000;
}
.success-color {
font-size: 11px;
font-weight: bold;
color: #52c41a;
}
.info-color {
font-size: 11px;
font-weight: bold;
color: #999;
}
</style>

View File

@@ -0,0 +1,127 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router';
import { fenToYuan } from '@vben/utils';
import { Button, Image } from 'ant-design-vue';
defineOptions({ name: 'ProductItem' });
defineProps({
spuId: {
type: Number,
default: 0,
},
picUrl: {
type: String,
default: 'https://img1.baidu.com/it/u=1601695551,235775011&fm=26&fmt=auto',
},
title: {
type: String,
default: '',
},
price: {
type: [String, Number],
default: '',
},
salesCount: {
type: [String, Number],
default: '',
},
stock: {
type: [String, Number],
default: '',
},
});
const { push } = useRouter();
/** 查看商品详情 */
function openDetail(spuId: number) {
push({ name: 'ProductSpuDetail', params: { id: spuId } });
}
</script>
<template>
<div
class="product-warp"
style="cursor: pointer"
@click.stop="openDetail(spuId)"
>
<!-- 左侧商品图片-->
<div class="product-warp-left mr-[24px]">
<Image
:initial-index="0"
:preview-src-list="[picUrl]"
:src="picUrl"
class="product-warp-left-img"
fit="contain"
preview-teleported
@click.stop
/>
</div>
<!-- 右侧商品信息 -->
<div class="product-warp-right">
<div class="description">{{ title }}</div>
<div class="my-[5px]">
<span class="mr-[20px]">库存: {{ stock || 0 }}</span>
<span>销量: {{ salesCount || 0 }}</span>
</div>
<div class="flex items-center justify-between">
<span class="price">{{ fenToYuan(price) }}</span>
<Button size="small" text type="primary">详情</Button>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
/** TODO @jawe下面的看看要不要用 tailwind 简化ai 友好; */
.button {
padding: 5px 10px;
color: white;
cursor: pointer;
background-color: #007bff;
border: none;
}
.product-warp {
display: flex;
align-items: center;
width: 100%;
padding: 10px;
margin-bottom: 10px;
background-color: rgb(128 128 128 / 30%);
border: 1px solid var(--el-border-color);
border-radius: 8px;
&-left {
width: 70px;
&-img {
width: 100%;
height: 100%;
border-radius: 8px;
}
}
&-right {
flex: 1;
.description {
display: -webkit-box;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 1; /* 显示一行 */
font-size: 16px;
font-weight: bold;
-webkit-box-orient: vertical;
}
.price {
color: #ff3000;
}
}
}
</style>

View File

@@ -0,0 +1,56 @@
<!-- emoji 表情选择组件 -->
<script lang="ts" setup>
import type { Emoji } from './emoji';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { List } from 'ant-design-vue';
import { useEmoji } from './emoji';
defineOptions({ name: 'EmojiSelectPopover' });
/** 选择 emoji 表情 */
// TODO @jawe这里有 linter 告警,看看要不要处理下;
const emits = defineEmits<{
(e: 'selectEmoji', v: Emoji);
}>();
const { getEmojiList } = useEmoji();
const emojiList = computed(() => getEmojiList());
function handleSelect(item: Emoji) {
// 整个 emoji 数据传递出去,方便以后输入框直接显示表情
emits('selectEmoji', item);
}
</script>
<template>
<a-popover :width="500" placement="top" trigger="click">
<template #content>
<List height="300px">
<ul class="ml-2 flex flex-wrap px-2">
<li
v-for="(item, index) in emojiList"
:key="index"
:style="{
borderColor: 'var(--primary)',
color: 'var(--primary)',
}"
:title="item.name"
class="icon-item w-1/10 mr-2 mt-1 flex cursor-pointer items-center justify-center border border-solid p-2"
@click="handleSelect(item)"
>
<img :src="item.url" class="h-[24px] w-[24px]" />
</li>
</ul>
</List>
</template>
<IconifyIcon
:size="30"
class="ml-[10px] cursor-pointer"
icon="twemoji:grinning-face"
/>
</a-popover>
</template>

View File

@@ -0,0 +1,88 @@
<!-- 图片选择 -->
<script lang="ts" setup>
import { message } from 'ant-design-vue';
import * as FileApi from '#/api/infra/file';
import Picture from '#/views/mall/promotion/kefu/asserts/picture.svg';
defineOptions({ name: 'PictureSelectUpload' });
/** 选择并上传文件 */
const emits = defineEmits<{
(e: 'sendPicture', v: string): void;
}>();
async function selectAndUpload() {
const files: any = await getFiles();
message.success('图片发送中请稍等。。。');
// TODO @jawe直接使用 updateFile不通过 FileApi。vben 这里的规范;
// TODO @jawe这里的上传看看能不能替换成 export function useUpload(directory?: string) {;它支持前端直传,更统一;
const res = await FileApi.updateFile({ file: files[0].file });
emits('sendPicture', res.data);
}
/** 唤起文件选择窗口,并获取选择的文件 */
async function getFiles(options = {}) {
const { multiple, accept, limit, fileSize } = {
multiple: true,
accept: 'image/jpeg, image/png, image/gif', // 默认选择图片
limit: 1,
fileSize: 500,
...options,
};
// 创建文件选择元素
const input = document.createElement('input');
input.type = 'file';
input.style.display = 'none';
if (multiple) input.multiple = true;
if (accept) input.accept = accept;
// 将文件选择元素添加到文档中
document.body.append(input);
// 触发文件选择元素的点击事件
input.click();
// 等待文件选择元素的 change 事件
// 移除不必要的 try/catch 包装,直接返回 Promise
return await new Promise((resolve, reject) => {
input.addEventListener('change', (event: any) => {
const filesArray = [...(event?.target?.files || [])];
// 从文档中移除文件选择元素
input.remove();
// 判断是否超出上传数量限制
if (filesArray.length > limit) {
// 使用 Error 对象作为 reject 的原因
reject(new Error(`超出上传数量限制,最多允许 ${limit} 个文件`));
return;
}
// 判断是否超出上传文件大小限制
const overSizedFiles = filesArray.filter(
(file: File) => file.size / 1024 ** 2 > fileSize,
);
if (overSizedFiles.length > 0) {
// 使用 Error 对象作为 reject 的原因
reject(new Error(`文件大小超出限制,单个文件最大允许 ${fileSize}MB`));
return;
}
// 生成文件列表,并添加 uid
const fileList = filesArray.map((file, index) => ({
file,
uid: Date.now() + index,
}));
resolve(fileList);
});
});
}
</script>
<template>
<div>
<!-- TODO @jawe看看能不能换成 antd Image 组件 -->
<img :src="Picture" class="h-[35px] w-[35px]" @click="selectAndUpload" />
</div>
</template>

View File

@@ -0,0 +1,17 @@
/** 客服消息类型枚举类 */
export const KeFuMessageContentTypeEnum = {
TEXT: 1, // 文本消息
IMAGE: 2, // 图片消息
VOICE: 3, // 语音消息
VIDEO: 4, // 视频消息
SYSTEM: 5, // 系统消息
// ========== 商城特殊消息 ==========
PRODUCT: 10, // 商品消息
ORDER: 11, // 订单消息"
};
/** Promotion 的 WebSocket 消息类型枚举类 */
export const WebSocketMessageTypeConstants = {
KEFU_MESSAGE_TYPE: 'kefu_message_type', // 客服消息类型
KEFU_MESSAGE_ADMIN_READ: 'kefu_message_read_status_change', // 客服消息管理员已读
};

View File

@@ -0,0 +1,126 @@
import { onMounted, ref } from 'vue';
import { isEmpty } from '@vben/utils';
const emojiList = [
{ name: '[笑掉牙]', file: 'xiaodiaoya.png' },
{ name: '[可爱]', file: 'keai.png' },
{ name: '[冷酷]', file: 'lengku.png' },
{ name: '[闭嘴]', file: 'bizui.png' },
{ name: '[生气]', file: 'shengqi.png' },
{ name: '[惊恐]', file: 'jingkong.png' },
{ name: '[瞌睡]', file: 'keshui.png' },
{ name: '[大笑]', file: 'daxiao.png' },
{ name: '[爱心]', file: 'aixin.png' },
{ name: '[坏笑]', file: 'huaixiao.png' },
{ name: '[飞吻]', file: 'feiwen.png' },
{ name: '[疑问]', file: 'yiwen.png' },
{ name: '[开心]', file: 'kaixin.png' },
{ name: '[发呆]', file: 'fadai.png' },
{ name: '[流泪]', file: 'liulei.png' },
{ name: '[汗颜]', file: 'hanyan.png' },
{ name: '[惊悚]', file: 'jingshu.png' },
{ name: '[困~]', file: 'kun.png' },
{ name: '[心碎]', file: 'xinsui.png' },
{ name: '[天使]', file: 'tianshi.png' },
{ name: '[晕]', file: 'yun.png' },
{ name: '[啊]', file: 'a.png' },
{ name: '[愤怒]', file: 'fennu.png' },
{ name: '[睡着]', file: 'shuizhuo.png' },
{ name: '[面无表情]', file: 'mianwubiaoqing.png' },
{ name: '[难过]', file: 'nanguo.png' },
{ name: '[犯困]', file: 'fankun.png' },
{ name: '[好吃]', file: 'haochi.png' },
{ name: '[呕吐]', file: 'outu.png' },
{ name: '[龇牙]', file: 'ziya.png' },
{ name: '[懵比]', file: 'mengbi.png' },
{ name: '[白眼]', file: 'baiyan.png' },
{ name: '[饿死]', file: 'esi.png' },
{ name: '[凶]', file: 'xiong.png' },
{ name: '[感冒]', file: 'ganmao.png' },
{ name: '[流汗]', file: 'liuhan.png' },
{ name: '[笑哭]', file: 'xiaoku.png' },
{ name: '[流口水]', file: 'liukoushui.png' },
{ name: '[尴尬]', file: 'ganga.png' },
{ name: '[惊讶]', file: 'jingya.png' },
{ name: '[大惊]', file: 'dajing.png' },
{ name: '[不好意思]', file: 'buhaoyisi.png' },
{ name: '[大闹]', file: 'danao.png' },
{ name: '[不可思议]', file: 'bukesiyi.png' },
{ name: '[爱你]', file: 'aini.png' },
{ name: '[红心]', file: 'hongxin.png' },
{ name: '[点赞]', file: 'dianzan.png' },
{ name: '[恶魔]', file: 'emo.png' },
];
export interface Emoji {
name: string;
url: string;
}
export const useEmoji = () => {
const emojiPathList = ref<any[]>([]);
/** 加载本地图片 */
const initStaticEmoji = async () => {
const pathList = import.meta.glob('../../asserts/*.{png,jpg,jpeg,svg}');
for (const path in pathList) {
const imageModule: any = await pathList[path]();
emojiPathList.value.push({ path, src: imageModule.default });
}
};
/** 初始化 */
onMounted(async () => {
if (isEmpty(emojiPathList.value)) {
await initStaticEmoji();
}
});
/**
* 将文本中的表情替换成图片
*
* @return 替换后的文本
* @param content 消息内容
*/
const replaceEmoji = (content: string) => {
let newData = content;
if (typeof newData !== 'object') {
const reg = /\[(.+?)\]/g; // [] 中括号
const zhEmojiName = newData.match(reg);
if (zhEmojiName) {
zhEmojiName.forEach((item) => {
const emojiFile = getEmojiFileByName(item);
newData = newData.replace(
item,
`<img style="width: 20px;height: 20px;margin:0 1px 3px 1px;vertical-align: middle;" src="${emojiFile}" alt=""/>`,
);
});
}
}
return newData;
};
/** 获得所有表情 */
function getEmojiList(): Emoji[] {
return emojiList.map((item) => ({
url: getEmojiFileByName(item.name),
name: item.name,
})) as Emoji[];
}
function getEmojiFileByName(name: string) {
for (const emoji of emojiList) {
if (emoji.name === name) {
const emojiPath = emojiPathList.value.find(
(item: { path: string; src: string }) =>
item.path.includes(emoji.file),
);
return emojiPath ? emojiPath.src : undefined;
}
}
return false;
}
return { replaceEmoji, getEmojiList };
};

View File

@@ -1,28 +1,134 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { defineOptions, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Button } from 'ant-design-vue';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import { useWebSocket } from '@vueuse/core';
import { message } from 'ant-design-vue';
import { useMallKefuStore } from '#/store/mall/kefu';
import {
KeFuConversationList,
KeFuMessageList,
MemberInfo,
} from './components';
import { WebSocketMessageTypeConstants } from './components/tools/constants';
defineOptions({ name: 'KeFu' });
const accessStore = useAccessStore();
const kefuStore = useMallKefuStore(); // 客服缓存
// ======================= WebSocket start =======================
const url = `${`${import.meta.env.VITE_BASE_URL}/infra/ws`.replace(
'http',
'ws',
)}?token=${accessStore.refreshToken}`; // 使用 refreshToken() :WebSocket 无法方便的刷新访问令牌
const server = ref(url); // WebSocket 服务地址
/** 发起 WebSocket 连接 */
const { data, close, open } = useWebSocket(server.value, {
autoReconnect: true,
heartbeat: true,
});
/** 监听 WebSocket 数据 */
watch(
() => data.value,
(newData) => {
if (!newData) return;
try {
// 1. 收到心跳
if (newData === 'pong') return;
// 2.1 解析 type 消息类型
const jsonMessage = JSON.parse(newData);
const type = jsonMessage.type;
if (!type) {
message.error(`未知的消息类型:${newData}`);
return;
}
// 2.2 消息类型KEFU_MESSAGE_TYPE
if (type === WebSocketMessageTypeConstants.KEFU_MESSAGE_TYPE) {
const message = JSON.parse(jsonMessage.content);
// 刷新会话列表
kefuStore.updateConversation(message.conversationId);
// 刷新消息列表
keFuChatBoxRef.value?.refreshMessageList(message);
return;
}
// 2.3 消息类型KEFU_MESSAGE_ADMIN_READ
if (type === WebSocketMessageTypeConstants.KEFU_MESSAGE_ADMIN_READ) {
// 更新会话已读
const message = JSON.parse(jsonMessage.content);
kefuStore.updateConversationStatus(message.conversationId);
}
} catch (error) {
console.error(error);
}
},
{
immediate: false, // 不立即执行
},
);
// ======================= WebSocket end =======================
/** 加载指定会话的消息列表 */
const keFuChatBoxRef = ref<InstanceType<typeof KeFuMessageList>>();
const memberInfoRef = ref<InstanceType<typeof MemberInfo>>();
// TODO @jawe这里没导入
const handleChange = (conversation: KeFuConversationRespVO) => {
keFuChatBoxRef.value?.getNewMessageList(conversation);
memberInfoRef.value?.initHistory(conversation);
};
const keFuConversationRef = ref<InstanceType<typeof KeFuConversationList>>();
/** 初始化 */
onMounted(() => {
// 加载会话列表
kefuStore.setConversationList().then(() => {
keFuConversationRef.value?.calculationLastMessageTime();
});
// 打开 websocket 连接
open();
});
/** 销毁 */
onBeforeUnmount(() => {
// 关闭 websocket 连接
close();
});
</script>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/mall/promotion/kefu/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/mall/promotion/kefu/index
代码pull request 贡献给我们
</Button>
<!-- TODO @jawestyle 使用 tailwindcssAI 友好 -->
<a-layout-content class="kefu-layout hrow">
<!-- 会话列表 -->
<KeFuConversationList ref="keFuConversationRef" @change="handleChange" />
<!-- 会话详情选中会话的消息列表 -->
<KeFuMessageList ref="keFuChatBoxRef" />
<!-- 会员信息选中会话的会员信息 -->
<MemberInfo ref="memberInfoRef" />
</a-layout-content>
</Page>
</template>
<style lang="scss">
.kefu-layout {
position: absolute;
top: 0;
left: 0;
flex: 1;
width: 100%;
height: 100%;
}
.hrow {
display: flex;
}
</style>

View File

@@ -120,6 +120,16 @@ export function useFormSchema(): VbenFormSchema[] {
allowClear: true,
},
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
rows: 4,
allowClear: true,
},
},
{
fieldName: 'startAndEndTime',
label: '活动时间',
@@ -128,8 +138,10 @@ export function useFormSchema(): VbenFormSchema[] {
componentProps: {
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
placeholder: [$t('common.startTimeText'), $t('common.endTimeText')],
allowClear: true,
placeholder: [
$t('utils.rangePicker.beginTime'),
$t('utils.rangePicker.endTime'),
],
},
},
{
@@ -154,22 +166,7 @@ export function useFormSchema(): VbenFormSchema[] {
},
rules: z.number().default(PromotionProductScopeEnum.ALL.scope),
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
rows: 4,
allowClear: true,
},
},
{
fieldName: 'rules',
label: '优惠设置',
component: 'Input',
formItemClass: 'items-start',
},
// TODO @puhui999选择完删除后自动就退出了 modal
{
fieldName: 'productSpuIds',
label: '选择商品',
@@ -180,6 +177,15 @@ export function useFormSchema(): VbenFormSchema[] {
return values.productScope === PromotionProductScopeEnum.SPU.scope;
},
},
rules: 'required',
},
// TODO @puhui999这里还有个分类
{
fieldName: 'rules',
label: '优惠设置',
component: 'Input',
formItemClass: 'items-start',
// TODO @puhui999这里可能要加个 rules: 'required',
},
];
}

View File

@@ -27,6 +27,7 @@ import RewardRule from './reward-rule.vue';
const emit = defineEmits(['success']);
const formData = ref<MallRewardActivityApi.RewardActivity>({
// TODO @puhui999这里的 conditionType、productScope 是不是可以删除呀。因为 data.ts 已经搞了 defaultValue
conditionType: PromotionConditionTypeEnum.PRICE.type,
productScope: PromotionProductScopeEnum.ALL.scope,
rules: [],
@@ -38,6 +39,8 @@ const getTitle = computed(() => {
: $t('ui.actionTitle.create', ['满减送']);
});
const rewardRuleRef = ref<InstanceType<typeof RewardRule>>();
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
@@ -50,8 +53,6 @@ const [Form, formApi] = useVbenForm({
showDefaultActions: false,
});
const rewardRuleRef = ref<InstanceType<typeof RewardRule>>();
// TODO @芋艿:这里需要在简化下;
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
@@ -60,30 +61,27 @@ const [Modal, modalApi] = useVbenModal({
return;
}
modalApi.lock();
// 提交表单
try {
const data = await formApi.getValues();
rewardRuleRef.value?.setRuleCoupon();
if (data.startAndEndTime && Array.isArray(data.startAndEndTime)) {
data.startTime = data.startAndEndTime[0];
data.endTime = data.startAndEndTime[1];
delete data.startAndEndTime;
}
data.rules?.forEach((item: any) => {
item.discountPrice = convertToInteger(item.discountPrice || 0);
if (data.conditionType === PromotionConditionTypeEnum.PRICE.type) {
item.limit = convertToInteger(item.limit || 0);
}
});
setProductScopeValues(data);
await (formData.value?.id
? updateRewardActivity(data as MallRewardActivityApi.RewardActivity)
: createRewardActivity(data as MallRewardActivityApi.RewardActivity));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
@@ -100,26 +98,23 @@ const [Modal, modalApi] = useVbenModal({
};
return;
}
// 加载数据
const data = modalApi.getData<MallRewardActivityApi.RewardActivity>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
const result = await getReward(data.id);
result.startAndEndTime = [result.startTime, result.endTime] as any[];
result.rules?.forEach((item: any) => {
item.discountPrice = formatToFraction(item.discountPrice || 0);
if (result.conditionType === PromotionConditionTypeEnum.PRICE.type) {
item.limit = formatToFraction(item.limit || 0);
}
});
formData.value = result;
// 设置到 values
await formApi.setValues(result);
await getProductScope();
@@ -153,6 +148,7 @@ async function getProductScope() {
}
}
// TODO @puhui999/Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/views/mall/promotion/coupon/template/data.ts 可以类似 /Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/views/mall/promotion/coupon/template/data.ts 的 productScopeValues微信交流
function setProductScopeValues(data: any) {
switch (formData.value.productScope) {
case PromotionProductScopeEnum.CATEGORY.scope: {

View File

@@ -4,7 +4,7 @@ import type { MallRewardActivityApi } from '#/api/mall/promotion/reward/rewardAc
import { nextTick, onMounted, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { CouponTemplateTakeTypeEnum, DICT_TYPE } from '@vben/constants';
import { useVModel } from '@vueuse/core';
import { Button, Input } from 'ant-design-vue';
@@ -30,12 +30,9 @@ interface GiveCoupon extends MallCouponTemplateApi.CouponTemplate {
}
const rewardRule = useVModel(props, 'modelValue', emits);
const list = ref<GiveCoupon[]>([]);
const CouponTemplateTakeTypeEnum = {
ADMIN: { type: 2 },
};
const list = ref<GiveCoupon[]>([]); // 选择的优惠劵列表
// TODO @puhui9991命名上可以弱化 coupon例如说 selectRef原因是本身就是 coupon-select.vue2相关的处理的方法最好都带 handle如果是处理事件例如说 deleteCoupon 改成 handleDelete
/** 选择优惠券 */
const couponSelectRef = ref<InstanceType<typeof CouponSelect>>();
function selectCoupon() {
@@ -78,6 +75,7 @@ async function initGiveCouponList() {
}
/** 设置赠送的优惠券 */
// TODO @puhui999这个有办法不提供就是不用 form.vue 去调用,更加透明~
function setGiveCouponList() {
if (!rewardRule.value) {
return;

View File

@@ -40,7 +40,8 @@ const isPriceCondition = computed(() => {
);
});
function addRule() {
/** 处理新增 */
function handleAdd() {
if (!formData.value.rules) {
formData.value.rules = [];
}
@@ -52,7 +53,8 @@ function addRule() {
});
}
function deleteRule(ruleIndex: number) {
/** 处理删除 */
function handleDelete(ruleIndex: number) {
formData.value.rules.splice(ruleIndex, 1);
}
@@ -80,7 +82,7 @@ defineExpose({ setRuleCoupon });
type="link"
danger
class="ml-2"
@click="deleteRule(index)"
@click="handleDelete(index)"
>
删除
</Button>
@@ -114,6 +116,7 @@ defineExpose({ setRuleCoupon });
</FormItem>
<!-- 优惠内容 -->
<!-- TODO @puhui999这里样式 AI 调整下1类似优惠劵折行啦2整体要左移点 -->
<FormItem label="优惠内容" :label-col="{ span: 4 }">
<div class="flex flex-col gap-4">
<!-- 订单金额优惠 -->
@@ -174,7 +177,7 @@ defineExpose({ setRuleCoupon });
<!-- 添加规则按钮 -->
<Col :span="24" class="mt-2">
<Button type="primary" @click="addRule">+ 添加优惠规则</Button>
<Button type="primary" @click="handleAdd">+ 添加优惠规则</Button>
</Col>
<!-- 提示信息 -->

View File

@@ -19,7 +19,7 @@ export function useGridFormSchema(): VbenFormSchema[] {
label: '优惠券名称',
component: 'Input',
componentProps: {
placeholder: '请输入优惠劵名',
placeholder: '请输入优惠券名称',
clearable: true,
},
},
@@ -29,7 +29,7 @@ export function useGridFormSchema(): VbenFormSchema[] {
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.PROMOTION_DISCOUNT_TYPE, 'number'),
placeholder: '请选择优惠类型',
placeholder: '请选择优惠类型',
clearable: true,
},
},

View File

@@ -5,17 +5,14 @@ import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTe
import { useVbenModal } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import * as CouponTemplateApi from '#/api/mall/promotion/coupon/couponTemplate';
import { getCouponTemplatePage } from '#/api/mall/promotion/coupon/couponTemplate';
import {
useGridColumns,
useGridFormSchema,
} from './select-data';
import { useGridColumns, useGridFormSchema } from './select-data';
defineOptions({ name: 'CouponSelect' });
const props = defineProps<{
takeType: number; // 领取方式
takeType?: number; // 领取方式
}>();
const emit = defineEmits(['success']);
@@ -31,7 +28,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await CouponTemplateApi.getCouponTemplatePage({
return await getCouponTemplatePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
@@ -54,7 +51,8 @@ const [Grid, gridApi] = useVbenVxeGrid({
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
// 从 gridApi 获取选中的记录
const selectedRecords = (gridApi.grid?.getCheckboxRecords() || []) as MallCouponTemplateApi.CouponTemplate[];
const selectedRecords = (gridApi.grid?.getCheckboxRecords() ||
[]) as MallCouponTemplateApi.CouponTemplate[];
await modalApi.close();
emit('success', selectedRecords);
},
@@ -62,8 +60,7 @@ const [Modal, modalApi] = useVbenModal({
</script>
<template>
<Modal title="选择优惠" class="w-3/5">
<Modal title="选择优惠" class="w-2/3">
<Grid />
</Modal>
</template>

View File

@@ -11,7 +11,7 @@ import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { sendCoupon } from '#/api/mall/promotion/coupon/coupon';
import { getCouponTemplatePage } from '#/api/mall/promotion/coupon/couponTemplate';
import { useFormSchema, useGridColumns } from './send-form-data.ts';
import { useFormSchema, useGridColumns } from './send-form-data';
/** 发送优惠券 */
async function handleSendCoupon(row: MallCouponTemplateApi.CouponTemplate) {

View File

@@ -19,9 +19,12 @@ import {
ElTooltip,
} from 'element-plus';
import * as DiyPageApi from '#/api/mall/promotion/diy/page';
import * as DiyTemplateApi from '#/api/mall/promotion/diy/template';
import { DiyEditor, PAGE_LIBS } from '#/views/mall/promotion/components';
import { updateDiyPageProperty } from '#/api/mall/promotion/diy/page';
import {
getDiyTemplateProperty,
updateDiyTemplateProperty,
} from '#/api/mall/promotion/diy/template';
import { DiyEditor, PAGE_LIBS } from '#/views/mall/promotion/components'; // 特殊:存储 reset 重置时,当前 selectedTemplateItem 值,从而进行恢复
/** 装修模板表单 */
defineOptions({ name: 'DiyTemplateDecorate' });
@@ -59,7 +62,7 @@ async function getPageDetail(id: any) {
text: '加载中...',
});
try {
formData.value = await DiyTemplateApi.getDiyTemplateProperty(id);
formData.value = await getDiyTemplateProperty(id);
// 拼接手机预览链接
const domain = import.meta.env.VITE_MALL_H5_DOMAIN;
@@ -116,20 +119,18 @@ async function submitForm() {
// 情况一:基础设置
if (i === 0) {
// 提交模板属性
await DiyTemplateApi.updateDiyTemplateProperty(
isEmpty(data) ? formData.value! : data,
);
await updateDiyTemplateProperty(isEmpty(data) ? formData.value! : data);
continue;
}
// 提交页面属性
// 情况二:提交当前正在编辑的页面
if (currentFormData.value?.name.includes(templateItem.name)) {
await DiyPageApi.updateDiyPageProperty(currentFormData.value!);
await updateDiyPageProperty(currentFormData.value!);
continue;
}
// 情况三:提交页面编辑缓存
if (!isEmpty(data)) {
await DiyPageApi.updateDiyPageProperty(data!);
await updateDiyPageProperty(data!);
}
}
ElMessage.success('保存成功');

View File

@@ -99,4 +99,18 @@ export function groupBy(array: any[], key: string) {
result[groupKey].push(item);
}
return result;
}
}
/**
* 解析 JSON 字符串
*
* @param str
*/
export function jsonParse(str: string) {
try {
return JSON.parse(str);
} catch {
console.warn(`str[${str}] 不是一个 JSON 字符串`);
return str;
}
}

View File

@@ -155,7 +155,6 @@ const IOT_DICT = {
IOT_DATA_SINK_TYPE_ENUM: 'iot_data_sink_type_enum', // IoT 数据流转目的类型
IOT_DATA_TYPE: 'iot_data_type', // IOT 数据类型
IOT_DEVICE_STATE: 'iot_device_state', // IOT 设备状态
IOT_DEVICE_STATUS: 'iot_device_status', // IOT 设备状态
IOT_LOCATION_TYPE: 'iot_location_type', // IOT 定位类型
IOT_NET_TYPE: 'iot_net_type', // IOT 联网方式
IOT_OTA_TASK_DEVICE_SCOPE: 'iot_ota_task_device_scope', // IoT OTA任务设备范围

78
pnpm-lock.yaml generated
View File

@@ -669,10 +669,10 @@ importers:
version: link:scripts/vsh
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.1(vite@7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
version: 6.0.1(vite@7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
'@vitejs/plugin-vue-jsx':
specifier: 'catalog:'
version: 5.1.1(vite@7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
version: 5.1.1(vite@7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
'@vue/test-utils':
specifier: 'catalog:'
version: 2.4.6
@@ -714,10 +714,10 @@ importers:
version: 3.6.1(sass@1.93.2)(typescript@5.9.3)(vue-tsc@2.2.10(typescript@5.9.3))(vue@3.5.22(typescript@5.9.3))
vite:
specifier: 'catalog:'
version: 7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
version: 7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
vitest:
specifier: 'catalog:'
version: 3.2.4(@types/node@22.18.12)(happy-dom@17.6.3)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
version: 3.2.4(@types/node@22.18.12)(happy-dom@17.6.3)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
vue:
specifier: ^3.5.17
version: 3.5.22(typescript@5.9.3)
@@ -797,6 +797,9 @@ importers:
'@vben/utils':
specifier: workspace:*
version: link:../../packages/utils
'@vueuse/components':
specifier: ^14.0.0
version: 14.0.0(vue@3.5.22(typescript@5.9.3))
'@vueuse/core':
specifier: 'catalog:'
version: 13.9.0(vue@3.5.22(typescript@5.9.3))
@@ -5307,6 +5310,11 @@ packages:
'@vue/test-utils@2.4.6':
resolution: {integrity: sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==}
'@vueuse/components@14.0.0':
resolution: {integrity: sha512-0PFAbAzKo+Ipt45R0OVHvZwjTj9oDZJQ/lc77d020fKl9GrxEIRvVIzMW1CZVn1vwmGhXEZPIF3erjixW2yqpg==}
peerDependencies:
vue: ^3.5.17
'@vueuse/core@10.11.1':
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
@@ -5318,6 +5326,11 @@ packages:
peerDependencies:
vue: ^3.5.17
'@vueuse/core@14.0.0':
resolution: {integrity: sha512-d6tKRWkZE8IQElX2aHBxXOMD478fHIYV+Dzm2y9Ag122ICBpNKtGICiXKOhWU3L1kKdttDD9dCMS4bGP3jhCTQ==}
peerDependencies:
vue: ^3.5.17
'@vueuse/core@9.13.0':
resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==}
@@ -5413,6 +5426,9 @@ packages:
'@vueuse/metadata@13.9.0':
resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==}
'@vueuse/metadata@14.0.0':
resolution: {integrity: sha512-6yoGqbJcMldVCevkFiHDBTB1V5Hq+G/haPlGIuaFZHpXC0HADB0EN1ryQAAceiW+ryS3niUwvdFbGiqHqBrfVA==}
'@vueuse/metadata@9.13.0':
resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==}
@@ -5432,6 +5448,11 @@ packages:
peerDependencies:
vue: ^3.5.17
'@vueuse/shared@14.0.0':
resolution: {integrity: sha512-mTCA0uczBgurRlwVaQHfG0Ja7UdGe4g9mwffiJmvLiTtp1G4AQyIjej6si/k8c8pUwTfVpNufck+23gXptPAkw==}
peerDependencies:
vue: ^3.5.17
'@vueuse/shared@9.13.0':
resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
@@ -15112,14 +15133,14 @@ snapshots:
dependencies:
vite-plugin-pwa: 1.1.0(vite@5.4.21(@types/node@24.9.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0))(workbox-build@7.3.0)(workbox-window@7.3.0)
'@vitejs/plugin-vue-jsx@5.1.1(vite@7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
'@vitejs/plugin-vue-jsx@5.1.1(vite@7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@babel/core': 7.28.4
'@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.4)
'@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.28.4)
'@rolldown/pluginutils': 1.0.0-beta.44
'@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.4)
vite: 7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
vue: 3.5.22(typescript@5.9.3)
transitivePeerDependencies:
- supports-color
@@ -15141,10 +15162,10 @@ snapshots:
vite: 5.4.21(@types/node@24.9.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)
vue: 3.5.22(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.1(vite@7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
'@vitejs/plugin-vue@6.0.1(vite@7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.29
vite: 7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
vue: 3.5.22(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.1(vite@7.1.11(@types/node@24.9.1)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
@@ -15161,13 +15182,13 @@ snapshots:
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))':
'@vitest/mocker@3.2.4(vite@7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.19
optionalDependencies:
vite: 7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
'@vitest/pretty-format@3.2.4':
dependencies:
@@ -15358,6 +15379,12 @@ snapshots:
js-beautify: 1.15.4
vue-component-type-helpers: 2.2.12
'@vueuse/components@14.0.0(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@vueuse/core': 14.0.0(vue@3.5.22(typescript@5.9.3))
'@vueuse/shared': 14.0.0(vue@3.5.22(typescript@5.9.3))
vue: 3.5.22(typescript@5.9.3)
'@vueuse/core@10.11.1(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@types/web-bluetooth': 0.0.20
@@ -15384,6 +15411,13 @@ snapshots:
'@vueuse/shared': 13.9.0(vue@3.5.22(typescript@5.9.3))
vue: 3.5.22(typescript@5.9.3)
'@vueuse/core@14.0.0(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 14.0.0
'@vueuse/shared': 14.0.0(vue@3.5.22(typescript@5.9.3))
vue: 3.5.22(typescript@5.9.3)
'@vueuse/core@9.13.0(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@types/web-bluetooth': 0.0.16
@@ -15428,6 +15462,8 @@ snapshots:
'@vueuse/metadata@13.9.0': {}
'@vueuse/metadata@14.0.0': {}
'@vueuse/metadata@9.13.0': {}
'@vueuse/motion@3.0.3(magicast@0.3.5)(vue@3.5.22(typescript@5.9.3))':
@@ -15461,6 +15497,10 @@ snapshots:
dependencies:
vue: 3.5.22(typescript@5.9.3)
'@vueuse/shared@14.0.0(vue@3.5.22(typescript@5.9.3))':
dependencies:
vue: 3.5.22(typescript@5.9.3)
'@vueuse/shared@9.13.0(vue@3.5.22(typescript@5.9.3))':
dependencies:
vue-demi: 0.14.10(vue@3.5.22(typescript@5.9.3))
@@ -21587,13 +21627,13 @@ snapshots:
dependencies:
vite: 7.1.11(@types/node@24.9.1)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
vite-node@3.2.4(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1):
vite-node@3.2.4(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -21762,7 +21802,7 @@ snapshots:
sass: 1.93.2
terser: 5.44.0
vite@7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1):
vite@7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1):
dependencies:
esbuild: 0.25.3
fdir: 6.5.0(picomatch@4.0.3)
@@ -21773,7 +21813,7 @@ snapshots:
optionalDependencies:
'@types/node': 22.18.12
fsevents: 2.3.3
jiti: 2.6.1
jiti: 1.21.7
less: 4.4.2
sass: 1.93.2
terser: 5.44.0
@@ -21855,11 +21895,11 @@ snapshots:
- typescript
- universal-cookie
vitest@3.2.4(@types/node@22.18.12)(happy-dom@17.6.3)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1):
vitest@3.2.4(@types/node@22.18.12)(happy-dom@17.6.3)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))
'@vitest/mocker': 3.2.4(vite@7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -21877,8 +21917,8 @@ snapshots:
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
vite-node: 3.2.4(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
vite-node: 3.2.4(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.18.12
@@ -21901,7 +21941,7 @@ snapshots:
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))
'@vitest/mocker': 3.2.4(vite@7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4