Browse Source

```
feat(upload): 新增文件上传组件及导入模板功能

- 新增 `zUpload` 和 `zUploadFile` 组件,支持图片上传、宽高比校验和 OSS 上传
- 新增 `ZImportTemplate` 导入模板组件,支持 Excel 文件上传与模板下载
- 添加统一文件上传接口 `fetchUpload`
- 扩展表单组件支持 `zUpload` 类型,并完善类型定义
- 增加商品相关接口和服务:前台类目、门店商品、标签管理等
- 路由新增商品模块及其子页面(desk-category、store-goods、tag)
- 优化表格组件,新增 `getTableCheckedRowKeys` 方法和时间字段映射配置
- 更新环境变量配置,增加 `VITE_OSS_BASE_URL` 支持
- 完善类型定义,包括 goods 模块相关接口和组件类型声明
```

zhangtao 3 weeks ago
parent
commit
ac5506bd73
42 changed files with 1486 additions and 41 deletions
  1. 2 0
      .env
  2. 2 1
      .env.test
  3. 2 2
      src/components/zt/ApiSelect/type/index.ts
  4. 4 2
      src/components/zt/Form/basic-form.vue
  5. 10 6
      src/components/zt/Form/types/form.ts
  6. 5 6
      src/components/zt/Modal/basic-modal.vue
  7. 1 3
      src/components/zt/Modal/hooks/useModal.ts
  8. 1 1
      src/components/zt/ModalForm/basic-model-form.vue
  9. 4 0
      src/components/zt/Table/hooks/useTable.ts
  10. 2 1
      src/components/zt/Table/props.ts
  11. 9 0
      src/components/zt/Table/types/index.ts
  12. 16 5
      src/components/zt/Table/z-table.vue
  13. 78 0
      src/components/zt/importTemplate/z-import-template.vue
  14. 18 3
      src/components/zt/upload/index.ts
  15. 15 0
      src/components/zt/upload/props.ts
  16. 86 2
      src/components/zt/upload/z-upload.vue
  17. 5 0
      src/components/zt/uploadFile/index.ts
  18. 18 0
      src/components/zt/uploadFile/z-upload-file.vue
  19. 1 1
      src/hooks/common/table.ts
  20. 5 1
      src/locales/langs/en-us.ts
  21. 5 1
      src/locales/langs/zh-cn.ts
  22. 3 0
      src/router/elegant/imports.ts
  23. 38 0
      src/router/elegant/routes.ts
  24. 4 0
      src/router/elegant/transform.ts
  25. 20 0
      src/service/api/common.ts
  26. 46 0
      src/service/api/goods/desk-category/index.ts
  27. 53 0
      src/service/api/goods/store-goods/index.ts
  28. 62 0
      src/service/api/goods/tag/index.ts
  29. 0 1
      src/service/request/index.ts
  30. 280 0
      src/typings/api.d.ts
  31. 1 0
      src/typings/api/system-manage.d.ts
  32. 14 0
      src/typings/components.d.ts
  33. 8 0
      src/typings/elegant-router.d.ts
  34. 2 0
      src/typings/vite-env.d.ts
  35. 0 1
      src/utils/service.ts
  36. 17 0
      src/utils/zt/index.ts
  37. 229 0
      src/views/goods/desk-category/index.vue
  38. 294 0
      src/views/goods/store-goods/index.vue
  39. 105 0
      src/views/goods/tag/index.vue
  40. 1 1
      src/views/manage/department/index.vue
  41. 2 2
      src/views/manage/menu/index.vue
  42. 18 1
      src/views/manage/user/index.vue

+ 2 - 0
.env

@@ -59,3 +59,5 @@ VITE_PROXY_LOG=Y
 # used to control whether to launch editor
 # by the way, this plugin is only available in dev mode, not in build mode
 VITE_DEVTOOLS_LAUNCH_EDITOR=code
+
+VITE_OSS_BASE_URL =  https://zswl-shop.oss-cn-chengdu.aliyuncs.com/

+ 2 - 1
.env.test

@@ -1,5 +1,6 @@
 # backend service base url, test environment
-VITE_SERVICE_BASE_URL=http://74949mkfh190.vicp.fun
+# VITE_SERVICE_BASE_URL=http://74949mkfh190.vicp.fun
+VITE_SERVICE_BASE_URL=http://192.168.1.206:8114
 # VITE_SERVICE_BASE_URL=https://mock.apifox.cn/m1/3109515-0-default
 
 

+ 2 - 2
src/components/zt/ApiSelect/type/index.ts

