Parcourir la source

提取建档表单

cc12458 il y a 1 mois
Parent
commit
3c96c4a359

+ 1 - 0
package.json

@@ -19,6 +19,7 @@
     "@vueuse/core": "^13.6.0",
     "@vueuse/router": "^13.6.0",
     "alova": "^3.0.20",
+    "dayjs": "^1.11.20",
     "echarts": "^5.5.1",
     "eruda": "^3.4.0",
     "eruda-features": "^2.1.0",

+ 8 - 0
pnpm-lock.yaml

@@ -20,6 +20,9 @@ importers:
       alova:
         specifier: ^3.0.20
         version: 3.3.4
+      dayjs:
+        specifier: ^1.11.20
+        version: 1.11.20
       echarts:
         specifier: ^5.5.1
         version: 5.6.0
@@ -1064,6 +1067,9 @@ packages:
   csstype@3.1.3:
     resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
 
+  dayjs@1.11.20:
+    resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
+
   de-indent@1.0.2:
     resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
 
@@ -3368,6 +3374,8 @@ snapshots:
 
   csstype@3.1.3: {}
 
+  dayjs@1.11.20: {}
+
   de-indent@1.0.2: {}
 
   debug@4.4.1:

+ 403 - 0
src/components/RegisterForm.vue

@@ -0,0 +1,403 @@
+<script setup lang="ts">
+import type { FormInstance } from 'vant';
+import type { Field, Fields, Option, RegisterModel } from '@/request/model';
+
+import { Toast } from '@/platform';
+import { useCaptcha, useRequest, useSerialRequest } from 'alova/client';
+import { getCaptchaMethod, registerFieldsMethod, dictionariesMethod, searchAccountMethod } from '@/request/api';
+
+import PickerDialog from '@/components/PickerDialog.vue';
+import CascaderDialog from '@/components/CascaderDialog.vue';
+
+const { searchable = true, searchForbiddenField = true } = defineProps<{
+  searchable?: boolean;
+  searchForbiddenField?: boolean;
+}>();
+const emit = defineEmits<{
+  submit: [payload: { model: RegisterModel; modelLabel: Partial<RegisterModel> }];
+}>();
+
+const formRef = useTemplateRef<FormInstance>('register-form');
+const modelLabel = ref<Partial<RegisterModel>>({ });
+const modelValue = ref<Partial<RegisterModel>>({ code: '' });
+const model = computed(() => ({ ...modelLabel.value, ...modelValue.value }));
+
+const { data: fields, loading } = useSerialRequest([dictionariesMethod, (dictionaries) => registerFieldsMethod(dictionaries)]).onSuccess(({ data }) => {
+  const sex = data.find((field) => field.name === 'sex');
+  if (sex) {
+    const unknown = (<any>sex).component?.options?.find((option: any) => option.value === '2');
+    modelLabel.value.sex = unknown?.label;
+    modelValue.value.sex = unknown?.value;
+  }
+});
+
+const forbiddenFields = shallowRef<Record<string, boolean>>({});
+const { loading: searching, send: search } = useRequest((data) => searchAccountMethod(data), {
+  immediate: false,
+}).onSuccess(({ data }) => {
+  if (!fields.value.some((field) => field.name === 'phone')) Reflect.deleteProperty(data, 'phone');
+  setValues(data);
+});
+
+let captchaLoaded = false;
+const {
+  loading: captchaLoading,
+  countdown,
+  send: getCaptcha,
+} = useCaptcha(() => getCaptchaMethod(model.value.phone!), { initialCountdown: 60 }).onSuccess(({ data }) => {
+  captchaLoaded = true;
+  Toast.success(data ?? '获取成功');
+});
+const getCaptchaHandle = async () => {
+  try {
+    await formRef.value?.validate('phone');
+    if (!model.value.phone) throw { message: `请输入手机号码` };
+    await getCaptcha();
+    const field = fields.value.find((field) => field.name === 'code');
+    if (field?.keyboard) {
+      field.keyboard.show = true;
+    }
+  } catch (e: any) {
+    Toast.warning(e?.message);
+  }
+};
+
+const searchHandle = async (key: 'cardno' | 'code') => {
+  if (!searchable) return;
+  const forbidden = { cardno: 'phone', code: 'cardno' }[key];
+  try {
+    await formRef.value?.validate(key);
+    forbiddenFields.value = {};
+    const { cardno, phone, code } = model.value;
+    await search({ cardno, phone, code })
+      .then((data) => {
+        forbiddenFields.value[forbidden] = !!(data as any)[forbidden];
+        triggerRef(forbiddenFields);
+      })
+      .catch();
+  } catch (e: any) {
+    Toast.warning(e?.message);
+  }
+};
+
+function onKeyboardBlur(field: Fields[number]) {
+  if ( field?.name === 'phone' && !captchaLoaded ) { getCaptchaHandle(); }
+  if ( field?.name === 'cardno' && model.value.cardno ) { searchHandle('cardno'); }
+  if ( field?.name === 'code' && model.value.phone ) { searchHandle('code'); }
+}
+
+function onSubmitHandle() {
+  emit('submit', { model: model.value as RegisterModel, modelLabel: modelLabel.value });
+}
+
+function fix(key: string) {
+  for (const field of fields.value) {
+    if (field.keyboard?.show && field.name !== key) field.keyboard.show = false;
+  }
+}
+
+const keyboardProps = reactive({
+  key: '',
+  props: {},
+  show: false,
+});
+const pickerProps = reactive({
+  key: '',
+  props: { options: [], selected: [] },
+  show: false,
+});
+const cascaderProps = reactive({
+  key: '',
+  props: { options: [], loading: false },
+  show: false,
+});
+
+const handle = (value: any, field: Field | string) => {
+  field = ((key: Field | string): Field => (typeof key === 'string' ? fields.value.find((field) => field.name === key)! : key))(field);
+  const key = field.name!;
+  if (field.component?.name === 'radio') {
+    const option = field?.component?.options?.find((option) => option.value === value);
+    (modelLabel.value as any)[key] = option?.label;
+    (modelValue.value as any)[key] = option?.value;
+  } else if (field.component?.name === 'picker') {
+    if (typeof value === 'string') {
+      value = value.split(',').map((value) => {
+        const [v, l] = value.split(':');
+        return { value, label: l ?? (field.component as any).options?.find((option: Option) => option.value === v)?.label ?? v };
+      });
+    }
+    (modelLabel.value as any)[key] = value.map((option: Option) => option.label).join(',');
+    (modelValue.value as any)[key] = value.map((option: Option) => option.value).join(',');
+  } else if (field.component?.name === 'cascader') {
+    (modelLabel.value as any)[key] = value.map((option: Option) => option.label).join('/');
+    (modelValue.value as any)[key] = value;
+  } else if (field.component?.name === 'code') {
+    (modelValue.value as any)[key] = value;
+  } else {
+    (modelLabel.value as any)[key] = value;
+    (modelValue.value as any)[key] = value;
+  }
+};
+
+/** 父页扫码等在 fields 未返回前调用 setValues 时,先合并到此,待 fields 就绪后一次性按 field 类型写回 */
+const pendingSetValues = shallowRef<Record<string, any> | null>(null);
+
+function applySetValues(values: Record<string, any> | null | undefined) {
+  forbiddenFields.value = {};
+  if (!values) {
+    modelLabel.value = {};
+    modelValue.value = {};
+    return;
+  }
+  for (const [key, value] of Object.entries(values)) {
+    const field = fields.value?.find((field) => field.name === key);
+    if (field) {
+      forbiddenFields.value[key] = searchForbiddenField && !!value && !['phone', 'cardno'].includes(key);
+      handle(value, field as any);
+    } else {
+      (modelValue as any).value[key] = value;
+    }
+  }
+}
+
+const setValues = (values: Record<string, any>) => {
+  if (!values) {
+    pendingSetValues.value = null;
+    applySetValues(null);
+    return;
+  }
+  if (!fields.value?.length) {
+    pendingSetValues.value = { ...(pendingSetValues.value ?? {}), ...values };
+    for (const [key, value] of Object.entries(values)) {
+      (modelValue as any).value[key] = value;
+    }
+    return;
+  }
+  pendingSetValues.value = null;
+  applySetValues(values);
+};
+
+watch(
+  () => fields.value?.length ?? 0,
+  (len) => {
+    if (len > 0 && pendingSetValues.value) {
+      const payload = pendingSetValues.value;
+      pendingSetValues.value = null;
+      applySetValues(payload);
+    }
+  },
+);
+
+function onFieldFocus(field: any) {
+  if (forbiddenFields.value[field.name]) return;
+  if (field.keyboard) {
+    keyboardProps.key = field.name;
+    keyboardProps.show = true;
+    keyboardProps.props = {
+      ...field.keyboard,
+      maxlength: field.control?.maxlength ?? Number.POSITIVE_INFINITY,
+      onBlur() {
+        keyboardProps.show = false;
+        onKeyboardBlur(field);
+      },
+    };
+  } else if (field.component?.name === 'picker') {
+    pickerProps.key = field.name;
+    pickerProps.show = true;
+    pickerProps.props = {
+      ...field.component.props,
+      title: field.control.label,
+      options: field.component.options,
+      selected: (modelValue.value as Record<string, string>)[field.name]?.split(','),
+    };
+  } else if (field.component?.name === 'cascader') {
+    cascaderProps.key = field.name;
+    cascaderProps.show = true;
+    if (typeof field.component.options === 'function') {
+      cascaderProps.props = {
+        ...field.component.props,
+        title: field.control.label,
+        options: [],
+        loading: true,
+      };
+      (async function () {
+        field.component.options = await field.component.options();
+        cascaderProps.props = {
+          ...field.component.props,
+          title: field.control.label,
+          options: field.component.options,
+          loading: false,
+          selected: (modelValue.value as Record<string, { value: string }[]>)[field.name]?.map((option) => option.value),
+        };
+      })();
+    } else {
+      cascaderProps.props = {
+        ...field.component.props,
+        title: field.control.label,
+        options: field.component.options,
+      };
+    }
+  }
+}
+function onFieldBlur(_field: any) {
+  keyboardProps.show = false;
+  pickerProps.show = false;
+  cascaderProps.show = false;
+}
+
+defineExpose({
+  submit() {
+    formRef.value?.submit();
+  },
+  setValues,
+  loading,
+  searching,
+});
+</script>
+
+<template>
+  <van-form class="register-form" ref="register-form" colon required="auto" scroll-to-error scroll-to-error-position="center" @submit="onSubmitHandle()">
+    <van-cell-group :border="false">
+      <template v-for="field in fields" :key="field.name">
+        <template v-if="!field.control?.hide || (typeof field.control?.hide === 'function' && !field.control.hide(model))">
+          <van-field
+            :model-value="field.control?.readonly ? modelLabel[field.name!] : model[field.name!]"
+            @update:model-value="handle($event, field)"
+            :name="field.name"
+            :id="field.name"
+            :rules="field.rules"
+            v-bind="(field.control ?? {}) as any"
+            :class="{ 'no-border': field.control?.border === false }"
+            @focus="onFieldFocus(field)"
+            @blur="onFieldBlur(field)"
+            :readonly="field.control?.readonly"
+            @click="onFieldFocus(field)"
+            :disabled="forbiddenFields[field.name!]"
+          >
+            <template #input v-if="field.component?.name === 'radio'">
+              <van-radio-group direction="horizontal" shape="dot" :disabled="forbiddenFields[field.name!]" v-model="modelValue[field.name!]" @change="handle($event, field)">
+                <van-radio v-for="option in field.component?.options" :key="option.value" :name="option.value">
+                  {{ option.label }}
+                </van-radio>
+              </van-radio-group>
+            </template>
+            <template #input v-else-if="field.component?.name === 'code'">
+              <van-password-input
+                style="width: 100%"
+                v-model:value="modelValue[field.name!]"
+                v-bind="(field.component as any)!.props"
+                :focused="field.keyboard?.show"
+                @focus="
+                  field.keyboard && (field.keyboard.show = true);
+                  fix('code');
+                "
+              />
+            </template>
+            <template #button>
+              <div class="text-primary cursor-pointer">
+                <template v-if="field.component?.name === 'code'">
+                  <div class="text-primary cursor-pointer" @click="getCaptchaHandle()">
+                    {{ captchaLoading ? '发送中...' : countdown > 0 ? `${countdown}后可重发` : '获取验证码' }}
+                  </div>
+                </template>
+                <template v-else>{{ field.suffix }}</template>
+              </div>
+            </template>
+          </van-field>
+        </template>
+      </template>
+    </van-cell-group>
+  </van-form>
+  <van-number-keyboard v-bind="keyboardProps.props" :show="keyboardProps.show" v-model="modelValue[keyboardProps.key]" @update:model-value="handle($event, keyboardProps.key)" />
+  <PickerDialog v-bind="pickerProps.props" v-model:show="pickerProps.show" @selected="handle($event, pickerProps.key)" />
+  <CascaderDialog v-bind="cascaderProps.props" v-model:show="cascaderProps.show" @selected="handle($event, cascaderProps.key)" />
+</template>
+
+<style scoped lang="scss">
+.register-form {
+  .van-field {
+    margin: 0;
+    padding: 0;
+  }
+
+  .van-field.no-border {
+    :deep(.van-field__control) {
+      padding: 0;
+      border: none;
+      border-radius: 8px;
+      text-align: center;
+    }
+  }
+
+  :deep(.van-field--disabled) {
+    --tw-text-opacity: 0.5;
+    --van-radio-checked-icon-color: rgba(56, 255, 110, var(--tw-text-opacity, 1));
+    .text-primary {
+      --tw-text-opacity: 0.5;
+    }
+  }
+
+  :deep(.van-field__label) {
+    margin-bottom: 24px;
+    padding: 8px 0;
+    min-width: 100px;
+    font-size: 18px;
+  }
+
+  :deep(.van-field__control) {
+    margin-bottom: 24px;
+    padding: 8px;
+    border: 1px solid var(--van-radio-checked-icon-color);
+    border-radius: 8px;
+    text-align: center;
+  }
+
+  :deep(.van-field__clear) {
+    align-self: flex-start;
+    display: flex;
+    align-items: center;
+    height: 42px;
+  }
+
+  :deep(.van-field__button) {
+    margin-bottom: 24px;
+    padding: 8px var(--van-padding-xs);
+    min-width: 100px;
+    font-size: 18px;
+    text-align: left;
+  }
+
+  :deep(.van-field__error-message) {
+    position: absolute;
+    top: 40px + 2px;
+  }
+
+  :deep(.van-password-input) {
+    margin: 0;
+  }
+
+  :deep(.van-password-input__security) {
+    justify-content: space-between;
+    align-items: center;
+    text-align: center;
+    $size: 40px;
+    height: $size + 2px;
+
+    &::after {
+      display: none;
+    }
+
+    li {
+      height: $size;
+      width: $size;
+      flex: none;
+      border: 1px solid #38ff6e;
+      border-radius: 8px;
+    }
+  }
+}
+
+.van-radio-group {
+  height: 40px + 2px;
+}
+</style>

