ソースを参照

🎨 表单基础与申报页

快乐的梦鱼 2 日 前
コミット
f4f44e2ca7
共有45 個のファイルを変更した3248 個の追加11 個の削除を含む
  1. 2 2
      package-lock.json
  2. 1 0
      package.json
  3. 72 0
      src/api/inheritor/SubmitApi.ts
  4. BIN
      src/assets/images/ImageFailed.png
  5. 35 0
      src/common/utils/CheckUtils.ts
  6. 44 0
      src/components/VNodeRenderer.vue
  7. 29 0
      src/components/dynamicf/ActionRender.ts
  8. 35 0
      src/components/dynamicf/ActionRender.vue
  9. 50 0
      src/components/dynamicf/CascaderFormItem.ts
  10. 165 0
      src/components/dynamicf/CascaderFormItem.vue
  11. 56 0
      src/components/dynamicf/CheckBoxToInt.vue
  12. 8 0
      src/components/dynamicf/CheckBoxValue.ts
  13. 51 0
      src/components/dynamicf/CheckBoxValue.vue
  14. 30 0
      src/components/dynamicf/Display/ShowDateOrNull.vue
  15. 86 0
      src/components/dynamicf/Display/ShowImageList.vue
  16. 76 0
      src/components/dynamicf/Display/ShowImageOrNull.vue
  17. 69 0
      src/components/dynamicf/Display/ShowInList.vue
  18. 35 0
      src/components/dynamicf/Display/ShowMomentOrNull.vue
  19. 48 0
      src/components/dynamicf/Display/ShowTagList.vue
  20. 72 0
      src/components/dynamicf/Display/ShowValueOrNull.vue
  21. 37 0
      src/components/dynamicf/Display/StateRenderer.vue
  22. 88 0
      src/components/dynamicf/Dropdown/IdAsValueDropdown.ts
  23. 253 0
      src/components/dynamicf/Dropdown/IdAsValueDropdown.vue
  24. 336 0
      src/components/dynamicf/Dropdown/IdAsValueTreeDropdown.vue
  25. 66 0
      src/components/dynamicf/IdAsValueTree.ts
  26. 180 0
      src/components/dynamicf/IdAsValueTree.vue
  27. 69 0
      src/components/dynamicf/NumberRange.vue
  28. 116 0
      src/components/dynamicf/PasswordStrengthMeter.vue
  29. 37 0
      src/components/dynamicf/PasswordWithStrengthInput.vue
  30. 29 0
      src/components/dynamicf/RadioValue.ts
  31. 77 0
      src/components/dynamicf/RadioValue.vue
  32. 25 0
      src/components/dynamicf/SelectValue.ts
  33. 74 0
      src/components/dynamicf/SelectValue.vue
  34. 99 0
      src/components/dynamicf/SimpleEditDynamicStringList.vue
  35. 128 0
      src/components/dynamicf/SimpleListDynamicForm.vue
  36. 42 0
      src/components/dynamicf/SimpleSelectFormItem.ts
  37. 69 0
      src/components/dynamicf/SimpleSelectFormItem.vue
  38. 140 0
      src/components/dynamicf/UploadImageFormItem.ts
  39. 169 0
      src/components/dynamicf/UploadImageFormItem.vue
  40. 6 0
      src/components/dynamicf/WhiteSpace.ts
  41. 16 0
      src/components/dynamicf/WhiteSpace.vue
  42. 28 0
      src/components/dynamicf/WrapperRangePicker.vue
  43. 24 0
      src/components/dynamicf/WrapperTimeRangePicker.vue
  44. 55 9
      src/components/dynamicf/index.ts
  45. 121 0
      src/views/inheritor/submit.vue

+ 2 - 2
package-lock.json

@@ -13,6 +13,7 @@
         "@vuemap/vue-amap": "^2.1.12",
         "ant-design-vue": "^4.2.6",
         "bootstrap": "^5.3.0",
+        "lodash-es": "^4.17.21",
         "mitt": "^3.0.1",
         "pinia": "^3.0.1",
         "tslib": "^2.8.1",
@@ -3121,8 +3122,7 @@
     "node_modules/lodash-es": {
       "version": "4.17.21",
       "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
-      "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
-      "license": "MIT"
+      "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
     },
     "node_modules/loose-envify": {
       "version": "1.4.0",

+ 1 - 0
package.json

@@ -16,6 +16,7 @@
     "@vuemap/vue-amap": "^2.1.12",
     "ant-design-vue": "^4.2.6",
     "bootstrap": "^5.3.0",
+    "lodash-es": "^4.17.21",
     "mitt": "^3.0.1",
     "pinia": "^3.0.1",
     "tslib": "^2.8.1",

+ 72 - 0
src/api/inheritor/SubmitApi.ts

@@ -0,0 +1,72 @@
+import { DataModel, transformArrayDataModel } from '@imengyu/js-request-transform';
+import { CommonContentApi } from '../CommonContent';
+import { AppServerRequestModule } from '../RequestModules';
+
+export class RecommendForm extends DataModel<RecommendForm> {
+  constructor() {
+    super(RecommendForm, "代表性传承人推荐表");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      birthday: { serverSide: 'string', serverSideDateFormat: 'YYYY-MM' },
+    }
+  }
+
+  ichId = '';
+  idPhoto: string = '';
+  type : number|null = null;
+  batch : number|null = null;
+  level : number|null = null;
+  region : number|null = null;
+  name = '';
+  ichName = '';
+  unit = '';
+  gender = '';
+  birthday: Date|null = null;
+  nation = '';
+  education = '';
+  job = '';
+  jobTitle = '';
+  honoraryTitle = '';
+  cityInheritorDay = '';
+  artisticYears: number|null = null;
+  idCrd = '';
+  jobUnit = '';
+  mobile = '';
+  postcode = '';
+  email = '';
+  address = '';
+  personalCv = '';
+  pedigree = '';
+  experience = '';
+  feature = '';
+  achievement = '';
+  teach = '';
+  activity = '';
+  information = '';
+  contribute = '';
+  photosJson: {
+    from: string,
+    mobile: string,
+    desc: string,
+    url: string,
+  }[] = [];
+  idCardImages = '';
+  authorize = '';
+}
+
+export class SubmitApi extends AppServerRequestModule<DataModel> {
+
+  constructor() {
+    super();
+  }
+
+  async submitRecommendForm(data: RecommendForm) {
+    return this.post(
+      '/ich/recommend/save', 
+      data.toServerSide(),
+      '代表性传承人推荐表提交',
+    );
+  }
+}
+
+export default new SubmitApi();

BIN
src/assets/images/ImageFailed.png


+ 35 - 0
src/common/utils/CheckUtils.ts

@@ -92,6 +92,40 @@ function compareVersion(v1: any, v2: any) {
   return 0
 }
 
+/**
+ * 检查密码的强度等级
+ * @param pwd 密码
+ * @returns 0:表示第一个级别 1:表示第二个级别 2:表示第三个级别 3:表示第四个级别 4:表示第五个级别
+ */
+export function checkPassWordSecrityLevel(pwd: string) : number{
+  let score = 0;
+  if (!pwd) {
+    return score;
+  }
+  // award every unique letter until 5 repetitions
+  const letters = {} as Record<string, number>;
+  for (let i = 0; i< pwd.length; i++) {
+    letters[pwd[i]] = (letters[pwd[i]] || 0) + 1;
+    score += 5.0 / letters[pwd[i]];
+  }
+
+  // bonus points for mixing it up
+  const variations = {
+    digits: /\d/.test(pwd),
+    lower: /[a-z]/.test(pwd),
+    upper: /[A-Z]/.test(pwd),
+    nonWords: /\W/.test(pwd),
+  } as Record<string, boolean>;
+
+  let variationCount = 0;
+  for (const check in variations) {
+    variationCount += (variations[check] == true) ? 1 : 0;
+  }
+  score += (variationCount - 1) * 10;
+
+  return Math.floor(score);
+}
+
 export default {
   checkIsNotEmpty,
   checkIsNotEmptyAndSpace,
@@ -100,5 +134,6 @@ export default {
 	checkIsChinesePhoneNumber,
   checkIsUrl,
   checkIsImageFile,
+  checkPassWordSecrityLevel,
   compareVersion,
 }

+ 44 - 0
src/components/VNodeRenderer.vue

@@ -0,0 +1,44 @@
+<script lang="ts">
+import { defineComponent, h, type PropType, type VNode } from 'vue'
+
+export default defineComponent({
+  name: 'VNodeRenderer',
+  props: {
+    vnode: {
+      type: Object as PropType<VNode>,
+      default: null
+    },
+    render: {
+      type: Function as PropType<(data: Record<string, unknown>|null) => VNode>,
+      default: null
+    },
+    renderChild: {
+      type: Boolean,
+      default: null
+    },
+    data: {
+      type: Object as PropType<Record<string, unknown>>,
+      default: null
+    },
+  },
+  render() {
+    if(this.vnode) {
+      if (typeof this.vnode === 'string')
+        return h('div', this.vnode);
+      const props = this.vnode.props;
+      if(props)
+        for(let key in this.data)
+          props[key] = this.data[key];
+      return this.renderChild ? 
+        h('div', { style: { height: '100%'} }, [
+          this.vnode
+        ]) : 
+        this.vnode;
+    }
+    if(this.render) {
+      return this.render(this.data);
+    }
+    return h('div');
+  }
+})
+</script>

+ 29 - 0
src/components/dynamicf/ActionRender.ts

@@ -0,0 +1,29 @@
+export interface ActionRenderProps {
+  /**
+   * 操作条目
+   */
+  actions: Array<ActionRenderItem>;
+}
+
+export interface ActionRenderItem {
+  /**
+   * 按钮文字
+   */
+  text: string,
+  /**
+   * 按钮键值
+   */
+  key?: string,
+  /**
+   * 这个按钮是否换行,默认否
+   */
+  wrap?: boolean,
+  /**
+   * 按钮类型
+   */
+  type?: 'primary'|'danger'|'success'|'warning'|'secondary',
+  /**
+   * 按钮点击回调
+   */
+  onClick?: (key: string|undefined, record: Record<string, unknown>) => void;
+}

+ 35 - 0
src/components/dynamicf/ActionRender.vue

@@ -0,0 +1,35 @@
+<template>
+  <span>
+    <a-button
+      v-for="(act, k) in actions" 
+      :key="k" 
+      :class="`mr-3 text-${act.type}` + (act.wrap ? ' display-block' : '')"
+      @click="actionClick(act)"
+    >{{act.text}}</a-button>
+  </span>
+</template>
+
+<script lang="ts">
+import type { DataModel } from '@imengyu/js-request-transform';
+import type { ActionRenderItem } from './ActionRender';
+import { defineComponent, type PropType } from "vue";
+
+export default defineComponent({
+  props: {
+    rawModel: {
+      type: Object as PropType<Record<string, unknown>>,
+    },
+    actions: {
+      type: Object as PropType<Array<ActionRenderItem>>,
+    },
+  },
+  methods: {
+    actionClick(action: ActionRenderItem) {
+      if (typeof action.onClick === 'function')
+        action.onClick(action.key, this.rawModel as DataModel);
+      else
+        console.warn('action ' + action.key + ' onClick is not a function!');
+    },
+  },
+});
+</script>

+ 50 - 0
src/components/dynamicf/CascaderFormItem.ts

@@ -0,0 +1,50 @@
+import type { CascaderProps } from "ant-design-vue";
+
+
+export type CascaderFormItemOptionType = CascaderProps['options'];
+
+export type LoadDataFun = (parentValue: string|number|null, level: number, parentObject: unknown) => Promise<CascaderFormItemOptionType>;
+export type OnChooseFun = (values: (string|number|null)[], objects: unknown[]) => void;
+
+/**
+ * CascaderFormItem 的公共接口
+ */
+export interface CascaderFormItemInterface {
+  /**
+   * 加载树形数据至当前选中层级
+   */
+  doLoadDataToCurrentValue: () => void;
+}
+/**
+ * CascaderFormItem 的公共接口
+ */
+export interface CascaderFormItemProps {
+  /**
+   * 初始化时加载数据
+   */
+  loadAtStart:  boolean,
+  /**
+   * 初始化时是否递归加载数据到当前选中的数据
+   */
+  loadCascaderToCurrentValueAtStart:  boolean,
+  /**
+   * 加载数据
+   */
+  loadData: LoadDataFun;
+  /**
+   * placeholder
+   */
+  placeholder?: string;
+  /**
+   * 选择后回调查找出的对象键,默认是id
+   */
+  onSelectFindIdKey?: string;
+  /**
+   * 选择后回调
+   */
+  onSelect?: OnChooseFun;
+  /**
+   * a-cascader 其他自定义参数
+   */
+  customProps?: CascaderProps;
+}

+ 165 - 0
src/components/dynamicf/CascaderFormItem.vue

@@ -0,0 +1,165 @@
+<template>
+  <a-cascader
+    :value="value"
+    :options="options"
+    :load-data="doLoadData"
+    :placeholder="placeholder"
+    @update:value="onUpdateValue"
+    change-on-select
+    v-bind="(customProps as any)"
+  />
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, type PropType, ref, toRefs, watch } from "vue";
+import type { CascaderProps } from "ant-design-vue";
+import type { LoadDataFun, OnChooseFun } from "./CascaderFormItem";
+
+export default defineComponent({
+  props: {
+    loadData: {
+      type: Function as PropType<LoadDataFun>,
+      default: null,
+    },
+    placeholder: {
+      type: String,
+      default: '请选择地址',
+    },
+    loadAtStart:  {
+      type: Boolean,
+      default: true,
+    },
+    loadCascaderToCurrentValueAtStart: {
+      type: Boolean,
+      default: true,
+    },
+    value: {},
+    /**
+     * 选择后回调
+     */
+    onSelect: {
+      type: Function as PropType<OnChooseFun>,
+      default: null,
+    },
+    onSelectFindIdKey: {
+      type: String,
+      default: 'id',
+    },
+    /**
+     * a-cascader 其他自定义参数
+     */
+    customProps: {
+      //type: Object as PropType<CascaderProps>,
+      default: null,
+    },
+  },
+  emits: [
+    'update:value'
+  ],
+  setup(props, context) {
+
+    const { loadAtStart, loadCascaderToCurrentValueAtStart, value, loadData, onSelect } = toRefs(props);
+    const options = ref<CascaderProps['options']>([]);
+
+    const doLoadData = ((selectedOptions) => {
+      const parent = selectedOptions && selectedOptions.length > 0 ? selectedOptions[selectedOptions.length - 1] : null;
+      loadData.value(parent?.value as number, selectedOptions ? selectedOptions.length : 0, parent)
+        .then((d) => {
+          if (!d)
+            throw new Error("loadData return invalid data!");
+          if (parent) {
+            //添加数据至指定层级下方
+            if (!parent.children)
+              parent.children = d;
+            else
+              parent.children = parent.children.concat(d);
+          } else {
+            //添加数据
+            options.value = d;
+            //这个时候加载一下默认选择项目
+            if (loadCascaderToCurrentValueAtStart.value)
+              doLoadDataToCurrentValue();
+          }
+        }).catch((e) => {
+          console.error(e);
+        });
+    }) as CascaderProps['loadData'];
+
+    function onUpdateValue(v: number[]) {
+      context.emit('update:value', v);
+
+      //选中后回调
+      if (typeof onSelect.value === 'function') {
+        const objArr = [] as unknown[];
+        const valueArrNow = v.concat();
+        //通过ID查找指定的对象
+        let optionsCurrent = options.value as CascaderProps['options'];
+        for (let i = 0; i < v.length; i++) {
+          if (!optionsCurrent)
+            break;
+          const item = optionsCurrent.find(k => k.value === valueArrNow[i]);
+          if (item) {
+            objArr.push(item);
+            optionsCurrent = item.children;//下一级
+          } else {
+            break;
+          }
+        }
+        //回调
+        onSelect.value(v, objArr);
+      }
+    }
+
+    //加载树形数据至当前选中层级
+    function doLoadDataToCurrentValue() {
+      const valueArrNow = (value.value as number[]).concat();
+
+      function findChildren(index: number, optionsCurrent: CascaderProps['options']) {
+        if (!optionsCurrent)
+          return;
+        //当前级数据,查找是否存在,
+        const option = optionsCurrent.find(k => k.value === valueArrNow[index]);
+        if (option && (!option.children || option.children.length === 0)) {
+          //存在,尝试加载下一级数据
+          loadData.value(valueArrNow[index], index, option)
+            .then((d) => {
+              if (!d)
+                throw new Error("loadData return invalid data!");
+              if (parent) {
+                if (!option.children)
+                  option.children = d;
+                else
+                  option.children = option.children.concat(d);
+                
+                //如果还没有达到输入选择的层级,则进行下一次加载
+                if (index < valueArrNow.length - 1) {
+                  findChildren(index + 1, option.children);
+                }
+              }
+            }).catch((e) => {
+              console.error(e);
+            });
+        }
+      }
+
+      findChildren(0, options.value);
+    }
+
+    onMounted(() => {
+      if (loadAtStart.value && typeof doLoadData === 'function')
+        doLoadData([]);
+    });
+
+    watch(value, () => {
+      doLoadDataToCurrentValue();
+    });
+
+    return {
+      doLoadData,
+      doLoadDataToCurrentValue,
+      onUpdateValue,
+      options,
+    };
+  },
+});
+</script>

