zhangjie 1 an în urmă
părinte
comite
30b2fa5fc0

+ 12 - 0
src/assets/svgs/sync.svg

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>icon-同步</title>
+    <g id="超管" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="05.01-学校管理" transform="translate(-40, -91)" fill-rule="nonzero">
+            <g id="icon-同步" transform="translate(40, 91)">
+                <rect id="矩形" fill="#000000" opacity="0" x="0" y="0" width="16" height="16"></rect>
+                <path d="M13.75,1.75 C14.0265625,1.75 14.25,1.9734375 14.25,2.25 L14.25,13.75 C14.25,14.0265625 14.0265625,14.25 13.75,14.25 L2.25,14.25 C1.9734375,14.25 1.75,14.0265625 1.75,13.75 L1.75,2.25 C1.75,1.9734375 1.9734375,1.75 2.25,1.75 Z M11.2375,7.8140625 L10.4453125,7.8140625 C10.3875,7.8140625 10.3390625,7.8609375 10.3390625,7.9203125 L10.3390625,9.153125 C10.3390625,9.4609375 10.090625,9.709375 9.784375,9.709375 L6.571875,9.709375 L6.571875,8.875 C6.571875,8.7859375 6.4703125,8.7375 6.4015625,8.7921875 L4.696875,10.13125 C4.6421875,10.1703125 4.6421875,10.253125 4.696875,10.2953125 L6.4015625,11.634375 C6.4703125,11.6890625 6.571875,11.6390625 6.571875,11.5515625 L6.571875,10.7171875 L9.7859375,10.7171875 C10.646875,10.7171875 11.34375,10.0171875 11.34375,9.153125 L11.34375,7.9203125 C11.34375,7.8625 11.296875,7.8140625 11.2375,7.8140625 Z M9.4296875,4.446875 L9.4296875,5.28125 L6.2140625,5.28125 C5.353125,5.28125 4.65625,5.98125 4.65625,6.8453125 L4.65625,8.078125 C4.65625,8.140625 4.703125,8.1875 4.7625,8.1875 L5.5546875,8.1875 C5.6125,8.1875 5.6609375,8.140625 5.6609375,8.08125 L5.6609375,6.8484375 C5.6609375,6.540625 5.909375,6.2921875 6.215625,6.2921875 L9.4296875,6.2921875 L9.4296875,7.1265625 C9.4296875,7.215625 9.53125,7.2640625 9.6,7.209375 L11.3046875,5.8703125 C11.359375,5.828125 11.359375,5.7453125 11.3046875,5.703125 L9.6,4.3640625 C9.53125,4.309375 9.4296875,4.359375 9.4296875,4.446875 Z" id="形状" fill="#595959"></path>
+            </g>
+        </g>
+    </g>
+</svg>

+ 227 - 0
src/components/ImportFile.vue