+ 82 - 0
src/modules/system/components/RolePicker.vue

@@ -0,0 +1,82 @@
+<script setup lang="ts">
+import type { CheckboxInstance } from 'vant';
+import type { RoleModel } from '@/request/model/manage.model';
+
+import { useRequest } from 'alova/client';
+import { getRoles } from '@/request/api/manage';
+
+const emits = defineEmits<{ complete: [RoleModel[]] }>();
+
+const show = defineModel('show', { default: false });
+const selected = defineModel<string>('selected');
+
+const { data: roles, loading } = useRequest(getRoles);
+
+const form = ref({ roleIds: [] });
+const submit = () => {
+  const roleIds = form.value.roleIds;
+  selected.value = roleIds.join(',');
+  const rawRoles = [];
+  for (const id of roleIds) {
+    const role = roles.value.find((role) => role.id === id);
+    if (role) rawRoles.push(role.__raw__);
+  }
+  emits('complete', rawRoles);
+  show.value = false;
+}
+
+watchEffect(() => {
+  form.value.roleIds = selected.value? selected.value.split(','): [];
+});
+
+const checkboxRefs = useTemplateRef<CheckboxInstance[]>('checkbox');
+const toggle = (index) => {
+  checkboxRefs.value[index].toggle();
+};
+</script>
+
+<template>
+  <van-popup v-model:show="show" position="top">
+    <van-form class="form-wrapper" @submit="submit(form)" label-align="top">
+      <van-checkbox-group v-model="form.roleIds" direction="horizontal" shape="square">
+        <van-field name="user_roles">
+          <template #label>选择角色<van-loading v-if="loading" class="ml-2" size="18" /> </template>
+          <template #input>
+            <van-checkbox-group v-model="form.roleIds" direction="horizontal" shape="square">
+              <van-cell-group inset>
+                <van-cell v-for="(item, index) in roles" clickable :key="item.id" @click="toggle(index)">
+                  <template #title>
+                    <div class="flex">
+                      <van-checkbox :name="item.id" ref="checkbox" @click.stop />
+                      {{ item.name }}
+                    </div>
+                  </template>
+                </van-cell>
+              </van-cell-group>
+            </van-checkbox-group>
+          </template>
+        </van-field>
+      </van-checkbox-group>
+      <div class="flex gap-4" style="padding: 16px">
+        <van-button round block native-type="submit">取消</van-button>
+        <van-button round block type="primary" native-type="submit">确认</van-button>
+      </div>
+    </van-form>
+  </van-popup>
+</template>
+
+<style scoped lang="scss">
+.form-wrapper {
+  :deep(.van-field__control) {
+    .van-checkbox-group,
+    .van-checkbox-group .van-cell-group {
+      --van-cell-group-inset-padding: 0;
+      flex: auto;
+    }
+    .van-checkbox-group {
+      max-height: 40vh;
+      overflow-y: auto;
+    }
+  }
+}
+</style>

+ 28 - 0
src/modules/system/components/RoleTags.vue

@@ -0,0 +1,28 @@
+<script setup lang="ts">
+import type { MenuLeafFlat } from '@/request/model/manage.model';
+
+const props = defineProps<{ menus?: MenuLeafFlat[]; loading?: boolean }>();
+const selected = defineModel('selected', { default: [] });
+
+const label = shallowRef<string[]>([]);
+watch(
+  [() => props.menus, selected],
+  ([menus, selected], oldValue, onCleanup) => {
+    for (const id of selected) {
+      const menu = menus.find((menu) => menu.id === id);
+      if (menu) label.value.push(menu.label);
+    }
+    triggerRef(label);
+    onCleanup(() => (label.value = []));
+  },
+  { immediate: true }
+);
+</script>
+
+<template>
+  <div class="flex flex-wrap gap-2">
+    <van-tag plain type="primary" v-for="item in label" :key="item">{{ item }}</van-tag>
+  </div>
+</template>
+
+<style scoped lang="scss"></style>