+ 56 - 0
src/components/dynamicf/CheckBoxToInt.vue

@@ -0,0 +1,56 @@
+<template>
+  <a-checkbox 
+    :checked="checked"
+    @update:checked="(v: boolean) => $emit('update:value', v)"
+    :disabled="disabled"
+  >
+    <slot>{{text}}</slot>
+  </a-checkbox>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue";
+
+export default defineComponent({
+  name: "CheckBoxToInt",
+  data() {
+    return {
+      checked: false
+    }
+  },
+  emits: [ 'update:value' ],
+  props: {
+    checkedValue: {
+      type: Number,
+      default: 1
+    },
+    uncheckedValue: {
+      type: Number,
+      default: 0
+    },
+    disabled: {
+      type: Boolean,
+      default: false
+    },
+    text: {
+      type: String,
+      default: '启用',
+    },
+    value: {
+      default: null,
+    }
+  },
+  mounted: function() {
+    this.loadChecked();
+  },
+  watch: {
+    value() { this.loadChecked(); },
+    checked() { this.$emit('update:value', this.checked ? this.checkedValue : this.uncheckedValue) }
+  },
+  methods: {
+    loadChecked() {
+      this.checked = this.value == this.checkedValue;
+    }
+  }
+});
+</script>

+ 8 - 0
src/components/dynamicf/CheckBoxValue.ts

@@ -0,0 +1,8 @@
+import type { CheckboxProps } from "ant-design-vue";
+
+export interface CheckBoxValueProps {
+  checkboxProps?: CheckboxProps,
+  text: string,
+  checkedValue?: unknown,
+  uncheckedValue?: unknown,
+}

+ 51 - 0
src/components/dynamicf/CheckBoxValue.vue

@@ -0,0 +1,51 @@
+<template>
+  <a-checkbox 
+    v-model:checked="checked" 
+    v-bind="checkboxProps"
+  >{{text}}</a-checkbox>
+</template>
+
+<script lang="ts">
+import { defineComponent, type PropType } from "vue";
+import type { CheckboxProps } from "ant-design-vue";
+
+export default defineComponent({
+  props: {
+    checkboxProps: {
+      type: Object as PropType<CheckboxProps>,
+      default: null,
+    },
+    text: {
+      type: String,
+      default: '',
+    },
+    checkedValue: {
+      default: true,
+    },
+    uncheckedValue: {
+      default: false,
+    },
+    value: {
+    },
+  },
+  emits: [ 'update:value' ],
+  watch: {
+    checked(v) {
+      this.$emit('update:value', v ? this.checkedValue : this.uncheckedValue);
+    },
+    value(v) {
+      const checked = v === this.checkedValue;
+      if (this.checked != checked)
+        this.checked = checked;
+    },
+  },
+  data() {
+    return {
+      checked: false,
+    }
+  },
+  mounted() {
+    this.checked = this.value === this.checkedValue;
+  },
+});
+</script>

+ 30 - 0
src/components/dynamicf/Display/ShowDateOrNull.vue

@@ -0,0 +1,30 @@
+<template>
+  <span :class="'vc-show-date '+size">
+    <span v-if="value !== undefined && value !== null">
+      {{ typeof value.format === 'function' ? value.format() : '不是日期类型' }}
+    </span>
+    <span v-else class="text-secondary"><i>{{ nullText }}</i></span>
+  </span>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+  name: "ShowDateOrNull",
+  props: {
+    nullText: {
+      default: '暂无',
+      type: String
+    },
+    size: {
+      default: '',
+      type: String
+    },
+    value: {
+      type: Object as import('vue').PropType<Date>,
+      default: null,
+    }
+  },
+});
+</script>

+ 86 - 0
src/components/dynamicf/Display/ShowImageList.vue

@@ -0,0 +1,86 @@
+<template>
+  <span v-if="!images||images.length==0">无图片</span>
+  <div v-else-if="images" class="image-list">
+    <a-image 
+      v-for="(image, k) in (showAll ? images : images.filter((_: unknown, i: number) => i < maxCount))"
+      :key="k"
+      :width="imgSize"
+      :height="imgSize"
+      :src="image"
+      :fallback="failImage"
+    />
+    <div v-if="images.length > maxCount" class="overflow-count" :style="{ 
+      width: `${imgSize}px`, 
+      height: `${imgSize}px`,
+      lineHeight: `${imgSize}px`,
+    }" @click="showAll=!showAll">
+      {{showAll ? '折叠' : `+${images.length - maxCount}` }}
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from "vue";
+
+export default defineComponent({
+  name: "ShowImageList",
+  props: {
+    images: {
+      type: Object as PropType<Array<string>>,
+      default: null,
+    },
+    size: {
+      type: [Number,String],
+      default: 30,
+    },
+    maxCount: {
+      type: Number,
+      default: 5,
+    },
+    failImage: {
+      default: () => require('@/assets/images/failed.svg'),
+      type: String
+    },
+  },
+  computed: {
+    imgSize() : number {
+      if (typeof this.size === 'string')
+        switch(this.size) {
+          case 'default': return 45;
+          case 'middle': return 30;
+          case 'small': return 20;
+        }
+      return this.size as number;
+    },  
+  },
+  data() {
+    return {
+      showAll: false,
+    };
+  },
+});
+</script>
+
+<style lang="scss">
+.image-list {
+  position: relative;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  flex-wrap: wrap;
+
+  .overflow-count {
+    color: #fff;
+    background-color: rgba(0,0,0,0.5);
+    text-align: center;
+    cursor: pointer;
+  }
+  .ant-image {
+    background-color: #ececec;
+    overflow: hidden;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
+}
+</style>

+ 76 - 0
src/components/dynamicf/Display/ShowImageOrNull.vue

@@ -0,0 +1,76 @@
+<template>
+  <div :style="{
+    display: 'inline-block',
+    overflow: 'hidden',
+    width: `${imgSize}px`,
+    height: `${imgSize}px`,
+  }">
+    <a-image
+      :src="imgUrl"
+      :fallback="failImage"
+      :width="imgSize"
+      :height="imgSize"
+    />
+  </div>
+</template>
+
+<script lang="ts">
+import common from '@/utils/common';
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+  name: "ShowImageOrNull",
+  props: {
+    nullImage: {
+      type: String,
+      default: () => require('@/assets/images/none.png'),
+    },
+    failImage: {
+      type: String,
+      default: () => require('@/assets/images/failed.svg'),
+    },
+    size: {
+      type: [Number,String],
+      default: 30,
+    },
+    src: {
+      type: String,
+      default: null,
+    }
+  },
+  data() {
+    return {
+      imgUrl: '',
+    }
+  },
+  computed: {
+    imgSize() : number {
+      if (typeof this.size === 'string')
+        switch(this.size) {
+          case 'default': return 55;
+          case 'middle': return 45;
+          case 'small': return 25;
+        }
+      return this.size as number;
+    },  
+  },
+  mounted() {
+    setTimeout(() => { this.loadImage(); },100);
+  },
+  watch: {
+    src() { this.loadImage(); }
+  },
+  methods: {
+    loadImage() {
+      if(common.isNullOrEmpty(this.src))
+        this.imgUrl = this.nullImage as string;
+      else
+        this.imgUrl = this.src as string;
+    },
+    onError() {
+      if(this.imgUrl != this.failImage)
+        this.imgUrl = this.failImage as string;
+    }
+  }
+});
+</script>

