瀏覽代碼

预约记录模块

huangxw 5 月之前
父節點
當前提交
81c1e1d6d9

+ 51 - 49
src/layout/components/Sidebar/SidebarItem.vue

@@ -16,7 +16,9 @@
         <el-sub-menu v-else ref="subMenu" :index="resolvePath(item.path)" teleported>
             <template v-if="item.meta" #title>
                 <svg-icon :icon-class="item.meta ? item.meta.icon : ''" />
-                <span class="menu-title" :title="hasTitle(item.meta?.title)">{{ item.meta?.title }}</span>
+                <el-badge :value="menuCounts[resolvePath(item.path)]" :offset="[10, 10]" :show-zero="false">
+                    <span class="menu-title" :title="hasTitle(item.meta?.title)">{{ item.meta?.title }}</span>
+                </el-badge>
             </template>
             <sidebar-item v-for="(child, index) in item.children" :key="child.path + index" :is-nest="true" :item="child" :base-path="resolvePath(child.path)" class="nest-menu" />
         </el-sub-menu>
@@ -31,72 +33,72 @@ import { RouteRecordRaw } from 'vue-router';
 import { useSideNumStore } from '@/store/modules/sideNum';
 const { menuCounts } = toRefs(useSideNumStore());
 const props = defineProps({
-  item: {
-    type: Object as PropType<RouteRecordRaw>,
-    required: true
-  },
-  isNest: {
-    type: Boolean,
-    default: false
-  },
-  basePath: {
-    type: String,
-    default: ''
-  }
+    item: {
+        type: Object as PropType<RouteRecordRaw>,
+        required: true
+    },
+    isNest: {
+        type: Boolean,
+        default: false
+    },
+    basePath: {
+        type: String,
+        default: ''
+    }
 });
 
 const onlyOneChild = ref<any>({});
 
 const hasOneShowingChild = (parent: RouteRecordRaw, children?: RouteRecordRaw[]) => {
-  if (!children) {
-    children = [];
-  }
-  const showingChildren = children.filter((item) => {
-    if (item.hidden) {
-      return false;
+    if (!children) {
+        children = [];
     }
-    onlyOneChild.value = item;
-    return true;
-  });
+    const showingChildren = children.filter((item) => {
+        if (item.hidden) {
+            return false;
+        }
+        onlyOneChild.value = item;
+        return true;
+    });
 
-  // When there is only one child router, the child router is displayed by default
-  if (showingChildren.length === 1) {
-    return true;
-  }
+    // When there is only one child router, the child router is displayed by default
+    if (showingChildren.length === 1) {
+        return true;
+    }
 
-  // Show parent if there are no child router to display
-  if (showingChildren.length === 0) {
-    onlyOneChild.value = { ...parent, path: '', noShowingChildren: true };
-    return true;
-  }
+    // Show parent if there are no child router to display
+    if (showingChildren.length === 0) {
+        onlyOneChild.value = { ...parent, path: '', noShowingChildren: true };
+        return true;
+    }
 
-  return false;
+    return false;
 };
 
 const resolvePath = (routePath: string, routeQuery?: string): any => {
-  if (isExternal(routePath)) {
-    return routePath;
-  }
-  if (isExternal(props.basePath as string)) {
-    return props.basePath;
-  }
-  if (routeQuery) {
-    let query = JSON.parse(routeQuery);
-    return { path: getNormalPath(props.basePath + '/' + routePath), query: query };
-  }
-  return getNormalPath(props.basePath + '/' + routePath);
+    if (isExternal(routePath)) {
+        return routePath;
+    }
+    if (isExternal(props.basePath as string)) {
+        return props.basePath;
+    }
+    if (routeQuery) {
+        let query = JSON.parse(routeQuery);
+        return { path: getNormalPath(props.basePath + '/' + routePath), query: query };
+    }
+    return getNormalPath(props.basePath + '/' + routePath);
 };
 
 const hasTitle = (title: string | undefined): string => {
-  if (!title || title.length <= 5) {
-    return '';
-  }
-  return title;
+    if (!title || title.length <= 5) {
+        return '';
+    }
+    return title;
 };
 </script>
 <style lang="scss" scoped>
 .el-menu-item.is-active {
-  background: var(--el-color-primary) !important;
-  color: #fff !important;
+    background: var(--el-color-primary) !important;
+    color: #fff !important;
 }
 </style>

+ 8 - 6
src/layout/components/Sidebar/index.vue

@@ -3,8 +3,11 @@
         <logo v-if="showLogo" :collapse="isCollapse" />
         <el-scrollbar :class="sideTheme" wrap-class="scrollbar-wrapper">
             <transition :enter-active-class="proxy?.animate.menuSearchAnimate.enter" mode="out-in">
-                <el-menu :default-active="activeMenu" :collapse="isCollapse" :background-color="bgColor" :text-color="textColor" :unique-opened="true" :active-text-color="theme" :collapse-transition="false" mode="vertical">
-                    <sidebar-item v-for="(r, index) in sidebarRouters" :key="r.path + index" :item="r" :base-path="r.path" />
+                <el-menu :default-active="activeMenu" :collapse="isCollapse" :background-color="bgColor"
+                    :text-color="textColor" :unique-opened="true" :active-text-color="theme"
+                    :collapse-transition="false" mode="vertical">
+                    <sidebar-item v-for="(r, index) in sidebarRouters" :key="r.path + index" :item="r"
+                        :base-path="r.path" />
                 </el-menu>
             </transition>
         </el-scrollbar>
@@ -58,11 +61,10 @@ onMounted(() => {
         '/appointment-record/experience': {
             apiUrl: '/dgtmedicine/bookingInfo/getUnconfirmedCount',
         },
-        '/appointment-record/appointment-record': {
-            isParent: true,
-            children: ['/appointment-record/experience']
-        },
     });
+    useSideNumStore().setMultipleParentConfigs({
+        '/appointment-record/appointment-record': ['/appointment-record/experience'],
+    })
     // 刷新侧边栏数字
     useSideNumStore().refreshAllCounts();
     console.log(useSideNumStore().menuCounts);

+ 58 - 3
src/store/modules/sideNum.ts

@@ -39,8 +39,23 @@ export const useSideNumStore = defineStore('sideNum', () => {
     // 获取所有有数量的菜单项
     const getActiveMenus = computed(() => {
         const activeMenus: Record<string, number> = {};
+        // 遍历所有已配置的菜单
         Object.keys(menuApiConfigs.value).forEach((key) => {
-            const count = getMenuCount(key); // 使用getMenuCount方法,支持父级菜单计算
+            const config = menuApiConfigs.value[key];
+            let count = 0;
+
+            if (config.isParent && config.children) {
+                // 父级菜单:使用存储的值,如果没有则计算子菜单总和
+                count =
+                    menuCounts.value[key] ||
+                    config.children.reduce((total, childPath) => {
+                        return total + (menuCounts.value[childPath] || 0);
+                    }, 0);
+            } else {
+                // 普通菜单:直接使用存储的值
+                count = menuCounts.value[key] || 0;
+            }
+
             if (count > 0) {
                 activeMenus[key] = count;
             }
@@ -77,7 +92,12 @@ export const useSideNumStore = defineStore('sideNum', () => {
             children: children
         };
     };
-
+    // 批量设置多个父级菜单的配置项
+    const setMultipleParentConfigs = (configs: Record<string, string[]>) => {
+        Object.keys(configs).forEach((menuPath) => {
+            setParentMenuConfig(menuPath, configs[menuPath]);
+        });
+    };
     // 批量设置多个菜单的接口配置
     const setMultipleApiConfigs = (configs: Record<string, MenuApiConfig>) => {
         Object.keys(configs).forEach((menuPath) => {
@@ -88,11 +108,33 @@ export const useSideNumStore = defineStore('sideNum', () => {
     // 更新指定菜单的气泡数量
     const updateMenuCount = (menuPath: string, count: number) => {
         menuCounts.value[menuPath] = Math.max(0, count); // 确保数量不为负数
+        // 自动更新包含此子菜单的父级菜单数量
+        Object.keys(menuApiConfigs.value).forEach((parentPath) => {
+            const config = menuApiConfigs.value[parentPath];
+            if (config.isParent && config.children && config.children.includes(menuPath)) {
+                // 重新计算父级菜单数量
+                const parentCount = config.children.reduce((total, childPath) => {
+                    return total + (menuCounts.value[childPath] || 0);
+                }, 0);
+                menuCounts.value[parentPath] = parentCount;
+            }
+        });
     };
 
     // 清空指定菜单的气泡数量
     const clearMenuCount = (menuPath: string) => {
         menuCounts.value[menuPath] = 0;
+        // 自动更新包含此子菜单的父级菜单数量
+        Object.keys(menuApiConfigs.value).forEach((parentPath) => {
+            const config = menuApiConfigs.value[parentPath];
+            if (config.isParent && config.children && config.children.includes(menuPath)) {
+                // 重新计算父级菜单数量
+                const parentCount = config.children.reduce((total, childPath) => {
+                    return total + (menuCounts.value[childPath] || 0);
+                }, 0);
+                menuCounts.value[parentPath] = parentCount;
+            }
+        });
     };
 
     // 清空所有菜单的气泡数量
@@ -157,6 +199,18 @@ export const useSideNumStore = defineStore('sideNum', () => {
         const nonParentMenus = Object.keys(menuApiConfigs.value).filter((menuPath) => !menuApiConfigs.value[menuPath].isParent);
         const promises = nonParentMenus.map((menuPath) => fetchMenuCount(menuPath));
         await Promise.allSettled(promises);
+        // 刷新完子菜单后,更新所有父级菜单的数量
+        Object.keys(menuApiConfigs.value).forEach((menuPath) => {
+            const config = menuApiConfigs.value[menuPath];
+            if (config.isParent && config.children) {
+                // 父级菜单数量 = 子菜单数量总和
+                const parentCount = config.children.reduce((total, childPath) => {
+                    return total + (menuCounts.value[childPath] || 0);
+                }, 0);
+                // 将计算的数量存储到menuCounts中
+                menuCounts.value[menuPath] = parentCount;
+            }
+        });
     };
 
     // 重置Store状态
@@ -185,6 +239,7 @@ export const useSideNumStore = defineStore('sideNum', () => {
         makeApiRequest,
         refreshMenuCount,
         refreshAllCounts,
-        resetStore
+        resetStore,
+        setMultipleParentConfigs
     };
 });

+ 203 - 0
src/utils/httpRequests.ts

@@ -0,0 +1,203 @@
+import request from '@/utils/request';
+import type { AxiosRequestConfig, AxiosResponse } from 'axios';
+
+// 定义通用的响应接口
+export interface ApiResponse<T = any> {
+    code: number;
+    data: T;
+    msg?: string;
+    message?: string;
+}
+
+// 定义请求配置接口
+export interface RequestConfig extends AxiosRequestConfig {
+    // 是否显示loading
+    loading?: boolean;
+    // 是否显示错误信息
+    showError?: boolean;
+    // 自定义错误处理
+    errorHandler?: (error: any) => void;
+}
+
+/**
+ * GET请求
+ * @param url 请求地址
+ * @param params 请求参数
+ * @param config 请求配置
+ * @returns Promise<T>
+ */
+export const get = <T = any>(url: string, params?: Record<string, any>, config?: RequestConfig): Promise<ApiResponse<T>> => {
+    return request.get(url, {
+        params,
+        ...config
+    });
+};
+
+/**
+ * POST请求
+ * @param url 请求地址
+ * @param data 请求数据
+ * @param config 请求配置
+ * @returns Promise<T>
+ */
+export const post = <T = any>(url: string, data?: any, config?: RequestConfig): Promise<ApiResponse<T>> => {
+    return request.post(url, data, config);
+};
+
+/**
+ * PUT请求
+ * @param url 请求地址
+ * @param data 请求数据
+ * @param config 请求配置
+ * @returns Promise<T>
+ */
+export const put = <T = any>(url: string, data?: any, config?: RequestConfig): Promise<ApiResponse<T>> => {
+    return request.put(url, data, config);
+};
+
+/**
+ * DELETE请求
+ * @param url 请求地址
+ * @param config 请求配置
+ * @returns Promise<T>
+ */
+export const del = <T = any>(url: string, config?: RequestConfig): Promise<ApiResponse<T>> => {
+    return request.delete(url, config);
+};
+
+/**
+ * PATCH请求
+ * @param url 请求地址
+ * @param data 请求数据
+ * @param config 请求配置
+ * @returns Promise<T>
+ */
+export const patch = <T = any>(url: string, data?: any, config?: RequestConfig): Promise<ApiResponse<T>> => {
+    return request.patch(url, data, config);
+};
+
+/**
+ * 上传文件
+ * @param url 请求地址
+ * @param formData 表单数据
+ * @param config 请求配置
+ * @returns Promise<T>
+ */
+export const upload = <T = any>(url: string, formData: FormData, config?: RequestConfig): Promise<ApiResponse<T>> => {
+    return request.post(url, formData, {
+        headers: {
+            'Content-Type': 'multipart/form-data'
+        },
+        ...config
+    });
+};
+
+/**
+ * 下载文件
+ * @param url 请求地址
+ * @param params 请求参数
+ * @param filename 文件名
+ * @param config 请求配置
+ * @returns Promise<Blob>
+ */
+export const download = (url: string, params?: Record<string, any>, filename?: string, config?: RequestConfig): Promise<Blob> => {
+    return request
+        .get(url, {
+            params,
+            responseType: 'blob',
+            ...config
+        })
+        .then((response: any) => {
+            // 创建下载链接
+            const blob = new Blob([response]);
+            const downloadUrl = window.URL.createObjectURL(blob);
+            const link = document.createElement('a');
+            link.href = downloadUrl;
+            link.download = filename || 'download';
+            document.body.appendChild(link);
+            link.click();
+            document.body.removeChild(link);
+            window.URL.revokeObjectURL(downloadUrl);
+            return blob;
+        });
+};
+
+/**
+ * 请求拦截器辅助方法
+ * @param config 请求配置
+ * @returns 处理后的配置
+ */
+export const requestInterceptor = (config: RequestConfig) => {
+    // 可以在这里添加通用的请求处理逻辑
+    // 比如添加loading、token等
+    return config;
+};
+
+/**
+ * 响应拦截器辅助方法
+ * @param response 响应数据
+ * @returns 处理后的响应
+ */
+export const responseInterceptor = <T = any>(response: AxiosResponse<ApiResponse<T>>) => {
+    // 可以在这里添加通用的响应处理逻辑
+    // 比如统一的错误处理、数据格式化等
+    return response.data;
+};
+
+/**
+ * 并发请求
+ * @param requests 请求数组
+ * @returns Promise<T[]>
+ */
+export const concurrent = <T = any>(requests: Array<Promise<any>>): Promise<T[]> => {
+    return Promise.allSettled(requests).then((results) => {
+        return results
+            .map((result) => {
+                if (result.status === 'fulfilled') {
+                    return result.value;
+                } else {
+                    console.error('Request failed:', result.reason);
+                    return null;
+                }
+            })
+            .filter(Boolean);
+    });
+};
+
+/**
+ * 重试请求
+ * @param requestFn 请求函数
+ * @param maxRetries 最大重试次数
+ * @param delay 重试延迟(毫秒)
+ * @returns Promise<T>
+ */
+export const retry = <T = any>(requestFn: () => Promise<T>, maxRetries: number = 3, delay: number = 1000): Promise<T> => {
+    return requestFn().catch((error) => {
+        if (maxRetries > 0) {
+            return new Promise((resolve) => {
+                setTimeout(() => {
+                    resolve(retry(requestFn, maxRetries - 1, delay));
+                }, delay);
+            });
+        } else {
+            throw error;
+        }
+    });
+};
+
+// 导出默认的request实例,以便直接使用
+export { request as default };
+
+// 导出所有方法
+export const httpRequests = {
+    get,
+    post,
+    put,
+    delete: del,
+    patch,
+    upload,
+    download,
+    concurrent,
+    retry,
+    request
+};

+ 83 - 62
src/views/appointment-record/experience/index.vue

@@ -6,11 +6,13 @@
                 <div class="d-flex">
                     <div class="flex1 ov-hd d-flex j-ed">
                         <el-form :model="queryParams" ref="queryFormRef" :inline="true" label-width="auto">
-                            <el-form-item label="账号" prop="newsTitle">
-                                <el-input v-model="queryParams.newsTitle" placeholder="请输入账号" clearable style="width: 180px" @keyup.enter="handleQuery" />
+                            <el-form-item label="账号" prop="phone">
+                                <el-input v-model="queryParams.phone" placeholder="请输入账号" clearable style="width: 180px"
+                                    @keyup.enter="handleQuery" />
                             </el-form-item>
-                            <el-form-item label="企业" prop="newsTitle">
-                                <el-input v-model="queryParams.newsTitle" placeholder="请输入账号" clearable style="width: 180px" @keyup.enter="handleQuery" />
+                            <el-form-item label="企业" prop="cpyName">
+                                <el-input v-model="queryParams.cpyName" placeholder="搜企业" clearable style="width: 180px"
+                                    @keyup.enter="handleQuery" />
                             </el-form-item>
                             <el-form-item>
                                 <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
@@ -22,32 +24,41 @@
             </div>
 
             <div class="d-flex flex1 ov-hd flex-cln pd-16">
-                <div class="d-flex j-sb mb-16">
-                    <div>
-                        <searchTabs v-model="queryParams.newsType" @change="handleQuery" :list="dm_dynamics_type" key-label="label" key-value="value" :isNum="false"></searchTabs>
-                    </div>
-                    <el-button type="primary" @click="router.push({ path: 'dyn-input', query: { newsType: queryParams.newsType } })">新增{{ selectDictLabel(dm_dynamics_type, queryParams.newsType)}}</el-button>
-                </div>
                 <div class="flex1 ov-hd">
                     <vxe-table :loading="loading" border :data="dataList" min-height="0" max-height="100%">
                         <vxe-column title="序号" align="center" type="seq" width="60" />
-                        <vxe-column title="姓名" align="center" field="newsTitle" :formatter="colNoData" width="400" />
+                        <vxe-column title="姓名" field="userName" :formatter="colNoData" />
+                        <vxe-column title="账户(手机号)" align="center" field="phone" width="140" :formatter="colNoData" />
+                        <vxe-column title="企业" field="cpyName" :formatter="colNoData" />
+                        <vxe-column title="备注" field="remark" :formatter="colNoData" />
+                        <vxe-column title="联系标记" width="180">
+                            <template #default="{ row }">
+                                <span v-if="row.contactStatus === '1'">已联系</span>
+                                <template v-else>
+                                    <el-button type="success" text @click="confirmContact(row)">确认联系</el-button>
+                                </template>
+                            </template>
+                        </vxe-column>
+                        <vxe-column title="操作" field="bookingTime" width="180">
+                            <template #default="{ row }">
+                                <el-button text type="primary" @click="updateRemark(row)">更新备注</el-button>
+                            </template>
+                        </vxe-column>
                     </vxe-table>
                 </div>
-                <pagination :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
+                <pagination :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize"
+                    @pagination="getList" />
             </div>
         </div>
     </div>
 </template>
 
-<script setup name="dyn-list" lang="ts">
+<script setup name="experience-list" lang="ts">
 import { colNoData } from '@/utils/noData';
-import { publishNews as publishNewsApi, unpublishNews as unpublishNewsApi, removeNews, fetchNewsList } from '@/api/dgtmedicine/news';
-import { DateRange } from '@/views/models/index';
-import { searchTabs } from '@/views/models';
+import { httpRequests } from '@/utils/httpRequests';
+
 const router = useRouter();
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
-const { news_status, dm_dynamics_type } = toRefs<any>(proxy?.useDict('news_status', 'dm_dynamics_type'));
 const loading = ref(true);
 const showSearch = ref(true);
 const total = ref(0);
@@ -59,22 +70,23 @@ const data = reactive<any>({
     queryParams: {
         pageNum: 1,
         pageSize: 10,
-        type: '1',
-        newsTitle: '',
-        newsStatus: '',
-        newsType: '10'
+        phone: '',
+        cpyName: ''
     },
     rules: {}
 });
 
 const { queryParams, form } = toRefs(data);
-/** 查询会员信息列表 */
+/** 查询预约体验记录列表 */
 const getList = async () => {
     loading.value = true;
-    const res = await fetchNewsList(queryParams.value);
-    dataList.value = res.rows;
-    total.value = res.total;
-    loading.value = false;
+    const res: any = await httpRequests.get('/dgtmedicine/bookingInfo/list', queryParams.value).finally(() => {
+        loading.value = false;
+    });
+    console.log(res);
+    if (!res || res.code !== 200) return;
+    dataList.value = res.rows || [];
+    total.value = res.total || 0;
 };
 
 /** 搜索按钮操作 */
@@ -89,51 +101,60 @@ const resetQuery = () => {
     handleQuery();
 };
 
-/** 编辑新闻 */
-const editNews = (row) => {
-    router.push({ path: `dyn-input`, query: { id: row.id, newsType: row?.newsType, newsStatus: row.newsStatus } });
-};
-
-
-/** 删除新闻 */
-const deleteNews = async (row) => {
-    ElMessageBox.confirm(`确认要删除 "${row.newsTitle}" 数据吗?`, '删除提示', {
+/** 确认联系 */
+const confirmContact = async (row: any) => {
+    ElMessageBox.confirm(`确认已联系 "${row.userName || '-'}" 吗?`, '确认联系', {
         confirmButtonText: '确认',
         cancelButtonText: '取消',
-        type: 'warning'
+        type: 'info'
     }).then(async () => {
-        const res = await removeNews([row.id]);
-        if (res) {
-            ElMessage.success('删除成功');
-            getList();
+        try {
+            const res = await httpRequests.post('/dgtmedicine/bookingInfo/edit', { id: row.id, contactStatus: '1' });
+            if (res && res.code === 200) {
+                ElMessage.success('确认联系成功');
+                getList();
+            } else {
+                ElMessage.error(res?.msg || '确认联系失败');
+            }
+        } catch (error) {
+            ElMessage.error('确认联系失败');
         }
     });
 };
 
-/** 上架新闻 */
-const publishNews = async (row) => {
-    try {
-        const res = await publishNewsApi(row.id); // Replace with your API call
-        if (res) {
-            ElMessage.success('上架成功');
-            getList();
+/** 更新备注 */
+const updateRemark = (row: any) => {
+    ElMessageBox.prompt('请输入备注内容', '更新备注', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        inputPattern: /.*/,
+        inputValue: row.remark || '',
+        inputType: 'textarea',
+        inputPlaceholder: '请输入备注内容',
+        inputValidator: (value) => {
+            if (value && value.length > 200) {
+                return '备注内容不能超过200个字符';
+            }
+            return true;
         }
-    } catch (error) {
-        ElMessage.error('下架失败');
-    }
-};
-
-/** 下架新闻 */
-const unpublishNews = async (row) => {
-    try {
-        const res = await unpublishNewsApi(row.id); // Replace with your API call
-        if (res) {
-            ElMessage.success('下架成功');
-            getList();
+    }).then(async ({ value }) => {
+        try {
+            const res = await httpRequests.post('/dgtmedicine/bookingInfo/edit', {
+                id: row.id,
+                remark: value || ''
+            });
+            if (res && res.code === 200) {
+                ElMessage.success('备注更新成功');
+                getList();
+            } else {
+                ElMessage.error(res?.msg || '备注更新失败');
+            }
+        } catch (error) {
+            ElMessage.error('备注更新失败');
         }
-    } catch (error) {
-        ElMessage.error('下架失败');
-    }
+    }).catch(() => {
+        // 用户取消操作
+    });
 };
 
 onMounted(() => {