+ 129 - 0
src/modules/system/logger.page.vue

@@ -0,0 +1,129 @@
+<script setup lang="ts">
+import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
+
+import type { LoggerModel } from '@/request/model/manage.model';
+import dayjs from 'dayjs';
+import { usePagination } from 'alova/client';
+import { listLoggers } from '@/request/api/manage';
+import { useRouteMeta } from '@/router/hooks/useRouteMeta';
+import { useFloatPanel } from '@/composables/FloatPanel';
+import LoggerPreview  from '@/modules/system/logger.preview.vue';
+
+const title = useRouteMeta('title', '日志管理', { priority: 'query' });
+const now = dayjs();
+
+const form = ref({ beginTime: '', endTime: '' });
+const reset = () => {
+  form.value = { beginTime: '', endTime: '' };
+  reload();
+};
+const { loading, data, isLastPage, page, reload, refresh } = usePagination((...args) => listLoggers(...args, form.value), {
+  initialData: { total: 0, data: [] },
+  initialPage: 1,
+  initialPageSize: 20,
+  append: true,
+  immediate: true,
+}).onComplete(() => (sending.value = false));
+const sending = ref(true);
+const onLoad = () => {
+  if (sending.value) return;
+  sending.value = true;
+  page.value += 1;
+};
+
+const [PreviewFloatPanel, preview] = useFloatPanel<LoggerModel, LoggerModel>(LoggerPreview);
+const onPreview = (item: LoggerModel, index: number) => {
+  preview.open(item);
+};
+
+const showDatePicker = ref(false);
+const displayDate = computed(() => {
+  const start = form.value.beginTime?.split(' ')[0] ?? '';
+  const end = form.value.beginTime?.split(' ')[0] ?? '';
+  if (!start && !end) return '';
+  return `${start} ~ ${end}`;
+});
+const editStartDate = ref<string[]>([]);
+const editEditDate = ref<string[]>([]);
+const openDatePicker = () => {
+  editStartDate.value = (form.value.beginTime ? dayjs(form.value.beginTime) : now.startOf('month')).format('YYYY-MM-DD').split('-');
+  editEditDate.value = (form.value.beginTime ? dayjs(form.value.endTime) : now).format('YYYY-MM-DD').split('-');
+  showDatePicker.value = true;
+};
+const closeDatePicker = (event?: { selectedValues: string[] }[]) => {
+  showDatePicker.value = false;
+  if (!Array.isArray(event) || event.length < 2) return;
+  let start = dayjs(event[0].selectedValues.join('-'));
+  let end = dayjs(event[1].selectedValues.join('-'));
+  if (start.isAfter(end)) [start, end] = [end, start];
+  form.value.beginTime = start.startOf('day').format('YYYY-MM-DD');
+  form.value.endTime = end.endOf('day').format('YYYY-MM-DD');
+  reload();
+};
+</script>
+
+<template>
+  <div class="page-container">
+    <div class="page-header flex py-4 px-4 overflow-hidden">
+      <div class="grow shrink-0 h-full min-w-16"></div>
+      <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden">
+        <div class="font-bold text-3xl text-nowrap text-center tracking-wide overflow-ellipsis overflow-hidden">{{ title }}</div>
+      </div>
+      <div class="grow shrink-0 h-full min-w-16 flex items-center justify-end overflow-hidden">
+        <router-link :to="{ path: '/screen' }" replace>
+          <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页" />
+        </router-link>
+      </div>
+    </div>
+    <div class="page-content flex flex-col overflow-hidden">
+      <div class="flex-none">
+        <van-form label-width="4em" @submit="reload()" @reset="reset()">
+          <van-cell-group inset class="grid grid-cols-2 gap-2">
+            <van-field :border="false" name="rolename" v-model="displayDate" label="日期" placeholder="请选择开始 - 结束日期" readonly @click="openDatePicker()" />
+            <div class="flex items-center justify-end gap-6">
+              <div class="flex gap-4">
+                <van-button plain size="small" native-type="reset">重置</van-button>
+                <van-button plain size="small" type="success" native-type="submit">搜索</van-button>
+              </div>
+            </div>
+          </van-cell-group>
+        </van-form>
+        <van-divider style="border-color: hsl(var(--primary-hover) / 0.5)" />
+      </div>
+      <div class="flex-auto overflow-y-scroll list-content-container">
+        <van-pull-refresh :modelValue="loading && page === 1" class="min-h-full" @refresh="reload()">
+          <van-list :loading="loading || sending" :finished="isLastPage" finished-text="没有更多了" @load="onLoad()">
+            <van-cell v-for="(item, index) in data" :key="item.uid" size="large" center>
+              <template #title>{{ item.title }}</template>
+              <template #label>
+                <van-tag plain type="primary">{{ item.operator }}</van-tag>
+                <span class="ml-2">{{ item.date }}</span>
+              </template>
+              <template #value>
+                <van-button square type="primary" text="查看" @click="onPreview(item, index)" />
+              </template>
+            </van-cell>
+          </van-list>
+        </van-pull-refresh>
+      </div>
+    </div>
+    <PreviewFloatPanel auto-height full closable />
+    <van-popup v-model:show="showDatePicker" position="top">
+      <van-picker-group title="日期" :tabs="['开始日期', '结束日期']" next-step-text="下一步" @confirm="closeDatePicker" @cancel="closeDatePicker()">
+        <van-date-picker v-model="editStartDate" :max-date="now.toDate()" />
+        <van-date-picker v-model="editEditDate" :max-date="now.toDate()" />
+      </van-picker-group>
+    </van-popup>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.list-content-container {
+  :deep(.van-cell__title) {
+    flex: auto;
+  }
+  :deep(.van-cell__value) {
+    flex: none;
+  }
+}
+</style>

+ 210 - 0
src/modules/system/logger.preview.vue

@@ -0,0 +1,210 @@
+<script setup lang="ts">
+import type { LoggerModel } from '@/request/model/manage.model';
+
+defineOptions({
+  name: 'LoggerPreview',
+  inheritAttrs: false,
+});
+
+const props = defineProps<
+  LoggerModel & {
+    onComplete?: (result?: LoggerModel) => void;
+    onCancel?: () => void;
+  }
+>();
+
+const BUSINESS_TYPES: Record<number, string> = {
+  0: '其它',
+  1: '新增',
+  2: '修改',
+  3: '删除',
+  4: '授权',
+  5: '导出',
+  6: '导入',
+  7: '强退',
+  8: '生成代码',
+  9: '清空数据',
+  10: '其它',
+};
+
+const OPERATOR_TYPES: Record<number, string> = {
+  1: '后台用户',
+  2: '手机端用户',
+};
+
+function display(v: unknown): string {
+  if (v === null || v === undefined || v === '') return '—';
+  return String(v);
+}
+
+function prettyJson(text: string | null | undefined): string {
+  if (text == null || text === '') return '—';
+  try {
+    return JSON.stringify(JSON.parse(text), null, 2);
+  } catch {
+    return text;
+  }
+}
+
+function mapDict(v: unknown, dict: Record<number, string>): string {
+  if (v === null || v === undefined || v === '') return '—';
+  const n = Number(v);
+  if (Number.isNaN(n)) return String(v);
+  return dict[n] ?? String(v);
+}
+
+const detail = computed(() => {
+  const raw = props.__raw__;
+  if (raw && typeof raw === 'object') {
+    return raw as Record<string, unknown>;
+  }
+  return {
+    operId: props.id,
+    title: props.title,
+    operTime: props.date,
+    operName: props.operator,
+  } as Record<string, unknown>;
+});
+
+const operParamPretty = computed(() => prettyJson(detail.value.operParam as string | undefined));
+const jsonResultPretty = computed(() => prettyJson(detail.value.jsonResult as string | undefined));
+
+const statusTagType = computed(() => {
+  const s = detail.value.status;
+  if (s === 0) return 'success' as const;
+  if (s === 1) return 'danger' as const;
+  return 'default' as const;
+});
+
+const statusLabel = computed(() => {
+  const s = detail.value.status;
+  if (s === 0) return '成功';
+  if (s === 1) return '失败';
+  return display(s);
+});
+
+const showError = computed(() => {
+  const msg = detail.value.errorMsg;
+  if (msg !== null && msg !== undefined && String(msg).trim() !== '') return true;
+  return detail.value.status === 1;
+});
+
+const collapseActive = ref<string[]>([]);
+</script>
+
+<template>
+  <div class="logger-preview pb-safe">
+    <van-cell-group inset title="概要">
+      <van-cell title="模块标题" :value="display(detail.title)" />
+      <van-cell title="操作 ID" :value="display(detail.operId)" />
+      <van-cell title="操作人" :value="display(detail.operName)" />
+      <van-cell title="操作时间" :value="display(detail.operTime)" />
+      <!--      <van-cell title="业务类型" :value="mapDict(detail.businessType, BUSINESS_TYPES)" />-->
+      <!--      <van-cell title="操作类别" :value="mapDict(detail.operatorType, OPERATOR_TYPES)" />-->
+      <!--      <van-cell title="部门" :value="display(detail.deptName)" />-->
+      <van-cell title="备注" :value="display(detail.remark)" />
+      <van-cell title="状态与请求">
+        <template #value>
+          <div class="tag-row">
+            <van-tag v-if="detail.requestMethod" plain type="primary">{{ display(detail.requestMethod) }}</van-tag>
+            <van-tag :type="statusTagType">{{ statusLabel }}</van-tag>
+          </div>
+        </template>
+      </van-cell>
+    </van-cell-group>
+
+    <van-cell-group v-if="showError" inset title="错误" class="mt-3">
+      <van-cell title="错误信息">
+        <template #value>
+          <span class="text-danger">{{ display(detail.errorMsg) }}</span>
+        </template>
+      </van-cell>
+    </van-cell-group>
+
+    <van-cell-group inset title="请求信息" class="mt-3">
+      <van-cell title="请求 URL" :value="display(detail.operUrl)" />
+      <van-cell title="IP" :value="display(detail.operIp)">
+        <template #value>
+          <div class="tag-row">
+            <van-tag v-if="detail.operLocation" plain>{{ detail.operLocation }}</van-tag>
+            <span>{{ display(detail.operIp) }}</span>
+          </div>
+        </template>
+      </van-cell>
+      <van-cell title="调用方法">
+        <template #value>
+          <span class="mono-wrap">{{ display(detail.method) }}</span>
+        </template>
+      </van-cell>
+    </van-cell-group>
+
+    <van-collapse v-model="collapseActive" class="mt-3 logger-preview__collapse">
+      <van-collapse-item title="请求参数" name="params">
+        <pre class="json-block">{{ operParamPretty }}</pre>
+      </van-collapse-item>
+      <van-collapse-item title="返回结果" name="result">
+        <pre class="json-block">{{ jsonResultPretty }}</pre>
+      </van-collapse-item>
+    </van-collapse>
+
+    <div class="px-4 py-3">
+      <van-button block type="primary" plain @click="onCancel?.()">关闭</van-button>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.logger-preview {
+  --van-cell-group-title-color: hsl(var(--primary-color-hover) / 0.5);
+  --van-cell-text-color: hsl(var(--primary-hover) / 0.5);
+  --van-cell-value-color: #fff;
+  --van-cell-value-font-size: 14px;
+  :deep(.van-cell__value) {
+    flex: 2;
+  }
+}
+
+.tag-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  justify-content: flex-end;
+}
+
+.text-danger {
+  color: var(--van-danger-color);
+  text-align: right;
+  word-break: break-word;
+}
+
+.mono-wrap {
+  display: block;
+  max-width: 100%;
+  font-size: 12px;
+  line-height: 1.45;
+  text-align: right;
+  word-break: break-all;
+  white-space: pre-wrap;
+}
+
+.logger-preview__collapse {
+  margin-left: 16px;
+  margin-right: 16px;
+  overflow: hidden;
+  border-radius: var(--van-radius-lg);
+}
+
+.json-block {
+  margin: 0;
+  padding: 8px 0 4px;
+  font-size: 12px;
+  line-height: 1.5;
+  word-break: break-all;
+  white-space: pre-wrap;
+  color: #fff;
+}
+
+.pb-safe {
+  padding-bottom: max(12px, env(safe-area-inset-bottom, 0px));
+}
+</style>