+ 69 - 0
src/components/dynamicf/Display/ShowInList.vue

@@ -0,0 +1,69 @@
+<template>
+  <div>{{ result }}</div>
+</template>
+
+<script lang="ts">
+import { KeyValue } from "@/utils/common";
+import { defineComponent, PropType } from "vue";
+
+export default defineComponent({
+  name: "ShowInList",
+  data() {
+    return {
+      result: ''
+    }
+  },
+  props: {
+    noMatchText: {
+      type: String,
+      default: '暂无',
+    },
+    useProp: {
+      type: Boolean,
+      default: true,
+    },
+    usePropName: {
+      type: String,
+      default: 'id',
+    },
+    usePropValue: {
+      type: String,
+      default: 'name',
+    },
+    list: {
+      type: Object as PropType<Array<KeyValue>>,
+      default: null,
+    },
+    value: {
+      default: null,
+    }
+  },
+  mounted: function() {
+    this.loadText();
+  },
+  watch: {
+    list() { this.loadText(); },
+    value() { this.loadText(); }
+  },
+  methods: {
+    loadText() {
+      const list = this.list as Array<KeyValue>;
+      if(list && this.value && list.length > 0){
+        for(let i = 0, c = list.length; i < c; i++){
+          if(this.useProp)
+            if(list[i][this.usePropName as string] == this.value) {
+              this.result = list[i][this.usePropValue as string] as string;
+              return;
+            }
+          else
+            if(list[i] == this.value) {
+              this.result = list[i] as unknown as string;
+              return;
+            }
+        }
+        this.result = this.noMatchText as string;
+      }else this.result = this.noMatchText as string;
+    }
+  }
+});
+</script>

+ 35 - 0
src/components/dynamicf/Display/ShowMomentOrNull.vue

@@ -0,0 +1,35 @@
+<template>
+  <span :class="'vc-show-date '+size">
+    <span v-if="value && value!=null">
+      {{ typeof value.format === 'function' ? value.format(dateFormat) : '不是日期类型' }}
+    </span>
+    <span v-else class="text-secondary"><i>{{ nullText }}</i></span>
+  </span>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import dayjs from 'dayjs';
+
+export default defineComponent({
+  name: "ShowDateOrNull",
+  props: {
+    nullText: {
+      default: '暂无',
+      type: String
+    },
+    dateFormat: {
+      default: 'YYYY-MM-DD HH:mm:ss',
+      type: String
+    },
+    size: {
+      default: '',
+      type: String
+    },
+    value: {
+      type: Object as import('vue').PropType<dayjs.Dayjs>,
+      default: null,
+    }
+  },
+});
+</script>

+ 48 - 0
src/components/dynamicf/Display/ShowTagList.vue

@@ -0,0 +1,48 @@
+<template>
+  <span v-if="!tags||tags.length==0">暂无</span>
+  <span v-else-if="small">
+    {{ tags[0] }}等{{tags.length}}个
+  </span>
+  <div v-else-if="tagsC" class="d-flex flex-row flex-wrap">
+    <a-tag v-for="(n, k) in tagsC" :key="k">{{n}}</a-tag>
+    <small class="text-primary" v-if="!expand && tags.length > maxCount" @click="expand=true">等{{tags.length}}个</small>
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from "vue";
+
+export default defineComponent({
+  name: "ShowImageList",
+  data() {
+    return {
+      expand: false,
+    };
+  },
+  computed: {
+    tagsC() {
+      if (!this.expand && this.maxCount < this.tags.length)
+        return this.tags.slice(0, this.maxCount)
+      else
+        return this.tags;
+    },
+    small() {
+      return this.size === 'small';
+    },
+  },
+  props: {
+    size: {
+      type: String,
+      default: '',
+    },
+    maxCount: {
+      type: Number,
+      default: 10,
+    },
+    tags: {
+      type: Object as PropType<Array<string>>,
+      default: null,
+    },
+  },
+});
+</script>

+ 72 - 0
src/components/dynamicf/Display/ShowValueOrNull.vue

@@ -0,0 +1,72 @@
+<template>
+  <span
+    :class="'vc-show-value'+(block? ' d-block' : '')+(clickable?' link':'')"
+    @click="onClick"
+  >
+    {{ prefix }}
+    <template v-if="(value && value!='') || value === 0">
+      <span v-if="typeof value === 'number'">{{ numericalPrecision > 0 ? (value as number).toFixed(numericalPrecision) : value }}</span>
+      <span v-else-if="typeof value === 'boolean'">{{ value ? '是' : '否' }}</span>
+      <span v-else-if="typeof value === 'object'">{{ JSON.stringify(value) }}</span>
+      <span v-else>{{ value }}</span>
+    </template>
+    <span v-else class="text-secondary"><i>{{ nullText }}</i></span>
+  </span>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue";
+
+export default defineComponent({
+  name: "ShowValueOrNull",
+  data() {
+    return {
+      result: ''
+    }
+  },
+  emits: [ 'click' ],
+  props: {
+    block: {
+      default: false,
+      type: Boolean
+    },
+    clickable: {
+      default: false,
+      type: Boolean
+    },
+    prefix: {
+      default: '',
+      type: String
+    },
+    numericalPrecision: {
+      default: 0,
+      type: Number
+    },
+    nullText: {
+      default: '暂无',
+      type: String
+    },
+    record: {
+      default: null,
+    },
+    value: {
+      default: null,
+    },
+  },
+  methods: {
+    onClick() {
+      if (this.clickable)
+        this.$emit('click', this.record);
+    },
+  },
+});
+</script>
+
+<style lang="scss">
+.vc-show-value {
+  &.link {
+    color: #008cff;
+    cursor: pointer;
+  }
+}
+</style>

+ 37 - 0
src/components/dynamicf/Display/StateRenderer.vue

@@ -0,0 +1,37 @@
+<template>
+  <span>
+    <a-badge 
+      v-if="currentState"
+      :status="currentState.badgeState" 
+      :color="currentState.badgeColor" 
+      :text="currentState.text"
+    />
+    <span v-else>未知状态:{{value}}</span>
+  </span>
+</template>
+
+<script lang="ts">
+import type { IDynamicFormItemSelectOption } from "@imengyu/vue-dynamic-form";
+import { defineComponent, type PropType } from "vue";
+
+export interface IStateOption extends IDynamicFormItemSelectOption {
+  badgeState?: 'success' | 'processing' | 'error' | 'default' | 'warning';
+  badgeColor?: string;
+}
+
+export default defineComponent({
+  props: {
+    value: {
+    },
+    stateValues: {
+      type: Object as PropType<Array<IStateOption>>,
+    },
+  },
+  computed: {
+    currentState() {
+      return (this.stateValues as IStateOption[])
+        .find(k => k.value === this.value || k.text === this.value);
+    },
+  },
+});
+</script>

+ 88 - 0
src/components/dynamicf/Dropdown/IdAsValueDropdown.ts

@@ -0,0 +1,88 @@
+import type { DataModel } from "@imengyu/js-request-transform";
+import type { SelectProps } from "ant-design-vue";
+import type { VNode } from "vue";
+
+/**
+ * 通用下拉框返回结构定义
+ */
+export interface DropdownValues<T> {
+  label: string,
+  value: number,
+  raw: T;
+}
+
+export type LoadDataFun<T extends DataModel> = (val: string | null) => Promise<DropdownValues<T>[]>;
+
+/**
+ * IdAsValueDropdown 的公共接口
+ */
+export interface IdAsValueDropdownInterface {
+  /**
+   * 获取某个ID的Lablel
+   * @param value 要获取的ID
+   */
+  getLableByValue(value: number): string;
+  /**
+   * 重新加载数据
+   * @param clearValue 是否需要清除选中数据,默认否
+   */
+  reload(clearValue?: boolean): void;
+}
+/**
+ * IdAsValueDropdown 的公共接口
+ */
+export interface IdAsValueDropdownProps<T extends DataModel> {
+  /**
+   * 允许清除
+   */
+  allowClear?: boolean,
+  /**
+   * 显示空?
+   */
+  showNull?: boolean,
+  /**
+   * 禁用
+   */
+  disabled?: boolean,
+  /**
+   * 多选?
+   */
+  multiple?: boolean,
+  /**
+   * 允许搜索
+   */
+  showSearch?: boolean,
+  placeholder?: string,
+  /**
+   * 未找到数据时的文案
+   */
+  notFoundContent?: string,
+  /**
+   * 初始化时加载数据
+   */
+  loadAtStart?: boolean,
+  /**
+   * 不使用后端筛选数据而是前端直接筛选
+   */
+  filterDirectly?: boolean,
+  /**
+   * 初始化时的搜索数据
+   */
+  intitialSearchValue?: Record<string, unknown>,
+  /**
+   * 加载数据回调
+   */
+  loadData: LoadDataFun<T>,
+  /**
+   * a-select 其他自定义参数
+   */
+  customProps?: SelectProps,
+  /**
+   * 是否自定义渲染option插槽
+   */
+  renderOption?: RenderOption;
+}
+export type RenderOption = (data: {
+  value: unknown,
+  label: string,
+}) => VNode;

+ 253 - 0
src/components/dynamicf/Dropdown/IdAsValueDropdown.vue

