Browse Source

feat(dict): 新增字典管理功能及相关组件

- 添加字典选择器(DictSelect)、字典标签(DictTag)和表格侧边栏布局(TableSiderLayout)组件
- 实现字典数据的获取、转换及存储逻辑,支持国际化
- 在.env.test中启用指定后端服务地址并禁用测试本地服务器地址
- 扩展button-icon.vue组件功能,增加popconfirm确认框与本地图标支持
- 优化ZTable组件,新增加载状态计算属性及标题渲染函数支持
- 完善类型定义文件,添加系统字典相关接口和通用工具方法
- 注册字典管理页面路由并引入对应视图文件
zhangtao 2 weeks ago
parent
commit
35882744b1

+ 2 - 2
.env.test

@@ -2,11 +2,11 @@
 # VITE_SERVICE_BASE_URL=http://74949mkfh190.vicp.fun
 # VITE_SERVICE_BASE_URL=http://192.168.1.253:8114 #付
 # VITE_SERVICE_BASE_URL=http://192.168.0.157:8114 #王
-# VITE_SERVICE_BASE_URL=http://192.168.1.166:8114 #张
+VITE_SERVICE_BASE_URL=http://192.168.1.166:8114 #张
 # VITE_SERVICE_BASE_URL=https://mock.apifox.cn/m1/3109515-0-default
 # VITE_SERVICE_BASE_URL=https://shop.platform.zswlgz.com #服务器
 # VITE_SERVICE_BASE_URL=/plt #测试打包服务器
-VITE_SERVICE_BASE_URL=http://47.109.84.152:8114 #测试本地服务器
+# VITE_SERVICE_BASE_URL=http://47.109.84.152:8114 #测试本地服务器
 
 
 # other backend service base url, test environment

+ 52 - 9
src/components/custom/button-icon.vue

@@ -1,5 +1,6 @@
 <script setup lang="ts">