@@ -0,0 +1,227 @@
+<template>
+  <a-modal v-model:open="visible" :title="title" :width="500">
+    <div class="import-body">
+      <slot></slot>
+
+      <div class="import-head">
+        <div>模版下载: {{ downloadFilename }}</div>
+        <a-button type="text" @click="toDownload">
+          <template #icon>
+            <svg-icon name="download"></svg-icon>
+          </template>
+          下载
+        </a-button>
+      </div>
+      <a-upload-dragger
+        :accept="accept"
+        :before-upload="handleBeforeUpload"
+        :disabled="loading"
+        :maxCount="1"
+        @remove="handleRemove"
+      >
+        <p>点击或将文件拖拽到这里上传(只能上传{{ formatStr }}文件)</p>
+      </a-upload-dragger>
+      <p
+        v-if="result.message"
+        :class="[
+          `info-tips`,
+          {
+            'tips-success': result.success,
+            'tips-error': !result.success,
+          },
+        ]"
+      >
+        {{ result.message }}
+      </p>
+    </div>
+
+    <template #footer>
+      <a-button type="primary" :disabled="loading" @click="confirm"
+        >确认</a-button
+      >
+      <a-button @click="close">取消</a-button>
+    </template>
+  </a-modal>
+</template>
+
+<script setup lang="ts">
+import useModal from "@/hooks/modal";
+import { computed, ref, watch } from "vue";
+import type { UploadProps } from "ant-design-vue";
+import { message } from "ant-design-vue";
+import { httpApp } from "@/plugins/axiosApp";
+
+/* modal */
+const { visible, open, close } = useModal();
+defineExpose({ open, close });
+
+// init modal
+watch(
+  () => visible.value,
+  (val) => {
+    if (!val) return;
+    uploadDataDict.value = "";
+    result.value = { success: true, message: "" };
+    loading.value = false;
+    fileList.value = [];
+  },
+  { immediate: true }
+);
+
+const props = withDefaults(
+  defineProps<{
+    title: string;
+    downloadHandle?: () => {} | undefined;
+    downloadUrl: string;
+    downloadFilename: String;
+    format: string[];
+    uploadUrl: string;
+    uploadData?: Record<string, any>;
+    maxSize?: number;
+    addFilenameParam?: string;
+    disabled?: boolean;
+  }>(),
+  {
+    title: "文件上传",
+    maxSize: 20 * 1024 * 1024,
+    addFilenameParam: "filename",
+    disabled: false,
+    format: () => ["jpg", "png"],
+  }
+);
+
+const accept = computed(() => {
+  return props.format.length
+    ? props.format.map((item) => `.${item}`).join()
+    : undefined;
+});
+
+const formatStr = computed(() => {
+  return props.format.join();
+});
+
+const emit = defineEmits([
+  "uploading",
+  "uploadError",
+  "uploadSuccess",
+  "validError",
+  "progress",
+]);
+
+const uploadDataDict = ref({});
+const result = ref({ success: true, message: "" });
+const loading = ref(false);
+const fileList = ref<UploadProps["fileList"]>([]);
+
+function toDownload() {
+  if (props.downloadHandle) {
+    props.downloadHandle();
+    return;
+  }
+
+  if (props.downloadUrl) {
+    window.open(props.downloadUrl);
+    return;
+  }
+}
+
+function checkFileFormat(filename: string) {
+  const fileFormat = filename.split(".").pop()?.toLocaleLowerCase();
+  return props.format.some((item) => item.toLocaleLowerCase() === fileFormat);
+}
+
+const handleBeforeUpload: UploadProps["beforeUpload"] = (file) => {
+  uploadDataDict.value = {
+    ...props.uploadData,
+    [props.addFilenameParam]: file.name,
+  };
+
+  if (file.size > props.maxSize) {
+    handleExceededSize();
+    return Promise.reject(result.value);
+  }
+
+  if (!checkFileFormat(file.name)) {
+    handleFormatError();
+    return Promise.reject(result.value);
+  }
+
+  fileList.value = [file];
+
+  return false;
+};
+
+const handleRemove = () => {
+  fileList.value = [];
+};
+
+function handleError(error: Error) {
+  console.log(error);
+  loading.value = false;
+  result.value = {
+    success: false,
+    message: "上传失败",
+  };
+  emit("uploadError", result.value);
+}
+function handleSuccess(datas: any) {
+  loading.value = false;
+  result.value = {
+    success: true,
+    message: "上传成功!",
+  };
+  message.success("上传成功!");
+  emit("uploadSuccess", {
+    ...result.value,
+    ...datas,
+  });
+}
+
+function handleFormatError() {
+  const content = `只支持文件格式为${props.format.join("/")}`;
+  result.value = {
+    success: false,
+    message: content,
+  };
+  loading.value = false;
+  message.error(content);
+  emit("validError", result.value);
+}
+function handleExceededSize() {
+  const content = `文件大小不能超过${Math.floor(props.maxSize / 1024)}M`;
+  result.value = {
+    success: false,
+    message: content,
+  };
+  loading.value = false;
+  message.error(content);
+  emit("validError", result.value);
+}
+
+async function confirm() {
+  if (!fileList.value?.length) return;
+  if (loading.value) return;
+
+  const formData = new FormData();
+  Object.entries(uploadDataDict.value).forEach(([k, v]) => {
+    formData.append(k, v as any);
+  });
+  formData.append("file", fileList.value[0] as any);
+  emit("uploading");
+
+  loading.value = true;
+
+  const res = await httpApp
+    .post(props.uploadUrl, formData, {
+      onUploadProgress: (progress) => {
+        emit("progress", progress);
+      },
+    })
+    .catch((error: Error) => {
+      handleError(error);
+    });
+  loading.value = false;
+  if (!res) return;
+  handleSuccess(res.data);
+}
+</script>