@@ -0,0 +1,253 @@
+<template>
+  <a-select
+    :value="valueV"
+    :mode="multiple ? 'multiple' : 'combobox'"
+    :allowClear="allowClear"
+    :showSearch="showSearch"
+    :disabled="disabled"
+    :placeholder="placeholder"
+    :default-active-first-option="false"
+    :notFoundContent="notFoundContent"
+    :options="data"
+    :filterOption="showSearch && filterDirectly ? filterOption : false"
+    @update:value="handleChange"
+    @search="handleSearch"
+    v-bind="customProps"
+    style="min-width: 150px"
+  >
+    <template v-if="renderOption" #option="data">
+      <VNodeRenderer :render="renderOption" :data="data" />
+    </template>
+    <a-select-option v-if="showNull" :value="null">(空)</a-select-option>
+  </a-select>
+</template>
+
+<script lang="ts">
+import VNodeRenderer from "@/components/VNodeRenderer.vue";
+import { type SelectProps } from "ant-design-vue";
+import { defineComponent, markRaw, type PropType, type VNode } from "vue";
+import { debounce } from 'lodash-es';
+import type { DropdownValues, LoadDataFun } from "./IdAsValueDropdown";
+import type { DataModel } from "@imengyu/js-request-transform";
+import CommonUtils from "@/common/utils/CommonUtils";
+
+/**
+ * IdAsValueDropdown 的公共接口
+ */
+export interface IdAsValueDropdownInterface {
+  /**
+   * 获取某个ID的Lablel
+   * @param value 要获取的ID
+   */
+  getLableByValue(value: number): string;
+  /**
+   * 重新加载数据
+   * @param clearValue 是否需要清除选中数据,默认否
+   */
+  reload(clearValue?: boolean): void;
+}
+/**
+ * IdAsValueDropdown 的公共接口
+ */
+export interface IdAsValueDropdownProps<T extends DataModel> {
+  /**
+   * 允许清除
+   */
+  allowClear?: boolean,
+  /**
+   * 显示空?
+   */
+  showNull?: boolean,
+  /**
+   * 禁用
+   */
+  disabled?: boolean,
+  /**
+   * 多选?
+   */
+  multiple?: boolean,
+  /**
+   * 允许搜索
+   */
+  showSearch?: boolean,
+  placeholder?: string,
+  /**
+   * 未找到数据时的文案
+   */
+  notFoundContent?: string,
+  /**
+   * 初始化时加载数据
+   */
+  loadAtStart?: boolean,
+  /**
+   * 不使用后端筛选数据而是前端直接筛选
+   */
+  filterDirectly?: boolean,
+  /**
+   * 初始化时的搜索数据
+   */
+  intitialSearchValue?: Record<string, unknown>,
+  /**
+   * 加载数据回调
+   */
+  loadData: LoadDataFun<T>,
+  /**
+   * a-select 其他自定义参数
+   */
+  customProps?: SelectProps,
+  /**
+   * 是否自定义渲染option插槽
+   */
+  renderOption?: RenderOption<T>;
+}
+type RenderOption<T> = (data: {
+  value: unknown,
+  label: string,
+  raw: T
+}) => VNode;
+
+/**
+ * 使用数据的ID作为value的下拉框包装
+ */
+export default defineComponent({
+  name: "IdAsValueDropdown",
+  data() {
+    return {
+      valueV: null,
+      data: [] as DropdownValues<DataModel>[],
+      lastLoadValue: null,
+      handleSearch: markRaw(debounce((val: string) => {
+        if (!this.filterDirectly)
+          this.doLoadData(val);
+      }, 500)),
+    };
+  },
+  emits: [
+    "update:value",
+    "change",
+    "loaded",
+  ],
+  props: {
+    showNull: {
+      default: false,
+      type: Boolean
+    },
+    renderOption: {
+      default: null,
+      type: Function as PropType<RenderOption<DataModel>>
+    },
+    allowClear: {
+      default: false,
+      type: Boolean
+    },
+    multiple: {
+      default: false,
+      type: Boolean
+    },
+    disabled: {
+      default: false,
+      type: Boolean
+    },
+    showSearch: {
+      default: true,
+      type: Boolean
+    },
+    placeholder: {
+      default: "输入可进行搜索",
+      type: String
+    },
+    notFoundContent: {
+      default: "未找到数据,请换个搜索词再试",
+      type: String
+    },
+    loadAtStart: {
+      default: true,
+      type: Boolean
+    },
+    filterDirectly: {
+      default: true,
+      type: Boolean
+    },
+    value: {
+      default: null,
+    },
+    intitialSearchValue: {
+      default: null,
+      type: String
+    },
+    loadData: {
+      type: Function as PropType<LoadDataFun<DataModel>>,
+      default: null,
+    },
+    /**
+     * a-select 其他自定义参数
+     */
+    customProps: {
+      type: Object as PropType<SelectProps>,
+      default: null,
+    },
+  },
+  methods: {
+    handleChange(value: unknown) {
+      this.$emit("change", value);
+      this.$emit("update:value", value);
+    },
+    doLoadData(val: string | null) {
+      if (typeof this.loadData === "function") {
+        const oldValue = this.valueV;
+        this.valueV = null;
+        (this.loadData as LoadDataFun<DataModel>)(val).then((d) => {
+          this.data = d;
+          setTimeout(() => {
+            this.valueV = oldValue;
+            this.$emit("loaded");
+          }, 30);
+        });
+      }
+    },
+    filterOption(input: string, option: {
+      label: string;
+    }) {
+      return !this.filterDirectly || option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0;
+    },
+    getLableByValue(value: number) {
+      for (let i = 0; i < this.data.length; i++) {
+        if (this.data[i].value == value) {
+          return this.data[i].label;
+        }
+      }
+      return "";
+    },
+    reload(clearValue = false) {
+      if (clearValue) {
+        this.valueV = null;
+        this.handleChange(null);
+      }
+      this.data = [];
+      this.doLoadData(this.intitialSearchValue as string);
+    },
+  },
+  watch: {
+    loadData() {
+      this.doLoadData(this.intitialSearchValue);
+    },
+    intitialSearchValue(v) {
+      if (!this.filterDirectly && !CommonUtils.isNullOrEmpty(v)) {
+        this.doLoadData(v);
+      }
+    },
+    value(v) {
+      this.valueV = v;
+    },
+  },
+  mounted() {
+    this.valueV = this.value;
+    setTimeout(() => {
+      if (this.loadAtStart) {
+        this.doLoadData(this.intitialSearchValue as string);
+      }
+    }, 300);
+  },
+  components: { VNodeRenderer }
+});
+</script>

+ 336 - 0
src/components/dynamicf/Dropdown/IdAsValueTreeDropdown.vue