+ 94 - 0
src/modules/system/role.edit.vue

@@ -0,0 +1,94 @@
+<script setup lang="ts">
+import type { CheckboxInstance, FormInstance } from 'vant';
+import type { MenuLeafFlat, RoleModel } from '@/request/model/manage.model';
+import { useForm } from 'alova/client';
+import { flattenMenuLeaves } from '@/request/model/manage.model';
+
+import { useRequest } from 'alova/client';
+import { editRole, getMenus } from '@/request/api/manage';
+
+const props = defineProps<Partial<RoleModel>>();
+const emits = defineEmits<{
+  complete: [model: RoleModel];
+  cancel: [];
+}>();
+
+const menus = shallowRef<MenuLeafFlat[]>([]);
+const { loading } = useRequest(getMenus).onSuccess(({ data }) => {
+  menus.value = flattenMenuLeaves(data);
+});
+const {
+  form,
+  loading: submitting,
+  send: submit,
+} = useForm(
+  (model) => {
+    const selected = new Set<string>();
+    for (const id of model.menuIds) {
+      const menu = menus.value.find((menu) => menu.id === id);
+      menu?.parents?.forEach((menu) => selected.add(menu.id));
+      if (menu) selected.add(menu.id);
+    }
+    return editRole({ ...model, menuIds: [...selected] });
+  },
+  { immediate: false, initialForm: { pid: '', rolename: '', menuIds: [] as string[], stateSel: '0' } }
+).onSuccess(({ data }) => emits('complete', data));
+
+watchEffect(() => {
+  form.value.pid = props.id ?? '';
+  form.value.rolename = props.name ?? '';
+  form.value.menuIds = props.menus ?? [];
+});
+
+const formRef = useTemplateRef<FormInstance>('form');
+const checkboxRefs = useTemplateRef<CheckboxInstance[]>('checkbox');
+const toggle = (index: number) => {
+  if (submitting.value) return;
+  checkboxRefs.value?.[index].toggle();
+  formRef.value?.validate('role_menus');
+};
+</script>
+
+<template>
+  <van-form class="form-wrapper" @submit="submit(form)" label-align="top" required :readonly="submitting">
+    <van-cell-group inset>
+      <van-field name="role_name" label="角色名" placeholder="请输入角色名称" v-model="form.rolename" :rules="[{ required: true, message: '请输入角色名称' }]" />
+      <van-field name="role_menus" :rules="[{ required: true, message: '请选择菜单权限' }]">
+        <template #label>菜单权限<van-loading v-if="loading" class="ml-2" size="18" /> </template>
+        <template #input>
+          <van-checkbox-group v-model="form.menuIds" direction="horizontal" shape="square">
+            <van-cell-group inset>
+              <van-cell v-for="(item, index) in menus" clickable :key="item.id" @click="toggle(index)">
+                <template #title>
+                  <div class="flex">
+                    <van-checkbox :name="item.id" ref="checkbox" :disabled="submitting" @click.stop />
+                    {{ item.label }}
+                  </div>
+                </template>
+              </van-cell>
+            </van-cell-group>
+          </van-checkbox-group>
+        </template>
+      </van-field>
+    </van-cell-group>
+    <div style="padding: 16px">
+      <van-button round block type="primary" native-type="submit" :loading="submitting">提交</van-button>
+    </div>
+  </van-form>
+</template>
+
+<style scoped lang="scss">
+.form-wrapper {
+  :deep(.van-field__control) {
+    .van-checkbox-group,
+    .van-checkbox-group .van-cell-group {
+      --van-cell-group-inset-padding: 0;
+      flex: auto;
+    }
+    .van-checkbox-group {
+      max-height: 40vh;
+      overflow-y: auto;
+    }
+  }
+}
+</style>

+ 132 - 0
src/modules/system/role.page.vue