@@ -9,11 +9,11 @@ export interface ApiSelectProps extends /* @vue-ignore */ SelectProps {
   /**
    * 绑定的展示字段
    */
-  labelFeild: string;
+  labelFeild?: string;
   /**
    * 绑定的值字段
    */
-  valueFeild: string;
+  valueFeild?: string;
   /**
    * 是否立即请求
    */

+ 4 - 2
src/components/zt/Form/basic-form.vue

@@ -47,7 +47,7 @@ export default defineComponent({
       } as ButtonProps;
     });
     function getComponentValue(schema: FormSchema) {
-      const timeArr = ['datetime', 'datetimerange'];
+      const timeArr = ['datetime', 'datetimerange', 'daterange'];
       if (schema.component == 'NDatePicker' && timeArr.includes(String(schema.componentProps?.type))) {
         return 'formatted-value';
       }
@@ -66,6 +66,7 @@ export default defineComponent({
       if (isTime && !schema.componentProps?.valueFormat) {
         newObj.valueFormat = 'yyyy-MM-dd HH:mm:ss';
       }
+
       return newObj;
     }
 
@@ -110,6 +111,7 @@ export default defineComponent({
       const schemas: FormSchema[] = unref(schemaRef) || (unref(getProps).schemas as any);
       for (const schema of schemas) {
         const { defaultValue } = schema;
+
         if (defaultValue !== undefined && defaultValue !== null) {
           schema.defaultValue = defaultValue;
         }
@@ -122,7 +124,7 @@ export default defineComponent({
         return {
           required: true,
           message: `${schema.label}不能为空`,
-          trigger: ['blur', 'input'],
+          trigger: ['blur', 'input', 'change'],
           validator: () => {
             if (
               formModel[schema.field] === undefined ||

+ 10 - 6
src/components/zt/Form/types/form.ts

@@ -46,20 +46,22 @@ import {
 } from 'naive-ui';
 import type { GridItemProps, GridProps } from 'naive-ui/lib/grid';
 import type { ButtonProps } from 'naive-ui/lib/button';
+import { zUpload } from '@/components/zt/upload';
 import { ApiSelect } from '../../ApiSelect';
 import type { ApiSelectProps } from '../../ApiSelect/type';
 import type { ApiTreeSelectProps } from '../../ApiTreeSelect/type';
 import { ApiTreeSelect } from '../../ApiTreeSelect';
+import type { zuploadProps } from '../../upload';
 // componentMap.ts
 export interface FormProps {
   model?: Recordable;
   labelWidth?: number | string;
   schemas?: FormSchema[];
-  inline: boolean;
+  inline?: boolean;
   layout?: string;
-  size: string;
-  labelPlacement: string;
-  isFull: boolean;
+  size?: string;
+  labelPlacement?: string;
+  isFull?: boolean;
   showActionButtonGroup?: boolean;
   showResetButton?: boolean;
   resetButtonOptions?: Partial<ButtonProps>;
@@ -124,7 +126,8 @@ export type FormSchema =
   | FormSchemaWithType<'NCalendar', CalendarProps>
   | FormSchemaWithType<'NDynamicTags', DynamicTagsProps>
   | FormSchemaWithType<'ApiSelect', ApiSelectProps>
-  | FormSchemaWithType<'ApiTreeSelect', ApiTreeSelectProps>;
+  | FormSchemaWithType<'ApiTreeSelect', ApiTreeSelectProps>
+  | FormSchemaWithType<'zUpload', zuploadProps>;
 
 export interface RenderCallbackParams {
   schema: FormSchema;
@@ -205,7 +208,8 @@ export const componentMap = {
   NDynamicTags,
   NMention,
   ApiSelect,
-  ApiTreeSelect
+  ApiTreeSelect,
+  zUpload
 } as const;
 
 export type ComponentMap = typeof componentMap;

+ 5 - 6
src/components/zt/Modal/basic-modal.vue

@@ -1,4 +1,3 @@
-// 修改 script 标签内相关部分如下:
 <script lang="ts">
 import { computed, defineComponent, getCurrentInstance, onMounted, ref, unref, useAttrs } from 'vue';
 import { deepMerge } from '@/utils/zt';
@@ -8,13 +7,15 @@ import type { ModalMethods, modalProps } from './types';
 export default defineComponent({
   name: 'BasicModal',
   props: { ...basicModalProps },
-  emits: ['registerModal', 'close', 'ok'],
+  emits: ['register', 'close', 'ok'],
   setup(props, { emit }) {
     const attrs = useAttrs();
     const propsRef = ref<Partial<typeof basicModalProps> | null>(null);
     const isModal = ref(false);
     const subLoading = ref(false);
     const loading = ref(false);
+    const instance = getCurrentInstance();
+
     const getProps = computed((): modalProps => {
       return { ...props, ...unref(propsRef) } as unknown as modalProps;
     });
@@ -66,11 +67,9 @@ export default defineComponent({
       setModalLoading
     };
 
-    const instance = getCurrentInstance();
-
     onMounted(() => {
       if (instance) {
-        emit('registerModal', modalMethods);
+        emit('register', modalMethods);
       }
     });
     return {
@@ -97,7 +96,7 @@ export default defineComponent({
       <div id="basic-modal-bar" class="w-full cursor-move">{{ getBindValue.title }}</div>
     </template>
     <template #default>
-      <NSpin :show="loading">
+      <NSpin :show="loading" content-style="--n-opacity-spinning:0">
         <NScrollbar class="pr-20px" :style="{ height: getBindValue.height + 'px' }">
           <slot name="default"></slot>
         </NScrollbar>

+ 1 - 3
src/components/zt/Modal/hooks/useModal.ts

@@ -1,9 +1,8 @@
-import { getCurrentInstance, ref, unref, watch } from 'vue';
+import { ref, unref, watch } from 'vue';
 import { tryOnUnmounted } from '@vueuse/core';
 import type { ModalMethods, UseModalReturnType, modalProps } from '../types';
 export function useModal(props: modalProps): UseModalReturnType {
   const modalRef = ref<Nullable<ModalMethods>>(null);
-  const currentInstance = getCurrentInstance();
 
   const getInstance = () => {
     const instance = unref(modalRef.value);
@@ -18,7 +17,6 @@ export function useModal(props: modalProps): UseModalReturnType {
       modalRef.value = null;
     });
     modalRef.value = modalInstance;
-    currentInstance?.emit('registerModal', modalInstance);
 
     watch(
       () => props,

+ 1 - 1
src/components/zt/ModalForm/basic-model-form.vue

@@ -89,7 +89,7 @@ export default defineComponent({
 </script>
 
 <template>
-  <BasicModal @register-modal="registerModal" @ok="handleSubmitForm">
+  <BasicModal @register="registerModal" @ok="handleSubmitForm">
     <BasicForm @register-form="registerForm"></BasicForm>
   </BasicModal>
 </template>

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

@@ -38,6 +38,9 @@ export function useTable(tableProps: ztTableProps): UseTableReturnType {
     },
     setTableConfig(config) {
       tableRef.value?.setTableConfig(config);
+    },
+    getTableCheckedRowKeys() {
+      return tableRef.value?.getTableCheckedRowKeys() as string[];
     }
   };
   return [register, methods];
@@ -48,6 +51,7 @@ export interface TableMethods {
   setSearchProps: (props: FormProps) => void;
   setTableLoading: (loading: boolean) => void;
   setTableConfig: (config: tableProp) => void;
+  getTableCheckedRowKeys: () => string[];
 }
 
 export type RegisterFn = (TableInstance: TableMethods) => void;

+ 2 - 1
src/components/zt/Table/props.ts

@@ -28,7 +28,8 @@ export const basicProps = {
       title: '',
       showDeleteButton: false,
       showAddButton: true,
-      keyField: 'id'
+      keyField: 'id',
+      scrollX: 1000
     }
   }
 };

+ 9 - 0
src/components/zt/Table/types/index.ts

@@ -28,6 +28,15 @@ export interface tableProp extends DataTableProps {
    *
    */
   showAddButton?: boolean;
+  /**
+   * 格式化时间字段传给后端
+   */
+
+  fieldMapToTime?: [string, [string, string], string?][];
+  /**
+   * 表格内容的横向宽度,如果列被水平固定了,则需要设定它
+   */
+  scrollX?: number;
 }
 
 export interface ztTableProps {

+ 16 - 5
src/components/zt/Table/z-table.vue

@@ -65,21 +65,25 @@ export default defineComponent({
           key: 'operate',
           title: '操作',
           align: 'center',
-          width: propsData.tableConfig.opWdith,
+          width: getTableProps.value.opWdith,
           fixed: 'right',
           render: row => <div class="flex-center gap-8px">{slots.op ? slots.op({ row }) : ''}</div>
         }
       ]
     });
-    const { checkedRowKeys } = useTableOperate(data, propsData.tableConfig.keyField, getData);
+    const { checkedRowKeys } = useTableOperate(data, getTableProps.value.keyField, getData);
     function setTableConfig(config: tableProp) {
       tableProps.value = config;
     }
+    function getTableCheckedRowKeys() {
+      return checkedRowKeys.value;
+    }
     const TableMethod: TableMethods = {
       refresh: getData,
       setSearchProps,
       setTableLoading,
-      setTableConfig
+      setTableConfig,
+      getTableCheckedRowKeys
     };
     function setTableLoading(flage: boolean) {
       loading.value = flage;
@@ -98,7 +102,14 @@ export default defineComponent({
       getData();
     }
     function handleSearch() {
-      getForm.value = getSeachForm();
+      const form = getSeachForm();
+      if (getTableProps.value.fieldMapToTime && form[getTableProps.value.fieldMapToTime[0][0] as unknown as string]) {
+        const [startTimeKey, endTimeKey] = getTableProps.value.fieldMapToTime[0][1];
+        form[startTimeKey] = form.Time[0];
+        form[endTimeKey] = form.Time[1];
+        delete form.Time;
+      }
+      getForm.value = form;
       getData();
     }
     return {
@@ -155,7 +166,7 @@ export default defineComponent({
       :data="data"
       size="small"
       :flex-height="!appStore.isMobile"
-      :scroll-x="962"
+      :scroll-x="getTableProps.scrollX"
       :loading="loading"
       remote
       :row-key="row => row[getTableProps.keyField]"

+ 78 - 0
src/components/zt/importTemplate/z-import-template.vue

@@ -0,0 +1,78 @@
+<script setup lang="ts">
+import { ref } from 'vue';
+import type { UploadFileInfo } from 'naive-ui';
+import { useModal } from '../Modal/hooks/useModal';
+import BasicModal from '../Modal/basic-modal.vue';
+const emit = defineEmits<{ (e: 'submit', file: File): void }>();
+const fileList = ref<UploadFileInfo[]>([]);
+const props = defineProps<{ url: string; modalText: string; templateText: string }>();
+const [registerModalImport, { openModal, setSubLoading, closeModal }] = useModal({
+  title: props.modalText,
+  subBtuText: '确定导入'
+});
+defineExpose({
+  openModal,
+  setSubLoading,
+  closeModal
+});
+function handleSubmitImport() {
+  setSubLoading(false);
+  if (!fileList.value.length) {
+    window.$message?.error('请上传文件');
+    return;
+  }
+  emit('submit', fileList.value[0].file as File);
+  setSubLoading(true);
+}
+function handleDownloadTemplate() {
+  if (!props.url) {
+    window.$message?.error('请传入下载模版URL');
+    return;
+  }
+  window.open(`${import.meta.env.VITE_SERVICE_BASE_URL}${props.url}`, '_blank');
+}
+</script>
+
+<template>
+  <BasicModal @register="registerModalImport" @ok="handleSubmitImport">
+    <div class="p2">
+      <NSteps vertical>
+        <NStep title="下载模版并填写">
+          <div class="text-gray">请按照模版格式填写Excel表格内容,表格数据最多支持1000条</div>
+          <NCard class="mt-3">
+            <div class="flex items-center justify-between">
+              <div class="flex items-center">
+                <icon-file-icons:microsoft-excel class="text-icon"></icon-file-icons:microsoft-excel>
+                <div class="ml3 text-gray">{{ templateText }}</div>
+              </div>
+              <NButton type="primary" @click="handleDownloadTemplate">下载模版</NButton>
+            </div>
+          </NCard>
+        </NStep>
+        <NStep title="上传文件并提交系统">
+          <div class="mb3 text-gray">提交后系统需要一段时间进行处理,请耐心等候</div>
+          <NUpload
+            v-model:file-list="fileList"
+            multiple
+            directory-dnd
+            :default-upload="false"
+            :max="1"
+            accept=".xlsx,.xls,.excel"
+          >
+            <NUploadDragger>
+              <div class="mb3">
+                <icon-icon-park-twotone:mail-download
+                  class="text-48px text-gray"
+                ></icon-icon-park-twotone:mail-download>
+              </div>
+              <NText>点击或者拖动文件到该区域来上传</NText>
+              <NP depth="3">支持.xls,.xlsx,.excel格式的文件</NP>
+            </NUploadDragger>
+          </NUpload>
+        </NStep>
+      </NSteps>
+    </div>
+  </BasicModal>
+</template>
+
+<style scoped></style>

+ 18 - 3
src/components/zt/upload/index.ts

@@ -1,4 +1,19 @@
-import type { uploadProps } from 'naive-ui';
-export interface Props {
-  uploadProps?: typeof uploadProps;
+import type { UploadProps } from 'naive-ui';
+import zUpload from './z-upload.vue';
+
+export interface zuploadProps extends /* @vue-ignore */ UploadProps {
+  tipText?: string;
+  /**
+   * 上传高度
+   */
+  aspectRatioH?: number;
+  /**
+   * 上传宽度
+   */
+  aspectRatioW?: number;
+  /**
+   * 绑定的值
+   */
+  value?: string;
 }
+export { zUpload };

+ 15 - 0
src/components/zt/upload/props.ts

@@ -0,0 +1,15 @@
+import { NUpload } from 'naive-ui';
+import type { zuploadProps } from '.';
+
+export const basicUploadPorps: zuploadProps = {
+  ...NUpload.props,
+  tipText: {
+    type: String
+  },
+  aspectRatioH: {
+    type: Number
+  },
+  aspectRatioW: {
+    type: Number
+  }
+};

+ 86 - 2
src/components/zt/upload/z-upload.vue

@@ -1,7 +1,91 @@
-<script setup lang="ts"></script>
+<script lang="ts" setup>
+import { ref, watch } from 'vue';
+import { type UploadCustomRequestOptions, type UploadFileInfo } from 'naive-ui';
+import dayjs from 'dayjs';
+import { fetchUpload } from '@/service/api/common';
+import type { zuploadProps } from '.';
+const props = defineProps<zuploadProps>();
+const emit = defineEmits<{
+  (e: 'update:value', fileList: string): void;
+}>();
+const fileListData = ref<UploadFileInfo[]>([]);
+const viewUrl = import.meta.env.VITE_OSS_BASE_URL;
+function handleBeforeUpload(data: { file: UploadFileInfo; fileList: UploadFileInfo[] }): Promise<boolean> {
+  if (props.aspectRatioW && props.aspectRatioH) {
+    return new Promise((resolve, reject) => {
+      const img = new Image();
+      img.src = URL.createObjectURL(data.file.file as File);
+      img.onload = () => {
+        const { width, height } = img;
+        const ratio = width / height;
+        const targetRatio = Number(props.aspectRatioW) / Number(props.aspectRatioH);
+        const ratioDescription = `${props.aspectRatioW}:${props.aspectRatioH}`;
+        if (Math.abs(ratio - targetRatio) > 0.01) {
+          const msg = `请上传${Number(props.aspectRatioW) > 1 ? '宽高' : ''}比为 ${ratioDescription} 的图片`;
+          window.$message?.error(msg);
+          reject(new Error(msg));
+        }
+        resolve(true);
+      };
+    });
+  }
+  // 扩展 beforeUpload 验证
+  if (typeof props.onBeforeUpload === 'function') {
+    return Promise.resolve(props.onBeforeUpload(data as any)).then(res => res ?? true);
+  }
+  return Promise.resolve(true);
+}
+async function customRequest({ file, onFinish, onError, onProgress }: UploadCustomRequestOptions) {
+  const config = {
+    onUploadProgress: ({ percent }: any) => {
+      onProgress({ percent: Math.ceil(percent) });
+    }
+  };
+  const { error, response, data: res } = await fetchUpload(file.file as File, config);
+  if (!error) {
+    file.url = viewUrl + res;
+    window.$message?.success(response.data.msg || '上传成功');
+    onFinish();
+  } else {
+    window.$message?.error(error.message);
+    onError();
+  }
+}
+function handleFileList() {
+  const fileJoin = fileListData.value.map(it => it.url).join(',');
+  emit('update:value', fileJoin);
+}
+
+watch(
+  () => props,
+  () => {
+    if (props.value) {
+      fileListData.value = props.value
+        .split(',')
+        .map(it => ({ url: it, status: 'finished', id: dayjs().valueOf() + it, name: it }));
+    }
+  },
+  { immediate: true, deep: true }
+);
+defineOptions({
+  inheritAttrs: false
+});
+</script>
 
 <template>
-  <NUpload></NUpload>
+  <div>
+    <NUpload
+      v-bind="$attrs"
+      v-model:file-list="fileListData"
+      list-type="image-card"
+      accept="image/*"
+      :custom-request="customRequest"
+      @before-upload="handleBeforeUpload"
+      @update-file-list="handleFileList"
+    ></NUpload>
+
+    <div v-if="tipText" class="mt3 text-gray">{{ tipText }}</div>
+  </div>
 </template>
 
 <style scoped></style>

+ 5 - 0
src/components/zt/uploadFile/index.ts

@@ -0,0 +1,5 @@
+import type { UploadProps } from 'naive-ui';
+
+export interface uploadFileProps extends /* @vue-ignore */ UploadProps {
+  width?: number;
+}

+ 18 - 0
src/components/zt/uploadFile/z-upload-file.vue

@@ -0,0 +1,18 @@
+<script setup lang="ts">
+import { computed } from 'vue';
+import { type uploadFileProps } from '.';
+const props = defineProps<uploadFileProps>();
+const BindValue = computed(() => {
+  return {
+    ...props
+  };
+});
+</script>
+
+<template>
+  <NUpload v-bind="BindValue">
+    <slot></slot>
+  </NUpload>
+</template>
+
+<style scoped></style>

+ 1 - 1
src/hooks/common/table.ts

@@ -233,7 +233,7 @@ export function defaultTransform<ApiData>(
   response: FlatResponseData<any, Api.Common.PaginatingQueryRecord<ApiData>>
 ): PaginationData<ApiData> {
   const { data, error } = response;
-  console.log(response, 'asdasdsa');
+  // console.log(response, 'asdasdsa');
 
   if (!error) {
     const { records, current, size, total } = data;

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

@@ -281,7 +281,11 @@ const local: App.I18n.Schema = {
     manage_config: '',
     manage_log: '',
     manage_schedule: '',
-    manage_department: ''
+    manage_department: '',
+    goods: '',
+    'goods_desk-category': '',
+    'goods_store-goods': '',
+    goods_tag: ''
   },
   page: {
     login: {

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

@@ -278,7 +278,11 @@ const local: App.I18n.Schema = {
     manage_config: '',
     manage_log: '',
     manage_schedule: '',
-    manage_department: ''
+    manage_department: '',
+    goods: '',
+    'goods_desk-category': '',
+    'goods_store-goods': '',
+    goods_tag: ''
   },
   page: {
     login: {

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

@@ -21,6 +21,9 @@ 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"),
+  "goods_desk-category": () => import("@/views/goods/desk-category/index.vue"),
+  "goods_store-goods": () => import("@/views/goods/store-goods/index.vue"),
+  goods_tag: () => import("@/views/goods/tag/index.vue"),
   home: () => import("@/views/home/index.vue"),
   manage_config: () => import("@/views/manage/config/index.vue"),
   manage_department: () => import("@/views/manage/department/index.vue"),

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

@@ -50,6 +50,44 @@ export const generatedRoutes: GeneratedRoute[] = [
       order: 10
     }
   },
+  {
+    name: 'goods',
+    path: '/goods',
+    component: 'layout.base',
+    meta: {
+      title: 'goods',
+      i18nKey: 'route.goods'
+    },
+    children: [
+      {
+        name: 'goods_desk-category',
+        path: '/goods/desk-category',
+        component: 'view.goods_desk-category',
+        meta: {
+          title: 'goods_desk-category',
+          i18nKey: 'route.goods_desk-category'
+        }
+      },
+      {
+        name: 'goods_store-goods',
+        path: '/goods/store-goods',
+        component: 'view.goods_store-goods',
+        meta: {
+          title: 'goods_store-goods',
+          i18nKey: 'route.goods_store-goods'
+        }
+      },
+      {
+        name: 'goods_tag',
+        path: '/goods/tag',
+        component: 'view.goods_tag',
+        meta: {
+          title: 'goods_tag',
+          i18nKey: 'route.goods_tag'
+        }
+      }
+    ]
+  },
   {
     name: 'home',
     path: '/home',

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

@@ -182,6 +182,10 @@ const routeMap: RouteMap = {
   "404": "/404",
   "500": "/500",
   "about": "/about",
+  "goods": "/goods",
+  "goods_desk-category": "/goods/desk-category",
+  "goods_store-goods": "/goods/store-goods",
+  "goods_tag": "/goods/tag",
   "home": "/home",
   "iframe-page": "/iframe-page/:url",
   "login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?",

+ 20 - 0
src/service/api/common.ts

@@ -0,0 +1,20 @@
+import { request } from '@/service/request';
+
+/**
+ * 统一上传文件
+ * @param data
+ * @returns
+ */
+export function fetchUpload(data: File, config?: any) {
+  return request({
+    url: '/admin/file/upload/img',
+    method: 'post',
+    data: {
+      file: data
+    },
+    headers: {
+      'Content-Type': 'multipart/form-data'
+    },
+    ...config
+  });
+}

+ 46 - 0
src/service/api/goods/desk-category/index.ts

@@ -0,0 +1,46 @@
+import { request } from '@/service/request';
+
+export function fetchGetDeskCategoryList(data: any) {
+  return request<Api.goods.ShopCategory[]>({
+    url: '/platform/shopCategory/list',
+    method: 'get',
+    params: data
+  });
+}
+
+export function fetchGategoryImport(data: File) {
+  return request({
+    url: '/platform/shopCategory/import',
+    method: 'post',
+    data: {
+      file: data
+    },
+    headers: {
+      'Content-Type': 'multipart/form-data'
+    }
+  });
+}
+
+/**
+ * 获取所有门店列表
+ * @returns
+ */
+export function fetchGetAllStoreList() {
+  return request({
+    url: '/hb/store/list',
+    method: 'get'
+  });
+}
+
+/**
+ * 修改前台类目
+ * @param data
+ * @returns
+ */
+export function fetchUpdateCategory(data: any) {
+  return request({
+    url: '/platform/shopCategory',
+    method: 'PUT',
+    data
+  });
+}

+ 53 - 0
src/service/api/goods/store-goods/index.ts

@@ -0,0 +1,53 @@
+import { request } from '@/service/request';
+
+/**
+ *
+ * @param data 导入渠道商品数据
+ * @returns
+ */
+export function fetchImportGoods(data: { file: File }) {
+  return request({
+    url: '/platform/channelProd/import',
+    method: 'post',
+    data,
+    headers: {
+      'Content-Type': 'multipart/form-data'
+    }
+  });
+}
+
+/** 分页查询渠道商品
+ *
+ * @param data
+ * @returns
+ */
+export function fetchGetGoodsList(data: any) {
+  return request<Api.goods.ShopSku[]>({
+    url: '/shop/shopProd/page',
+    method: 'get',
+    data
+  });
+}
+
+/**
+ * 设置销售渠道及价格
+ * @param data
+ * @returns
+ */
+export function fetchSetUpChannels(data: any) {
+  return request<any>({
+    url: '/platform/channelProd/setUpChannels',
+    method: 'PUT',
+    data
+  });
+}
+/**
+ * 获取全部渠道商
+ * @returns
+ */
+export function fetchGetAllChannelList() {
+  return request<Api.goods.Channel[]>({
+    url: '/platform/channel/listAll',
+    method: 'get'
+  });
+}

+ 62 - 0
src/service/api/goods/tag/index.ts

@@ -0,0 +1,62 @@
+import { request } from '@/service/request';
+
+/**
+ *
+ * @returns 获取所有标签列表
+ */
+export function fetchGetAllTagList() {
+  return request<Api.goods.tag[]>({
+    url: '/platform/label/listAll',
+    method: 'get'
+  });
+}
+
+/**
+ * 分页获取标签列表
+ * @param params
+ * @returns
+ */
+export function fetchGetTagList(params: any) {
+  return request<Api.goods.tag[]>({
+    url: '/platform/label/page',
+    method: 'get',
+    params
+  });
+}
+
+/**
+ * 添加标签
+ * @param data
+ * @returns
+ */
+export function fetchAddTag(data: any) {
+  return request({
+    url: '/platform/label',
+    method: 'post',
+    data
+  });
+}
+
+/**
+ * 编辑标签
+ */
+export function fetchEditTag(data: any) {
+  return request({
+    url: '/platform/label',
+    method: 'put',
+    data
+  });
+}
+
+/**
+ * 删除标签
+ * @param data
+ * @returns
+ */
+export function fetchDeleteTag(data: any) {
+  return request({
+    url: '/platform/label',
+    method: 'delete',
+    data
+  });
+}

+ 0 - 1
src/service/request/index.ts

@@ -9,7 +9,6 @@ import type { RequestInstanceState } from './type';
 
 const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
 const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
-
 export const request = createFlatRequest(
   {
     baseURL,

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

@@ -91,4 +91,284 @@ declare namespace Api {
       home: import('@elegant-router/types').LastLevelRouteKey;
     }
   }
+
+  namespace goods {
+    interface ShopCategory {
+      children?: ShopCategory[];
+      /**
+       * 编码
+       */
+      code?: string;
+      /**
+       * 前台类目模板id(一级时有)
+       */
+      frontCategoryId?: number;
+      /**
+       * 主键
+       */
+      id?: number;
+      /**
+       * 是否删除(0 正常 1 已被删除)
+       */
+      isDelete?: number;
+      /**
+       * 是否叶子(1-是,2-不是)
+       */
+      isLeaves?: number;
+      /**
+       * 类目等级
+       */
+      level?: number;
+      /**
+       * 类目名称
+       */
+      name?: string;
+      /**
+       * 序号
+       */
+      num?: number;
+      /**
+       * 操作人
+       */
+      operateUser?: string;
+      /**
+       * 父级分类编码
+       */
+      parentCode?: string;
+      /**
+       * 父级分类名称
+       */
+      parentName?: string;
+      /**
+       * 门店ID
+       */
+      shopId?: number;
+      [property: string]: any;
+    }
+    interface tag {
+      /**
+       * 创建日期
+       */
+      creationDate?: string;
+      /**
+       * 主键
+       */
+      id?: number;
+      /**
+       * 是否删除(0-未删除,1-已删除)
+       */
+      isDelete?: number;
+      /**
+       * 标签名称
+       */
+      name?: string;
+      /**
+       * 标签类型
+       */
+      type?: number;
+      [property: string]: any;
+    }
+    interface ChannelProd {
+      /**
+       * 渠道ID
+       */
+      channelId?: number;
+      /**
+       * 渠道对应商品售价
+       */
+      channelProdPrice?: number;
+      /**
+       * 出货价
+       */
+      deliveryPrice?: number;
+      /**
+       * 海博商品ID
+       */
+      hbSkuId?: string;
+      /**
+       * 主键
+       */
+      id?: number;
+      /**
+       * 删除状态(0-正常,1-删除)
+       */
+      isDelete?: number;
+      /**
+       * 进货价
+       */
+      purchasePrice?: number;
+      /**
+       * 记录时间
+       */
+      recTime?: string;
+      /**
+       * 门店ID
+       */
+      shopId?: number;
+      /**
+       * 门店名称
+       */
+      shopName?: string;
+      /**
+       * 单品ID
+       */
+      skuId?: number;
+      /**
+       * 修改时间
+       */
+      updateTime?: string;
+      /**
+       * 渠道名称       */
+      channelName?: string;
+      [property: string]: any;
+    }
+    interface Sku {
+      /**
+       * 实际库存
+       */
+      actualStocks?: number;
+      /**
+       * 单品编码(海博)
+       */
+      hbSkuId?: number;
+      /**
+       * 商品编码(海博)
+       */
+      hbSpuId?: number;
+      /**
+       * 0 正常 1 已被删除
+       */
+      isDelete?: number;
+      /**
+       * 商品条形码
+       */
+      modelId?: string;
+      /**
+       * 原价
+       */
+      oriPrice?: number;
+      /**
+       * 商家编码
+       */
+      partyCode?: string;
+      /**
+       * sku图片
+       */
+      pic?: string;
+      /**
+       * 价格
+       */
+      price?: number;
+      /**
+       * 商品ID
+       */
+      prodId?: number;
+      /**
+       * 商品名称
+       */
+      prodName?: string;
+      /**
+       * 销售属性组合字符串,格式是p1:v1;p2:v2
+       */
+      properties?: string;
+      /**
+       * 记录时间
+       */
+      recTime?: string;
+      skuCode?: string;
+      /**
+       * 单品ID
+       */
+      skuId?: number;
+      /**
+       * sku名称
+       */
+      skuName?: string;
+      /**
+       * 积分价格
+       */
+      skuScore?: number;
+      /**
+       * 状态:0禁用 1 启用
+       */
+      status?: number;
+      /**
+       * 库存
+       */
+      stocks?: number;
+      /**
+       * 修改时间
+       */
+      updateTime?: string;
+      version?: number;
+      /**
+       * 体积
+       */
+      volume?: number;
+      /**
+       * 重量
+       */
+      weight?: number;
+      weightUnit?: string;
+      [property: string]: any;
+    }
+    interface ShopSku {
+      /**
+       * 渠道商品
+       */
+      channelProdList?: ChannelProd[];
+      /**
+       * 门店商品表ID
+       */
+      id?: number;
+      /**
+       * 0 正常 1 已被删除
+       */
+      isDelete?: number;
+      /**
+       * 记录时间
+       */
+      recTime?: string;
+      /**
+       * 门店ID
+       */
+      shopId?: number;
+      /**
+       * 商品对应门店价格
+       */
+      shopSkuPrice?: number;
+      /**
+       * 商品对应门店库存
+       */
+      shopSkuStocks?: number;
+      /**
+       * 单品ID
+       */
+      skuId?: number;
+      /**
+       * 商品详细
+       */
+      sku: Sku;
+      /**
+       * 修改时间
+       */
+      updateTime?: string;
+      [property: string]: any;
+    }
+    interface Channel {
+      /**
+       * 渠道名称
+       */
+      channelName?: string;
+      /**
+       * 主键
+       */
+      id?: number;
+      /**
+       * 渠道类型(1-企业用户,2-B端用户,3-C端用户)
+       */
+      type?: string;
+      [property: string]: any;
+    }
+  }
 }

+ 1 - 0
src/typings/api/system-manage.d.ts

@@ -69,6 +69,7 @@ declare namespace Api {
       mobile: string;
       status: number;
       roleIdList: string;
+      depts: DepartmentModel[];
     };
     type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>;
     type RoleMenuList = {

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

@@ -29,6 +29,7 @@ declare module 'vue' {
     IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
     IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
     IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
+    'IconBxs:download': typeof import('~icons/bxs/download')['default']
     IconCarbonPlay: typeof import('~icons/carbon/play')['default']
     IconCarbonStop: typeof import('~icons/carbon/stop')['default']
     'IconCharm:download': typeof import('~icons/charm/download')['default']
@@ -41,6 +42,7 @@ declare module 'vue' {
     IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
     'IconIc:roundPlus': typeof import('~icons/ic/round-plus')['default']
     'IconIconParkOutline:equalRatio': typeof import('~icons/icon-park-outline/equal-ratio')['default']
+    'IconIconParkTwotone:mailDownload': typeof import('~icons/icon-park-twotone/mail-download')['default']
     IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
     IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
     IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
@@ -95,6 +97,7 @@ declare module 'vue' {
     NGi: typeof import('naive-ui')['NGi']
     NGrid: typeof import('naive-ui')['NGrid']
     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']
@@ -105,6 +108,7 @@ declare module 'vue' {
     NMessageProvider: typeof import('naive-ui')['NMessageProvider']
     NModal: typeof import('naive-ui')['NModal']
     NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
+    NP: typeof import('naive-ui')['NP']
     NPagination: typeof import('naive-ui')['NPagination']
     NPopconfirm: typeof import('naive-ui')['NPopconfirm']
     NPopover: typeof import('naive-ui')['NPopover']
@@ -123,10 +127,17 @@ declare module 'vue' {
     NTabPane: typeof import('naive-ui')['NTabPane']
     NTabs: typeof import('naive-ui')['NTabs']
     NTag: typeof import('naive-ui')['NTag']
+    NText: typeof import('naive-ui')['NText']
     NThing: typeof import('naive-ui')['NThing']
+    NTimeline: typeof import('naive-ui')['NTimeline']
+    NTimelineItem: typeof import('naive-ui')['NTimelineItem']
     NTooltip: typeof import('naive-ui')['NTooltip']
     NTree: typeof import('naive-ui')['NTree']
     NTreeSelect: typeof import('naive-ui')['NTreeSelect']
+    NUpload: typeof import('naive-ui')['NUpload']
+    NUploadDragger: typeof import('naive-ui')['NUploadDragger']
+    NUploadFileList: typeof import('naive-ui')['NUploadFileList']
+    NUploadTrigger: typeof import('naive-ui')['NUploadTrigger']
     NWatermark: typeof import('naive-ui')['NWatermark']
     PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
     ProCard: typeof import('pro-naive-ui')['ProCard']
@@ -152,7 +163,10 @@ declare module 'vue' {
     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']
+    ZEditTable: typeof import('./../components/zt/editTable/z-edit-table.vue')['default']
+    ZImportTemplate: typeof import('./../components/zt/importTemplate/z-import-template.vue')['default']
     ZTable: typeof import('./../components/zt/Table/z-table.vue')['default']
     ZUpload: typeof import('./../components/zt/upload/z-upload.vue')['default']
+    ZUploadFile: typeof import('./../components/zt/uploadFile/z-upload-file.vue')['default']
   }
 }

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

@@ -36,6 +36,10 @@ declare module "@elegant-router/types" {
     "404": "/404";
     "500": "/500";
     "about": "/about";
+    "goods": "/goods";
+    "goods_desk-category": "/goods/desk-category";
+    "goods_store-goods": "/goods/store-goods";
+    "goods_tag": "/goods/tag";
     "home": "/home";
     "iframe-page": "/iframe-page/:url";
     "login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?";
@@ -130,6 +134,7 @@ declare module "@elegant-router/types" {
     | "404"
     | "500"
     | "about"
+    | "goods"
     | "home"
     | "iframe-page"
     | "login"
@@ -161,6 +166,9 @@ declare module "@elegant-router/types" {
     | "iframe-page"
     | "login"
     | "about"
+    | "goods_desk-category"
+    | "goods_store-goods"
+    | "goods_tag"
     | "home"
     | "manage_config"
     | "manage_department"

+ 2 - 0
src/typings/vite-env.d.ts

@@ -10,6 +10,8 @@ declare namespace Env {
   /** Interface for import.meta */
   // eslint-disable-next-line @typescript-eslint/no-shadow
   interface ImportMeta extends ImportMetaEnv {
+    /** The environment */
+    readonly VITE_OSS_BASE_URL: string;
     /** The base url of the application */
     readonly VITE_BASE_URL: string;
     /** The title of the application */

+ 0 - 1
src/utils/service.ts

@@ -54,7 +54,6 @@ export function getServiceBaseURL(env: Env.ImportMeta, isProxy: boolean) {
   other.forEach(item => {
     otherBaseURL[item.key] = isProxy ? item.proxyPattern : item.baseURL;
   });
-
   return {
     baseURL: isProxy ? createProxyPattern() : baseURL,
     otherBaseURL

+ 17 - 0
src/utils/zt/index.ts

@@ -282,3 +282,20 @@ export function buildMenuTree(data: any) {
 
   return removeEmptyChildren(sortByOrderNum(rootNodes));
 }
+/**
+ * 判断一个对象的所有字段是否有值(非 null 和 undefined)
+ * @param obj 要检查的对象
+ * @returns 所有字段有值返回 true,否则 false
+ */
+function areAllFieldsFilled(obj: Record<string, any>): boolean {
+  return Object.values(obj).every(value => unref(value) !== null && unref(value) !== undefined);
+}
+
+/**
+ * 判断数组中每个对象是否所有字段都有值
+ * @param arr 要检查的对象数组
+ * @returns 所有对象字段都有值返回 true,否则 false
+ */
+export function areAllItemsAllFieldsFilled(arr: Array<Record<string, any>>): boolean {
+  return arr.every(item => areAllFieldsFilled(item));
+}

+ 229 - 0
src/views/goods/desk-category/index.vue

@@ -0,0 +1,229 @@
+<script setup lang="tsx">
+import { nextTick, ref } from 'vue';
+import { NButton, NImage, type UploadFileInfo } from 'naive-ui';
+import type { InternalRowData } from 'naive-ui/es/data-table/src/interface';
+import {
+  fetchGategoryImport,
+  fetchGetAllStoreList,
+  fetchGetDeskCategoryList,
+  fetchUpdateCategory
+} from '@/service/api/goods/desk-category';
+import { fetchGetAllTagList } from '@/service/api/goods/tag';
+import { useAppStore } from '@/store/modules/app';
+import { useModalFrom } from '@/components/zt/ModalForm/hooks/useModalForm';
+import { useForm } from '@/components/zt/Form/hooks/useForm';
+import type { ModalMethods } from '@/components/zt/Modal/types';
+const appStore = useAppStore();
+const deskData = ref<Api.goods.ShopCategory[]>([]);
+const loading = ref(false);
+const fileList = ref<UploadFileInfo[]>([]);
+const [registerSearchForm, { getFieldsValue: getSearchForm }] = useForm({
+  schemas: [
+    {
+      field: 'name',
+      component: 'NInput',
+      label: '类目名称'
+    },
+    {
+      label: '门店列表',
+      component: 'ApiSelect',
+      field: 'shopId',
+      componentProps: {
+        api: fetchGetAllStoreList,
+        labelFeild: 'shopName',
+        valueFeild: 'hbStationId',
+        onUpdateValue: () => {
+          nextTick(() => {
+            getData();
+          });
+        }
+      }
+    }
+  ],
+  labelWidth: 120,
+  layout: 'horizontal',
+  gridProps: {
+    cols: '1 xl:4 s:1 l:3',
+    itemResponsive: true
+  },
+  collapsedRows: 1
+});
+const importTemplateRef = ref<ModalMethods>();
+const tableColumns: NaiveUI.TableColumn<InternalRowData>[] = [
+  {
+    title: '分类名称',
+    key: 'name'
+  },
+  {
+    title: '分类图标',
+    key: 'icon',
+    render(rowData) {
+      const row = rowData as Api.goods.ShopCategory;
+      return <NImage src={row.icon} class="h-[40px] w-[40px]" />;
+    }
+  },
+  {
+    title: '关联商品',
+    key: 'icon'
+  },
+  {
+    title: '标签',
+    key: 'labelName'
+  },
+  {
+    title: '排序',
+    key: 'num'
+  },
+  {
+    title: '操作',
+    key: 'op',
+    fixed: 'right',
+    align: 'center',
+    render: row => (
+      <div class="flex-center gap-8px">
+        {row.level == 0 && (
+          <NButton type="primary" ghost size="small" onClick={() => edit(row)}>
+            编辑
+          </NButton>
+        )}
+      </div>
+    )
+  }
+];
+const [
+  registerModalForm,
+  {
+    openModal: openModalForm,
+    setFieldsValue: setModalFormValue,
+    getFieldsValue: getModalFormValue,
+    closeModal: closeModalForm
+  }
+] = useModalFrom({
+  modalConfig: {
+    title: '一级分类',
+    isShowHeaderText: true
+  },
+  formConfig: {
+    schemas: [
+      { label: '', field: 'id', show: false, component: 'NInput' },
+      { label: '分类名称', field: 'name', component: 'NInput', required: true },
+      {
+        label: '分类图标',
+        field: 'icon',
+        component: 'zUpload',
+        componentProps: {
+          tipText: '建议图片尺寸比例 1:1',
+          aspectRatioW: 1,
+          aspectRatioH: 1,
+          max: 1
+        },
+        required: true
+      },
+      {
+        label: '标签',
+        field: 'label',
+        component: 'ApiSelect',
+        componentProps: {
+          api: fetchGetAllTagList,
+          labelFeild: 'name',
+          valueFeild: 'id'
+        }
+      },
+      {
+        label: '排序',
+        component: 'NInputNumber',
+        field: 'num',
+        labelMessage: '数字越小越靠前',
+        required: true,
+        defaultValue: 1
+      }
+    ],
+    labelWidth: 120,
+    layout: 'horizontal',
+    gridProps: {
+      cols: '1',
+      itemResponsive: true
+    }
+  }
+});
+async function handleSubmit() {
+  const { error } = await fetchGategoryImport(fileList.value[0].file as File);
+  if (!error) {
+    importTemplateRef.value?.closeModal();
+  }
+  importTemplateRef.value?.setSubLoading(false);
+}
+function edit(row: Recordable) {
+  openModalForm(row);
+  setModalFormValue({ ...row });
+}
+
+async function getData() {
+  const { data, error } = await fetchGetDeskCategoryList(getSearchForm());
+  if (!error) {
+    deskData.value = data;
+  }
+}
+async function handleSubmitForm() {
+  const form = await getModalFormValue();
+  const { error } = await fetchUpdateCategory(form);
+  if (!error) {
+    closeModalForm();
+    getData();
+  }
+}
+function handleOpen() {
+  importTemplateRef.value?.openModal();
+}
+</script>
+
+<template>
+  <LayoutTable>
+    <NCard :bordered="false" size="small">
+      <NCollapse display-directive="show" :default-expanded-names="['desk-search']">
+        <NCollapseItem title="搜索" name="desk-search">
+          <BasicForm @register-form="registerSearchForm" @submit="getData" @reset="getData" />
+        </NCollapseItem>
+      </NCollapse>
+    </NCard>
+    <NCard title="前台类目" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
+      <template #header-extra>
+        <TableHeaderOperation :loading="loading" @refresh="getData">
+          <template #prefix>
+            <NButton size="small" @click="handleOpen">
+              <template #icon>
+                <icon-file-icons:microsoft-excel class="text-icon"></icon-file-icons:microsoft-excel>
+              </template>
+              Excel导入分类
+            </NButton>
+          </template>
+        </TableHeaderOperation>
+      </template>
+      <NDataTable
+        :columns="tableColumns"
+        :data="deskData"
+        size="small"
+        :flex-height="!appStore.isMobile"
+        :scroll-x="1088"
+        :loading="loading"
+        :row-key="row => row.id"
+        remote
+        class="sm:h-full"
+      />
+    </NCard>
+    <ZImportTemplate
+      ref="importTemplateRef"
+      url="/platform/shopCategory/exportTemplate"
+      template-text="店内分类导入模版.xlsx"
+      modal-text="Excel导入分类"
+      @submit="handleSubmit"
+    ></ZImportTemplate>
+    <BasicModelForm @register-modal-form="registerModalForm" @submit-form="handleSubmitForm"></BasicModelForm>
+  </LayoutTable>
+</template>
+
+<style scoped>
+:deep(.n-upload-trigger) {
+  width: 100% !important;
+}
+</style>

+ 294 - 0
src/views/goods/store-goods/index.vue

@@ -0,0 +1,294 @@
+<script setup lang="tsx">
+import { computed, ref } from 'vue';
+import { NButton, NImage, NInputNumber, NSelect } from 'naive-ui';
+import dayjs from 'dayjs';
+import { fetchGetAllStoreList } from '@/service/api/goods/desk-category';
+import { fetchGetAllChannelList, fetchGetGoodsList, fetchImportGoods } from '@/service/api/goods/store-goods';
+import { areAllItemsAllFieldsFilled } from '@/utils/zt';
+import { useTable } from '@/components/zt/Table/hooks/useTable';
+import type { ModalMethods } from '@/components/zt/Modal/types';
+import SvgIcon from '@/components/custom/svg-icon.vue';
+import { useModal } from '@/components/zt/Modal/hooks/useModal';
+type Price = { channelId: number | undefined; channelProdPrice: number; id: number };
+const importTemplateRef = ref<ModalMethods>();
+const columns: NaiveUI.TableColumn<Api.goods.ShopSku>[] = [
+  {
+    type: 'selection',
+    align: 'center',
+    width: 48,
+    fixed: 'left'
+  },
+  {
+    key: 'skuName',
+    title: '商品信息',
+    align: 'left',
+    width: 300,
+    fixed: 'left',
+    ellipsis: {
+      tooltip: true
+    },
+    render: row => {
+      if (!row.sku) {
+        return '---';
+      }
+      return (
+        <div class={'flex items-center'}>
+          <NImage src={row.sku.pic} class="h-[80px] w-[80px]" />
+          <div class={'ml-[10px]'}>
+            <div class={'text-[16px] font-semibold'}>{row.sku.skuName || '--'}</div>
+            <div class={'text-gray'}>海博商品ID: {row.sku.hbSkuId || '--'} </div>
+            <div class={'text-gray'}>商品编码:{row.sku.partyCode || '--'} </div>
+            <div class={'text-gray'}>SPUID: {row.sku.hbSpuId || '--'} </div>
+            <div class={'text-gray'}>外部商品编码:{row.sku.skuCode || '--'} </div>
+            <div class={'text-gray'}>条形码:{row.sku.modelId || '--'} </div>
+            <div class={'text-gray'}>
+              重量/规格/单位:{row.sku.weight || '--'} / {row.sku.weightUnit || '--'} / {row.sku.weightUnit || '--'}
+            </div>
+          </div>
+        </div>
+      );
+    }
+  },
+  {
+    key: 'shopName',
+    title: '门店名称',
+    align: 'center',
+    width: 120,
+    ellipsis: {
+      tooltip: true
+    },
+    render: row => {
+      const nameList = row.channelProdList?.map(it => it.shopName);
+      return <div>{nameList?.join(',')}</div>;
+    }
+  },
+  {
+    key: 'shopSkuStocks',
+    title: '线上可售库存',
+    align: 'center',
+    width: 120
+  },
+  {
+    key: 'purchasePrice',
+    title: '进货价(元)',
+    align: 'center',
+    width: 120,
+    render: row => {
+      if (row.channelProdList) return <div>{row.channelProdList[0].purchasePrice}</div>;
+      return '--';
+    }
+  },
+  {
+    key: 'deliveryPrice',
+    title: '出货价/控制价(元)',
+    align: 'center',
+    width: 150,
+    render: row => {
+      if (row.channelProdList) return <div>{row.channelProdList[0].deliveryPrice}</div>;
+      return '--';
+    }
+  },
+  {
+    key: 'name',
+    title: '销售渠道及价格',
+    align: 'center',
+    width: 150,
+    render: row => {
+      return row.channelProdList?.map(it => {
+        return (
+          <div>
+            {it.channelName} : {it.channelProdPrice}
+          </div>
+        );
+      });
+    }
+  }
+];
+const options = ref<Api.goods.Channel[]>([]);
+const PriceColumns: NaiveUI.TableColumn<Price>[] = [
+  {
+    title: '销售渠道',
+    key: 'channelId',
+    align: 'center',
+    width: 250,
+    render: row => {
+      return (
+        <NSelect
+          options={options.value}
+          labelField="channelName"
+          valueField="id"
+          value={row.channelId}
+          onUpdate:value={value => {
+            row.channelId = Number(value);
+          }}
+        ></NSelect>
+      );
+    }
+  },
+  {
+    title: '售价(元)',
+    key: 'channelProdPrice',
+    align: 'center',
+    width: 250,
+    render: row => {
+      return (
+        <NInputNumber
+          value={row.channelProdPrice}
+          precision={2}
+          onUpdate:value={value => {
+            row.channelProdPrice = Number(value);
+          }}
+          min={1}
+        />
+      );
+    }
+  },
+  {
+    title: () => {
+      return (
+        <div onClick={() => handleAddPrice()}>
+          <SvgIcon
+            icon={'proicons:add-square'}
+            class={'cursor-pointer text-24px'}
+            style={'color:var(--n-color)'}
+          ></SvgIcon>
+        </div>
+      );
+    },
+    key: 'action',
+    width: 80,
+    render: row => {
+      return (
+        <div onClick={() => handleDelPrice(row.id)}>
+          <SvgIcon
+            icon={'proicons:subtract-square'}
+            class={'cursor-pointer text-24px'}
+            style={'color:#f5222d'}
+          ></SvgIcon>
+        </div>
+      );
+    }
+  }
+];
+const PriceData = ref<Price[]>([]);
+const selectData = ref<Api.goods.ShopSku>();
+const [registerTable, { getTableCheckedRowKeys }] = useTable({
+  searchFormConfig: {
+    schemas: [
+      {
+        label: '门店名称',
+        component: 'ApiSelect',
+        field: 'shopId',
+        componentProps: {
+          api: fetchGetAllStoreList,
+          labelFeild: 'shopName',
+          valueFeild: 'hbStationId'
+        }
+      },
+      {
+        label: '商品名称',
+        component: 'NInput',
+        field: 'shopId'
+      }
+    ],
+    inline: false,
+    size: 'small',
+    labelPlacement: 'left',
+    isFull: false
+  },
+  tableConfig: {
+    keyField: 'id',
+    title: '商品列表',
+    showAddButton: false,
+    scrollX: 1200
+  }
+});
+
+const [registerModalPrice, { openModal: openPriceModal, setSubLoading: setSubModalLoding }] = useModal({
+  title: '设置渠道及价格',
+  width: 800,
+  height: 300
+});
+
+const isDisabledExport = computed(() => {
+  return !getTableCheckedRowKeys().length;
+});
+
+async function handleSubmitImport(file: File) {
+  const { error } = await fetchImportGoods({ file });
+  if (!error) {
+    importTemplateRef.value?.closeModal();
+  }
+  importTemplateRef.value?.setSubLoading(false);
+}
+function openImportModal() {
+  importTemplateRef.value?.openModal();
+}
+function handleModalPrice(row: Api.goods.ShopSku) {
+  selectData.value = row;
+  openPriceModal();
+}
+function handleAddPrice() {
+  if (PriceData.value.length == 3) return;
+  PriceData.value.push({
+    channelId: undefined,
+    channelProdPrice: 1,
+    id: dayjs().valueOf()
+  });
+}
+function handleDelPrice(id: number) {
+  PriceData.value = PriceData.value.filter(item => item.id != id);
+}
+
+function handleSubmitPrice() {
+  setSubModalLoding(false);
+  if (!PriceData.value.length) {
+    window.$message?.error('最少填写一条数据');
+    return;
+  }
+  if (!areAllItemsAllFieldsFilled(PriceData.value)) {
+    window.$message?.error('请填写完整数据');
+    return;
+  }
+  setSubModalLoding(true);
+
+  console.log(PriceData.value, 'asdsad');
+}
+async function getData() {
+  const { data, error } = await fetchGetAllChannelList();
+  if (!error) {
+    options.value = data;
+  }
+}
+getData();
+</script>
+
+<template>
+  <LayoutTable>
+    <ZTable :columns="columns" :api="fetchGetGoodsList" @register="registerTable">
+      <template #op="{ row }">
+        <NButton size="small" ghost type="primary" @click="handleModalPrice(row)">设置渠道及价格</NButton>
+      </template>
+      <template #prefix>
+        <NSpace>
+          <NButton size="small" @click="openImportModal">导入商品销售渠道及价格</NButton>
+          <NButton size="small">导出全部</NButton>
+          <NButton size="small" :disabled="isDisabledExport">导出选中数据</NButton>
+          <NButton size="small">修改记录</NButton>
+        </NSpace>
+      </template>
+    </ZTable>
+    <ZImportTemplate
+      ref="importTemplateRef"
+      url="/platform/channelProd/template/download"
+      template-text="商品渠道及价格导入模版.xlsx"
+      modal-text="导入商品销售渠道及价格"
+      @submit="handleSubmitImport"
+    ></ZImportTemplate>
+    <BasicModal @register="registerModalPrice" @ok="handleSubmitPrice">
+      <NDataTable :columns="PriceColumns" :data="PriceData" :row-key="row => row.id" />
+    </BasicModal>
+  </LayoutTable>
+</template>
+
+<style scoped></style>

+ 105 - 0
src/views/goods/tag/index.vue

@@ -0,0 +1,105 @@
+<script setup lang="tsx">
+import { NButton, NPopconfirm } from 'naive-ui';
+import type { InternalRowData } from 'naive-ui/es/data-table/src/interface';
+import { fetchAddTag, fetchDeleteTag, fetchEditTag, fetchGetTagList } from '@/service/api/goods/tag';
+import { useTable } from '@/components/zt/Table/hooks/useTable';
+import { useModalFrom } from '@/components/zt/ModalForm/hooks/useModalForm';
+
+const columns: NaiveUI.TableColumn<InternalRowData>[] = [
+  {
+    key: 'name',
+    title: '标签名称',
+    align: 'center',
+    minWidth: 100
+  }
+];
+
+const [registerTable, { refresh, setTableLoading }] = useTable({
+  searchFormConfig: {
+    schemas: [
+      {
+        field: 'name',
+        label: '标签名称',
+        component: 'NInput'
+      }
+    ],
+    inline: false,
+    size: 'small',
+    labelPlacement: 'left',
+    isFull: false
+  },
+  tableConfig: {
+    keyField: 'id',
+    title: '标签列表',
+    showAddButton: true
+  }
+});
+
+async function handleDelete(row: Recordable) {
+  setTableLoading(true);
+  await fetchDeleteTag([row.id]);
+  refresh();
+}
+const [registerModalForm, { openModal, closeModal, getFieldsValue, setFieldsValue }] = useModalFrom({
+  modalConfig: {
+    title: '标签 ',
+    width: 400,
+    isShowHeaderText: true,
+    height: 100
+  },
+  formConfig: {
+    schemas: [
+      {
+        label: '',
+        field: 'id',
+        component: 'NInput',
+        show: false
+      },
+      {
+        field: 'name',
+        label: '标签名称',
+        component: 'NInput',
+        required: true
+      }
+    ],
+    gridProps: {
+      cols: '1'
+    },
+    labelWidth: 120
+  }
+});
+async function handleSubmit() {
+  const form = await getFieldsValue();
+  if (form.id) {
+    await fetchEditTag(form);
+  } else {
+    await fetchAddTag(form);
+  }
+  closeModal();
+  refresh();
+}
+
+async function edit(row: Recordable) {
+  openModal(row);
+  setFieldsValue(row);
+}
+</script>
+
+<template>
+  <LayoutTable>
+    <ZTable :columns="columns" :api="fetchGetTagList" @register="registerTable" @add="openModal">
+      <template #op="{ row }">
+        <NButton size="small" ghost type="primary" @click="edit(row)">编辑</NButton>
+        <NPopconfirm @positive-click="handleDelete(row)">
+          <template #trigger>
+            <NButton size="small" type="error" ghost>删除</NButton>
+          </template>
+          确定删除吗?
+        </NPopconfirm>
+      </template>
+    </ZTable>
+    <BasicModelForm @register-modal-form="registerModalForm" @submit-form="handleSubmit"></BasicModelForm>
+  </LayoutTable>
+</template>
+
+<style scoped></style>

+ 1 - 1
src/views/manage/department/index.vue

@@ -220,7 +220,7 @@ async function handleSubmit() {
         remote
         class="sm:h-full"
       />
-      <BasicModal @register-modal="registerModal" @ok="handleSubmit">
+      <BasicModal @register="registerModal" @ok="handleSubmit">
         <BasicForm @register-form="registerForm">
           <template #parentId="{ model, field }">
             <NTreeSelect

+ 2 - 2
src/views/manage/menu/index.vue

@@ -30,7 +30,7 @@ const [registerForm, { setFieldsValue, validate, getFieldsValue, updateSchema }]
   showActionButtonGroup: false,
   layout: 'horizontal',
   gridProps: {
-    cols: '1 xl:4 s:1 l:3',
+    cols: '1',
     itemResponsive: true
   },
   collapsedRows: 1
@@ -123,7 +123,7 @@ const colums: NaiveUI.TableColumn<InternalRowData>[] = [
     align: 'center'
   },
   {
-    key: 'order',
+    key: 'orderNum',
     title: $t('page.manage.menu.order'),
     align: 'center',
     width: 60

+ 18 - 1
src/views/manage/user/index.vue

@@ -34,6 +34,23 @@ const columns: NaiveUI.TableColumn<InternalRowData>[] = [
     align: 'center',
     minWidth: 100
   },
+  {
+    key: 'depts',
+    title: '所属部门',
+    align: 'center',
+    width: 220,
+    ellipsis: {
+      tooltip: true
+    },
+    render(row) {
+      const depts = row.depts as Recordable[];
+      let text = '';
+      depts.forEach(item => {
+        text += `${item.deptName};`;
+      });
+      return text;
+    }
+  },
 
   {
     key: 'mobile',
@@ -254,7 +271,7 @@ async function edit(row: Recordable) {
   openModal(row);
   const res = await fetchDetaileUser(row.userId);
   setModalLoading(false);
-  setFieldsValue({ ...res.data, userId: row.userId });
+  setFieldsValue({ ...res.data, userId: row.userId, deptIds: res.data?.depts.map(it => it.deptId) });
   updateSchema([
     { field: 'password', required: false },
     { field: 'comfirmPassword', required: false }