-import type { PopoverPlacement } from 'naive-ui';
+import { type VNode, computed, useAttrs } from 'vue';
+import type { ButtonProps, PopoverPlacement } from 'naive-ui';
 import { twMerge } from 'tailwind-merge';
 
 defineOptions({
@@ -10,36 +11,78 @@ defineOptions({
 interface Props {
   /** Button class */
   class?: string;
+  /** Show popconfirm icon */
+  showPopconfirmIcon?: boolean;
   /** Iconify icon name */
   icon?: string;
+  /** Local icon name */
+  localIcon?: string;
   /** Tooltip content */
   tooltipContent?: string;
   /** Tooltip placement */
   tooltipPlacement?: PopoverPlacement;
+  /** Popconfirm content - can be string or VNode */
+  popconfirmContent?: string | VNode;
   zIndex?: number;
+  quaternary?: boolean;
+  [key: string]: any;
 }
 
 const props = withDefaults(defineProps<Props>(), {
   class: '',
+  showPopconfirmIcon: true,
   icon: '',
+  localIcon: '',
   tooltipContent: '',
   tooltipPlacement: 'bottom',
-  zIndex: 98
+  popconfirmContent: '',
+  zIndex: 98,
+  quaternary: true
 });
 
+interface Emits {
+  (e: 'positiveClick'): void;
+}
+
+const emit = defineEmits<Emits>();
+
 const DEFAULT_CLASS = 'h-[36px] text-icon';
+
+const attrs: ButtonProps = useAttrs();
+
+const quaternary = computed(() => {
+  return !(attrs.text || attrs.dashed || attrs.ghost) && props.quaternary;
+});
+
+const handlePositiveClick = () => {
+  emit('positiveClick');
+};
 </script>
 
 <template>
   <NTooltip :placement="tooltipPlacement" :z-index="zIndex" :disabled="!tooltipContent">
     <template #trigger>
-      <NButton quaternary :class="twMerge(DEFAULT_CLASS, props.class)" v-bind="$attrs">
-        <div class="flex-center gap-8px">
-          <slot>
-            <SvgIcon :icon="icon" />
-          </slot>
-        </div>
-      </NButton>
+      <NPopconfirm :show-icon="showPopconfirmIcon" :disabled="!popconfirmContent" @positive-click="handlePositiveClick">
+        <template #default>
+          <component :is="popconfirmContent" v-if="typeof popconfirmContent !== 'string'" />
+          <template v-else>{{ popconfirmContent }}</template>
+        </template>
+        <template #trigger>
+          <NButton
+            :quaternary="quaternary"
+            :class="twMerge(DEFAULT_CLASS, props.class)"
+            :focusable="false"
+            v-bind="attrs"
+          >
+            <div class="flex-center gap-8px">
+              <slot>
+                <SvgIcon v-if="icon" :icon="icon" />
+                <SvgIcon v-else :local-icon="localIcon" />
+              </slot>
+            </div>
+          </NButton>
+        </template>
+      </NPopconfirm>
     </template>
     {{ tooltipContent }}
   </NTooltip>

+ 4 - 2
src/components/zt/Table/hooks/useTable.ts

@@ -1,4 +1,5 @@
-import { onUnmounted, ref, unref, watch } from 'vue';
+import type { ComputedRef } from 'vue';
+import { computed, onUnmounted, ref, unref, watch } from 'vue';
 import type { tableProp, ztTableProps } from '../types';
 import type { FormProps } from '../../Form/types/form';
 export function useTable(tableProps: ztTableProps): UseTableReturnType {
@@ -29,7 +30,7 @@ export function useTable(tableProps: ztTableProps): UseTableReturnType {
     refresh: async () => {
       await tableRef.value?.refresh();
     },
-
+    getTableLoding: computed(() => tableRef.value?.getTableLoding ?? false),
     setSearchProps(props) {
       tableRef.value?.setSearchProps(props);
     },
@@ -64,6 +65,7 @@ export interface TableMethods {
   getTableData: () => any[];
   getSeachForm: () => Recordable;
   setFieldsValue: (values: Recordable) => void;
+  getTableLoding: ComputedRef<boolean>;
 }
 
 export type RegisterFn = (TableInstance: TableMethods) => void;

+ 2 - 1
src/components/zt/Table/types/index.ts

@@ -1,3 +1,4 @@
+import type { VNodeChild } from 'vue';
 import type { DataTableProps } from 'naive-ui';
 import type { FormProps } from '../../Form/types/form';
 
@@ -17,7 +18,7 @@ export interface tableProp extends DataTableProps {
   /**
    * 表格标题
    */
-  title: string;
+  title: string | (() => VNodeChild);
 
   /**
    * 是否显示批量删除按钮

+ 4 - 1
src/components/zt/Table/z-table.vue

@@ -88,6 +88,8 @@ export default defineComponent({
     function getTableData() {
       return data.value;
     }
+    const getTableLoding = computed(() => loading.value);
+
     const TableMethod: TableMethods = {
       refresh: fetchSearchData,
       setSearchProps,
@@ -96,7 +98,8 @@ export default defineComponent({
       getTableCheckedRowKeys,
       getTableData,
       getSeachForm,
-      setFieldsValue
+      setFieldsValue,
+      getTableLoding
     };
     function setTableLoading(flage: boolean) {
       loading.value = flage;

+ 37 - 0
src/components/zt/dict-select/index.vue

@@ -0,0 +1,37 @@
+<script setup lang="ts">
+import { useAttrs } from 'vue';
+import type { SelectProps } from 'naive-ui';
+import { useDict } from '@/hooks/business/dict';
+
+defineOptions({ name: 'DictSelect' });
+
+interface Props {
+  dictCode: string;
+  immediate?: boolean;
+  multiple?: boolean;
+  [key: string]: any;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  immediate: false,
+  multiple: false
+});
+
+const value = defineModel<string | string[] | null>('value', { required: false });
+
+const attrs: SelectProps = useAttrs();
+const { options } = useDict(props.dictCode, props.immediate);
+</script>
+
+<template>
+  <NSelect
+    v-model:value="value"
+    :multiple="multiple"
+    :loading="!options.length"
+    :options="options"
+    :clear-filter-after-select="false"
+    v-bind="attrs"
+  />
+</template>
+
+<style scoped></style>

+ 62 - 0
src/components/zt/dict-tag/index.vue

@@ -0,0 +1,62 @@
+<script setup lang="ts">
+import { computed, useAttrs } from 'vue';
+import type { TagProps } from 'naive-ui';
+import { jsonClone } from '@sa/utils';
+import { useDict } from '@/hooks/business/dict';
+import { isNotNull } from '@/utils/common';
+import { $t } from '@/locales';
+
+defineOptions({ name: 'DictTag' });
+
+interface Props {
+  value?: string[] | number[] | string | number;
+  dictCode?: string;
+  immediate?: boolean;
+  dictData?: Api.System.DictData;
+  [key: string]: any;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  immediate: false,
+  dictData: undefined,
+  dictCode: '',
+  value: () => []
+});
+
+const attrs = useAttrs() as TagProps;
+
+const { transformDictData } = useDict(props.dictCode, props.immediate);
+
+const dictTagData = computed<Api.System.DictData[]>(() => {
+  if (props.dictData) {
+    const dictData = jsonClone(props.dictData);
+    if (dictData.dictLabel?.startsWith(`dict.${dictData.dictType}.`)) {
+      dictData.dictLabel = $t(dictData.dictLabel as App.I18n.I18nKey);
+    }
+    return [dictData];
+  }
+  // 避免 props.value 为 0 时,无法触发
+  if (props.dictCode && isNotNull(props.value)) {
+    return transformDictData(props.value) || [];
+  }
+
+  return [];
+});
+</script>
+
+<template>
+  <div v-if="dictTagData.length">
+    <NTag
+      v-for="item in dictTagData"
+      :key="item.dictValue"
+      class="m-1"
+      :class="[item.cssClass]"
+      v-bind="attrs"
+      :type="item.listClass || 'default'"
+    >
+      {{ item.dictLabel }}
+    </NTag>
+  </div>
+</template>
+
+<style scoped></style>

+ 115 - 0
src/components/zt/table-sider-layout/index.vue

@@ -0,0 +1,115 @@
+<script setup lang="ts">
+import { breakpointsTailwind, useBreakpoints } from '@vueuse/core';
+
+defineOptions({
+  name: 'TableSiderLayout'
+});
+
+interface Props {
+  defaultExpanded?: boolean;
+  siderTitle?: string;
+}
+
+withDefaults(defineProps<Props>(), {
+  defaultExpanded: false,
+  siderTitle: undefined
+});
+
+const time = new Date().getTime();
+const breakpoints = useBreakpoints(breakpointsTailwind);
+const isCollapse = breakpoints.smaller('lg');
+</script>
+
+<template>
+  <NGrid
+    v-if="isCollapse"
+    class="min-h-500px flex-col-stretch gap-16px overflow-auto"
+    :x-gap="12"
+    :y-gap="12"
+    item-responsive
+    responsive="screen"
+  >
+    <NGridItem span="24 s:24 1034:10 m:8 l:7 xl:6 xxl:5">
+      <NCard
+        :bordered="false"
+        size="small"
+        class="sider-layout-card h-full card-wrapper"
+        content-class="sider-layout-card-content"
+      >
+        <NCollapse v-if="isCollapse" :default-expanded-names="defaultExpanded ? [`table-sider-layout${time}`] : []">
+          <NCollapseItem :title="siderTitle" :name="`table-sider-layout${time}`" display-directive="show">
+            <slot name="sider" />
+            <template #header>
+              <slot name="header">
+                <span>{{ siderTitle }}</span>
+              </slot>
+            </template>
+            <template #header-extra>
+              <slot name="header-extra" />
+            </template>
+          </NCollapseItem>
+        </NCollapse>
+      </NCard>
+    </NGridItem>
+    <NGridItem class="content" span="24 s:24 m:16 l:17 xl:18 xxl:19">
+      <slot />
+    </NGridItem>
+  </NGrid>
+  <NLayout v-else has-sider>
+    <NLayoutSider
+      collapse-mode="transform"
+      :native-scrollbar="false"
+      :collapsed-width="0"
+      :width="320"
+      show-trigger="bar"
+    >
+      <NCard
+        :bordered="false"
+        size="small"
+        class="sider-layout-card h-full card-wrapper"
+        content-class="sider-layout-card-content"
+      >
+        <slot name="sider" />
+        <template #header>
+          <slot name="header">
+            <span>{{ siderTitle }}</span>
+          </slot>
+        </template>
+        <template #header-extra>
+          <slot name="header-extra" />
+        </template>
+      </NCard>
+    </NLayoutSider>
+    <NLayoutContent content-class="bg-transparent">
+      <slot />
+    </NLayoutContent>
+  </NLayout>
+</template>
+
+<style scoped lang="scss">
+.title {
+  font-weight: 500;
+  font-size: 16px;
+  transition: color 0.3s var(--n-bezier);
+  flex: 1;
+  min-width: 0;
+  color: var(--n-title-text-color);
+}
+
+.content {
+  min-height: calc(100vh - 196px - var(--calc-footer-height, 0px));
+}
+
+:deep(.n-collapse-item__header) {
+  padding-top: 0 !important;
+}
+
+:deep(.n-layout-content) {
+  background-color: transparent;
+  padding-left: 25px;
+}
+
+:deep(.n-layout-sider) {
+  background-color: transparent;
+}
+</style>

+ 2 - 1
src/enum/index.ts

@@ -3,5 +3,6 @@ export enum SetupStoreId {
   Theme = 'theme-store',
   Auth = 'auth-store',
   Route = 'route-store',
-  Tab = 'tab-store'
+  Tab = 'tab-store',
+  Dict = 'dict-store'
 }

+ 85 - 0
src/hooks/business/dict.ts

@@ -0,0 +1,85 @@
+import { ref, watch } from 'vue';
+import { storeToRefs } from 'pinia';
+import { fetchGetDictDataByType } from '@/service/api/config/dict';
+import { useDictStore } from '@/store/modules/dict';
+import { isNull } from '@/utils/common';
+import { $t } from '@/locales';
+
+export function useDict(dictType: string, immediate: boolean = true) {
+  const dictStore = useDictStore();
+  const { dictData: dictList } = storeToRefs(dictStore);
+
+  const data = ref<Api.System.DictData[]>([]);
+  const record = ref<Record<string, string>>({});
+  const options = ref<CommonType.Option[]>([]);
+
+  async function getData() {
+    const dicts = dictStore.getDict(dictType);
+    if (dicts) {
+      data.value = dicts;
+      return;
+    }
+    const { data: dictData, error } = await fetchGetDictDataByType(dictType);
+    if (error) return;
+    dictData.forEach(dict => {
+      if (dict.dictLabel?.startsWith(`dict.${dictType}.`)) {
+        dict.dictLabel = $t(dict.dictLabel as App.I18n.I18nKey);
+      }
+    });
+    dictStore.setDict(dictType, dictData);
+    data.value = dictData;
+  }
+
+  async function getRecord() {
+    if (!data.value.length) {
+      await getData();
+    }
+    data.value.forEach(dict => {
+      record.value[dict.dictValue!] = dict.dictLabel!;
+    });
+  }
+
+  async function getOptions() {
+    if (!data.value.length) {
+      await getData();
+    }
+
+    options.value = data.value.map(dict => ({ label: dict.dictLabel!, value: dict.dictValue! }));
+  }
+
+  function transformDictData(dictValue: string[] | number[] | string | number) {
+    if (!data.value.length || isNull(dictValue)) return undefined;
+    if (Array.isArray(dictValue)) {
+      return data.value.filter(dict => dictValue.some(value => dict.dictValue === value.toString()));
+    }
+    return data.value.filter(dict => dict.dictValue === dictValue.toString());
+  }
+
+  if (immediate) {
+    getData().then(() => {
+      getRecord();
+      getOptions();
+    });
+  } else {
+    watch(
+      () => dictList.value[dictType],
+      val => {
+        if (val && val.length) {
+          getRecord();
+          getOptions();
+        }
+      },
+      { immediate: true }
+    );
+  }
+
+  return {
+    data,
+    record,
+    options,
+    getData,
+    getRecord,
+    getOptions,
+    transformDictData
+  };
+}

+ 2 - 1
src/locales/langs/en-us.ts

@@ -305,7 +305,8 @@ const local: App.I18n.Schema = {
     'delivery_after-sales-order': '',
     finance: '',
     'finance_commodity-freight': '',
-    finance_summary: ''
+    finance_summary: '',
+    config_dict: ''
   },
   page: {
     login: {

+ 2 - 1
src/locales/langs/zh-cn.ts

@@ -302,7 +302,8 @@ const local: App.I18n.Schema = {
     'delivery_after-sales-order': '',
     finance: '',
     'finance_commodity-freight': '',
-    finance_summary: ''
+    finance_summary: '',
+    config_dict: ''
   },
   page: {
     login: {

+ 1 - 0
src/router/elegant/imports.ts

@@ -21,6 +21,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
   "iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
   login: () => import("@/views/_builtin/login/index.vue"),
   about: () => import("@/views/about/index.vue"),
+  config_dict: () => import("@/views/config/dict/index.vue"),
   "config_fright-config": () => import("@/views/config/fright-config/index.vue"),
   "delivery_after-sales-order": () => import("@/views/delivery/after-sales-order/index.vue"),
   "delivery_normal-order": () => import("@/views/delivery/normal-order/index.vue"),

+ 9 - 0
src/router/elegant/routes.ts

@@ -59,6 +59,15 @@ export const generatedRoutes: GeneratedRoute[] = [
       i18nKey: 'route.config'
     },
     children: [
+      {
+        name: 'config_dict',
+        path: '/config/dict',
+        component: 'view.config_dict',
+        meta: {
+          title: 'config_dict',
+          i18nKey: 'route.config_dict'
+        }
+      },
       {
         name: 'config_fright-config',
         path: '/config/fright-config',

+ 1 - 0
src/router/elegant/transform.ts

@@ -183,6 +183,7 @@ const routeMap: RouteMap = {
   "500": "/500",
   "about": "/about",
   "config": "/config",
+  "config_dict": "/config/dict",
   "config_fright-config": "/config/fright-config",
   "delivery": "/delivery",
   "delivery_after-sales-order": "/delivery/after-sales-order",

+ 93 - 0
src/service/api/config/dict/index.ts

@@ -0,0 +1,93 @@
+import { request } from '@/service/request';
+
+/** 根据字典类型查询字典数据信息 */
+export function fetchGetDictDataByType(dictType: string) {
+  return request<Api.System.DictData[]>({
+    url: `/system/dict/data/type/${dictType}`,
+    method: 'get'
+  });
+}
+
+/** 获取字典选择框列表 */
+export function fetchGetDictTypeOption() {
+  return request<Api.System.DictType[]>({
+    url: '/system/dict/type/optionselect',
+    method: 'get'
+  });
+}
+
+/** 获取字典类型列表 */
+export function fetchGetDictTypeList(params?: Api.System.DictTypeSearchParams) {
+  return request<Api.System.DictTypeList>({
+    url: '/system/dict/type/list',
+    method: 'get',
+    params
+  });
+}
+
+/** 新增字典类型 */
+export function fetchCreateDictType(data: Api.System.DictTypeOperateParams) {
+  return request<boolean>({
+    url: '/system/dict/type',
+    method: 'post',
+    data
+  });
+}
+
+/** 修改字典类型 */
+export function fetchUpdateDictType(data: Api.System.DictTypeOperateParams) {
+  return request<boolean>({
+    url: '/system/dict/type',
+    method: 'put',
+    data
+  });
+}
+
+/** 批量删除字典类型 */
+export function fetchBatchDeleteDictType(dictIds: CommonType.IdType[]) {
+  return request<boolean>({
+    url: `/system/dict/type/${dictIds.join(',')}`,
+    method: 'delete'
+  });
+}
+/** 刷新缓存 */
+export function fetchRefreshCache() {
+  return request<boolean>({
+    url: `/system/dict/type/refreshCache`,
+    method: 'delete'
+  });
+}
+/** 获取字典数据列表 */
+export function fetchGetDictDataList(params?: Api.System.DictDataSearchParams) {
+  return request<Api.System.DictDataList>({
+    url: '/system/dict/data/list',
+    method: 'get',
+    params
+  });
+}
+
+/** 新增字典数据 */
+export function fetchCreateDictData(data: Api.System.DictDataOperateParams) {
+  return request<boolean>({
+    url: '/system/dict/data',
+    method: 'post',
+    data
+  });
+}
+
+/** 修改字典数据 */
+export function fetchUpdateDictData(data: Api.System.DictDataOperateParams) {
+  return request<boolean>({
+    url: '/system/dict/data',
+    method: 'put',
+    data
+  });
+}
+
+/** 批量删除字典数据 */
+export function fetchBatchDeleteDictData(dictCodes: CommonType.IdType[]) {
+  return request<boolean>({
+    url: `/system/dict/data/${dictCodes.join(',')}`,
+    method: 'delete'
+  });
+}

+ 42 - 0
src/store/modules/dict/index.ts

@@ -0,0 +1,42 @@
+import { ref } from 'vue';
+import { defineStore } from 'pinia';
+import { $t } from '@/locales';
+import { SetupStoreId } from '@/enum';
+
+export const useDictStore = defineStore(SetupStoreId.Dict, () => {
+  const dictData = ref<{ [key: string]: Api.System.DictData[] }>({});
+
+  const getDict = (key: string) => {
+    return dictData.value[key]?.map(item => ({
+      ...item,
+      dictLabel: item.dictLabel?.startsWith(`dict.${item.dictType}.`)
+        ? $t(item.dictLabel as App.I18n.I18nKey)
+        : item.dictLabel
+    }));
+  };
+
+  const setDict = (key: string, dict: Api.System.DictData[]) => {
+    dictData.value[key] = dict;
+  };
+
+  const removeDict = (key: string) => {
+    if (key in dictData.value) {
+      // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+      delete dictData.value[key];
+    }
+  };
+
+  const cleanDict = () => {
+    dictData.value = {};
+  };
+
+  return {
+    dictData,
+    getDict,
+    setDict,
+    removeDict,
+    cleanDict
+  };
+});
+
+export default useDictStore;

+ 72 - 0
src/typings/api.d.ts

@@ -1913,4 +1913,76 @@ declare namespace Api {
       [property: string]: any;
     }
   }
+  namespace System {
+    /** 字典类型 */
+    type DictType = Common.CommonRecord<{
+      /** 字典主键 */
+      dictId: CommonType.IdType;
+      /** 字典名称 */
+      dictName: string;
+      /** 字典类型 */
+      dictType: string;
+      /** 备注 */
+      remark: string;
+    }>;
+
+    /** dict type search params */
+    type DictTypeSearchParams = CommonType.RecordNullable<
+      Pick<Api.System.DictType, 'dictName' | 'dictType'> & Api.Common.CommonSearchParams
+    >;
+
+    /** dict type operate params */
+    type DictTypeOperateParams = CommonType.RecordNullable<
+      Pick<Api.System.DictType, 'dictId' | 'dictName' | 'dictType' | 'remark'>
+    >;
+
+    /** dict type list */
+    type DictTypeList = Api.Common.PaginatingQueryRecord<DictType>;
+
+    /** 字典数据 */
+    type DictData = Common.CommonRecord<{
+      /** 样式属性(其他样式扩展) */
+      cssClass: string;
+      /** 字典编码 */
+      dictCode: CommonType.IdType;
+      /** 字典标签 */
+      dictLabel: string;
+      /** 字典排序 */
+      dictSort: number;
+      /** 字典类型 */
+      dictType: string;
+      /** 字典键值 */
+      dictValue: string;
+      /** 是否默认(Y是 N否) */
+      isDefault: Common.commonStatus;
+      /** 表格回显样式 */
+      listClass: NaiveUI.ThemeColor;
+      /** 备注 */
+      remark: string;
+    }>;
+
+    /** dict data search params */
+    type DictDataSearchParams = CommonType.RecordNullable<
+      Pick<Api.System.DictData, 'dictLabel' | 'dictType'> & Api.Common.CommonSearchParams
+    >;
+
+    /** dict data operate params */
+    type DictDataOperateParams = CommonType.RecordNullable<
+      Pick<
+        Api.System.DictData,
+        | 'dictCode'
+        | 'dictSort'
+        | 'dictLabel'
+        | 'dictValue'
+        | 'dictType'
+        | 'cssClass'
+        | 'listClass'
+        | 'isDefault'
+        | 'remark'
+      >
+    >;
+
+    /** dict data list */
+    type DictDataList = Api.Common.PaginatingQueryRecord<DictData>;
+  }
 }

+ 21 - 0
src/typings/common.d.ts

@@ -16,10 +16,31 @@ declare namespace CommonType {
    */
   type Option<K = string, M = string> = { value: K; label: M };
 
+  /** The record type */
+  type Record<K extends string | number = string> = { [key in K]: string };
+
   type YesOrNo = 'Y' | 'N';
 
   /** add null to all properties */
   type RecordNullable<T> = {
     [K in keyof T]?: T[K] | null;
   };
+
+  /** The id type */
+  type IdType = string | number;
+
+  /** The res error code */
+  type ErrorCode = '401' | '403' | '404' | 'default';
+
+  /** The configuration options for constructing tree structure data */
+  type TreeConfig = {
+    /** id field name */
+    idField: string;
+    /** parent id field name */
+    parentIdField?: string;
+    /** children field name */
+    childrenField?: string;
+    /** filter function */
+    filterFn?: (node: any) => boolean;
+  };
 }

+ 7 - 0
src/typings/components.d.ts

@@ -19,6 +19,8 @@ declare module 'vue' {
     CountTo: typeof import('./../components/custom/count-to.vue')['default']
     CustomIconSelect: typeof import('./../components/custom/custom-icon-select.vue')['default']
     DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
+    DictSelect: typeof import('./../components/zt/dict-select/index.vue')['default']
+    DictTag: typeof import('./../components/zt/dict-tag/index.vue')['default']
     ExceptionBase: typeof import('./../components/common/exception-base.vue')['default']
     FullScreen: typeof import('./../components/common/full-screen.vue')['default']
     GithubLink: typeof import('./../components/custom/github-link.vue')['default']
@@ -83,11 +85,15 @@ declare module 'vue' {
     NFormItem: typeof import('naive-ui')['NFormItem']
     NGi: typeof import('naive-ui')['NGi']
     NGrid: typeof import('naive-ui')['NGrid']
+    NGridItem: typeof import('naive-ui')['NGridItem']
     NIcon: typeof import('naive-ui')['NIcon']
     NImage: typeof import('naive-ui')['NImage']
     NInput: typeof import('naive-ui')['NInput']
     NInputGroup: typeof import('naive-ui')['NInputGroup']
     NInputNumber: typeof import('naive-ui')['NInputNumber']
+    NLayout: typeof import('naive-ui')['NLayout']
+    NLayoutContent: typeof import('naive-ui')['NLayoutContent']
+    NLayoutSider: typeof import('naive-ui')['NLayoutSider']
     NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
     NMenu: typeof import('naive-ui')['NMenu']
     NMessageProvider: typeof import('naive-ui')['NMessageProvider']
@@ -146,6 +152,7 @@ declare module 'vue' {
     SystemLogo: typeof import('./../components/common/system-logo.vue')['default']
     TableColumnSetting: typeof import('./../components/advanced/table-column-setting.vue')['default']
     TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
+    TableSiderLayout: typeof import('./../components/zt/table-sider-layout/index.vue')['default']
     ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
     WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
     WebSiteLink: typeof import('./../components/custom/web-site-link.vue')['default']

+ 2 - 0
src/typings/elegant-router.d.ts

@@ -37,6 +37,7 @@ declare module "@elegant-router/types" {
     "500": "/500";
     "about": "/about";
     "config": "/config";
+    "config_dict": "/config/dict";
     "config_fright-config": "/config/fright-config";
     "delivery": "/delivery";
     "delivery_after-sales-order": "/delivery/after-sales-order";
@@ -193,6 +194,7 @@ declare module "@elegant-router/types" {
     | "iframe-page"
     | "login"
     | "about"
+    | "config_dict"
     | "config_fright-config"
     | "delivery_after-sales-order"
     | "delivery_normal-order"

+ 16 - 1
src/utils/common.ts

@@ -68,11 +68,18 @@ export function commonExport(url: string, params: any, filename: string) {
     window.$message?.error('文件未命名');
     return false;
   }
+  const newParams = { ...params };
+  if (newParams.createTime) {
+    newParams.startTime = newParams.createTime[0];
+    newParams.endTime = newParams.createTime[1];
+    delete newParams.createTime;
+  }
+
   return new Promise((resolve, reject) => {
     request({
       url,
       method: 'get',
-      params,
+      params: newParams,
       responseType: 'blob'
     })
       .then(res => {
@@ -99,3 +106,11 @@ export function commonExport(url: string, params: any, filename: string) {
       });
   });
 }
+/** 判断是否为空 */
+export function isNull(value: any) {
+  return value === undefined || value === null || value === '';
+}
+/** 判断是否为空 */
+export function isNotNull(value: any) {
+  return value !== undefined && value !== null && value !== '';
+}

+ 185 - 0
src/views/config/dict/dict.data.ts

@@ -0,0 +1,185 @@
+import { h } from 'vue';
+import { NSwitch, NTag } from 'naive-ui';
+import type { FormSchema } from '@/components/zt/Form/types/form';
+import DictTag from '@/components/zt/dict-tag/index.vue';
+
+export const modelTypeDict: FormSchema[] = [
+  {
+    label: '字典名称',
+    field: 'dictName',
+    component: 'NInput',
+    required: true
+  },
+  {
+    label: '字典类型',
+    field: 'dictType',
+    component: 'NInput',
+    required: true
+  },
+  {
+    label: '是否启用',
+    field: 'status',
+    component: 'NSwitch',
+    defaultValue: 0,
+    componentProps: {
+      checkedValue: 0,
+      uncheckedValue: 1
+    }
+  },
+  {
+    label: '备注',
+    field: 'remark',
+    component: 'NInput',
+    componentProps: {
+      type: 'textarea'
+    }
+  },
+  {
+    label: 'dictId',
+    field: 'dictId',
+    component: 'NInput',
+    show: false
+  }
+];
+const listClassOptions: Record<string, string>[] = [
+  { label: 'Text', value: 'text' },
+  { label: 'Default', value: 'default' },
+  { label: 'Tertiary', value: 'tertiary' },
+  { label: 'Primary', value: 'primary' },
+  { label: 'Info', value: 'info' },
+  { label: 'Success', value: 'success' },
+  { label: 'Warning', value: 'warning' },
+  { label: 'Error', value: 'error' }
+];
+
+export const modelDataDict: FormSchema[] = [
+  {
+    label: '字典类型',
+    field: 'dictCode',
+    component: 'NInput',
+    show: false
+  },
+  {
+    label: '字典类型',
+    field: 'dictType',
+    component: 'NInput',
+    componentProps: {
+      disabled: true
+    }
+  },
+  {
+    label: '字典标签',
+    field: 'dictLabel',
+    component: 'NInput',
+    required: true
+  },
+  {
+    label: '字典键值',
+    field: 'dictValue',
+    component: 'NInput',
+    required: true
+  },
+  {
+    label: '是否启用',
+    field: 'status',
+    component: 'NSwitch',
+    defaultValue: 0,
+    componentProps: {
+      checkedValue: 0,
+      uncheckedValue: 1
+    }
+  },
+  {
+    label: '字典排序',
+    field: 'dictSort',
+    component: 'NInputNumber',
+    defaultValue: 1
+  },
+  {
+    label: '标签样式',
+    field: 'listClass',
+    component: 'NSelect',
+    componentProps: {
+      options: listClassOptions,
+      renderLabel: renderTagLabel
+    }
+  },
+  {
+    label: '备注',
+    field: 'remark',
+    component: 'NInput',
+    componentProps: {
+      type: 'textarea'
+    }
+  }
+];
+export const columns: NaiveUI.TableColumn<Api.System.DictData>[] = [
+  {
+    key: 'dictLabel',
+    title: '字典标签',
+    align: 'center',
+    minWidth: 80,
+    resizable: true,
+    ellipsis: {
+      tooltip: true
+    },
+    render(row) {
+      return h(DictTag, { dictData: row });
+    }
+  },
+
+  {
+    key: 'dictValue',
+    title: '字典键值',
+    align: 'center',
+    minWidth: 80,
+    resizable: true,
+    ellipsis: {
+      tooltip: true
+    }
+  },
+  {
+    title: '字典状态',
+    key: 'status',
+    align: 'center',
+    render(rowData) {
+      return h(NSwitch, { value: Number(rowData.status), uncheckedValue: 1, checkedValue: 0 });
+    }
+  },
+  {
+    key: 'dictSort',
+    title: '字典排序',
+    align: 'center',
+    minWidth: 80,
+    resizable: true,
+    ellipsis: {
+      tooltip: true
+    }
+  },
+  {
+    key: 'remark',
+    title: '备注',
+    align: 'center',
+    minWidth: 80,
+    resizable: true,
+    ellipsis: {
+      tooltip: true
+    }
+  },
+  {
+    key: 'createTime',
+    title: '创建时间',
+    align: 'center',
+    minWidth: 80,
+    resizable: true,
+    ellipsis: {
+      tooltip: true
+    }
+  }
+];
+function renderTagLabel(option: { label: string; value: string }) {
+  if (option.value === 'text') {
+    return option.label;
+  }
+  return h(NTag, { size: 'small', type: option.value as any }, () => option.label);
+}

+ 357 - 0
src/views/config/dict/index.vue

@@ -0,0 +1,357 @@
+<script setup lang="tsx">
+import { ref } from 'vue';
+import { useRoute } from 'vue-router';
+import { NEllipsis, NTooltip, type TreeOption } from 'naive-ui';
+import {
+  fetchBatchDeleteDictData,
+  fetchBatchDeleteDictType,
+  fetchCreateDictData,
+  fetchCreateDictType,
+  fetchGetDictDataList,
+  fetchGetDictTypeOption,
+  fetchUpdateDictData,
+  fetchUpdateDictType
+} from '@/service/api/config/dict';
+import { useDict } from '@/hooks/business/dict';
+import { copyTextToClipboard } from '@/utils/zt';
+import { useTable } from '@/components/zt/Table/hooks/useTable';
+import { $t } from '@/locales';
+import ButtonIcon from '@/components/custom/button-icon.vue';
+import { useModalFrom } from '@/components/zt/ModalForm/hooks/useModalForm';
+import { columns, modelDataDict, modelTypeDict } from './dict.data';
+const [registerTable, { refresh, getTableLoding, setTableConfig, setFieldsValue, getSeachForm }] = useTable({
+  searchFormConfig: {
+    schemas: [
+      {
+        label: '字典名称',
+        field: 'dictName',
+        component: 'NInput'
+      },
+      {
+        label: '字典类型',
+        field: 'dictType',
+        component: 'NInput',
+        show: false
+      }
+    ],
+    labelWidth: 120,
+    layout: 'horizontal',
+    size: 'small',
+    gridProps: {
+      cols: '1 xl:4 s:1 l:3',
+      itemResponsive: true
+    },
+    collapsedRows: 1
+  },
+  tableConfig: {
+    keyField: 'dictCode',
+    title: '字典列表'
+  }
+});
+const [
+  registerModalForm,
+  {
+    openModal: openModalType,
+    getFieldsValue: getModalTypeFields,
+    closeModal: closeModalType,
+    setFieldsValue: setModalTypeFields
+  }
+] = useModalFrom({
+  formConfig: {
+    schemas: modelTypeDict,
+    labelWidth: 120,
+    gridProps: {
+      cols: '1'
+    }
+  },
+  modalConfig: {
+    title: '字典类型',
+    width: 800,
+    isShowHeaderText: true
+  }
+});
+const [
+  registerModalDataForm,
+  {
+    openModal: openModalData,
+    getFieldsValue: getModalDataFields,
+    closeModal: closeModalData,
+    setFieldsValue: setModalDataFields
+  }
+] = useModalFrom({
+  formConfig: {
+    schemas: modelDataDict,
+    labelWidth: 120,
+    gridProps: {
+      cols: '1'
+    }
+  },
+  modalConfig: {
+    title: '字典数据',
+    width: 800,
+    isShowHeaderText: true
+  }
+});
+useDict('sys_user_sex');
+
+const route = useRoute();
+const dictData = ref<Api.System.DictType[]>([]);
+const selectedKeys = ref<string[]>([]);
+const dictPattern = ref<string>();
+async function getTreeData() {
+  const { data: tree, error } = await fetchGetDictTypeOption();
+  if (!error) {
+    dictData.value = tree;
+    handleClickTree(route.query.dictType ? [route.query.dictType as string] : []);
+  }
+}
+getTreeData();
+async function handleClickTree(keys: string[]) {
+  console.log(keys, '点事件');
+
+  const dictType = keys.length ? keys[0] : null;
+  selectedKeys.value = keys;
+  await setFieldsValue({ dictType });
+  const dictDataType = dictData.value.find(item => item.dictType === dictType);
+
+  setTableConfig({
+    title: dictDataType
+      ? () => (
+          <NEllipsis lineClamp={2} class="flex">
+            <span>{dictDataType.dictName}</span>
+            <span class="cursor-copy" onClick={async () => await copyTextToClipboard(dictDataType.dictType)}>
+              {` (${dictDataType.dictType} )`}
+            </span>
+          </NEllipsis>
+        )
+      : '字典列表',
+    keyField: 'dictCode',
+    showAddButton: Boolean(dictDataType)
+  });
+
+  window.history.pushState(null, '', `${route.path}${dictType ? `?dictType=${dictType}` : ''}`);
+  refresh();
+}
+
+function dictFilter(pattern: string, node: TreeOption) {
+  const dictName = node.dictName as string;
+  const dictType = node.dictType as string;
+  return dictName.includes(pattern) || dictType.includes(pattern);
+}
+function renderLabel({ option }: { option: TreeOption }) {
+  return (
+    <NTooltip placement="left">
+      {{
+        trigger: () => (
+          <div class="w-200px flex gap-6px overflow-hidden text-ellipsis whitespace-nowrap">
+            <span>{option.dictName}</span>
+            <span class="text-12px text-gray-500">( {option.dictType} )</span>
+          </div>
+        ),
+        default: () => (
+          <div class="flex-col">
+            <span>
+              {option.dictName} {option.dictType}
+            </span>
+            {option.remark ? <span>( {option.remark} )</span> : null}
+            <span>{option.createTime}</span>
+          </div>
+        )
+      }}
+    </NTooltip>
+  );
+}
+
+function renderSuffix({ option }: { option: TreeOption }) {
+  return (
+    <div class="flex-center gap-12px">
+      <ButtonIcon
+        text
+        type="primary"
+        icon="material-symbols:drive-file-rename-outline-outline"
+        tooltip-content={$t('common.edit')}
+        onClick={(event: Event) => {
+          event.stopPropagation();
+          handleEditType(option as Api.System.DictType);
+        }}
+      />
+      <ButtonIcon
+        text
+        type="error"
+        icon="material-symbols:delete-outline"
+        tooltip-content={$t('common.delete')}
+        popconfirm-content={`确定删除 ${option.dictType} ?`}
+        onClick={(event: Event) => event.stopPropagation()}
+        onPositiveClick={() => handleDeleteType(option as Api.System.DictType)}
+      />
+    </div>
+  );
+}
+function handleResetTreeData() {
+  dictPattern.value = '';
+  getTreeData();
+}
+async function handleDeleteType(dictType: Api.System.DictType) {
+  const { error } = await fetchBatchDeleteDictType([dictType.dictId]);
+  if (error) return;
+  window.$message?.success($t('common.deleteSuccess'));
+  getTreeData();
+}
+function handleEditType(row: Api.System.DictType) {
+  openModalType(row);
+  setModalTypeFields(row);
+}
+async function handleSubmitDictType() {
+  const form = await getModalTypeFields();
+  if (form.dictId) {
+    await fetchUpdateDictType(form);
+  } else {
+    await fetchCreateDictType(form);
+  }
+  closeModalType();
+  getTreeData();
+}
+async function handleSubmitDictData() {
+  const form = await getModalDataFields();
+  if (form.dictCode) {
+    await fetchUpdateDictData(form);
+  } else {
+    await fetchCreateDictData(form);
+  }
+  closeModalData();
+  refresh();
+}
+function handleAddModalData() {
+  openModalData();
+  setModalDataFields(getSeachForm());
+}
+function handleEditData(row: Api.System.DictData) {
+  openModalData();
+  setModalDataFields(row);
+}
+async function handleDelete(row: Api.System.DictData) {
+  await fetchBatchDeleteDictData([row.dictCode]);
+  refresh();
+}
+</script>
+
+<template>
+  <TableSiderLayout>
+    <template #header-extra>
+      <ButtonIcon
+        size="small"
+        icon="material-symbols:add-rounded"
+        class="h-18px text-icon"
+        tooltip-content="新增"
+        @click.stop="() => openModalType()"
+      />
+      <ButtonIcon
+        size="small"
+        icon="material-symbols:refresh-rounded"
+        class="h-18px text-icon"
+        tooltip-content="刷新"
+        @click.stop="() => handleResetTreeData()"
+      />
+    </template>
+    <template #sider>
+      <NSpin class="dict-tree" :show="getTableLoding">
+        <NInput v-model:value="dictPattern" clearable :placeholder="$t('common.keywordSearch')" />
+        <NTree
+          v-model:selected-keys="selectedKeys"
+          block-node
+          show-line
+          :data="dictData"
+          :show-irrelevant-nodes="false"
+          :pattern="dictPattern"
+          :filter="dictFilter"
+          class="infinite-scroll h-full min-h-200px py-3"
+          key-field="dictType"
+          label-field="dictName"
+          virtual-scroll
+          :selectable="!getTableLoding"
+          :render-label="renderLabel"
+          :render-suffix="renderSuffix"
+          @update:selected-keys="handleClickTree"
+        >
+          <template #empty>
+            <NEmpty description="暂无数据" class="h-full min-h-200px justify-center" />
+          </template>
+        </NTree>
+      </NSpin>
+    </template>
+    <LayoutTable class="h-full">
+      <ZTable
+        :columns="columns"
+        :immediate="false"
+        :api="fetchGetDictDataList"
+        @register="registerTable"
+        @add="handleAddModalData"
+      >
+        <template #op="{ row }">
+          <ButtonIcon
+            size="small"
+            icon="material-symbols:drive-file-rename-outline-outline"
+            type="primary"
+            class="h-18px text-icon"
+            tooltip-content="编辑"
+            @click.stop="() => handleEditData(row)"
+          />
+          <ButtonIcon
+            size="small"
+            icon="material-symbols:delete-outline"
+            type="error"
+            class="h-18px text-icon"
+            tooltip-content="删除"
+            popconfirm-content="确定删除该字典吗"
+            @positive-click="handleDelete(row)"
+          />
+        </template>
+      </ZTable>
+    </LayoutTable>
+    <BasicModelForm @register-modal-form="registerModalForm" @submit-form="handleSubmitDictType"></BasicModelForm>
+    <BasicModelForm @register-modal-form="registerModalDataForm" @submit-form="handleSubmitDictData"></BasicModelForm>
+  </TableSiderLayout>
+</template>
+
+<style scoped lang="scss">
+.dict-tree {
+  .n-button {
+    --n-padding: 8px !important;
+  }
+
+  :deep(.n-tree__empty) {
+    height: 100%;
+    justify-content: center;
+  }
+
+  :deep(.n-spin-content) {
+    height: 100%;
+  }
+
+  :deep(.infinite-scroll) {
+    height: calc(100vh - 228px - var(--calc-footer-height, 0px)) !important;
+    max-height: calc(100vh - 228px - var(--calc-footer-height, 0px)) !important;
+  }
+
+  @media screen and (max-width: 1024px) {
+    :deep(.infinite-scroll) {
+      height: calc(100vh - 227px - var(--calc-footer-height, 0px)) !important;
+      max-height: calc(100vh - 227px - var(--calc-footer-height, 0px)) !important;
+    }
+  }
+
+  :deep(.n-tree-node) {
+    height: 30px;
+  }
+
+  :deep(.n-tree-node-switcher) {
+    height: 30px;
+  }
+
+  :deep(.n-tree-node-switcher__icon) {
+    font-size: 16px !important;
+    height: 16px !important;
+    width: 16px !important;
+  }
+}
+</style>

+ 4 - 1
src/views/delivery/after-sales-order/index.vue

@@ -260,7 +260,10 @@ async function getNums() {
   const form = getFieldsValue();
   const params = {
     ...form,
-    channelIdList: channelIdList.value
+    channelIdList: channelIdList.value,
+    startTime: form.createTime ? form.createTime[0] : null,
+    endTime: form.createTime ? form.createTime[1] : null,
+    createTime: null
   };
   const { data: keyData } = await fetchGetAfterSalesStatusNum(params);
   if (!keyData) return;

+ 4 - 1
src/views/delivery/normal-order/index.vue

@@ -245,7 +245,10 @@ async function getNums() {
   const form = getFieldsValue();
   const params = {
     ...form,
-    channelIdList: channelIdList.value
+    channelIdList: channelIdList.value,
+    startTime: form.createTime ? form.createTime[0] : null,
+    endTime: form.createTime ? form.createTime[1] : null,
+    createTime: null
   };
   const { data: keyData } = await fetchGetDeliveryStatusNum(params);
   if (!keyData) return;

+ 1 - 1
src/views/finance/commodity-freight/index.vue

@@ -134,7 +134,7 @@ const [registerTable, { refresh, setTableLoading, setFieldsValue, getSeachForm,
       {
         label: '结算周期',
         component: 'NDatePicker',
-        field: 'Time',
+        field: 'createTime',
         componentProps: {
           type: 'datetimerange'
         }

+ 1 - 1
src/views/finance/summary/index.vue

@@ -85,7 +85,7 @@ const [registerTable, { refresh, setTableLoading, setFieldsValue, getSeachForm,
       {
         label: '结算周期',
         component: 'NDatePicker',
-        field: 'Time',
+        field: 'createTime',
         componentProps: {
           type: 'datetimerange'
         }