@@ -0,0 +1,132 @@
+<script setup lang="ts">
+import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
+
+import type { MenuLeafFlat, RoleModel } from '@/request/model/manage.model';
+import { usePagination, useRequest } from 'alova/client';
+import { deleteRole, getMenus, listRoles } from '@/request/api/manage';
+import { flattenMenuLeaves } from '@/request/model/manage.model';
+import { useRouteMeta } from '@/router/hooks/useRouteMeta';
+import { useFloatPanel } from '@/composables/FloatPanel';
+import RoleTags from '@/modules/system/components/RoleTags.vue';
+import RoleEdit from './role.edit.vue';
+
+const title = useRouteMeta('title', '角色管理', { priority: 'query' });
+
+const form = ref({ rolename: '' });
+const reset = () => {
+  form.value = { rolename: '' };
+  reload();
+};
+const { loading, data, isLastPage, page, replace, remove, reload, refresh } = usePagination((...args) => listRoles(...args, form.value), {
+  initialData: { total: 0, data: [] },
+  initialPage: 1,
+  initialPageSize: 20,
+  append: true,
+  immediate: true,
+}).onComplete(() => (sending.value = false));
+const sending = ref(true);
+const onLoad = () => {
+  if (sending.value) return;
+  sending.value = true;
+  page.value += 1;
+};
+
+const [EditFloatPanel, edit] = useFloatPanel<RoleModel, RoleModel>(RoleEdit);
+
+const onDelete = (item: RoleModel, index: number) => {
+  showConfirmDialog({
+    title: '确定删除吗?',
+    message: `删除角色: ${item.name}`,
+    confirmButtonText: '删除',
+    async beforeClose(action) {
+      if (action !== 'confirm') return true;
+      try {
+        await deleteRole(item.id).send(true);
+        remove(item);
+        showSuccessToast(`删除角色: ${item.name} 成功`);
+        return true;
+      } catch {
+        return false;
+      }
+    },
+  });
+};
+
+const onEdit = async (item: RoleModel | null, index: number) => {
+  const result = await edit.open(item);
+  if (!result) return;
+  if (result.id) {
+    replace(result, item);
+    showSuccessToast(`修改角色: ${result.name} 成功`);
+  } else {
+    reload();
+    showSuccessToast(`创建角色: ${result.name} 成功`);
+  }
+};
+
+const menus = shallowRef<MenuLeafFlat[]>([]);
+const { loading: menus_loading } = useRequest(getMenus).onSuccess(({ data }) => {
+  menus.value = flattenMenuLeaves(data);
+});
+</script>
+
+<template>
+  <div class="page-container">
+    <div class="page-header flex py-4 px-4 overflow-hidden">
+      <div class="grow shrink-0 h-full min-w-16"></div>
+      <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden">
+        <div class="font-bold text-3xl text-nowrap text-center tracking-wide overflow-ellipsis overflow-hidden">{{ title }}</div>
+      </div>
+      <div class="grow shrink-0 h-full min-w-16 flex items-center justify-end overflow-hidden">
+        <router-link :to="{ path: '/screen' }" replace>
+          <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页" />
+        </router-link>
+      </div>
+    </div>
+    <div class="page-content flex flex-col overflow-hidden">
+      <div class="flex-none">
+        <van-form label-width="4em" @submit="reload()" @reset="reset()">
+          <van-cell-group inset class="grid grid-cols-2 gap-2">
+            <van-field :border="false" name="rolename" v-model="form.rolename" label="名称" placeholder="请输入角色名称" />
+            <div class="flex items-center justify-end gap-6">
+              <div class="flex gap-4">
+                <van-button plain size="small" native-type="reset">重置</van-button>
+                <van-button plain size="small" type="success" native-type="submit">搜索</van-button>
+              </div>
+              <div class="flex justify-end gap-4 flex-auto">
+                <van-button size="small" type="primary" @click="onEdit(null, -1)">创建角色</van-button>
+              </div>
+            </div>
+          </van-cell-group>
+        </van-form>
+        <van-divider style="border-color: hsl(var(--primary-hover) / 0.5)" />
+      </div>
+      <div class="flex-auto overflow-y-scroll list-content-container">
+        <van-pull-refresh :modelValue="loading && page === 1" class="min-h-full" @refresh="reload()">
+          <van-list :loading="loading || sending" :finished="isLastPage" finished-text="没有更多了" @load="onLoad()">
+            <van-cell v-for="(item, index) in data" :key="item.id" size="large" center>
+              <template #title>{{ item.name }}</template>
+              <template #label><RoleTags v-model:selected="item.menus" :menus :loading="menus_loading" /></template>
+              <template #value>
+                <van-button square type="danger" text="删除" @click="onDelete(item, index)" />
+                <van-button square type="primary" text="修改" @click="onEdit(item, index)" />
+              </template>
+            </van-cell>
+          </van-list>
+        </van-pull-refresh>
+      </div>
+    </div>
+    <EditFloatPanel auto-height closable />
+  </div>
+</template>
+
+<style scoped lang="scss">
+.list-content-container {
+  :deep(.van-cell__title) {
+    flex: auto;
+  }
+  :deep(.van-cell__value) {
+    flex: none;
+  }
+}
+</style>

+ 91 - 0
src/modules/system/user.edit.vue

@@ -0,0 +1,91 @@
+<script setup lang="ts">
+import type { CheckboxInstance, FormInstance } from 'vant';
+import type { UserModel } from '@/request/model/manage.model';
+import { useForm } from 'alova/client';
+
+import { useRequest } from 'alova/client';
+import { editUser, getRoles } from '@/request/api/manage';
+
+const props = defineProps<Partial<UserModel>>();
+const emits = defineEmits<{
+  complete: [model: UserModel];
+  cancel: [];
+}>();
+
+const { data: roles, loading } = useRequest(getRoles);
+const {
+  form,
+  loading: submitting,
+  send: submit,
+} = useForm(
+  (model) => {
+    const rawRoles = [];
+    for (const id of model.roleIds) {
+      const role = roles.value.find((role) => role.id === id);
+      if (role) rawRoles.push(role.__raw__);
+    }
+    return editUser({ ...model, roles: rawRoles, roleIds: model.roleIds.join(',') });
+  },
+  { immediate: false, initialForm: { pid: '', userid: '', username: '', roleIds: [] as string[], stateSel: '0' } }
+).onSuccess(({ data }) => emits('complete', data));
+
+watchEffect(() => {
+  form.value.pid = props.id ?? '';
+  form.value.userid = props.account ?? '';
+  form.value.username = props.name ?? '';
+  form.value.roleIds = props.roles?.map((item) => item.id) ?? [];
+});
+
+const formRef = useTemplateRef<FormInstance>('form');
+const checkboxRefs = useTemplateRef<CheckboxInstance[]>('checkbox');
+const toggle = (index: number) => {
+  if (submitting.value) return;
+  checkboxRefs.value?.[index].toggle();
+  formRef.value?.validate('role_menus');
+};
+</script>
+
+<template>
+  <van-form class="form-wrapper" @submit="submit(form)" label-align="top" required :readonly="submitting">
+    <van-cell-group inset>
+      <van-field name="user_id" label="账号" placeholder="请输入登录账号" v-model="form.userid" :rules="[{ required: true, message: '请输入登录账号' }]" />
+      <van-field name="user_name" label="姓名" placeholder="请输入真实姓名" v-model="form.username" :rules="[{ required: true, message: '请输入真实姓名' }]" />
+      <van-field name="user_roles" :rules="[{ required: true, message: '请选择角色' }]">
+        <template #label>用户角色<van-loading v-if="loading" class="ml-2" size="18" /> </template>
+        <template #input>
+          <van-checkbox-group v-model="form.roleIds" direction="horizontal" shape="square">
+            <van-cell-group inset>
+              <van-cell v-for="(item, index) in roles" clickable :key="item.id" :title="`复选框 ${item}`" @click="toggle(index)">
+                <template #title>
+                  <div class="flex">
+                    <van-checkbox :name="item.id" ref="checkbox" :disabled="submitting" @click.stop />
+                    {{ item.name }}
+                  </div>
+                </template>
+              </van-cell>
+            </van-cell-group>
+          </van-checkbox-group>
+        </template>
+      </van-field>
+    </van-cell-group>
+    <div style="padding: 16px">
+      <van-button round block type="primary" native-type="submit" :loading="submitting">提交</van-button>
+    </div>
+  </van-form>
+</template>
+
+<style scoped lang="scss">
+.form-wrapper {
+  :deep(.van-field__control) {
+    .van-checkbox-group,
+    .van-checkbox-group .van-cell-group {
+      --van-cell-group-inset-padding: 0;
+      flex: auto;
+    }
+    .van-checkbox-group {
+      max-height: 40vh;
+      overflow-y: auto;
+    }
+  }
+}
+</style>

+ 176 - 0
src/modules/system/user.page.vue