@@ -0,0 +1,336 @@
+<template>
+  <div v-if="showDisplayValue" class="display-value" @click="handleDisplayValueClick">
+    <span>{{displayValue}}</span>
+  </div>
+  <a-tree-select
+    v-else
+    ref="selectRef"
+    style="min-width: 150px"
+    :defaultOpen="true"
+    :value="valueV"
+    :dropdown-style="dropdownStyle"
+    :notFoundContent="notFoundContent"
+    :tree-data="treeData"
+    :load-data="handleLoadData"
+    :treeDataSimpleMode="true"
+    :placeholder="placeholder"
+    :allow-clear="allowClear"
+    :multiple="multiple"
+    v-bind="customProps"
+    @blur="handleSelectBlur"
+    @update:value="handleChange"
+  />
+</template>
+
+<script lang="ts">
+import { TreeDataItem } from "@/models/ui/TreeCommon";
+import { SelectProps } from "ant-design-vue";
+import { defineComponent, PropType } from "vue";
+
+export type LoadDataFun = (pid: string|number, level: number) => Promise<TreeDataItem[]>;
+export type CheckClickableFun = (item: TreeDataItem) => Promise<boolean>;
+
+export type GetDiaplayValue = (ref: IdAsValueTreeDropdownInterface) => string;
+export type GetRef = (ref: IdAsValueTreeDropdownInterface) => void;
+
+/**
+ * IdAsValueTreeDropdown 的公共接口
+ */
+export interface IdAsValueTreeDropdownInterface {
+  /**
+   * 获取某个ID的树(正排列)
+   * @param value 要获取的ID
+   */
+  getTree(value: number) : Array<TreeDataItem>;
+  /**
+   * 获取某个ID的Lablel
+   * @param value 要获取的ID
+   */
+  getLableByValue(value: number) : string;
+  /**
+   * 重新加载数据
+   */
+  reload(): void;
+}
+/**
+ * IdAsValueTreeDropdown 的公共接口
+ */
+export interface IdAsValueTreeDropdownProps {
+  /**
+   * 允许清除
+   */
+  allowClear?: boolean,
+  /**
+   * 多选?
+   */
+  multiple?: boolean,
+  dropdownStyle?: Record<string, unknown>,
+  disabled?: boolean,
+  placeholder?: string,
+  /**
+   * 未找到数据时的文案
+   */
+  notFoundContent?: string,
+  /**
+   * 初始化时加载数据
+   */
+  loadAtStart?: boolean,
+  /**
+   * 加载数据
+   */
+  loadData?: LoadDataFun,
+  /**
+   * 自定义检查条目是否可点击回调
+   */
+  checkClickable?: CheckClickableFun,
+  /**
+   * 获取显示数据回调
+   */
+  getDisplayValue?: GetDiaplayValue,
+  /**
+   * 是否在非激活时显示临时字符串(防止树形数据没有加载,而无法显示当前值)
+   */
+  showDisplayValueBeforeEdit?: boolean,
+  /**
+   * 子数据最大层级
+   */
+  maxLevel?: number,
+  /**
+   * 是否只有最后一级可以点击
+   */
+  onlyLastLevelClickable?: boolean,
+  /**
+   * a-select 其他自定义参数
+   */
+  customProps?: SelectProps,
+}
+
+/**
+ * 使用数据的ID作为value的下拉框包装
+ */
+export default defineComponent({
+  name: "IdAsValueTreeDropdown",
+  emits: [
+    'update:value',
+    'change',
+    'blur',
+  ],
+  props: {
+    allowClear: {
+      default: true,
+      type: Boolean
+    },
+    multiple: {
+      default: false,
+      type: Boolean
+    },
+    dropdownStyle: {
+      type: Object,
+      default: () => { return { maxHeight: '400px', overflow: 'auto' } }
+    },
+    disabled: {
+      default: false,
+      type: Boolean
+    },
+    placeholder: {
+      default: '请选择,输入可进行搜索',
+      type: String
+    },
+    notFoundContent: {
+      default: '未找到数据,请换个搜索词再试',
+      type: String
+    },
+    loadAtStart: {
+      default: true,
+      type: Boolean
+    },
+    value: {
+      default: null,
+    },
+    loadData: {
+      type: Function as PropType<LoadDataFun>,
+      default: null,
+    },
+    checkClickable: {
+      type: Function as PropType<CheckClickableFun>,
+      default: null,
+    },
+    getDisplayValue: {
+      type: Function as PropType<GetDiaplayValue>,
+      default: null,
+    },
+    defaultDisplayValue: {
+      type: String,
+      default: '',
+    },
+    showDisplayValueBeforeEdit: {
+      default: false,
+      type: Boolean
+    },
+    maxLevel: {
+      default: 0,
+      type: Number,
+    },
+    onlyLastLevelClickable: {
+      default: false,
+      type: Boolean
+    },
+    /**
+     * a-select 其他自定义参数
+     */
+    customProps: {
+      type: Object as PropType<SelectProps>,
+      default: null,
+    },
+  },
+  computed: {
+    displayValue() : string {
+      if (this.valueV != null && this.valueV != 0 && this.defaultDisplayValue != '')
+        return this.defaultDisplayValue;
+      if (this.getDisplayValue)
+        return (this.getDisplayValue as GetDiaplayValue)(this as IdAsValueTreeDropdownInterface); 
+      return '';
+    },
+  },
+  methods: {
+    handleChange(value: unknown) {
+      this.$nextTick(() => {
+        if(value != this.value) {
+          this.$emit('update:value', value); 
+          this.$emit('change', value); 
+        }
+      })
+    },
+    handleLoadData(treeNode: { dataRef: TreeDataItem }) {
+      return new Promise((resolve: (value?: unknown) => void) => {
+        const { id, level } = treeNode.dataRef;
+        this.doLoadData(id, level as number).then(() => resolve()).catch(() => resolve());
+      });
+    },
+    handleDisplayValueClick() {
+      this.showDisplayValue = false;
+      setTimeout(() => {
+        (this.$refs.selectRef as {
+          focus: () => void
+        }).focus();
+      }, 200);
+    },
+    handleSelectBlur() {
+      if(this.showDisplayValueBeforeEdit) {
+        if(this.valueV != null && this.valueV != 0 && this.defaultDisplayValue != '')
+          this.showDisplayValue = true;
+        else if (this.getLableByValue(this.valueV as number) === '') //只有没有在列表中搜索到数据时,才显示临时数据
+          this.showDisplayValue = true;
+      }
+    },
+    doLoadData(pid: string|number|null, level: number) {
+      const loadData = this.loadData;
+      if(typeof loadData === 'function') {
+        return (loadData as LoadDataFun)(pid as string, level).then((d) => {
+          for(let i = this.treeData.length - 1; i >= 0; i--)
+            if(this.treeData[i].pId == pid)
+              this.treeData.splice(i, 1);
+          d.forEach(h => {
+            h.level = level + 1;
+            if(this.maxLevel > 0 && h.level >= this.maxLevel)
+              h.isLeaf = true;
+            if(typeof this.checkClickable === 'function')
+              this.checkClickable(h).then((v: boolean) => h.selectable = v);
+            else if(this.maxLevel > 0) { 
+              if(h.level >= this.maxLevel)
+                h.selectable = false;
+              if(this.onlyLastLevelClickable) 
+                h.selectable = (h.level == this.maxLevel);
+            }
+            this.treeData.push(h)
+          });
+        });
+      } else 
+        return Promise.resolve();
+    },
+
+    /**
+     * 获取某个ID的树(正排列)
+     */
+    getTree(value: number) {
+      const result = new Array<TreeDataItem>();
+      let child : TreeDataItem|null = this.treeData.find((v) => v.id == value) as TreeDataItem;
+      while(child) {
+        result.unshift(child);
+        if(child.pId == 0) child = null;
+        else child = this.treeData.find((v) => v.id == (child as TreeDataItem).pId) as TreeDataItem;
+      }
+      return result;
+    },
+    /**
+     * 获取某个ID的Lablel
+     */
+    getLableByValue(value: number) {
+      const data = this.treeData;
+      for (let i = 0; i < data.length; i++) {
+        if(data[i].value == value) {
+          return data[i].title;
+        }
+      }
+      return '';
+    },
+    /**
+     * 重新加载数据
+     */
+    reload() {
+      this.treeData = [];
+      this.doLoadData(0, 0) 
+    },
+  },
+  watch: {
+    value(v) {
+      this.valueV = v;
+    },
+    showDisplayValueBeforeEdit(v, old) {
+      if(!old && v) {
+        this.showDisplayValue = true;
+      }
+    },
+  },
+  data() {
+    return {
+      showDisplayValue: false,
+      valueV: null as null|number|string,
+      treeData: [] as TreeDataItem[],
+    }
+  },
+  mounted() { 
+    this.valueV = this.value;
+    if(this.showDisplayValueBeforeEdit)
+      this.showDisplayValue = true;
+    setTimeout(() => { 
+      if(this.loadAtStart) {
+        this.treeData = [];
+        this.doLoadData(0, 0) ;
+      }
+    } , 300);
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.display-value {
+  min-width: 150px;
+  padding: 4px 11px;
+  color: rgba(0, 0, 0, 0.85);
+  font-size: 14px;
+  line-height: 1.5715;
+  background-color: #fff;
+  background-image: none;
+  border: 1px solid #d9d9d9;
+  border-radius: 2px;
+}
+//暗黑主题
+body[data-theme="dark"] {
+  .display-value {
+    color: #dedede;
+    background-color: #1f1f1f;
+    border: 1px solid #434343;
+  }
+}
+</style>

+ 66 - 0
src/components/dynamicf/IdAsValueTree.ts

@@ -0,0 +1,66 @@
+import { TreeNode } from "@/models/ui/TreeCommon";
+import { SelectProps } from "ant-design-vue";
+
+export type LoadDataFun = (pid: string|number, level: number) => Promise<TreeNode[]>;
+export type CheckClickableFun = (item: TreeNode) => Promise<boolean>;
+
+export type GetDiaplayValue = (ref: IdAsValueTreeInterface) => string;
+export type GetRef = (ref: IdAsValueTreeInterface) => void;
+
+/**
+ * IdAsValueTree 的公共接口
+ */
+export interface IdAsValueTreeInterface {
+  /**
+   * 获取某个ID的树(正排列)
+   * @param value 要获取的ID
+   */
+  getTree(value: number) : Array<TreeNode>;
+  /**
+   * 获取某个ID的Lablel
+   * @param value 要获取的ID
+   */
+  getLableByValue(value: number) : string;
+  /**
+   * 重新加载数据
+   */
+  reload(): void;
+}
+/**
+ * IdAsValueTree 的公共接口
+ */
+export interface IdAsValueTreeProps {
+  /**
+   * 允许清除
+   */
+  allowClear?: boolean,
+  /**
+   * 多选?
+   */
+  multiple?: boolean,
+  disabled?: boolean,
+  /**
+   * 初始化时加载数据
+   */
+  loadAtStart?: boolean,
+  /**
+   * 加载数据
+   */
+  loadData?: LoadDataFun,
+  /**
+   * 自定义检查条目是否可点击回调
+   */
+  checkClickable?: CheckClickableFun,
+  /**
+   * 子数据最大层级
+   */
+  maxLevel?: number,
+  /**
+   * 是否只有最后一级可以点击
+   */
+  onlyLastLevelClickable?: boolean,
+  /**
+   * a-select 其他自定义参数
+   */
+  customProps?: SelectProps,
+}

+ 180 - 0
src/components/dynamicf/IdAsValueTree.vue

@@ -0,0 +1,180 @@
+<template>
+  <div class="IdAsValueTree">
+    <a-tree
+      ref="selectRef"
+      style="min-width: 150px"
+      :defaultOpen="true"
+      v-model:expandedKeys="expandedKeys"
+      v-model:checkedKeys="checkedKeys"
+      checkable
+      :tree-data="treeData"
+      :load-data="handleLoadData"
+      :allow-clear="allowClear"
+      v-bind="customProps"
+    />
+  </div>
+</template>
+
+<script lang="ts">
+import { TreeNode } from "@/models/ui/TreeCommon";
+import { TreeProps } from "ant-design-vue";
+import { defineComponent, PropType } from "vue";
+import { CheckClickableFun, LoadDataFun } from "./IdAsValueTree";
+
+/**
+ * 使用数据的ID作为value的tree包装
+ */
+export default defineComponent({
+  name: "IdAsValueTree",
+  emits: [
+    'update:value',
+    'change',
+    'blur',
+  ],
+  props: {
+    allowClear: {
+      default: true,
+      type: Boolean
+    },
+    disabled: {
+      default: false,
+      type: Boolean
+    },
+    loadAtStart: {
+      default: true,
+      type: Boolean
+    },
+    value: {
+      default: null,
+    },
+    loadData: {
+      type: Function as PropType<LoadDataFun>,
+      default: null,
+    },
+    checkClickable: {
+      type: Function as PropType<CheckClickableFun>,
+      default: null,
+    },
+    maxLevel: {
+      default: 0,
+      type: Number,
+    },
+    onlyLastLevelClickable: {
+      default: false,
+      type: Boolean
+    },
+    /**
+     * a-select 其他自定义参数
+     */
+    customProps: {
+      type: Object as PropType<TreeProps>,
+      default: null,
+    },
+  },
+  methods: {
+    handleChange() {
+      this.$nextTick(() => {
+        this.$emit('update:value', this.checkedKeys); 
+        this.$emit('change', this.checkedKeys); 
+      })
+    },
+    handleLoadData(treeNode: { dataRef: TreeNode }|null) {
+      return new Promise((resolve: (value?: unknown) => void) => {
+        this.doLoadData(treeNode?.dataRef || null).then(() => resolve()).catch(() => resolve());
+      });
+    },
+    doLoadData(dataRef: TreeNode|null) {
+      const { id, level } = dataRef || { id: 0, level: 0 };
+      const pid = id as number;
+      const loadData = this.loadData;
+      if(typeof loadData === 'function') {
+        return (loadData as LoadDataFun)(pid, level as number).then((d) => {
+          if (dataRef && !dataRef.children)
+            dataRef.children = [];
+          d.forEach(h => {
+            h.level = level as number + 1;
+            if(this.maxLevel > 0 && h.level >= this.maxLevel)
+              h.isLeaf = true;
+            if(typeof this.checkClickable === 'function')
+              this.checkClickable(h).then((v: boolean) => h.selectable = v);
+            else if(this.maxLevel > 0) { 
+              if(h.level >= this.maxLevel)
+                h.selectable = false;
+              if(this.onlyLastLevelClickable) 
+                h.selectable = (h.level == this.maxLevel);
+            }
+            if (dataRef)
+              dataRef.children?.push(h);
+            else
+              this.treeData.push(h);
+          });
+        });
+      } else 
+        return Promise.resolve();
+    },
+
+    /**
+     * 获取某个ID的树(正排列)
+     */
+    getTree(value: number) {
+      const result = new Array<TreeNode>();
+      let child : undefined|TreeNode = (this.treeData as TreeNode[]).find((v) => v.id == value);
+      while(child) {
+        result.unshift(child);
+        if(child.pid == 0) child = undefined;
+        else child = (this.treeData as TreeNode[]).find((v) => v.id == (child as TreeNode).pid);
+      }
+      return result;
+    },
+    /**
+     * 获取某个ID的Lablel
+     */
+    getLableByValue(value: number) {
+      const data = this.treeData;
+      for (let i = 0; i < data.length; i++) {
+        if(data[i].id == value) {
+          return data[i].title;
+        }
+      }
+      return '';
+    },
+    /**
+     * 重新加载数据
+     */
+    reload() {
+      this.treeData = [];
+    },
+  },
+  watch: {
+    value(v) {
+      this.checkedKeys = (v as string[]);
+    },
+    checkedKeys() {
+      this.handleChange();
+    },
+  },
+  data() {
+    return {
+      expandedKeys: [] as string[],
+      checkedKeys: [] as string[],
+      treeData: [] as TreeNode[],
+    }
+  },
+  mounted() { 
+    this.checkedKeys = (this.value as unknown as string[]) || [];
+    setTimeout(() => { 
+      if(this.loadAtStart) {
+        this.treeData = [];
+        this.handleLoadData(null);
+      }
+    } , 300);
+  }
+});
+</script>
+
+<style>
+.IdAsValueTree {
+  border: 1px solid #efefef;
+  padding: 10px;
+}
+</style>

+ 69 - 0
src/components/dynamicf/NumberRange.vue

@@ -0,0 +1,69 @@
+<template>
+  <div class="vc-number-range d-flex flex-row align-items-center">
+    <a-input-number placeholder="最小值" :disabled="disabled" :value="realValue[0]" @update:value="(v: number) => onUpdateValue(v, 0)" v-bind="customProps" />
+    <span class="p-2">-</span>
+    <a-input-number placeholder="最大值" :disabled="disabled" :value="realValue[1]" @update:value="(v: number) => onUpdateValue(v, 1)" v-bind="customProps" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+/**
+ * 下拉框表单控件,用于解决 a-select 不能选择对象的问题
+ */
+import { Form, type InputNumberProps } from 'ant-design-vue';
+import { defineProps, defineEmits, type PropType, ref, watch, onMounted } from 'vue';
+
+const props = defineProps({
+  /**
+   * 是否禁用
+   */
+  disabled: {
+    type: Boolean,
+    default: false
+  },
+  /**
+   * 选择值
+   */
+  value: {
+  },
+  /**
+   * a-number-input 其他自定义参数
+   */
+  customProps: {
+    type: Object as PropType<InputNumberProps>,
+    default: null,
+  },
+});
+
+const emits = defineEmits([
+  'update:value',
+]);
+
+const realValue = ref<number[]>([]);
+const { onFieldChange } = Form.useInjectFormItemContext();
+
+watch(() => props.value, (v) => {
+  if ((v as number[])?.length == 2)
+    realValue.value = v as number[];
+  else {
+    if (realValue.value.length === 1 && realValue.value[0] === undefined) {
+      realValue.value = [];
+      emits('update:value', []);
+    } else {
+      realValue.value = [];
+    }
+  }
+});
+onMounted(() => {
+  realValue.value = (props.value as number[])?.length == 2 ? props.value as number[] : [];
+});
+
+function onUpdateValue(v : number, index: number) {
+  realValue.value[index] = v;
+  if (realValue.value.length < 2)
+    realValue.value.push(0);
+  emits('update:value', realValue.value);
+  onFieldChange();
+}
+
+</script>

+ 116 - 0
src/components/dynamicf/PasswordStrengthMeter.vue

@@ -0,0 +1,116 @@
+<template>
+  <div class="password-meter">
+    <div class="bar">
+      <div :class="'level'+level"></div>
+    </div>
+    <span :class="'level'+level">密码强度 {{levelString}}</span>
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import { checkPassWordSecrityLevel } from '@/common/utils/CheckUtils'
+
+/**
+ * 密码强度显示组件
+ */
+export default defineComponent({
+  props: {
+    password: {
+      type: String,
+      default: '',
+    }
+  },
+  data() {
+    return {
+      level: 0,
+      levelString: '',
+    }
+  },
+  watch: {
+    password(v: string) {
+      this.level = Math.floor((checkPassWordSecrityLevel(v) / 100) * 5);
+      switch(this.level) {
+        case 0: this.levelString = '非常弱'; break;
+        case 1: this.levelString =  '弱'; break;
+        case 2: this.levelString =  '中等'; break;
+        case 3: this.levelString =  '强'; break;
+        case 4: this.levelString =  '非常强'; break;
+      }
+    },
+  }
+})
+</script>
+
+<style lang="scss">
+.password-meter {
+  position: relative;
+  height: 20px;
+  margin: 10px 0;
+
+  .bar {
+    position: absolute;
+    left: 0;
+    top: 0;
+    bottom: 0;
+    right: 130px;
+    background-color: #e7e7e7;
+    border: 1px solid #a1a1a1;
+
+    div {
+      position: absolute;
+      left: 0;
+      top: 0;
+      bottom: 0;
+
+      &.level0 {
+        width: 0;
+        background-color: #000;
+      }
+      &.level1 {
+        width: 25%;
+        background-color: #ca410a;
+      }
+      &.level2 {
+        width: 50%;
+        background-color: #d8c40c;
+      }
+      &.level3 {
+        width: 75%;
+        background-color: #9ab814;
+      }
+      &.level4 {
+        width: 100%;
+        background-color: #2fbe0b;
+      } 
+    }
+  }
+  > span {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    width: 100px;
+    right: 0;
+    font-size: 12px;
+
+    &.level0 {
+      color: #646464;
+    }
+    &.level1 {
+      color: #a8380c;
+    }
+    &.level2 {
+      color: #af9f0e;
+    }
+    &.level3 {
+      color: #91ac18;
+    }
+    &.level4 {
+      color: #26920b;
+    }
+  }
+
+
+
+}
+</style>

+ 37 - 0
src/components/dynamicf/PasswordWithStrengthInput.vue

@@ -0,0 +1,37 @@
+<template>
+  <div>
+    <a-input 
+      :value="value"
+      @update:value="(v: string) => $emit('update:value', v)"
+      :disabled="disabled"
+      type="password"
+      v-bind="(item?.additionalProps as {})"
+    />
+    <PasswordStrengthMeter :password="(value as string)" />
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, type PropType } from "vue";
+import PasswordStrengthMeter from "./PasswordStrengthMeter.vue";
+import type { IDynamicFormItem } from "@imengyu/vue-dynamic-form";
+
+export default defineComponent({
+  props: {
+    item: {
+      type: Object as PropType<IDynamicFormItem>,
+    },
+    disabled: {
+      type: Boolean
+    },
+    value: {},
+    additionalProps: {
+      type: Object as PropType<Record<string, unknown>>,
+    },
+  },
+  emits: [
+    "update:value"
+  ],
+  components: { PasswordStrengthMeter }
+});
+</script>

+ 29 - 0
src/components/dynamicf/RadioValue.ts

@@ -0,0 +1,29 @@
+import type { RadioGroupProps, RadioProps } from "ant-design-vue";
+
+export interface IDynamicFormItemRadioValueOption {
+  text: string,
+  value: unknown,
+}
+
+export interface SimpleRadioValueFormItemProps {
+  /**
+   * 是否禁用
+   */
+  disabled: boolean;
+  /**
+   * 选项数据
+   */
+  options: IDynamicFormItemRadioValueOption[];
+  /**
+   * 选择值
+   */
+  value: unknown;
+  /**
+   * a-radio 其他自定义参数
+   */
+  customProps: RadioProps;
+  /**
+   * a-radio 其他自定义参数
+   */
+  customGroupProps: RadioGroupProps;
+}

+ 77 - 0
src/components/dynamicf/RadioValue.vue

@@ -0,0 +1,77 @@
+<template>
+  <a-radio-group 
+    :value="selectValue"
+    @update:value="onUpdateValue"
+    :disabled="disabled"
+    v-bind="customGroupProps"
+  >
+    <a-radio 
+      v-for="it in options"
+      :key="it.text"
+      :value="it.text"
+      v-bind="customProps"
+    >
+      {{it.text}}
+    </a-radio>
+  </a-radio-group>
+</template>
+
+<script lang="ts" setup>
+/**
+ * 下拉框表单控件,用于解决 a-select 不能选择对象的问题
+ */
+import type { RadioGroupProps, RadioProps } from 'ant-design-vue';
+import { defineProps, defineEmits, type PropType, ref, watch, onMounted } from 'vue';
+import type { IDynamicFormItemRadioValueOption } from './RadioValue';
+
+const props = defineProps({
+  /**
+   * 是否禁用
+   */
+  disabled: {
+    type: Boolean,
+    default: false
+  },
+  /**
+   * 选项数据
+   */
+  options: {
+    type: Object as PropType<IDynamicFormItemRadioValueOption[]>,
+    default: null,
+  },
+  value: {
+  },
+  customProps: {
+    type: Object as PropType<RadioProps>,
+    default: null,
+  },
+  customGroupProps: {
+    type: Object as PropType<RadioGroupProps>,
+    default: null,
+  },
+});
+
+const emits = defineEmits([
+  'update:value',
+]);
+
+const selectValue = ref<string|null>('');
+
+function setRadioValue() {
+  selectValue.value = props.options.find(k => (k.value === props.value))?.text || null;
+  if (selectValue.value === null)
+    selectValue.value = props.options.find(k => (typeof k.value === typeof props.value))?.text || null;
+}
+
+watch(() => props.value, () => {
+  setRadioValue();
+});
+onMounted(() => {
+  setRadioValue();
+});
+
+function onUpdateValue(v : unknown) {
+  emits('update:value', props.options.find(k => k.text === v)?.value);
+}
+
+</script>

+ 25 - 0
src/components/dynamicf/SelectValue.ts

@@ -0,0 +1,25 @@
+import type { SelectProps } from "ant-design-vue";
+
+export interface IDynamicFormItemSelectValueOption {
+  text: string,
+  value: unknown,
+}
+
+export interface SimpleSelectValueFormItemProps {
+  /**
+   * 是否禁用
+   */
+  disabled: boolean;
+  /**
+   * 选项数据
+   */
+  options: IDynamicFormItemSelectValueOption[];
+  /**
+   * 选择值
+   */
+  value: unknown;
+  /**
+   * a-select 其他自定义参数
+   */
+  customProps: SelectProps;
+}

+ 74 - 0
src/components/dynamicf/SelectValue.vue

@@ -0,0 +1,74 @@
+<template>
+  <a-select
+    :value="selectValue"
+    @update:value="onUpdateValue"
+    :disabled="disabled"
+    v-bind="customProps"
+  >
+    <a-select-option v-for="it in options" :key="it.text" :value="it.text">
+      {{it.text}}
+    </a-select-option>
+  </a-select>
+</template>
+
+<script lang="ts" setup>
+/**
+ * 下拉框表单控件,用于解决 a-select 不能选择对象的问题
+ */
+import type { SelectProps } from 'ant-design-vue/lib/vc-select';
+import { defineProps, defineEmits, type PropType, ref, watch, onMounted } from 'vue';
+import type { IDynamicFormItemSelectValueOption } from './SelectValue';
+
+const props = defineProps({
+  /**
+   * 是否禁用
+   */
+  disabled: {
+    type: Boolean,
+    default: false
+  },
+  /**
+   * 选项数据
+   */
+  options: {
+    type: Object as PropType<IDynamicFormItemSelectValueOption[]>,
+    default: null,
+  },
+  /**
+   * 选择值
+   */
+  value: {
+  },
+  /**
+   * a-select 其他自定义参数
+   */
+  customProps: {
+    type: Object as PropType<SelectProps>,
+    default: null,
+  },
+});
+
+const emits = defineEmits([
+  'update:value',
+]);
+
+const selectValue = ref<string|null>('');
+
+function setSelectValue() {
+  selectValue.value = props.options.find(k => (k.value === props.value))?.text || null;
+  if (selectValue.value === null)
+    selectValue.value = props.options.find(k => (typeof k.value === typeof props.value))?.text || null;
+}
+
+watch(() => props.value, () => {
+  setSelectValue();
+});
+onMounted(() => {
+  setSelectValue();
+});
+
+function onUpdateValue(v : unknown) {
+  emits('update:value', props.options.find(k => k.text === v)?.value);
+}
+
+</script>

+ 99 - 0
src/components/dynamicf/SimpleEditDynamicStringList.vue

@@ -0,0 +1,99 @@
+<template>
+  <div>
+    <div>
+      <a-button @click="() => value?.push('')">
+        添加 <template #icon><PlusOutlined /></template>
+      </a-button>
+      <a-popconfirm
+        v-if="value && value.length > 0"
+        title="真的要清空所有条目吗?"
+        @confirm="value?.splice(0, value.length)"
+      >
+        <a-button class="ml-3" danger >
+          <template #icon>
+            <CloseSquareOutlined />
+          </template>
+            清空
+        </a-button>
+      </a-popconfirm>
+    </div>
+    <div class="mt-2 position-relative" v-for="(v, k) in value" :key="k">
+      <input 
+        class="display-inline-block ant-input" 
+        style="width:calc(100% - 100px)" 
+        v-model="value[k]" 
+        :maxlength="maxLength" 
+        :disabled="disabled" 
+        :placeholder="placeholder" />
+      <a-popconfirm
+        title="确定删除此条目吗?"
+        @confirm="value?.slice(value.indexOf(v), 1)"
+      >
+        <a-button class="ml-3">
+          <template #icon><DeleteOutlined /></template>
+        </a-button>
+      </a-popconfirm>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, type PropType } from 'vue'
+import { PlusOutlined, DeleteOutlined, CloseSquareOutlined } from '@ant-design/icons-vue'
+
+/**
+ * 简单的字符串列表动态表单
+ */
+export default defineComponent({
+  name: 'SimpleEditDynamicStringList',
+  components: {
+    PlusOutlined,
+    DeleteOutlined,
+    CloseSquareOutlined,
+  },  
+  emits: [
+    'update:value',
+    'blur',
+  ],
+  props: {
+    /**
+     * ID
+     */
+    id: {
+      required: false
+    },
+    /**
+     * 参数数组
+     */
+    value: {
+      type: Object as PropType<Array<string>>
+    },
+    /**
+     * 输入框 placeholder
+     */
+    placeholder: {
+      type: String,
+      default: '请输入参数值',
+    },
+    /**
+     * 输入框最大输入长度
+     */
+    maxLength: {
+      type: Number,
+      default: 50,
+    },
+    /**
+     * sf
+     */
+    disabled: {
+      type: Boolean,
+      default: false,
+    },
+  },
+  data() {
+    return {
+      deleteConfirm: false,
+    }
+  },
+})
+</script>

+ 128 - 0
src/components/dynamicf/SimpleListDynamicForm.vue

@@ -0,0 +1,128 @@
+<template>
+  <a-row :gutter="gutter">
+    <a-col :span="span" v-for="(item, key) in items" :key="key">
+      <div class="d-flex flex-row">
+        <div class="flex-grow-1">
+          <a-form-item v-if="item.type=='text'" :label="item.label" :labelCol="labelCol" :wrapperCol="wrapperCol" :name="item.name||item.key">
+            <a-input 
+              :required="item.required"
+              v-model:value="item.value"
+              :placeholder="item.placeholder||''"
+              :addon-before="item.prefix"
+              :addon-after="item.suffix"
+              :max-length="item.appenderParams?(item.appenderParams.maxLength):undefined"
+            />
+          </a-form-item>
+          <a-form-item v-else-if="item.type=='number'" :label="item.label" :labelCol="labelCol" :wrapperCol="wrapperCol" :name="item.name||item.key">
+            <a-input-number 
+              :required="item.required"
+              v-model:value="item.value"
+              :style="item.suffix&&item.suffix!=''?'':'width: 100%;'"
+              :min="item.appenderParams?(item.appenderParams.min):undefined"
+              :max="item.appenderParams?(item.appenderParams.max):undefined"
+              :step="item.appenderParams?(item.appenderParams.step):undefined"
+            />
+            <span v-if="item.suffix&&item.suffix!=''" class="ml-2">{{item.suffix}}</span>
+          </a-form-item>
+          <a-form-item v-if="item.type=='checkbox'" :label="item.label" :labelCol="labelCol" :wrapperCol="wrapperCol" :name="item.name||item.key">
+            <a-checkbox v-model:checked="item.value">{{item.label}}</a-checkbox>
+          </a-form-item>
+          <a-form-item v-else-if="item.type=='text-array'" :label="item.label" :labelCol="labelCol" :wrapperCol="wrapperCol" :name="item.name||item.key">
+            <SimpleEditDynamicStringList
+              :array="(item.value as string[])"
+              placeholder="请输入参数值"
+            />
+          </a-form-item>
+          <a-form-item v-else-if="item.type=='text-select'" :label="item.label" :labelCol="labelCol" :wrapperCol="wrapperCol" :name="item.name||item.key">
+            <a-select
+              :required="item.required"
+              v-model:value="item.value"
+              style="width: 100%"
+              :options="item.appenderParams?.options"
+            >
+            </a-select>
+          </a-form-item>
+        </div>
+        <a-popconfirm
+          v-if="canDelete"
+          title="确定删除此参数吗?"
+          @confirm="$emit('delete', item, key)"
+        >
+          <a-button class="ml-3">
+            <template #icon><DeleteOutlined /></template>
+          </a-button>
+        </a-popconfirm>
+      </div>
+    </a-col>
+  </a-row>
+</template>
+
+<script lang="ts">
+import { defineComponent, type PropType } from 'vue'
+import {  DeleteOutlined } from '@ant-design/icons-vue'
+import SimpleEditDynamicStringList from './SimpleEditDynamicStringList.vue';
+
+export interface SimpleListDynamicFormItem {
+  label: string;
+  value: unknown;
+  name: string;
+  key: string;
+  required?: boolean,
+  type: 'text'|'number'|'checkbox'|'radio'|'switch'|'text-array'|'text-select';
+  placeholder ?: string;
+  prefix ?: string;
+  suffix ?: string;
+  appenderParams ?: {
+    [index: string]: unknown
+  };
+}
+
+/**
+ * 简单的列表动态表单
+ */
+export default defineComponent({
+  name: 'SimpleListDynamicForm',
+  components: {
+    SimpleEditDynamicStringList,
+    DeleteOutlined,
+  },
+  props: {
+    /**
+     * 表单条目
+     */
+    items: {
+      required: true,
+      type: Object as PropType<SimpleListDynamicFormItem[]>
+    },
+    /**
+     * 是否可以删除表单条目
+     */
+    canDelete: {
+      default: true,
+      type: Boolean
+    },
+    span: {
+      default: 7,
+      type: Number
+    },
+    gutter: {
+      default: 30,
+    },
+    labelCol: {
+      default: () => { return { span: 10 } },
+      type: Object
+    },
+    wrapperCol: {
+      default: () => { return { span: 14 } },
+      type: Object
+    },
+    /**
+     * 是否显示删除按钮
+     */
+    showDelete: {
+      default: false,
+      type: Boolean
+    },
+  },
+})
+</script>

+ 42 - 0
src/components/dynamicf/SimpleSelectFormItem.ts

@@ -0,0 +1,42 @@
+import type { SelectProps } from "ant-design-vue";
+import type { VNode } from 'vue';
+
+export interface IDynamicFormItemSelectOption {
+  text: string,
+  value: string|number,
+  badgeState?: 'success'|'error'|'default'|'processing'|'warning',
+  badgeColor?: string,
+  raw?: unknown;
+}
+
+export interface IDynamicFormItemAdditionalOptions {
+  options: IDynamicFormItemSelectOption[],
+}
+
+export interface SimpleSelectFormItemProps {
+  /**
+   * 是否禁用
+   */
+  disabled: boolean;
+  /**
+   * 选项数据
+   */
+  options: IDynamicFormItemSelectOption[];
+  /**
+   * 选择值
+   */
+  value: unknown;
+  /**
+   * a-select 其他自定义参数
+   */
+  customProps: SelectProps;
+  /**
+   * 是否自定义渲染option插槽
+   */
+  renderOption?: RenderOption;
+}
+
+export type RenderOption = (data: {
+  value: unknown,
+  label: string,
+}) => VNode;

+ 69 - 0
src/components/dynamicf/SimpleSelectFormItem.vue

@@ -0,0 +1,69 @@
+<template>
+  <a-select
+    :value="value"
+    @update:value="onUpdateValue"
+    :disabled="disabled"
+    v-bind="customProps"
+  >
+    <template v-if="renderOption" #option="data">
+      <VNodeRenderer :render="renderOption" :data="data" />
+    </template>
+    <a-select-option v-for="it in options" :key="it.value" :value="it.value">
+      <a-badge v-if="it.badgeColor" :color="it.badgeColor" :text="it.text" />
+      <a-badge v-else-if="it.badgeState" :status="it.badgeState" :text="it.text" />
+      <span v-else>{{it.text}}</span>
+    </a-select-option>
+  </a-select>
+</template>
+
+<script lang="ts" setup>
+/**
+ * 简单下拉框表单控件
+ */
+import VNodeRenderer from '@/components/VNodeRenderer.vue';
+import type { SelectProps } from 'ant-design-vue/lib/vc-select';
+import { defineProps, defineEmits, type PropType } from 'vue';
+import type { IDynamicFormItemSelectOption, RenderOption } from './SimpleSelectFormItem';
+
+defineProps({
+  /**
+   * 是否禁用
+   */
+  disabled: {
+    type: Boolean,
+    default: false
+  },
+  /**
+   * 选项数据
+   */
+  options: {
+    type: Object as PropType<IDynamicFormItemSelectOption[]>,
+    default: null,
+  },
+  /**
+   * 选择值
+   */
+  value: {
+  },
+  /**
+   * a-select 其他自定义参数
+   */
+  customProps: {
+    type: Object as PropType<SelectProps>,
+    default: null,
+  },
+  renderOption: {
+    default: null,
+    type: Function as PropType<RenderOption>
+  },
+});
+
+const emits = defineEmits([
+  'update:value',
+]);
+
+function onUpdateValue(v : unknown) {
+  emits('update:value', v);
+}
+
+</script>

+ 140 - 0
src/components/dynamicf/UploadImageFormItem.ts

@@ -0,0 +1,140 @@
+import StringUtils from "@/common/utils/StringUtils";
+import { message, type UploadProps } from "ant-design-vue";
+
+export interface UploadImageFormItemProps {
+  /**
+   * 是否禁用
+   */
+  disabled?: boolean;
+  /**
+   * 上传工厂类
+   */
+  uploadCo: UploadCoInterface;
+  /**
+   * 上传之前的自定义检查回调
+   * 如果返回false,将停止上传
+   */
+  beforeUpload?: (file: FileItem) => boolean;
+  /**
+   * 是否限制单图上传
+   */
+  single?: boolean;
+  /**
+   * single 为false时,限制最多上传图片的数量
+   */
+  maxCount?: number
+  /**
+   * 类样式
+   */
+   uploadClass?: unknown;
+  /**
+   * single 模式下图片显示大小
+   */
+  singleImageSize?: { width: number, height: number },
+  /**
+   * 参数,可以是单张 string,多张 string[]
+   */
+  value?: unknown;
+  /**
+   * a-upload 其他自定义参数
+   */
+  customProps?: UploadProps;
+}
+
+/**
+ * 上传工厂接口
+ */
+export interface UploadCoInterface {
+  /**
+   * 请求上传Token
+   */
+  requestUploadToken : (key: string,  bucketNameDa: string, expire ?:number) => Promise<string>,
+  /**
+    * 上传主函数。由 ant-upload 调用。
+    */
+  uploadRequest: (requestOption: AntUploadRequestOption) => void,
+  /**
+    * 获取上传返回后的URL函数。
+    */
+  getUrlByUploadResponse: (response: unknown) => string,
+}
+
+export interface FileItem {
+  uid: string;
+  name?: string;
+  status?: string;
+  response?: string;
+  url: string;
+  type: string;
+  size: number;
+  originFileObj?: unknown;
+}
+
+export interface FileInfo {
+  file: FileItem;
+  fileList: FileItem[];
+}
+
+export interface AntUploadRequestOption {
+  action: string|Promise<string>;
+  filename: string;
+  data : unknown;
+  file: File;
+  headers: { [index: string]: string; };
+  withCredentials: boolean;
+  method: string;
+  onProgress: (e: number) => void;
+  onSuccess: (ret : { url: string, key: string }, xhr : XMLHttpRequest|null) => void;
+  onError: (err : Error|null|undefined, ret : unknown) => void;
+}
+
+/**
+ * 上传图片大小检查组合代码。
+ * @param limitSizeMB 限制大小MB.
+ * @returns 
+ */
+export function useBeforeUploadImageChecker(limitSizeMB = 8) : (file: FileItem) => boolean {
+  return (file) => {
+    const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
+    if (!isJpgOrPng) 
+      message.error('请选择图片文件!');
+    const isLt2M = file.size / 1024 / 1024 < limitSizeMB;
+    if (!isLt2M) 
+      message.error(`图片大小不能大于${limitSizeMB}MB!`);
+    return isJpgOrPng && isLt2M;
+  };
+}
+
+/**
+ * 上传视频大小检查组合代码。
+ * @param limitSizeMB 限制大小MB.
+ * @returns 
+ */
+export function useBeforeUploadVideoChecker(limitSizeMB = 256) : (file: FileItem) => boolean {
+  return (file) => {
+    const isVideo = file.type.startsWith('video/');
+    if (!isVideo) 
+      message.error('请选择视频文件!');
+    const isLt2M = file.size / 1024 / 1024 < limitSizeMB;
+    if (!isLt2M) 
+      message.error(`视频大小不能大于${limitSizeMB}MB!`);
+    return isVideo && isLt2M;
+  };
+}
+
+/**
+ * 把字符串URL数组转为a-upload已上传的条目
+ * @param arr URL数组
+ */
+export function stringUrlsToUploadedItems(arr: string[]) : FileItem[] {
+  return arr.map((i, k) => {
+    return {
+      uid: k.toString(),
+      name: StringUtils.getFileName(i),
+      status: 'done',
+      url: i,
+      size: 0,
+      type: '',
+    }
+  });
+}

+ 169 - 0
src/components/dynamicf/UploadImageFormItem.vue

@@ -0,0 +1,169 @@
+<template>
+  <a-upload 
+    v-bind="customProps"
+    :disabled="disabled"
+    v-model:file-list="uploadSubImgList"
+    list-type="picture-card"
+    :class="uploadClass"
+    :max-count="maxCount"
+    :show-upload-list="!single"
+    :customRequest="handleUpload"
+    :before-upload="beforeUpload"
+    @change="handleUploadSubImgChange"
+  >
+    <template v-if="single">
+      <a-image v-if="value != ''" 
+        :src="(value as string)"
+        alt="avatar"
+        :width="singleImageSize.width"
+        :height="singleImageSize.height"
+        :preview="false"
+        :fallback="failImage" 
+      />
+      <div v-else :style="{ width: singleImageSize.width, height: singleImageSize.height }">
+        <loading-outlined v-if="uploadingSubImg"></loading-outlined>
+        <plus-outlined v-else></plus-outlined>
+        <div class="ant-upload-text">上传</div>
+      </div>
+    </template>
+    <template v-else>
+      <loading-outlined v-if="uploadingSubImg"></loading-outlined>
+      <plus-outlined v-else></plus-outlined>
+      <div class="ant-upload-text">上传</div>
+    </template>
+  </a-upload>
+</template>
+
+<script lang="ts" setup>
+/**
+ * 上传图片表单控件
+ */
+import { 
+  stringUrlsToUploadedItems, type UploadCoInterface, 
+  type AntUploadRequestOption, type FileInfo, type FileItem 
+} from './UploadImageFormItem';
+import { message, type UploadProps } from 'ant-design-vue';
+import { PlusOutlined, LoadingOutlined } from '@ant-design/icons-vue';
+import { defineProps, defineEmits, type PropType, ref, onMounted, watch } from 'vue';
+import FailImage from '@/assets/images/imageFailed.png';
+
+const props = defineProps({
+  /**
+   * 是否禁用
+   */
+  disabled: {
+    type: Boolean,
+    default: false
+  },
+  /**
+   * 预览图加载失败时显示图片
+   */
+  failImage: {
+    type: String,
+    default: () => FailImage,
+  },
+  /**
+   * 上传工厂类
+   */
+  uploadCo: {
+    type: Object as PropType<UploadCoInterface>,
+    default: null,
+  },
+  /**
+   * 上传之前的自定义检查回调
+   * (file: FileItem) => boolean
+   * 如果返回false,将停止上传
+   */
+  beforeUpload: {
+    type: Function,
+    default: null,
+  },
+  /**
+   * 类样式
+   */
+  uploadClass: {},
+  /**
+   * 是否限制单图上传
+   */
+  single: {
+    type: Boolean,
+    default: false
+  },
+  /**
+   * single 为false时,限制最多上传图片的数量
+   */
+  maxCount: {
+    type: Number,
+    default: 0,
+  },
+  /**
+   * single 模式下图片显示大小
+   */
+  singleImageSize: {
+    type: Object as PropType<{ width: number, height: number }>,
+    default: () => ({ width: 100, height: 100 })
+  },
+  /**
+   * 参数,可以是单张 string,多张 string[]
+   */
+  value: {},
+  /**
+   * a-upload 其他自定义参数
+   */
+  customProps: {
+    type: Object as PropType<UploadProps>,
+    default: null,
+  },
+});
+
+const emits = defineEmits([
+  'update:value',
+]);
+
+const uploadSubImgList = ref<FileItem[]>([]);
+const uploadingSubImg = ref(false);
+
+onMounted(() => {
+  //将之前上传的图片包括URL设置到已上传列表中
+  if (!props.single) {
+    setTimeout(() => {
+      uploadSubImgList.value = stringUrlsToUploadedItems(props.value instanceof Array ? (props.value as string[] || []) : [])
+    }, 400);
+  }
+});
+
+watch(() => props.value, () => {
+  if (!props.single) {
+    uploadSubImgList.value = stringUrlsToUploadedItems(props.value instanceof Array ? (props.value as string[] || []) : [])
+  }
+});
+
+function handleUpload(requestOption: AntUploadRequestOption) {
+  props.uploadCo?.uploadRequest(requestOption);
+}
+function handleUploadSubImgChange(info: FileInfo) {
+  if (info.file.status === 'uploading') {
+    uploadingSubImg.value = true;
+    return;
+  }
+  if (info.file.status === 'removed') {
+    if (props.single)
+      emits('update:value', '');
+    else
+      emits('update:value', (props.value as string[] || []).filter(url => url != info.file.url));
+    return;
+  }
+  if (info.file.status === 'done') {
+    const url = props.uploadCo?.getUrlByUploadResponse(info.file.response) || '';
+    if (props.single)
+      emits('update:value', url);
+    else
+      emits('update:value', (props.value as string[] || []).concat([ url ]));
+    uploadingSubImg.value = false;
+  }
+  if (info.file.status === 'error') {
+    uploadingSubImg.value = false;
+    message.error('上传失败!' + info.file.response);
+  }
+}
+</script>

+ 6 - 0
src/components/dynamicf/WhiteSpace.ts

@@ -0,0 +1,6 @@
+/**
+ * WhiteSpace 的公共接口
+ */
+export interface WhiteSpaceProps {
+  height: number,
+}

+ 16 - 0
src/components/dynamicf/WhiteSpace.vue

@@ -0,0 +1,16 @@
+<template>
+  <div :style="{ height: height }"></div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue";
+
+export default defineComponent({
+  props: {
+    height: {
+      type: [Number,String],
+      default: 0,
+    },
+  }
+});
+</script>

+ 28 - 0
src/components/dynamicf/WrapperRangePicker.vue

@@ -0,0 +1,28 @@
+<template>
+  <a-range-picker
+    :value="value"
+    @update:value="(v: unknown) => $emit('update:value', v)"
+    :showTime="showTime"
+    v-bind="additionalProps"
+  />
+</template>
+
+<script lang="ts">
+import { defineComponent, type PropType } from "vue";
+
+export default defineComponent({
+  props: {
+    additionalProps: {
+      type: Object as PropType<Record<string, unknown>>,
+    },
+    showTime: {
+      type: Boolean,
+    },
+    value: {
+    },
+  },
+  emist: [
+    'update:value'
+  ],
+});
+</script>

+ 24 - 0
src/components/dynamicf/WrapperTimeRangePicker.vue

@@ -0,0 +1,24 @@
+<template>
+  <a-time-range-picker
+    :value="value"
+    @update:value="(v: unknown) => $emit('update:value', v)"
+    v-bind="additionalProps"
+  />
+</template>
+
+<script lang="ts">
+import { defineComponent, type PropType } from "vue";
+
+export default defineComponent({
+  props: {
+    additionalProps: {
+      type: Object as PropType<Record<string, unknown>>,
+    },
+    value: {
+    },
+  },
+  emist: [
+    'update:value'
+  ],
+});
+</script>

+ 55 - 9
src/components/dynamicf/index.ts

@@ -5,6 +5,27 @@ import {
   Rate, Switch, Textarea, TimePicker
 } from "ant-design-vue";
 import { DynamicFormItemRegistry, type IDynamicFormOptions, configDefaultDynamicFormOptions } from "@imengyu/vue-dynamic-form";
+import PasswordWithStrengthInput from "./PasswordWithStrengthInput.vue";
+import CheckBoxValue from "./CheckBoxValue.vue";
+import SimpleSelectFormItem from "./SimpleSelectFormItem.vue";
+import IdAsValueDropdown from "./Dropdown/IdAsValueDropdown.vue";
+import IdAsValueTreeDropdown from "./Dropdown/IdAsValueTreeDropdown.vue";
+import SelectValue from "./SelectValue.vue";
+import IdAsValueTree from "./IdAsValueTree.vue";
+import UploadImageFormItem from "./UploadImageFormItem.vue";
+import WrapperTimeRangePicker from "./WrapperTimeRangePicker.vue";
+import WrapperRangePicker from "./WrapperRangePicker.vue";
+import ActionRender from "./ActionRender.vue";
+import CheckBoxToInt from "./CheckBoxToInt.vue";
+import RadioValueVue from "./RadioValue.vue";
+import StateRendererVue from "./Display/StateRenderer.vue";
+import ShowDateOrNullVue from "./Display/ShowDateOrNull.vue";
+import ShowImageListVue from "./Display/ShowImageList.vue";
+import ShowValueOrNullVue from "./Display/ShowValueOrNull.vue";
+import CascaderFormItemVue from "./CascaderFormItem.vue";
+import SimpleEditDynamicStringListVue from "./SimpleEditDynamicStringList.vue";
+import WhiteSpaceVue from "./WhiteSpace.vue";
+import NumberRange from "./NumberRange.vue";
 
 export const defaultConfig = {
   internalWidgets: {
@@ -30,16 +51,41 @@ export const defaultConfig = {
 export function registerAllFormComponents() {
   configDefaultDynamicFormOptions(defaultConfig);
 
-  DynamicFormItemRegistry.register('text', markRaw(Input), {}, 'modelValue')
-    .register('password', markRaw(Input.Password), {}, 'modelValue')
-    .register('number', markRaw(InputNumber), {}, 'modelValue')
-    .register('text-area', markRaw(Textarea), {}, 'modelValue')
-    .register('switch', markRaw(Switch), {}, 'modelValue')
-    .register('check-box', markRaw(Checkbox), {}, 'modelValue')
+  DynamicFormItemRegistry
+
+    .register('text', markRaw(Input))
+    .register('password', markRaw(Input), { type: "password" })
+    .register('number', markRaw(InputNumber))
+    .register('text-area', markRaw(Textarea))
+    .register('password-with-strength', markRaw(PasswordWithStrengthInput))
+    .register('switch', markRaw(Switch), {}, 'checked')
+    .register('cascader', markRaw(CascaderFormItemVue))
+    .register('check-box', markRaw(Checkbox), {}, 'checked')
+    .register('check-box-int', markRaw(CheckBoxToInt))
+    .register('check-box-value', markRaw(CheckBoxValue))
+    .register('radio-value', markRaw(RadioValueVue))
+    .register('number-range', markRaw(NumberRange))
     .register('rate', markRaw(Rate))
-    .register('date', markRaw(DatePicker), {}, 'pickerValue')
-    .register('time', markRaw(TimePicker), {}, 'modelValue')
+    .register('select', markRaw(SimpleSelectFormItem))
+    .register('select-value', markRaw(SelectValue))
+    .register('select-id', markRaw(IdAsValueDropdown))
+    .register('select-tree-id', markRaw(IdAsValueTreeDropdown))
+    .register('tree-id', markRaw(IdAsValueTree))
+    .register('date', markRaw(DatePicker))
+    .register('time', markRaw(TimePicker))
     .register('date-time', markRaw(DatePicker), { showTime: true })
+    .register('date-range', markRaw(WrapperRangePicker))
+    .register('time-range', markRaw(WrapperTimeRangePicker))
+    .register('date-time-range', markRaw(WrapperRangePicker), { showTime: true })
+    .register('single-image', markRaw(UploadImageFormItem), { single: true })
+    .register('mulit-image', markRaw(UploadImageFormItem))
+    .register('actions', markRaw(ActionRender))
     .register('alert', markRaw(Alert))
-    .register('static-image', markRaw(Image), {}, "src");
+    .register('string-list', markRaw(SimpleEditDynamicStringListVue))
+    .register('static-image', markRaw(Image), {}, "src")
+    .register('static-state', markRaw(StateRendererVue))
+    .register('static-value', markRaw(ShowValueOrNullVue))
+    .register('static-date', markRaw(ShowDateOrNullVue))
+    .register('static-image-list', markRaw(ShowImageListVue), {}, "images")
+    .register('space', markRaw(WhiteSpaceVue))
 }

+ 121 - 0
src/views/inheritor/submit.vue

@@ -8,8 +8,129 @@
         <div class="title">
           <h2>项目申报</h2>
         </div>
+        <DynamicForm 
+          :model="formModel" 
+          :options="formOptions"
+        />
       </div>
     </section>
   </div>
 </template>
 
+<script setup lang="ts">
+import CommonContent from '@/api/CommonContent';
+import { RecommendForm } from '@/api/inheritor/SubmitApi';
+import type { IdAsValueDropdownProps } from '@/components/dynamicf/Dropdown/IdAsValueDropdown';
+import type { DataModel } from '@imengyu/js-request-transform';
+import { DynamicForm, type IDynamicFormOptions } from '@imengyu/vue-dynamic-form';
+import { ref } from 'vue';
+
+const formModel = ref(new RecommendForm());
+const formOptions = ref<IDynamicFormOptions>({
+  formLabelCol: { span: 6 },
+  formWrapperCol: { span: 24 },
+  formAdditionaProps: {
+    layout: 'vertical'
+  },
+  formItems: [
+    { 
+      label: '证件照', 
+      name: 'idPhoto',
+      type: 'single-image',
+      additionalProps: {
+
+      },
+    },
+    {
+      label: '传承人姓名',
+      name: 'name',
+      type: 'text',
+      additionalProps: {
+        placeholder: '请输入姓名'
+      },
+    },
+    {
+      label: '项目名称',
+      name: 'ichName',
+      type: 'text',
+      additionalProps: {
+        placeholder: '请输入项目名称' 
+      }
+    },
+    {
+      label: '类型',
+      name: 'type',
+      type: 'select-id',
+      additionalProps: {
+        placeholder: '请选择类型',
+        loadData: async () =>
+          (await CommonContent.getCategoryList(4)).map(p => ({
+            label: p.title,
+            value: p.id,
+            raw: p
+          }))
+      } as IdAsValueDropdownProps<DataModel>,
+    },
+    {
+      label: '保护单位',
+      name: 'unit',
+      type: 'text',
+      additionalProps: {
+        placeholder: '请输入保护单位' 
+      }
+    },
+    {
+      label: '性别',
+      name: 'gender',
+      type: 'select',
+      additionalProps: {
+        options: [
+          { text: '男', value: '男' },
+          { text: '女', value: '女' },
+        ]
+      },
+    },
+    {
+      label: '生日',
+      name: 'birthday',
+      type: 'date',
+      additionalProps: {
+        placeholder: '请输入出生日期' 
+      }
+    },
+    {
+      label: '民族',
+      name: 'name',
+      type: 'text',
+      additionalProps: {
+        placeholder: '请输入民族' 
+      }
+    },
+    {
+      label: '传承人姓名',
+      name: 'name',
+      type: 'text',
+      additionalProps: {
+        placeholder: '请输入姓名' 
+      }
+    },
+    {
+      label: '职业',
+      name: 'job',
+      type: 'text',
+      additionalProps: {
+        placeholder: '请输入职业' 
+      }
+    },
+    {
+      label: '职务职称',
+      name: 'jobTitle',
+      type: 'text',
+      additionalProps: {
+        placeholder: '请输入职务职称' 
+      }
+    },
+  ]
+});
+</script>
+