+ 6 - 1
src/features/authManagement/AuthManagement.vue

@@ -56,6 +56,8 @@
             accept=".lic"
             :before-upload="beforeUpload"
             :disabled="importLoading"
+            :maxCount="1"
+            @remove="handleRemove"
           >
             <a-button>
               <template #icon>
@@ -92,7 +94,7 @@ import {
 import { message } from "ant-design-vue";
 import type { UploadProps } from "ant-design-vue";
 
-import { onMounted, reactive, ref } from "vue";
+import { onMounted, reactive } from "vue";
 import { downloadFileURL } from "@/utils/utils";
 import useLoading from "@/hooks/loading";
 
@@ -167,6 +169,9 @@ const beforeUpload: UploadProps["beforeUpload"] = (file) => {
   fileList = [file];
   return false;
 };
+const handleRemove = () => {
+  fileList = [];
+};
 const { loading: importLoading, setLoading: setImportLoading } = useLoading();
 async function uploadFile() {
   if (!fileList?.length) return;

+ 63 - 78
src/features/courseManagement/CourseManagement.vue

@@ -28,7 +28,7 @@
         class="part-action"
         :size="6"
       >
-        <a-button type="text" @click="newCourse">
+        <a-button type="text" @click="toAdd">
           <template #icon>
             <svg-icon name="add"></svg-icon>
           </template>
@@ -111,7 +111,7 @@
               v-if="store.isGreaterThanEqualRootOrgAdmin"
               class="action-cell"
             >
-              <a-button type="text" @click="showModal(record)">编辑</a-button>
+              <a-button type="text" @click="toEdit(record)">编辑</a-button>
               <a-button
                 type="text"
                 :danger="record.enable"
@@ -125,42 +125,6 @@
       </a-table>
     </div>
 
-    <a-modal
-      v-model:open="visible"
-      title="科目信息页"
-      okText="确定"
-      cancelText="取消"
-      :width="438"
-      @ok="handleOk"
-    >
-      <a-form :labelCol="{ span: 4 }">
-        <a-form-item v-show="store.isSuperAdmin" label="学校">
-          <RootOrgSelect
-            v-model:value="courseObj.rootOrgId"
-            :disabled="!!courseObj.id"
-          />
-        </a-form-item>
-        <a-form-item label="科目代码">
-          <a-input
-            v-model:value="courseObj.code"
-            :disabled="!!courseObj.id"
-          ></a-input>
-        </a-form-item>
-        <a-form-item label="科目名称">
-          <a-input v-model:value="courseObj.name"></a-input>
-        </a-form-item>
-        <a-form-item label="科目类型">
-          <CourseTypeSelect v-model:value="courseObj.type" />
-        </a-form-item>
-        <a-form-item label="状态">
-          <a-radio-group v-model:value="courseObj.enable">
-            <a-radio :value="true">启用</a-radio>
-            <a-radio :value="false">禁用</a-radio>
-          </a-radio-group>
-        </a-form-item>
-      </a-form>
-    </a-modal>
-
     <a-modal
       v-model:open="importModalVisible"
       title="批量科目导入"
@@ -169,20 +133,39 @@
       :width="438"
       @ok="handleImport"
     >
-      <a-form>
+      <a-form :labelCol="{ style: { width: '72px' } }">
         <a-form-item v-if="store.isSuperAdmin" label="学校">
-          <RootOrgSelect v-model:value="courseObj.rootOrgId" />
+          <RootOrgSelect v-model:value="rootOrgId" />
         </a-form-item>
         <a-form-item label="文件地址">
-          <input id="file-input" :multiple="false" type="file" />
+          <a-upload
+            accept=".xls,.xlsx"
+            :before-upload="beforeUpload"
+            :disabled="importLoading"
+            :maxCount="1"
+            @remove="handleRemove"
+          >
+            <a-button>
+              <template #icon>
+                <svg-icon name="file"></svg-icon>
+              </template>
+              选择文件
+            </a-button>
+          </a-upload>
         </a-form-item>
         <a-form-item label="下载模板">
-          <a-button class="download-tpl-btn" @click="downloadTpl"
-            >下载模板</a-button
-          >
+          <a-button @click="downloadTpl">
+            <template #icon>
+              <svg-icon name="download"></svg-icon>
+            </template>
+            下载模板
+          </a-button>
         </a-form-item>
       </a-form>
     </a-modal>
+
+    <!-- ModifyCourse -->
+    <ModifyCourse ref="modifyCourseRef" :row-data="curRow" @modified="search" />
   </div>
 </template>
 
@@ -199,6 +182,9 @@ import { Course, Course_Type } from "@/types";
 import { downloadFileURL } from "@/utils/utils";
 import { message } from "ant-design-vue";
 import { watch, onMounted, ref, reactive, toRaw, h } from "vue";
+import type { UploadProps } from "ant-design-vue";
+import useLoading from "@/hooks/loading";
+import ModifyCourse from "./ModifyCourse.vue";
 
 const store = useMainStore();
 store.currentLocation = "";
@@ -302,34 +288,16 @@ onMounted(async () => {
   await search();
 });
 
-const visible = ref<boolean>(false);
-
-const showModal = (record: Course) => {
-  Object.assign(courseObj, record);
-  visible.value = true;
-};
-
-const handleOk = async () => {
-  await updateCourse(toRaw(courseObj));
-  visible.value = false;
-  await search();
-  void message.success({ content: "操作成功" });
-};
-
-const initCourse = <Course>(<unknown>{
-  id: undefined,
-  code: "",
-  name: "",
-  enable: true,
-  type: undefined,
-  rootOrgId: store.userInfo.rootOrgId,
-});
-const courseObj = reactive({ ...initCourse });
-
-const newCourse = () => {
-  Object.assign(courseObj, initCourse);
-  showModal(courseObj);
-};
+const modifyCourseRef = ref();
+const curRow = ref<Course>({} as Course);
+function toAdd() {
+  curRow.value = {} as Course;
+  modifyCourseRef.value?.open();
+}
+function toEdit(row: Course) {
+  curRow.value = row;
+  modifyCourseRef.value?.open();
+}
 
 function checkEmpty(selectIds: number[]): boolean {
   if (selectIds && selectIds.length > 0) {
@@ -347,17 +315,34 @@ async function handleToggleCourses(enable: boolean, ids: number[]) {
 }
 
 /** <handleImport> */
+let fileList = $ref<UploadProps["fileList"]>([]);
+const beforeUpload: UploadProps["beforeUpload"] = (file) => {
+  fileList = [file];
+  return false;
+};
+const handleRemove = () => {
+  fileList = [];
+};
+const { loading: importLoading, setLoading: setImportLoading } = useLoading();
 let importModalVisible = $ref<boolean>(false);
 async function handleImport() {
-  const files = (document.querySelector("#file-input") as HTMLInputElement)
-    .files;
-  const fileToImport = files && files[0];
-  if (!fileToImport) {
+  if (!rootOrgId) {
+    void message.error("请先选择结构");
+    return;
+  }
+  if (!fileList?.length) {
     void message.warn({ content: "请选择文件" });
     return;
   }
-  (document.querySelector("#file-input") as HTMLInputElement).value = "";
-  const res = await importCourses(rootOrgId, fileToImport);
+  setImportLoading(true);
+
+  const res = await importCourses(rootOrgId, fileList[0] as any).catch(
+    () => {}
+  );
+  if (!res) return;
+
+  setImportLoading(false);
+
   if (!res.data.hasError) {
     importModalVisible = false;
     void message.success({ content: "导入成功" });

+ 162 - 0
src/features/courseManagement/ModifyCourse.vue

@@ -0,0 +1,162 @@
+<template>
+  <a-modal v-model:open="visible" :title="title" :width="500">
+    <a-form
+      ref="formRef"
+      :labelCol="{ style: { width: '90px' } }"
+      :model="formData"
+      :rules="rules"
+    >
+      <a-form-item v-if="store.isSuperAdmin" label="学校" name="rootOrgId">
+        <RootOrgSelect
+          v-model:value="formData.rootOrgId"
+          :disabled="isEdit"
+          selectFirst
+        />
+      </a-form-item>
+      <a-form-item label="科目代码" name="code">
+        <a-input v-model:value="formData.code" :disabled="isEdit"></a-input>
+      </a-form-item>
+      <a-form-item label="科目名称" name="name">
+        <a-input v-model:value="formData.name"></a-input>
+      </a-form-item>
+      <a-form-item label="科目类型" name="type">
+        <CourseTypeSelect v-model:value="formData.type" />
+      </a-form-item>
+      <a-form-item label="状态" name="enable">
+        <a-radio-group v-model:value="formData.enable">
+          <a-radio :value="true">启用</a-radio>
+          <a-radio :value="false">禁用</a-radio>
+        </a-radio-group>
+      </a-form-item>
+    </a-form>
+
+    <template #footer>
+      <a-button type="primary" :disabled="loading" @click="confirm"
+        >确认</a-button
+      >
+      <a-button @click="close">取消</a-button>
+    </template>
+  </a-modal>
+</template>
+
+<script setup lang="ts">
+import useModal from "@/hooks/modal";
+import { nextTick, reactive, ref, watch } from "vue";
+import type { Rule, FormInstance } from "ant-design-vue/es/form";
+import useLoading from "@/hooks/loading";
+import { updateCourse } from "@/api/courseManagementPage";
+import { message } from "ant-design-vue";
+import { useMainStore } from "@/store";
+import { objAssign, objModifyAssign } from "@/utils/utils";
+import { Course } from "@/types";
+
+const store = useMainStore();
+
+/* modal */
+const { visible, open, close } = useModal();
+defineExpose({ open, close });
+
+const props = defineProps<{
+  rowData: Course;
+}>();
+const emit = defineEmits(["modified"]);
+
+const isEdit = $computed(() => !!props.rowData.id);
+const title = $computed(() => `${isEdit ? "编辑" : "新增"}科目`);
+
+const defaultFormData = {
+  id: undefined,
+  code: "",
+  name: "",
+  enable: true,
+  type: undefined,
+  rootOrgId: null,
+};
+type FormDataType = typeof defaultFormData;
+
+const formRef = ref<FormInstance>();
+const formData = reactive<FormDataType>({ ...defaultFormData });
+const rules: Record<string, Rule[]> = {
+  rootOrgId: [
+    {
+      required: true,
+      message: "请选择学校",
+    },
+  ],
+  name: [
+    {
+      required: true,
+      message: "请输入科目名称",
+    },
+    {
+      max: 30,
+      message: "最多30个字符",
+    },
+  ],
+  code: [
+    {
+      required: true,
+      message: "请输入科目代码",
+    },
+    {
+      pattern: /^[a-zA-Z0-9]{1,}$/,
+      message: "科目代码只能是数字和字母",
+    },
+    {
+      max: 30,
+      message: "最多30个字符",
+    },
+  ],
+  type: [
+    {
+      required: true,
+      message: "请选择科目类型",
+    },
+  ],
+  enable: [
+    {
+      required: true,
+      message: "请选择状态",
+    },
+  ],
+};
+
+/* confirm */
+const { loading, setLoading } = useLoading();
+async function confirm() {
+  const err = await formRef.value?.validate();
+  if (err) return;
+
+  setLoading(true);
+  const datas = objAssign(formData, {});
+  const res = await updateCourse(datas).catch(() => false);
+  setLoading(false);
+  if (!res) return;
+  message.success("修改成功!");
+  emit("modified", datas);
+  close();
+}
+
+// init modal
+watch(
+  () => visible.value,
+  (val) => {
+    if (!val) {
+      formRef.value?.clearValidate();
+      return;
+    }
+    nextTick(() => {
+      modalBeforeOpen();
+    });
+  },
+  { immediate: true }
+);
+
+function modalBeforeOpen() {
+  if (props.rowData.id) {
+    objModifyAssign(formData, props.rowData);
+  } else {
+    objModifyAssign(formData, defaultFormData);
+  }
+}
+</script>

+ 1 - 1
src/features/rootOrg/RootOrg.vue

@@ -3,7 +3,7 @@
     <a-space class="part-action" :size="12">
       <a-button type="text" @click="handleRootOrgSync">
         <template #icon>
-          <svg-icon name="add"></svg-icon>
+          <svg-icon name="sync"></svg-icon>
         </template>
         同步
       </a-button>

+ 2 - 2
src/features/userManagement/ModifyUser.vue

@@ -111,8 +111,8 @@ const rules: Record<string, Rule[]> = {
       message: "请输入登录名",
     },
     {
-      pattern: /^[a-zA-Z0-9]{4}$/,
-      message: "请输入登录名",
+      pattern: /^[a-zA-Z0-9]{1,}$/,
+      message: "登录名只能是数字和字母",
     },
     {
       max: 30,

+ 42 - 16
src/features/userManagement/UserManagement.vue

@@ -123,22 +123,36 @@
       title="批量用户导入"
       okText="确定"
       cancelText="取消"
+      :width="438"
       @ok="handleImport"
     >
-      <a-form>
-        <a-form-item v-show="store.isSuperAdmin" label="学校">
-          <!-- <RootOrgSelect
-            v-show="store.isSuperAdmin"
-            v-model:value="userObj.rootOrgId"
-          /> -->
+      <a-form :labelCol="{ style: { width: '72px' } }">
+        <a-form-item v-if="store.isSuperAdmin" label="学校">
+          <RootOrgSelect v-model:value="rootOrgId" />
         </a-form-item>
         <a-form-item label="文件地址">
-          <input id="file-input" :multiple="false" type="file" />
+          <a-upload
+            accept=".xls,.xlsx"
+            :before-upload="beforeUpload"
+            :disabled="importLoading"
+            :maxCount="1"
+            @remove="handleRemove"
+          >
+            <a-button>
+              <template #icon>
+                <svg-icon name="file"></svg-icon>
+              </template>
+              选择文件
+            </a-button>
+          </a-upload>
         </a-form-item>
         <a-form-item label="下载模板">
-          <a-button class="download-tpl-btn" @click="downloadTpl"
-            >下载模板</a-button
-          >
+          <a-button @click="downloadTpl">
+            <template #icon>
+              <svg-icon name="download"></svg-icon>
+            </template>
+            下载模板
+          </a-button>
         </a-form-item>
       </a-form>
     </a-modal>
@@ -162,6 +176,8 @@ import { downloadFileURL } from "@/utils/utils";
 import { message, Modal } from "ant-design-vue";
 import { watch, onMounted, h, ref } from "vue";
 import ModifyUser from "./ModifyUser.vue";
+import type { UploadProps } from "ant-design-vue";
+import useLoading from "@/hooks/loading";
 
 const store = useMainStore();
 store.currentLocation = "";
@@ -315,23 +331,33 @@ function handleResetUsers(ids: number[]) {
 }
 
 /** <handleImport> */
+// offline-auth
+let fileList = $ref<UploadProps["fileList"]>([]);
+const beforeUpload: UploadProps["beforeUpload"] = (file) => {
+  fileList = [file];
+  return false;
+};
+const handleRemove = () => {
+  fileList = [];
+};
+const { loading: importLoading, setLoading: setImportLoading } = useLoading();
 let importModalVisible = $ref<boolean>(false);
 async function handleImport() {
   if (!rootOrgId) {
     void message.error("请先选择结构");
     return;
   }
-  const files = (document.querySelector("#file-input") as HTMLInputElement)
-    .files;
-  const fileToImport = files && files[0];
-  if (!fileToImport) {
+  if (!fileList?.length) {
     void message.warn({ content: "请选择文件" });
     return;
   }
 
-  (document.querySelector("#file-input") as HTMLInputElement).value = "";
+  setImportLoading(true);
+
+  const res = await importUsers(rootOrgId, fileList[0] as any).catch(() => {});
+  if (!res) return;
 
-  const res = await importUsers(rootOrgId, fileToImport);
+  setImportLoading(false);
   if (!res.data.hasError) {
     importModalVisible = false;
     void message.success({ content: "导入成功" });

+ 1 - 1
src/styles/ant-custom.less

@@ -6,7 +6,7 @@
   .ant-message-custom-content {
     padding: 11px 15px;
     border: 1px solid #fff;
-    border-radius: 8px;
+    border-radius: 4px;
     color: var(--app-main-text-color);
   }
   .ant-message-error {