@@ -0,0 +1,176 @@
+<script setup lang="ts">
+import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
+
+import type { UserModel } from '@/request/model/manage.model';
+import { usePagination } from 'alova/client';
+import { deleteUser, listUsers, resetUserPassword } from '@/request/api/manage';
+import { useRouteMeta } from '@/router/hooks/useRouteMeta';
+import { useFloatPanel } from '@/composables/FloatPanel';
+import UserEdit from '@/modules/system/user.edit.vue';
+import RolePicker from '@/modules/system/components/RolePicker.vue';
+
+const title = useRouteMeta('title', '用户管理', { priority: 'query' });
+
+const form = ref({ userid: '', username: '', roleIds: '' });
+const reset = () => {
+  form.value = { userid: '', username: '', roleIds: '' };
+  displayRoleName.value = '';
+  reload();
+};
+const { loading, data, isLastPage, page, replace, remove, reload, refresh } = usePagination((...args) => listUsers(...args, form.value), {
+  initialData: { total: 0, data: [] },
+  initialPage: 1,
+  initialPageSize: 20,
+  append: true,
+  immediate: true,
+}).onComplete(() => (sending.value = false));
+const sending = ref(true);
+const onLoad = () => {
+  if (sending.value) return;
+  sending.value = true;
+  page.value += 1;
+};
+
+const [EditFloatPanel, edit] = useFloatPanel<UserModel, UserModel>(UserEdit);
+
+const onResetPassword = (item: UserModel, index: number) => {
+  showConfirmDialog({
+    title: '确定重置用户密码为初始密码吗?',
+    message: `重置用户: ${item.name} 的密码`,
+    confirmButtonText: '重置',
+    async beforeClose(action) {
+      if (action !== 'confirm') return true;
+      try {
+        await resetUserPassword(item.id).send(true);
+        showSuccessToast(`重置用户: ${item.name} 的密码成功`);
+        return true;
+      } catch {
+        return false;
+      }
+    },
+  });
+};
+
+const onDelete = (item: UserModel, index: number) => {
+  showConfirmDialog({
+    title: '确定删除吗?',
+    message: `删除用户: ${item.name}`,
+    confirmButtonText: '删除',
+    async beforeClose(action) {
+      if (action !== 'confirm') return true;
+      try {
+        await deleteUser(item.id).send(true);
+        remove(item);
+        showSuccessToast(`删除用户: ${item.name} 成功`);
+        return true;
+      } catch {
+        return false;
+      }
+    },
+  });
+};
+
+const onEdit = async (item: UserModel | null, index: number) => {
+  const result = await edit.open(item);
+  if (!result) return;
+  if (result.id) {
+    replace(result, item);
+    showSuccessToast(`修改用户: ${result.name} 成功`);
+  } else {
+    reload();
+    showSuccessToast(`创建用户: ${result.name} 成功`);
+  }
+};
+
+const showRolePicker = ref(false);
+const displayRoleName = ref('');
+const onRolePickerComplete = (roles: any[]) => {
+  const length = roles.length;
+  const value = roles
+    .slice(0, 2)
+    .map((role) => role.rolename || role.name || '-')
+    .join(',');
+  displayRoleName.value = length <= 2 ? value : `${value} + ${length - 2} 项`;
+  reload();
+};
+</script>
+
+<template>
+  <div class="page-container">
+    <div class="page-header flex py-4 px-4 overflow-hidden">
+      <div class="grow shrink-0 h-full min-w-16"></div>
+      <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden">
+        <div class="font-bold text-3xl text-nowrap text-center tracking-wide overflow-ellipsis overflow-hidden">{{ title }}</div>
+      </div>
+      <div class="grow shrink-0 h-full min-w-16 flex items-center justify-end overflow-hidden">
+        <router-link :to="{ path: '/screen' }" replace>
+          <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页" />
+        </router-link>
+      </div>
+    </div>
+    <div class="page-content flex flex-col overflow-hidden">
+      <div class="flex-none">
+        <van-form label-width="4em" @submit="reload()" @reset="reset()">
+          <van-cell-group inset class="grid grid-cols-2 gap-2">
+            <van-field :border="false" name="userid" v-model="form.userid" label="账号" placeholder="请输入账号" />
+            <van-field :border="false" name="username" v-model="form.username" label="姓名" placeholder="请输入姓名" />
+            <van-field :border="false" name="roleIds" v-model="displayRoleName" label="角色" placeholder="请选择角色" readonly @click="showRolePicker = true" />
+            <div class="flex items-center justify-end gap-6">
+              <div class="flex gap-4">
+                <van-button plain size="small" native-type="reset">重置</van-button>
+                <van-button plain size="small" type="success" native-type="submit">搜索</van-button>
+              </div>
+              <div class="flex justify-end gap-4 flex-auto">
+                <van-button size="small" type="primary" @click="onEdit(null, -1)">创建用户</van-button>
+              </div>
+            </div>
+          </van-cell-group>
+        </van-form>
+        <van-divider style="border-color: hsl(var(--primary-hover) / 0.5)" />
+      </div>
+      <div class="flex-auto overflow-y-scroll list-content-container">
+        <van-pull-refresh :modelValue="loading && page === 1" class="min-h-full" @refresh="reload()">
+          <van-list :loading="loading || sending" :finished="isLastPage" finished-text="没有更多了" @load="onLoad()">
+            <van-cell v-for="(item, index) in data" :key="item.id" size="large" center>
+              <template #title>
+                <van-row>
+                  <van-col span="8">
+                    <label class="mr-2" style="color: hsl(var(--primary) / 0.8)">账号:</label>
+                    <span>{{ item.account }}</span>
+                  </van-col>
+                  <van-col span="12">
+                    <label class="mr-2" style="color: hsl(var(--primary) / 0.8)">姓名:</label>
+                    <span>{{ item.name }}</span>
+                  </van-col>
+                </van-row>
+              </template>
+              <template #label>
+                <div class="flex flex-wrap gap-2">
+                  <van-tag plain type="primary" v-for="role in item.roles" :key="role.id">{{ role.name }}</van-tag>
+                </div>
+              </template>
+              <template #value>
+                <van-button square type="danger" text="删除" @click="onDelete(item, index)" />
+                <van-button square type="primary" text="修改" @click="onEdit(item, index)" />
+                <van-button square type="warning" text="重置密码" @click="onResetPassword(item, index)" />
+              </template>
+            </van-cell>
+          </van-list>
+        </van-pull-refresh>
+      </div>
+    </div>
+    <EditFloatPanel auto-height closable />
+    <RolePicker v-model:show="showRolePicker" v-model:selected="form.roleIds" @complete="onRolePickerComplete" />
+  </div>
+</template>
+
+<style scoped lang="scss">
+.list-content-container {
+  :deep(.van-cell__title) {
+    flex: auto;
+  }
+  :deep(.van-cell__value) {
+    flex: none;
+  }
+}
+</style>

+ 31 - 348
src/pages/register.page.vue

@@ -1,232 +1,52 @@
 <script setup lang="ts">
 import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
-import { Toast } from '@/platform';
 
-import { getCaptchaMethod, registerAccountMethod, registerFieldsMethod, dictionariesMethod, searchAccountMethod } from '@/request/api';
-import type { Field, Fields, Option, RegisterModel } from '@/request/model';
+import type RegisterForm from '@/components/RegisterForm.vue';
+import type { RegisterModel } from '@/request/model';
+
 import { useRouteQuery } from '@vueuse/router';
-import { useCaptcha, useRequest, useSerialRequest } from 'alova/client';
+import { useRequest } from 'alova/client';
+import { getApplicationMethod, registerAccountMethod } from '@/request/api';
 
-import type { FormInstance } from 'vant';
-import { RadioGroup as vanRadioGroup } from 'vant';
-import PickerDialog from '@/components/PickerDialog.vue';
-import CascaderDialog from '@/components/CascaderDialog.vue';
 import { useFlowStore, useVisitor } from '@/stores';
 
-const formRef = useTemplateRef<FormInstance>('register-form');
-const modelLabel = ref<Partial<RegisterModel>>({ });
-const modelValue = ref<Partial<RegisterModel>>({ code: '' });
-const model = computed(() => ({ ...modelLabel.value, ...modelValue.value }));
 
-const { data: fields, loading } = useSerialRequest([dictionariesMethod, (dictionaries) => registerFieldsMethod(dictionaries)]).onSuccess(({ data }) => {
-  const sex = data.find((field) => field.name === 'sex');
-  if (sex) {
-    const unknown = (<any>sex).component?.options?.find((option: any) => option.value === '2');
-    modelLabel.value.sex = unknown?.label;
-    modelValue.value.sex = unknown?.value;
-  }
-});
+useRequest(getApplicationMethod, { initialData: { image: {} } });
 
 const flow = useFlowStore();
 const visitor = useVisitor();
-const { loading: submitting, send: submit } = useRequest(registerAccountMethod, { immediate: false }).onSuccess(({ data }) => {
-  visitor.updatePatient(modelLabel.value, data);
-  flow.router.push();
-});
+const { loading: submitting, send: submit } = useRequest(registerAccountMethod, { immediate: false });
 
-const forbiddenFields = shallowRef<Record<string, boolean>>({});
-const { loading: searching, send: search } = useRequest((data) => searchAccountMethod(data), {
-  immediate: false,
-}).onSuccess(({ data }) => {
-  if (!fields.value.some((field) => field.name === 'phone')) Reflect.deleteProperty(data, 'phone');
-  setValues(data);
-});
+const formRef = ref<InstanceType<typeof RegisterForm> | null>(null);
+async function onSubmitFromForm(payload: { model: RegisterModel; modelLabel: Partial<RegisterModel> }) {
+  const patientId = await submit(payload.model);
+  visitor.updatePatient(payload.modelLabel, patientId);
+  flow.router.push();
+}
 
-let captchaLoaded = false;
-const { loading: captchaLoading, countdown, send: getCaptcha } = useCaptcha(
-  () => getCaptchaMethod(model.value.phone!),
-  { initialCountdown: 60 },
-).onSuccess(({ data }) => {
-  captchaLoaded = true;
-  Toast.success(data ?? '获取成功');
-});
-const getCaptchaHandle = async () => {
-  try {
-    await formRef.value?.validate('phone');
-    if ( !model.value.phone ) throw { message: `请输入手机号码` };
-    await getCaptcha();
-    const field = fields.value.find(field => field.name === 'code');
-    if ( field?.keyboard ) { field.keyboard.show = true; }
-  } catch ( e: any ) {
-    Toast.warning(e?.message);
-  }
-};
+const scan = useRouteQuery<string>('scan');
 
-const searchHandle = async (key: 'cardno' | 'code') => {
-  const forbidden = { cardno: 'phone', code: 'cardno' }[key];
+function applyScanToForm() {
+  const key = scan.value;
+  if (!key) return;
   try {
-    await formRef.value?.validate(key);
-    forbiddenFields.value = {};
-    const { cardno, phone, code } = model.value;
-    await search({ cardno, phone, code })
-      .then((data) => {
-        forbiddenFields.value[forbidden] = !!(data as any)[forbidden];
-        triggerRef(forbiddenFields);
-      })
-      .catch();
-  } catch (e: any) {
-    Toast.warning(e?.message);
-  }
-};
-
-function onKeyboardBlur(field: Fields[number]) {
-  if ( field?.name === 'phone' && !captchaLoaded ) { getCaptchaHandle(); }
-  if ( field?.name === 'cardno' && model.value.cardno ) { searchHandle('cardno'); }
-  if ( field?.name === 'code' && model.value.phone ) { searchHandle('code'); }
-}
-
-function onSubmitHandle() {
-  submit(model.value);
+    const { model } = JSON.parse(sessionStorage.getItem(`scan_${key}`) ?? '');
+    nextTick(() => formRef.value?.setValues(model));
+  } catch (_e: any) {}
 }
 
-function fix(key: string) {
-  for ( const field of fields.value ) {
-    if (field.keyboard?.show && field.name !== key ) field.keyboard.show = false;
-  }
-}
+watch(scan, applyScanToForm, { immediate: true, flush: 'post' });
 
-const scan = useRouteQuery<string>('scan');
-watch(scan, key => {
-  if ( key ) {
-    try {
-      const { model } = JSON.parse(sessionStorage.getItem(`scan_${ key }`) ?? '');
-      setValues(model);
-    } catch ( e: any ) {}
-  }
-}, { immediate: true });
+watch(formRef, (inst) => {
+  if (inst && scan.value) applyScanToForm();
+}, { flush: 'post' });
 
 onBeforeUnmount(() => {
-  for ( let i = 0; i < sessionStorage.length; i++ ) {
+  for (let i = 0; i < sessionStorage.length; i++) {
     const key = sessionStorage.key(i);
-    if ( key?.startsWith('scan_') ) sessionStorage.removeItem(key);
+    if (key?.startsWith('scan_')) sessionStorage.removeItem(key);
   }
 });
-
-const keyboardProps = reactive({
-  key: '',
-  props: {},
-  show: false,
-});
-const pickerProps = reactive({
-  key: '',
-  props: { options: [], selected: [] },
-  show: false,
-});
-const cascaderProps = reactive({
-  key: '',
-  props: { options: [], loading: false },
-  show: false,
-});
-
-const handle = (value: any, field: Field | string) => {
-  field = ((key: Field | string): Field => (typeof key === 'string' ? fields.value.find((field) => field.name === key)! : key))(field);
-  const key = field.name!;
-  if (field.component?.name === 'radio') {
-    const option = field?.component?.options?.find((option) => option.value === value);
-    (modelLabel.value as any)[key] = option?.label;
-    (modelValue.value as any)[key] = option?.value;
-  } else if (field.component?.name === 'picker') {
-    if (typeof value === 'string') {
-      value = value.split(',').map((value) => {
-        const [v, l] = value.split(':');
-        return { value, label: l ?? (field.component as any).options?.find((option: Option) => option.value === v)?.label ?? v };
-      });
-    }
-    (modelLabel.value as any)[key] = value.map((option: Option) => option.label).join(',');
-    (modelValue.value as any)[key] = value.map((option: Option) => option.value).join(',');
-  } else if (field.component?.name === 'cascader') {
-    (modelLabel.value as any)[key] = value.map((option: Option) => option.label).join('/');
-    (modelValue.value as any)[key] = value;
-  } else if (field.component?.name === 'code') {
-    (modelValue.value as any)[key] = value;
-  } else {
-    (modelLabel.value as any)[key] = value;
-    (modelValue.value as any)[key] = value;
-  }
-};
-const setValues = (values: Record<string, any>) => {
-  forbiddenFields.value = {};
-  if (!values) {
-    modelLabel.value = {};
-    modelValue.value = {};
-  }
-  for (const [key, value] of Object.entries(values ?? {})) {
-    const field = fields.value?.find((field) => field.name === key);
-    if (field) {
-      forbiddenFields.value[key] = !!value && !['phone', 'cardno'].includes(key);
-      handle(value, field as any)
-    } else {
-      (modelValue as any).value[key] = value;
-    }
-  }
-}
-
-function onFieldFocus(field: any) {
-  if (forbiddenFields.value[field.name]) return;
-  if (field.keyboard) {
-    keyboardProps.key = field.name;
-    keyboardProps.show = true;
-    keyboardProps.props = {
-      ...field.keyboard,
-      maxlength: field.control?.maxlength ?? Number.POSITIVE_INFINITY,
-      onBlur() {
-        keyboardProps.show = false;
-        onKeyboardBlur(field);
-      },
-    }
-  } else if (field.component?.name === 'picker') {
-    pickerProps.key = field.name;
-    pickerProps.show = true;
-    pickerProps.props = {
-      ...field.component.props,
-      title: field.control.label,
-      options: field.component.options,
-      selected: (modelValue.value as Record<string, string>)[field.name]?.split(','),
-    };
-  } else if (field.component?.name === 'cascader') {
-    cascaderProps.key = field.name;
-    cascaderProps.show = true;
-    if (typeof field.component.options === 'function') {
-      cascaderProps.props = {
-        ...field.component.props,
-        title: field.control.label,
-        options: [],
-        loading: true,
-      };
-      (async function () {
-        field.component.options = await field.component.options();
-        cascaderProps.props = {
-          ...field.component.props,
-          title: field.control.label,
-          options: field.component.options,
-          loading: false,
-          selected: (modelValue.value as Record<string, { value: string }[]>)[field.name]?.map((option) => option.value),
-        };
-      })();
-    } else {
-      cascaderProps.props = {
-        ...field.component.props,
-        title: field.control.label,
-        options: field.component.options,
-      };
-    }
-  }
-}
-function onFieldBlur(field: any) {
-  keyboardProps.show = false;
-  pickerProps.show = false;
-  cascaderProps.show = false;
-}
 </script>
 <template>
   <div>
@@ -234,160 +54,23 @@ function onFieldBlur(field: any) {
       <div class="grow shrink-0 h-full min-w-16"></div>
       <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden">
         <div class="flex justify-center font-bold text-3xl text-nowrap text-center tracking-wide overflow-ellipsis overflow-hidden">
-          建档 <van-loading v-if="searching" style="margin-left: 4px; color: #38ff6e;"></van-loading>
+          建档 <van-loading v-if="formRef?.searching" style="margin-left: 4px; color: #38ff6e"></van-loading>
         </div>
       </div>
       <div class="grow shrink-0 h-full min-w-16 flex items-center justify-end overflow-hidden">
         <router-link :to="{ path: '/screen' }" replace>
-          <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页">
+          <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页" />
         </router-link>
       </div>
     </div>
     <div class="page-content p-6 overflow-auto">
-      <van-form class="register-form" ref="register-form" colon required="auto" scroll-to-error scroll-to-error-position="center" @submit="onSubmitHandle()">
-        <van-cell-group :border="false">
-          <template v-for="field in fields" :key="field.name">
-            <template v-if="!field.control?.hide || (typeof field.control?.hide === 'function' && !field.control.hide(model))">
-              <van-field
-                :model-value="field.control?.readonly ? modelLabel[field.name] : model[field.name]" @update:model-value="handle($event, field)"
-                :name="field.name" :id="field.name"
-                :rules="field.rules" v-bind="field.control"
-                :class="{'no-border': field.control?.border === false}"
-                @focus="onFieldFocus(field)" @blur="onFieldBlur(field)"
-                :readonly="field.control?.readonly" @click="onFieldFocus(field)"
-                :disabled="forbiddenFields[field.name]"
-              >
-                <template #input v-if="field.component?.name === 'radio'">
-                  <van-radio-group direction="horizontal" shape="dot" :disabled="forbiddenFields[field.name]" v-model="modelValue[field.name]" @change="handle($event, field)">
-                    <van-radio v-for="option in field.component?.options" :key="option.value" :name="option.value">
-                      {{ option.label }}
-                    </van-radio>
-                  </van-radio-group>
-                </template>
-                <template #input v-else-if="field.component?.name === 'code'">
-                  <van-password-input
-                    style="width: 100%;"
-                    v-model:value="modelValue[field.name]" v-bind="(field.component as any)!.props"
-                    :focused="field.keyboard?.show" @focus="field.keyboard && (field.keyboard.show = true);fix('code')"
-                  />
-                </template>
-                <template #button>
-                  <div class="text-primary cursor-pointer">
-                    <template v-if="field.component?.name === 'code'">
-                      <div class="text-primary cursor-pointer" @click="getCaptchaHandle()">
-                        {{ captchaLoading ? '发送中...' : countdown > 0 ? `${ countdown }后可重发` : '获取验证码' }}
-                      </div>
-                    </template>
-                    <template v-else>{{ field.suffix }}</template>
-                  </div>
-                </template>
-              </van-field>
-            </template>
-          </template>
-        </van-cell-group>
-      </van-form>
+      <RegisterForm ref="formRef" :search-forbidden-field="!extra_hack" @submit="onSubmitFromForm" />
       <div class="m-4">
         <div class="m-auto size-16 cursor-pointer">
-          <van-loading v-if="submitting || loading" type="spinner" size="64" color="#38ff6e" />
-          <img v-else class="size-full" src="@/assets/images/next-step.svg" alt="提交" @click="formRef?.submit()">
+          <van-loading v-if="submitting || formRef?.loading" type="spinner" size="64" color="#38ff6e" />
+          <img v-else class="size-full" src="@/assets/images/next-step.svg" alt="提交" @click="formRef?.submit()" />
         </div>
       </div>
-      <van-number-keyboard v-bind="keyboardProps.props" :show="keyboardProps.show" v-model="modelValue[keyboardProps.key]" @update:model-value="handle($event, keyboardProps.key)"></van-number-keyboard>
-      <PickerDialog v-bind="pickerProps.props" v-model:show="pickerProps.show" @selected="handle($event, pickerProps.key)"></PickerDialog>
-      <CascaderDialog v-bind="cascaderProps.props" v-model:show="cascaderProps.show" @selected="handle($event, cascaderProps.key)"></CascaderDialog>
     </div>
   </div>
 </template>
-<style scoped lang="scss">
-.register-form {
-  .van-field {
-    margin: 0;
-    padding: 0;
-  }
-
-  .van-field.no-border {
-    :deep(.van-field__control) {
-      padding: 0;
-      border: none;
-      border-radius: 8px;
-      text-align: center;
-    }
-  }
-
-  :deep(.van-field--disabled) {
-    --tw-text-opacity: 0.5;
-    --van-radio-checked-icon-color: rgba(56, 255, 110, var(--tw-text-opacity, 1));
-    .text-primary {
-      --tw-text-opacity: 0.5;
-    }
-  }
-
-  :deep(.van-field__label) {
-    margin-bottom: 24px;
-    padding: 8px 0;
-    min-width: 100px;
-    font-size: 18px;
-  }
-
-  :deep(.van-field__control) {
-    margin-bottom: 24px;
-    padding: 8px;
-    border: 1px solid var(--van-radio-checked-icon-color);
-    border-radius: 8px;
-    text-align: center;
-  }
-
-  :deep(.van-field__clear) {
-    align-self: flex-start;
-    display: flex;
-    align-items: center;
-    height: 42px;
-  }
-
-  :deep(.van-field__button) {
-    margin-bottom: 24px;
-    padding: 8px var(--van-padding-xs);
-    min-width: 100px;
-    font-size: 18px;
-    text-align: left;
-  }
-
-  :deep(.van-field__error-message) {
-    position: absolute;
-    top: 40px + 2px;
-  }
-
-  :deep(.van-password-input) {
-    margin: 0;
-  }
-
-  :deep(.van-password-input__security) {
-    justify-content: space-between;
-    align-items: center;
-    text-align: center;
-    $size: 40px;
-    height: $size + 2px;
-
-    &::after {
-      display: none;
-    }
-
-    li {
-      height: $size;
-      width: $size;
-      flex: none;
-      border: 1px solid #38ff6e;
-      border-radius: 8px;
-    }
-  }
-}
-
-.van-radio-group {
-  height: 40px + 2px;
-}
-
-.sub-option.checked {
-  color: #fff;
-  background-color: var(--primary-color);
-}
-</style>

+ 91 - 2
src/request/api/manage.ts

@@ -1,8 +1,8 @@
-import type { MenuModel, UserModel } from '@/request/model/manage.model';
+import type { MenuModel, UserModel, RoleModel,  LoggerModel } from '@/request/model/manage.model';
 
 import HTTP from '@/request/alova';
 import { cacheFor } from '@/request/api/index';
-import { fromMenusData, fromUserData } from '@/request/model/manage.model';
+import { fromMenusData, fromUserData, fromRoleData, fromLoggerData } from '@/request/model/manage.model';
 
 export function login(data: { account: string; password: string }) {
   return HTTP.Post<string, any>(
@@ -39,6 +39,82 @@ export function getMenus() {
   });
 }
 
+export function getRoles() {
+  return HTTP.Get<(RoleModel & { __raw__: any })[], any>(`/admin/right_RoleMgr/optionselect`, {
+    transform(data) {
+      return Array.isArray(data) ? data.map((item) => Object.assign(fromRoleData(item), { __raw__: item })) : [];
+    },
+    hitSource: /^role-/,
+  });
+}
+
+export function listRoles(page = 1, size = 20, query = {}) {
+  const params = { page, limit: size };
+  return HTTP.Post<{ data: RoleModel[]; total: number }, any>(
+    `/admin/right_RoleMgr/listPain`,
+    { ...params, ...query },
+    {
+      params,
+      transform(data) {
+        return { total: data.TotalRecordCount, data: data.Items.map(fromRoleData) };
+      },
+      hitSource: /^role-/,
+    }
+  );
+}
+
+export function editRole(data: { pid?: string } & Record<string, any>) {
+  return HTTP.Post<RoleModel, any>(data.pid ? `/admin/right_RoleMgr/update` : `/admin/right_RoleMgr/Add`, data, {
+    transform() {
+      return fromRoleData(data);
+    },
+    name: `role-edit`,
+  });
+}
+
+export function deleteRole(id: string) {
+  return HTTP.Post(
+    `/admin/right_RoleMgr/BatchDelete`,
+    {},
+    {
+      params: { ids: id },
+      name: `role-delete`,
+    }
+  );
+}
+
+export function listUsers(page = 1, size = 20, query = {}) {
+  const params = { page, limit: size };
+  return HTTP.Post<{ data: UserModel[]; total: number }, any>(
+    `/portal/userMgr/listPain`,
+    { ...params, ...query },
+    {
+      params,
+      transform(data) {
+        return { total: data.TotalRecordCount, data: data.Items.map(fromUserData) };
+      },
+    }
+  );
+}
+
+export function editUser(data: { pid?: string } & Record<string, any>) {
+  if (!data.pid) data.isFirst = 0;
+  return HTTP.Post<UserModel, any>(data.pid ? `/portal/userMgr/update` : `/portal/userMgr/Add`, data, {
+    transform() {
+      return fromUserData(data);
+    },
+  });
+}
+
+export function deleteUser(id: string) {
+  return HTTP.Post(`/portal/userMgr/BatchDelete`, {}, { params: { ids: id } });
+}
+
+export function resetUserPassword(pid: string) {
+  return HTTP.Post(`/portal/userMgr/resetPassWord`, { userPid: pid });
+}
+
+
 export function updateUserPassword(data: { password: string; again: string; pid: string | number }, token?: string) {
   return HTTP.Post(
     `/portal/userMgr/UpdatePassWord`,
@@ -50,3 +126,16 @@ export function updateUserPassword(data: { password: string; again: string; pid:
     { headers: { token } }
   );
 }
+
+export function listLoggers(page = 1, size = 20, query = {}) {
+  const params = { page, limit: size };
+  return HTTP.Get<{ data: LoggerModel[]; total: number }, any>(
+    `/monitor/operlog/list`,
+    {
+      params: { ...params, ...query },
+      transform(data) {
+        return { total: data.TotalRecordCount, data: data.Items.map(fromLoggerData) };
+      },
+    }
+  );
+}

+ 28 - 1
src/request/model/manage.model.ts

@@ -1,5 +1,7 @@
+import { randomUUID } from '@/tools';
+
 // ---------------------------------------------------------------------------
-// 角色 / 用户
+// 角色 / 用户 / 日志(接口与后端 DTO 映射)
 // ---------------------------------------------------------------------------
 
 export interface RoleModel {
@@ -47,6 +49,31 @@ export function fromUserData(data: Record<string, any>): UserModel {
   };
 }
 
+export interface LoggerModel {
+  uid: string;
+  id?: string | number;
+  title: string;
+  date: string;
+  operator: string;
+  __raw__?: any;
+}
+/**
+ * 将接口返回的日志条目转为前端 `LoggerModel`。
+ *
+ * @param data - 原始接口对象
+ * @returns 规范化后的日志模型
+ */
+export function fromLoggerData(data: Record<string, any>): LoggerModel {
+  return {
+    uid:  data.pid ?? randomUUID(),
+    id: data.operId,
+    title: data.title,
+    date: data.operTime,
+    operator: data.operName,
+    __raw__: data,
+  };
+}
+
 // ---------------------------------------------------------------------------
 // 菜单树(类型、展平、按 id 查找 / 过滤)
 // ---------------------------------------------------------------------------

+ 9 - 2
src/router/hooks/useRouteMeta.ts

@@ -4,6 +4,7 @@ import type { RouteMeta }             from 'vue-router';
 
 
 export interface ReactiveRouteOptionsWithTransform<V, R> {
+  priority?: 'query' | 'meta';
   route?: ReturnType<typeof useRoute>;
   transform?: (val: V) => R;
 }
@@ -15,13 +16,18 @@ export function useRouteMeta<T extends RouteMeta = RouteMeta, K = T>(
 ): Ref<K> {
 
   const {
+    priority = 'meta',
     route = useRoute(),
     transform = value => value as any as K,
   } = options;
 
+  let query = route.query[name] as any;
   let meta = route.meta[ name ] as any;
 
-  tryOnScopeDispose(() => { meta = undefined; });
+  tryOnScopeDispose(() => {
+    query = undefined;
+    meta = undefined;
+  });
 
 
   let _trigger: () => void;
@@ -30,7 +36,8 @@ export function useRouteMeta<T extends RouteMeta = RouteMeta, K = T>(
     return {
       get() {
         track();
-        return transform(meta !== undefined && meta !== '' ? meta : toValue(defaultValue));
+        const value = priority === 'query' && query ? query : meta;
+        return transform(value !== undefined && value !== '' ? value : toValue(defaultValue));
       },
       set(v) {
         meta = v;

+ 8 - 0
src/router/index.ts

@@ -18,6 +18,14 @@ const router = createRouter({
     { path: '/report/:id(\\w+)?', component: () => import('@/modules/report/report.page.vue'), meta: { title: '健康分析报告' } },
     { path: '/scheme/:id(\\w+)?', component: () => import('@/modules/report/scheme.page.vue'), meta: { title: '调理方案', toggle: false } },
     { path: '/crossing/:flow(\\w+)?', component: () => import('@/pages/crossing.page.vue') },
+    {
+      path: '/manage',
+      children: [
+        { path: 'system/role', component: () => import('@/modules/system/role.page.vue'), meta: { title: '角色管理' } },
+        { path: 'system/user', component: () => import('@/modules/system/user.page.vue'), meta: { title: '用户管理' } },
+        { path: 'system/logger', component: () => import('@/modules/system/logger.page.vue'), meta: { title: '日志管理' } },
+      ],
+    },
     { path: '/forbidden', name: 'forbidden', component: () => import('@/router/pages/forbidden.page.vue') },
     { path: '/', redirect: '/screen' },
   ],