diff --git a/web/src/assets/images/tool.png b/web/src/assets/images/tool.png new file mode 100644 index 0000000..35606c1 Binary files /dev/null and b/web/src/assets/images/tool.png differ diff --git a/web/src/components/Application/Application.vue b/web/src/components/Application/Application.vue new file mode 100644 index 0000000..9c812a7 --- /dev/null +++ b/web/src/components/Application/Application.vue @@ -0,0 +1,40 @@ + + + diff --git a/web/src/components/Application/index.ts b/web/src/components/Application/index.ts new file mode 100644 index 0000000..5cea04a --- /dev/null +++ b/web/src/components/Application/index.ts @@ -0,0 +1,3 @@ +import AppProvider from './Application.vue'; + +export { AppProvider }; diff --git a/web/src/components/CitySelector/citySelector.vue b/web/src/components/CitySelector/citySelector.vue new file mode 100644 index 0000000..3e88442 --- /dev/null +++ b/web/src/components/CitySelector/citySelector.vue @@ -0,0 +1,105 @@ + + + diff --git a/web/src/components/CitySelector/props.ts b/web/src/components/CitySelector/props.ts new file mode 100644 index 0000000..f4f21ee --- /dev/null +++ b/web/src/components/CitySelector/props.ts @@ -0,0 +1,22 @@ +import type { PropType } from 'vue'; +import { NCascader } from 'naive-ui'; + +export const basicProps = { + ...NCascader.props, + defaultValue: { + type: [Number, String, Array], + default: null, + }, + value: { + type: [Number, String, Array], + default: null, + }, + dataType: { + type: String as PropType<'p' | 'pc' | 'pca'>, + default: 'pca', + }, + checkStrategy: { + type: String as PropType<'child' | 'all'>, + default: 'child', + }, +}; diff --git a/web/src/components/ComplexMemberPicker/index.vue b/web/src/components/ComplexMemberPicker/index.vue new file mode 100644 index 0000000..ca9a58f --- /dev/null +++ b/web/src/components/ComplexMemberPicker/index.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/web/src/components/ComplexMemberPicker/props.ts b/web/src/components/ComplexMemberPicker/props.ts new file mode 100644 index 0000000..727c266 --- /dev/null +++ b/web/src/components/ComplexMemberPicker/props.ts @@ -0,0 +1,13 @@ +import { NSelect } from 'naive-ui'; + +export const basicProps = { + ...NSelect.props, + defaultValue: { + type: [Array], + default: null, + }, + value: { + type: [Array], + default: null, + }, +}; diff --git a/web/src/components/CountTo/CountTo.vue b/web/src/components/CountTo/CountTo.vue new file mode 100644 index 0000000..dfe248d --- /dev/null +++ b/web/src/components/CountTo/CountTo.vue @@ -0,0 +1,111 @@ + + diff --git a/web/src/components/CountTo/index.ts b/web/src/components/CountTo/index.ts new file mode 100644 index 0000000..946e525 --- /dev/null +++ b/web/src/components/CountTo/index.ts @@ -0,0 +1,4 @@ +import { withInstall } from '@/utils'; +import countTo from './CountTo.vue'; + +export const CountTo = withInstall(countTo); diff --git a/web/src/components/DatePicker/datePicker.vue b/web/src/components/DatePicker/datePicker.vue new file mode 100644 index 0000000..8c56c7b --- /dev/null +++ b/web/src/components/DatePicker/datePicker.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/web/src/components/DatePicker/props.ts b/web/src/components/DatePicker/props.ts new file mode 100644 index 0000000..91e3f5e --- /dev/null +++ b/web/src/components/DatePicker/props.ts @@ -0,0 +1,22 @@ +import type { PropType } from 'vue'; +import { NDatePicker } from 'naive-ui'; + +export const basicProps = { + ...NDatePicker.props, + formValue: { + type: String as PropType | undefined | Date, + default: () => '', + }, + startValue: { + type: String as PropType | undefined | Date, + default: () => '', + }, + endValue: { + type: String as PropType | undefined | Date, + default: () => '', + }, + showShortcuts: { + type: Boolean as PropType, + default: () => true, + }, +}; diff --git a/web/src/components/DialogContent/index.ts b/web/src/components/DialogContent/index.ts new file mode 100644 index 0000000..80103ec --- /dev/null +++ b/web/src/components/DialogContent/index.ts @@ -0,0 +1,3 @@ +import DialogContent from './index.vue'; + +export { DialogContent }; diff --git a/web/src/components/DialogContent/index.vue b/web/src/components/DialogContent/index.vue new file mode 100644 index 0000000..b35b1ef --- /dev/null +++ b/web/src/components/DialogContent/index.vue @@ -0,0 +1,12 @@ + + diff --git a/web/src/components/Editor/editor.vue b/web/src/components/Editor/editor.vue new file mode 100644 index 0000000..062db48 --- /dev/null +++ b/web/src/components/Editor/editor.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/web/src/components/FileChooser/index.vue b/web/src/components/FileChooser/index.vue new file mode 100644 index 0000000..3a6abcb --- /dev/null +++ b/web/src/components/FileChooser/index.vue @@ -0,0 +1,366 @@ + + + + + diff --git a/web/src/components/FileChooser/src/Chooser.vue b/web/src/components/FileChooser/src/Chooser.vue new file mode 100644 index 0000000..bcf8993 --- /dev/null +++ b/web/src/components/FileChooser/src/Chooser.vue @@ -0,0 +1,413 @@ + + + + + diff --git a/web/src/components/FileChooser/src/Preview.vue b/web/src/components/FileChooser/src/Preview.vue new file mode 100644 index 0000000..f00a3cf --- /dev/null +++ b/web/src/components/FileChooser/src/Preview.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/web/src/components/FileChooser/src/Upload.vue b/web/src/components/FileChooser/src/Upload.vue new file mode 100644 index 0000000..23eed3a --- /dev/null +++ b/web/src/components/FileChooser/src/Upload.vue @@ -0,0 +1,114 @@ + + + diff --git a/web/src/components/FileChooser/src/model.ts b/web/src/components/FileChooser/src/model.ts new file mode 100644 index 0000000..597a109 --- /dev/null +++ b/web/src/components/FileChooser/src/model.ts @@ -0,0 +1,51 @@ +import { VNode } from '@vue/runtime-core'; + +export type Attachment = { + id: number; + appId: string; + memberId: number; + cateId: number; + drive: string; + name: string; + kind: string; + metaType: string; + naiveType: string; + path: string; + fileUrl: string; + size: number; + ext: string; + md5: string; + status: number; + createdAt: string; + updatedAt: string; + sizeFormat: string; +}; + +export interface KindOption { + key: string; + label: any; + icon: VNode; + extra: any; + disabled: boolean; +} + +export type FileType = 'image' | 'doc' | 'audio' | 'video' | 'zip' | 'other' | 'default'; + +export function getFileType(fileType: string): string { + switch (fileType) { + case 'image': + return '图片'; + case 'doc': + return '文档'; + case 'audio': + return '音频'; + case 'video': + return '视频'; + case 'zip': + return '压缩包'; + case 'other': + case 'default': + return '文件'; + } + return '文件'; +} diff --git a/web/src/components/Form/index.ts b/web/src/components/Form/index.ts new file mode 100644 index 0000000..0f37df5 --- /dev/null +++ b/web/src/components/Form/index.ts @@ -0,0 +1,4 @@ +export { default as BasicForm } from './src/BasicForm.vue'; +export { useForm } from './src/hooks/useForm'; +export * from './src/types/form'; +export * from './src/types/index'; diff --git a/web/src/components/Form/src/BasicForm.vue b/web/src/components/Form/src/BasicForm.vue new file mode 100644 index 0000000..783ea60 --- /dev/null +++ b/web/src/components/Form/src/BasicForm.vue @@ -0,0 +1,378 @@ + + + + + diff --git a/web/src/components/Form/src/helper.ts b/web/src/components/Form/src/helper.ts new file mode 100644 index 0000000..5db0e32 --- /dev/null +++ b/web/src/components/Form/src/helper.ts @@ -0,0 +1,42 @@ +import { ComponentType } from './types/index'; + +/** + * @description: 生成placeholder + */ +export function createPlaceholderMessage(component: ComponentType) { + if (component === 'NInput') return '请输入'; + if ( + ['NPicker', 'NSelect', 'NCheckbox', 'NRadio', 'NSwitch', 'NDatePicker', 'NTimePicker'].includes( + component + ) + ) + return '请选择'; + return ''; +} + +const DATE_TYPE = ['DatePicker', 'MonthPicker', 'WeekPicker', 'TimePicker']; + +function genType() { + return [...DATE_TYPE, 'RangePicker']; +} + +/** + * 时间字段 + */ +export const dateItemType = genType(); + +export function defaultType(component) { + if (component === 'NInput') return ''; + if (component === 'NInputNumber') return null; + return [ + 'NPicker', + 'NSelect', + 'NCheckbox', + 'NRadio', + 'NSwitch', + 'NDatePicker', + 'NTimePicker', + ].includes(component) + ? '' + : undefined; +} diff --git a/web/src/components/Form/src/hooks/useForm.ts b/web/src/components/Form/src/hooks/useForm.ts new file mode 100644 index 0000000..601ac5e --- /dev/null +++ b/web/src/components/Form/src/hooks/useForm.ts @@ -0,0 +1,86 @@ +import type { FormProps, FormActionType, UseFormReturnType } from '../types/form'; +import type { DynamicProps } from '/#/utils'; + +import { ref, onUnmounted, unref, nextTick, watch } from 'vue'; +import { isProdMode } from '@/utils/env'; +import { getDynamicProps } from '@/utils'; + +type Props = Partial>; + +export function useForm(props?: Props): UseFormReturnType { + const formRef = ref>(null); + const loadedRef = ref>(false); + + async function getForm() { + const form = unref(formRef); + if (!form) { + console.error( + 'The form instance has not been obtained, please make sure that the form has been rendered when performing the form operation!' + ); + } + await nextTick(); + return form as FormActionType; + } + + function register(instance: FormActionType) { + isProdMode() && + onUnmounted(() => { + formRef.value = null; + loadedRef.value = null; + }); + if (unref(loadedRef) && isProdMode() && instance === unref(formRef)) return; + + formRef.value = instance; + loadedRef.value = true; + + watch( + () => props, + () => { + props && instance.setProps(getDynamicProps(props)); + }, + { + immediate: true, + deep: true, + } + ); + } + + const methods: FormActionType = { + setProps: async (formProps: Partial) => { + const form = await getForm(); + await form.setProps(formProps); + }, + + resetFields: async () => { + getForm().then(async (form) => { + await form.resetFields(); + }); + }, + + clearValidate: async (name?: string | string[]) => { + const form = await getForm(); + await form.clearValidate(name); + }, + + getFieldsValue: () => { + return unref(formRef)?.getFieldsValue() as T; + }, + + setFieldsValue: async (values: T) => { + const form = await getForm(); + await form.setFieldsValue(values); + }, + + submit: async (): Promise => { + const form = await getForm(); + return form.submit(); + }, + + validate: async (nameList?: any[]): Promise => { + const form = await getForm(); + return form.validate(nameList); + }, + }; + + return [register, methods]; +} diff --git a/web/src/components/Form/src/hooks/useFormContext.ts b/web/src/components/Form/src/hooks/useFormContext.ts new file mode 100644 index 0000000..2d2a212 --- /dev/null +++ b/web/src/components/Form/src/hooks/useFormContext.ts @@ -0,0 +1,11 @@ +import { provide, inject } from 'vue'; + +const key = Symbol('formElRef'); + +export function createFormContext(instance) { + provide(key, instance); +} + +export function useFormContext() { + return inject(key); +} diff --git a/web/src/components/Form/src/hooks/useFormEvents.ts b/web/src/components/Form/src/hooks/useFormEvents.ts new file mode 100644 index 0000000..992a393 --- /dev/null +++ b/web/src/components/Form/src/hooks/useFormEvents.ts @@ -0,0 +1,107 @@ +import type { ComputedRef, Ref } from 'vue'; +import type { FormProps, FormSchema, FormActionType } from '../types/form'; +import { unref, toRaw } from 'vue'; +import { isFunction } from '@/utils/is'; + +declare type EmitType = (event: string, ...args: any[]) => void; + +interface UseFormActionContext { + emit: EmitType; + getProps: ComputedRef; + getSchema: ComputedRef; + formModel: Recordable; + formElRef: Ref; + defaultFormModel: Recordable; + loadingSub: Ref; + handleFormValues: Function; +} + +export function useFormEvents({ + emit, + getProps, + formModel, + getSchema, + formElRef, + defaultFormModel, + loadingSub, + handleFormValues, +}: UseFormActionContext) { + // 验证 + async function validate() { + return unref(formElRef)?.validate(); + } + + // 提交 + async function handleSubmit(e?: Event): Promise { + e && e.preventDefault(); + loadingSub.value = true; + const { submitFunc } = unref(getProps); + if (submitFunc && isFunction(submitFunc)) { + await submitFunc(); + return; + } + const formEl = unref(formElRef); + if (!formEl) return; + try { + await validate(); + loadingSub.value = false; + emit('submit', formModel); + return; + } catch (error) { + loadingSub.value = false; + return; + } + } + + //清空校验 + async function clearValidate() { + // @ts-ignore + await unref(formElRef)?.restoreValidation(); + } + + //重置 + async function resetFields(): Promise { + const { resetFunc, submitOnReset } = unref(getProps); + resetFunc && isFunction(resetFunc) && (await resetFunc()); + + const formEl = unref(formElRef); + if (!formEl) return; + Object.keys(formModel).forEach((key) => { + formModel[key] = unref(defaultFormModel)[key] || null; + }); + await clearValidate(); + const fromValues = handleFormValues(toRaw(unref(formModel))); + emit('reset', fromValues); + submitOnReset && (await handleSubmit()); + } + + //获取表单值 + function getFieldsValue(): Recordable { + const formEl = unref(formElRef); + if (!formEl) return {}; + return handleFormValues(toRaw(unref(formModel))); + } + + //设置表单字段值 + async function setFieldsValue(values: Recordable): Promise { + const fields = unref(getSchema) + .map((item) => item.field) + .filter(Boolean); + + Object.keys(values).forEach((key) => { + const value = values[key]; + if (fields.includes(key)) { + formModel[key] = value; + } + }); + } + + return { + handleSubmit, + validate, + resetFields, + getFieldsValue, + clearValidate, + setFieldsValue, + }; +} diff --git a/web/src/components/Form/src/hooks/useFormValues.ts b/web/src/components/Form/src/hooks/useFormValues.ts new file mode 100644 index 0000000..2d45bbe --- /dev/null +++ b/web/src/components/Form/src/hooks/useFormValues.ts @@ -0,0 +1,54 @@ +import { isArray, isFunction, isObject, isString, isNullOrUnDef } from '@/utils/is'; +import { unref } from 'vue'; +import type { Ref, ComputedRef } from 'vue'; +import type { FormSchema } from '../types/form'; +import { set } from 'lodash-es'; + +interface UseFormValuesContext { + defaultFormModel: Ref; + getSchema: ComputedRef; + formModel: Recordable; +} +export function useFormValues({ defaultFormModel, getSchema, formModel }: UseFormValuesContext) { + // 加工 form values + function handleFormValues(values: Recordable) { + if (!isObject(values)) { + return {}; + } + const res: Recordable = {}; + for (const item of Object.entries(values)) { + let [, value] = item; + const [key] = item; + if ( + !key || + (isArray(value) && value.length === 0) || + isFunction(value) || + isNullOrUnDef(value) + ) { + continue; + } + // 删除空格 + if (isString(value)) { + value = value.trim(); + } + set(res, key, value); + } + return res; + } + + //初始化默认值 + function initDefault() { + const schemas = unref(getSchema); + const obj: Recordable = {}; + schemas.forEach((item) => { + const { defaultValue } = item; + if (!isNullOrUnDef(defaultValue)) { + obj[item.field] = defaultValue; + formModel[item.field] = defaultValue; + } + }); + defaultFormModel.value = obj; + } + + return { handleFormValues, initDefault }; +} diff --git a/web/src/components/Form/src/props.ts b/web/src/components/Form/src/props.ts new file mode 100644 index 0000000..46582e7 --- /dev/null +++ b/web/src/components/Form/src/props.ts @@ -0,0 +1,82 @@ +import type { CSSProperties, PropType } from 'vue'; +import { FormSchema } from './types/form'; +import type { GridProps, GridItemProps } from 'naive-ui/lib/grid'; +import type { ButtonProps } from 'naive-ui/lib/button'; +import { propTypes } from '@/utils/propTypes'; +export const basicProps = { + // 标签宽度 固定宽度 + labelWidth: { + type: [Number, String] as PropType, + default: 80, + }, + // 表单配置规则 + schemas: { + type: [Array] as PropType, + default: () => [], + }, + //布局方式 + layout: { + type: String, + default: 'inline', + }, + //是否展示为行内表单 + inline: { + type: Boolean, + default: false, + }, + //大小 + size: { + type: String, + default: 'medium', + }, + //标签位置 + labelPlacement: { + type: String, + default: 'left', + }, + //组件是否width 100% + isFull: { + type: Boolean, + default: true, + }, + //是否显示操作按钮(查询/重置) + showActionButtonGroup: propTypes.bool.def(true), + // 显示重置按钮 + showResetButton: propTypes.bool.def(true), + //重置按钮配置 + resetButtonOptions: Object as PropType>, + // 显示确认按钮 + showSubmitButton: propTypes.bool.def(true), + // 确认按钮配置 + submitButtonOptions: Object as PropType>, + //展开收起按钮 + showAdvancedButton: propTypes.bool.def(true), + // 确认按钮文字 + submitButtonText: { + type: String, + default: '查询', + }, + //重置按钮文字 + resetButtonText: { + type: String, + default: '重置', + }, + //grid 配置 + gridProps: Object as PropType, + //gi配置 + giProps: Object as PropType, + //grid 样式 + baseGridStyle: { + type: Object as PropType, + }, + //是否折叠 + collapsed: { + type: Boolean, + default: false, + }, + //默认展示的行数 + collapsedRows: { + type: Number, + default: 1, + }, +}; diff --git a/web/src/components/Form/src/types/form.ts b/web/src/components/Form/src/types/form.ts new file mode 100644 index 0000000..eaa2e4a --- /dev/null +++ b/web/src/components/Form/src/types/form.ts @@ -0,0 +1,63 @@ +import { ComponentType } from './index'; +import type { CSSProperties } from 'vue'; +import type { GridProps, GridItemProps } from 'naive-ui/lib/grid'; +import type { ButtonProps } from 'naive-ui/lib/button'; +import {PermissionsEnum} from "@/enums/permissionsEnum"; +import { ActionItem } from '@/components/Table'; + +export interface FormSchema { + field: string; + label: string; + labelMessage?: string; + labelMessageStyle?: object | string; + defaultValue?: any; + component?: ComponentType; + componentProps?: object | any; + slot?: string; + rules?: object | object[]; + giProps?: GridItemProps; + isFull?: boolean; + suffix?: string; + auth?: PermissionsEnum | PermissionsEnum[] | string | string[]; + ifShow?: boolean | ((action: ActionItem) => boolean); +} + +export interface FormProps { + model?: Recordable; + labelWidth?: number | string; + schemas?: FormSchema[]; + inline: boolean; + layout?: string; + size: string; + labelPlacement: string; + isFull: boolean; + showActionButtonGroup?: boolean; + showResetButton?: boolean; + resetButtonOptions?: Partial; + showSubmitButton?: boolean; + showAdvancedButton?: boolean; + submitButtonOptions?: Partial; + submitButtonText?: string; + resetButtonText?: string; + gridProps?: GridProps; + giProps?: GridItemProps; + resetFunc?: () => Promise; + submitFunc?: () => Promise; + submitOnReset?: boolean; + baseGridStyle?: CSSProperties; + collapsedRows?: number; +} + +export interface FormActionType { + submit: () => Promise; + setProps: (formProps: Partial) => Promise; + setFieldsValue: (values: T) => Promise; + clearValidate: (name?: string | string[]) => Promise; + getFieldsValue: () => Recordable; + resetFields: () => Promise; + validate: (nameList?: any[]) => Promise; +} + +export type RegisterFn = (formInstance: FormActionType) => void; + +export type UseFormReturnType = [RegisterFn, FormActionType]; diff --git a/web/src/components/Form/src/types/index.ts b/web/src/components/Form/src/types/index.ts new file mode 100644 index 0000000..6a12ff7 --- /dev/null +++ b/web/src/components/Form/src/types/index.ts @@ -0,0 +1,29 @@ +export type ComponentType = + | 'NInput' + | 'NInputGroup' + | 'NInputPassword' + | 'NInputSearch' + | 'NInputTextArea' + | 'NInputNumber' + | 'NInputCountDown' + | 'NSelect' + | 'NTreeSelect' + | 'NRadioButtonGroup' + | 'NRadioGroup' + | 'NCheckbox' + | 'NCheckboxGroup' + | 'NAutoComplete' + | 'NCascader' + | 'NDatePicker' + | 'NMonthPicker' + | 'NRangePicker' + | 'NWeekPicker' + | 'NTimePicker' + | 'NSwitch' + | 'NStrengthMeter' + | 'NUpload' + | 'NIconPicker' + | 'NRender' + | 'NSlider' + | 'NRate' + | 'ComplexMemberPicker'; diff --git a/web/src/components/IconSelector/AntdSelector.vue b/web/src/components/IconSelector/AntdSelector.vue new file mode 100644 index 0000000..fb034c1 --- /dev/null +++ b/web/src/components/IconSelector/AntdSelector.vue @@ -0,0 +1,97 @@ + + + + diff --git a/web/src/components/IconSelector/Ionicons5Selector.vue b/web/src/components/IconSelector/Ionicons5Selector.vue new file mode 100644 index 0000000..f6d6b38 --- /dev/null +++ b/web/src/components/IconSelector/Ionicons5Selector.vue @@ -0,0 +1,94 @@ + + + + diff --git a/web/src/components/IconSelector/index.vue b/web/src/components/IconSelector/index.vue new file mode 100644 index 0000000..94356e8 --- /dev/null +++ b/web/src/components/IconSelector/index.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/web/src/components/IconSelector/props.ts b/web/src/components/IconSelector/props.ts new file mode 100644 index 0000000..6ef95c7 --- /dev/null +++ b/web/src/components/IconSelector/props.ts @@ -0,0 +1,14 @@ +import type { PropType } from 'vue'; +import { NInput } from 'naive-ui'; + +export const basicProps = { + ...NInput.props, + option: { + type: String as PropType, + default: 'antd', // ionicons5 | antd + }, + value: { + type: String as PropType, + default: () => '', + }, +}; diff --git a/web/src/components/LoadingContent/index.ts b/web/src/components/LoadingContent/index.ts new file mode 100644 index 0000000..d557abc --- /dev/null +++ b/web/src/components/LoadingContent/index.ts @@ -0,0 +1,3 @@ +import LoadingContent from './index.vue'; + +export { LoadingContent }; diff --git a/web/src/components/LoadingContent/index.vue b/web/src/components/LoadingContent/index.vue new file mode 100644 index 0000000..1312a08 --- /dev/null +++ b/web/src/components/LoadingContent/index.vue @@ -0,0 +1,12 @@ + + diff --git a/web/src/components/Lockscreen/Lockscreen.vue b/web/src/components/Lockscreen/Lockscreen.vue new file mode 100644 index 0000000..1c2fec3 --- /dev/null +++ b/web/src/components/Lockscreen/Lockscreen.vue @@ -0,0 +1,315 @@ + + + + + diff --git a/web/src/components/Lockscreen/Recharge.vue b/web/src/components/Lockscreen/Recharge.vue new file mode 100644 index 0000000..4eef443 --- /dev/null +++ b/web/src/components/Lockscreen/Recharge.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/web/src/components/Lockscreen/index.ts b/web/src/components/Lockscreen/index.ts new file mode 100644 index 0000000..7e086dc --- /dev/null +++ b/web/src/components/Lockscreen/index.ts @@ -0,0 +1,3 @@ +import LockScreen from './Lockscreen.vue'; + +export { LockScreen }; diff --git a/web/src/components/MessageContent/index.ts b/web/src/components/MessageContent/index.ts new file mode 100644 index 0000000..b1f036b --- /dev/null +++ b/web/src/components/MessageContent/index.ts @@ -0,0 +1,3 @@ +import MessageContent from './index.vue'; + +export { MessageContent }; diff --git a/web/src/components/MessageContent/index.vue b/web/src/components/MessageContent/index.vue new file mode 100644 index 0000000..1ad18ec --- /dev/null +++ b/web/src/components/MessageContent/index.vue @@ -0,0 +1,12 @@ + + diff --git a/web/src/components/Modal/index.ts b/web/src/components/Modal/index.ts new file mode 100644 index 0000000..586a945 --- /dev/null +++ b/web/src/components/Modal/index.ts @@ -0,0 +1,3 @@ +export { default as basicModal } from './src/basicModal.vue'; +export { useModal } from './src/hooks/useModal'; +export * from './src/type'; diff --git a/web/src/components/Modal/src/basicModal.vue b/web/src/components/Modal/src/basicModal.vue new file mode 100644 index 0000000..8b1f9b1 --- /dev/null +++ b/web/src/components/Modal/src/basicModal.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/web/src/components/Modal/src/hooks/useModal.ts b/web/src/components/Modal/src/hooks/useModal.ts new file mode 100644 index 0000000..0b737d6 --- /dev/null +++ b/web/src/components/Modal/src/hooks/useModal.ts @@ -0,0 +1,54 @@ +import { ref, unref, getCurrentInstance, watch } from 'vue'; +import { isProdMode } from '@/utils/env'; +import { ModalMethods, UseModalReturnType } from '../type'; +import { getDynamicProps } from '@/utils'; +import { tryOnUnmounted } from '@vueuse/core'; +export function useModal(props): UseModalReturnType { + const modalRef = ref>(null); + const currentInstance = getCurrentInstance(); + + const getInstance = () => { + const instance = unref(modalRef.value); + if (!instance) { + console.error('useModal instance is undefined!'); + } + return instance; + }; + + const register = (modalInstance: ModalMethods) => { + isProdMode() && + tryOnUnmounted(() => { + modalRef.value = null; + }); + modalRef.value = modalInstance; + currentInstance?.emit('register', modalInstance); + + watch( + () => props, + () => { + props && modalInstance.setProps(getDynamicProps(props)); + }, + { + immediate: true, + deep: true, + } + ); + }; + + const methods: ModalMethods = { + setProps: (props): void => { + getInstance()?.setProps(props); + }, + openModal: () => { + getInstance()?.openModal(); + }, + closeModal: () => { + getInstance()?.closeModal(); + }, + setSubLoading: (status) => { + getInstance()?.setSubLoading(status); + }, + }; + + return [register, methods]; +} diff --git a/web/src/components/Modal/src/props.ts b/web/src/components/Modal/src/props.ts new file mode 100644 index 0000000..9fafa12 --- /dev/null +++ b/web/src/components/Modal/src/props.ts @@ -0,0 +1,30 @@ +import { NModal } from 'naive-ui'; + +export const basicProps = { + ...NModal.props, + // 确认按钮文字 + subBtuText: { + type: String, + default: '确认', + }, + showIcon: { + type: Boolean, + default: false, + }, + width: { + type: Number, + default: 446, + }, + title: { + type: String, + default: '', + }, + maskClosable: { + type: Boolean, + default: false, + }, + preset: { + type: String, + default: 'dialog', + }, +}; diff --git a/web/src/components/Modal/src/type/index.ts b/web/src/components/Modal/src/type/index.ts new file mode 100644 index 0000000..804e741 --- /dev/null +++ b/web/src/components/Modal/src/type/index.ts @@ -0,0 +1,19 @@ +import type { DialogOptions } from 'naive-ui/lib/dialog'; +/** + * @description: 弹窗对外暴露的方法 + */ +export interface ModalMethods { + setProps: (props) => void; + openModal: () => void; + closeModal: () => void; + setSubLoading: (status) => void; +} + +/** + * 支持修改,DialogOptions 參數 + */ +export type ModalProps = DialogOptions; + +export type RegisterFn = (ModalInstance: ModalMethods) => void; + +export type UseModalReturnType = [RegisterFn, ModalMethods]; diff --git a/web/src/components/SvgIcon/index.vue b/web/src/components/SvgIcon/index.vue new file mode 100644 index 0000000..db6d71e --- /dev/null +++ b/web/src/components/SvgIcon/index.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/web/src/components/Table/index.ts b/web/src/components/Table/index.ts new file mode 100644 index 0000000..38725ad --- /dev/null +++ b/web/src/components/Table/index.ts @@ -0,0 +1,4 @@ +export { default as BasicTable } from './src/Table.vue'; +export { default as TableAction } from './src/components/TableAction.vue'; +export * from './src/types/table'; +export * from './src/types/tableAction'; diff --git a/web/src/components/Table/src/Table.vue b/web/src/components/Table/src/Table.vue new file mode 100644 index 0000000..ebd7585 --- /dev/null +++ b/web/src/components/Table/src/Table.vue @@ -0,0 +1,353 @@ + + + + diff --git a/web/src/components/Table/src/componentMap.ts b/web/src/components/Table/src/componentMap.ts new file mode 100644 index 0000000..9fbb3fd --- /dev/null +++ b/web/src/components/Table/src/componentMap.ts @@ -0,0 +1,41 @@ +import type { Component } from 'vue'; +import { + NInput, + NSelect, + NCheckbox, + NInputNumber, + NSwitch, + NDatePicker, + NTimePicker, +} from 'naive-ui'; +import type { ComponentType } from './types/componentType'; + +export enum EventEnum { + NInput = 'on-input', + NInputNumber = 'on-input', + NSelect = 'on-update:value', + NSwitch = 'on-update:value', + NCheckbox = 'on-update:value', + NDatePicker = 'on-update:value', + NTimePicker = 'on-update:value', +} + +const componentMap = new Map(); + +componentMap.set('NInput', NInput); +componentMap.set('NInputNumber', NInputNumber); +componentMap.set('NSelect', NSelect); +componentMap.set('NSwitch', NSwitch); +componentMap.set('NCheckbox', NCheckbox); +componentMap.set('NDatePicker', NDatePicker); +componentMap.set('NTimePicker', NTimePicker); + +export function add(compName: ComponentType, component: Component) { + componentMap.set(compName, component); +} + +export function del(compName: ComponentType) { + componentMap.delete(compName); +} + +export { componentMap }; diff --git a/web/src/components/Table/src/components/TableAction.vue b/web/src/components/Table/src/components/TableAction.vue new file mode 100644 index 0000000..d0f30b8 --- /dev/null +++ b/web/src/components/Table/src/components/TableAction.vue @@ -0,0 +1,161 @@ + + + diff --git a/web/src/components/Table/src/components/editable/CellComponent.ts b/web/src/components/Table/src/components/editable/CellComponent.ts new file mode 100644 index 0000000..8085e0c --- /dev/null +++ b/web/src/components/Table/src/components/editable/CellComponent.ts @@ -0,0 +1,47 @@ +import type { FunctionalComponent, defineComponent } from 'vue'; +import type { ComponentType } from '../../types/componentType'; +import { componentMap } from '@/components/Table/src/componentMap'; + +import { h } from 'vue'; + +import { NPopover } from 'naive-ui'; + +export interface ComponentProps { + component: ComponentType; + rule: boolean; + popoverVisible: boolean; + ruleMessage: string; +} + +export const CellComponent: FunctionalComponent = ( + { component = 'NInput', rule = true, ruleMessage, popoverVisible }: ComponentProps, + { attrs } +) => { + const Comp = componentMap.get(component) as typeof defineComponent; + + const DefaultComp = h(Comp, attrs); + if (!rule) { + return DefaultComp; + } + return h( + NPopover, + { 'display-directive': 'show', show: !!popoverVisible, manual: 'manual' }, + { + trigger: () => DefaultComp, + default: () => + h( + 'span', + { + style: { + color: 'red', + width: '90px', + display: 'inline-block', + }, + }, + { + default: () => ruleMessage, + } + ), + } + ); +}; diff --git a/web/src/components/Table/src/components/editable/EditableCell.vue b/web/src/components/Table/src/components/editable/EditableCell.vue new file mode 100644 index 0000000..e8fdd8e --- /dev/null +++ b/web/src/components/Table/src/components/editable/EditableCell.vue @@ -0,0 +1,418 @@ + + + + diff --git a/web/src/components/Table/src/components/editable/helper.ts b/web/src/components/Table/src/components/editable/helper.ts new file mode 100644 index 0000000..48e7682 --- /dev/null +++ b/web/src/components/Table/src/components/editable/helper.ts @@ -0,0 +1,15 @@ +import { ComponentType } from '../../types/componentType'; + +/** + * @description: 生成placeholder + */ +export function createPlaceholderMessage(component: ComponentType) { + if (component === 'NInput') return '请输入'; + if ( + ['NPicker', 'NSelect', 'NCheckbox', 'NRadio', 'NSwitch', 'NDatePicker', 'NTimePicker'].includes( + component + ) + ) + return '请选择'; + return ''; +} diff --git a/web/src/components/Table/src/components/editable/index.ts b/web/src/components/Table/src/components/editable/index.ts new file mode 100644 index 0000000..064763d --- /dev/null +++ b/web/src/components/Table/src/components/editable/index.ts @@ -0,0 +1,49 @@ +import type { BasicColumn } from '@/components/Table/src/types/table'; +import { h, Ref } from 'vue'; + +import EditableCell from './EditableCell.vue'; + +export function renderEditCell(column: BasicColumn) { + return (record, index) => { + const _key = column.key; + const value = record[_key]; + record.onEdit = async (edit: boolean, submit = false) => { + if (!submit) { + record.editable = edit; + } + + if (!edit && submit) { + const res = await record.onSubmitEdit?.(); + if (res) { + record.editable = false; + return true; + } + return false; + } + // cancel + if (!edit && !submit) { + record.onCancelEdit?.(); + } + return true; + }; + return h(EditableCell, { + value, + record, + column, + index, + }); + }; +} + +export type EditRecordRow = Partial< + { + onEdit: (editable: boolean, submit?: boolean) => Promise; + editable: boolean; + onCancel: Fn; + onSubmit: Fn; + submitCbs: Fn[]; + cancelCbs: Fn[]; + validCbs: Fn[]; + editValueRefs: Recordable; + } & T +>; diff --git a/web/src/components/Table/src/components/settings/ColumnSetting.vue b/web/src/components/Table/src/components/settings/ColumnSetting.vue new file mode 100644 index 0000000..6923973 --- /dev/null +++ b/web/src/components/Table/src/components/settings/ColumnSetting.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/web/src/components/Table/src/const.ts b/web/src/components/Table/src/const.ts new file mode 100644 index 0000000..82ebca6 --- /dev/null +++ b/web/src/components/Table/src/const.ts @@ -0,0 +1,11 @@ +import componentSetting from '@/settings/componentSetting'; + +const { table } = componentSetting; + +const { apiSetting, defaultPageSize, pageSizes } = table; + +export const DEFAULTPAGESIZE = defaultPageSize; + +export const APISETTING = apiSetting; + +export const PAGESIZES = pageSizes; diff --git a/web/src/components/Table/src/hooks/useColumns.ts b/web/src/components/Table/src/hooks/useColumns.ts new file mode 100644 index 0000000..8ff93fa --- /dev/null +++ b/web/src/components/Table/src/hooks/useColumns.ts @@ -0,0 +1,171 @@ +import { ref, Ref, ComputedRef, unref, computed, watch, toRaw, h } from 'vue'; +import type { BasicColumn, BasicTableProps } from '../types/table'; +import { isEqual, cloneDeep } from 'lodash-es'; +import { isArray, isString, isBoolean, isFunction } from '@/utils/is'; +import { usePermission } from '@/hooks/web/usePermission'; +import { ActionItem } from '@/components/Table'; +import { renderEditCell } from '../components/editable'; +import { NTooltip, NIcon } from 'naive-ui'; +import { FormOutlined } from '@vicons/antd'; + +export function useColumns(propsRef: ComputedRef) { + const columnsRef = ref(unref(propsRef).columns) as unknown as Ref; + let cacheColumns = unref(propsRef).columns; + + const getColumnsRef = computed(() => { + const columns = cloneDeep(unref(columnsRef)); + + handleActionColumn(propsRef, columns); + if (!columns) return []; + return columns; + }); + + const { hasPermission } = usePermission(); + + function isIfShow(action: ActionItem): boolean { + const ifShow = action.ifShow; + + let isIfShow = true; + + if (isBoolean(ifShow)) { + isIfShow = ifShow; + } + if (isFunction(ifShow)) { + isIfShow = ifShow(action); + } + return isIfShow; + } + + const renderTooltip = (trigger, content) => { + return h(NTooltip, null, { + trigger: () => trigger, + default: () => content, + }); + }; + + const getPageColumns = computed(() => { + const pageColumns = unref(getColumnsRef); + const columns = cloneDeep(pageColumns); + return columns + .filter((column) => { + return hasPermission(column.auth as string[]) && isIfShow(column); + }) + .map((column) => { + //默认 ellipsis 为true + column.ellipsis = typeof column.ellipsis === 'undefined' ? { tooltip: true } : false; + const { edit } = column; + if (edit) { + column.render = renderEditCell(column); + if (edit) { + const title: any = column.title; + column.title = () => { + return renderTooltip( + h('span', {}, [ + h('span', { style: { 'margin-right': '5px' } }, title), + h( + NIcon, + { + size: 14, + }, + { + default: () => h(FormOutlined), + } + ), + ]), + '该列可编辑' + ); + }; + } + } + return column; + }); + }); + + watch( + () => unref(propsRef).columns, + (columns) => { + columnsRef.value = columns; + cacheColumns = columns; + } + ); + + function handleActionColumn(propsRef: ComputedRef, columns: BasicColumn[]) { + const { actionColumn } = unref(propsRef); + if (!actionColumn) return; + if (!columns.find((col) => col.key === 'action')) { + if ((actionColumn as any).fixed && (actionColumn as any).fixed === 'left') { + columns.unshift({ + ...(actionColumn as any), + }); + } else { + columns.push({ + ...(actionColumn as any), + }); + } + } + } + + //设置 + function setColumns(columnList: string[]) { + const columns: any[] = cloneDeep(columnList); + if (!isArray(columns)) return; + + if (!columns.length) { + columnsRef.value = []; + return; + } + const cacheKeys = cacheColumns.map((item) => item.key); + //针对拖拽排序 + if (!isString(columns[0])) { + columnsRef.value = columns; + } else { + const newColumns: any[] = []; + cacheColumns.forEach((item) => { + if (columnList.includes(item.key)) { + newColumns.push({ ...item }); + } + }); + if (!isEqual(cacheKeys, columns)) { + newColumns.sort((prev, next) => { + return cacheKeys.indexOf(prev.key) - cacheKeys.indexOf(next.key); + }); + } + columnsRef.value = newColumns; + } + } + + //获取 + function getColumns(): BasicColumn[] { + const columns = toRaw(unref(getColumnsRef)); + return columns.map((item) => { + return { ...item, title: item.title, key: item.key, fixed: item.fixed || undefined }; + }); + } + + //获取原始 + function getCacheColumns(isKey?: boolean): any[] { + return isKey ? cacheColumns.map((item) => item.key) : cacheColumns; + } + + //更新原始数据单个字段 + function setCacheColumnsField(key: string | undefined, value: Partial) { + if (!key || !value) { + return; + } + cacheColumns.forEach((item) => { + if (item.key === key) { + Object.assign(item, value); + return; + } + }); + } + + return { + getColumnsRef, + getCacheColumns, + setCacheColumnsField, + setColumns, + getColumns, + getPageColumns, + }; +} diff --git a/web/src/components/Table/src/hooks/useDataSource.ts b/web/src/components/Table/src/hooks/useDataSource.ts new file mode 100644 index 0000000..79e46cf --- /dev/null +++ b/web/src/components/Table/src/hooks/useDataSource.ts @@ -0,0 +1,179 @@ +import { ref, ComputedRef, unref, computed, onMounted, watchEffect, watch } from 'vue'; +import type { BasicTableProps } from '../types/table'; +import type { PaginationProps } from '../types/pagination'; +import { isBoolean, isFunction } from '@/utils/is'; +import { APISETTING } from '../const'; + +export function useDataSource( + propsRef: ComputedRef, + { getPaginationInfo, setPagination, setLoading, tableData }, + emit +) { + const dataSourceRef = ref([]); + + watchEffect(() => { + tableData.value = unref(dataSourceRef); + }); + + watch( + () => unref(propsRef).dataSource, + () => { + const { dataSource }: any = unref(propsRef); + dataSource && (dataSourceRef.value = dataSource); + }, + { + immediate: true, + } + ); + + const getRowKey = computed(() => { + const { rowKey }: any = unref(propsRef); + return rowKey + ? rowKey + : () => { + return 'key'; + }; + }); + + const getDataSourceRef = computed(() => { + const dataSource = unref(dataSourceRef); + if (!dataSource || dataSource.length === 0) { + return unref(dataSourceRef); + } + return unref(dataSourceRef); + }); + + async function fetch(opt?) { + try { + setLoading(true); + const { request, pagination, beforeRequest, afterRequest }: any = unref(propsRef); + if (!request) return; + //组装分页信息 + const pageField = APISETTING.pageField; + const sizeField = APISETTING.sizeField; + const totalField = APISETTING.totalField; + const listField = APISETTING.listField; + const itemCountField = APISETTING.itemCountField; + + let pageParams = {}; + const { page = 1, pageSize = 10 } = unref(getPaginationInfo) as PaginationProps; + + if ((isBoolean(pagination) && !pagination) || isBoolean(getPaginationInfo)) { + pageParams = {}; + } else { + pageParams[pageField] = (opt && opt[pageField]) || page; + pageParams[sizeField] = pageSize; + } + + let params = { + ...pageParams, + }; + if (beforeRequest && isFunction(beforeRequest)) { + // The params parameter can be modified by outsiders + params = (await beforeRequest(params)) || params; + } + const res = await request(params); + + const resultTotal = res[totalField] || 0; + const currentPage = res[pageField]; + const itemCount = res[itemCountField] || 0; + + // 如果数据异常,需获取正确的页码再次执行 + if (resultTotal) { + if (page > resultTotal) { + setPagination({ + [pageField]: resultTotal, + }); + fetch(opt); + } + } + let resultInfo = res[listField] ? res[listField] : []; + if (afterRequest && isFunction(afterRequest)) { + // can modify the data returned by the interface for processing + resultInfo = (await afterRequest(resultInfo)) || resultInfo; + } + + // 表数据加载 + setTimeout(function () { + const once = 20; + if (resultInfo.length < once) { + // once = resultInfo.length; + // 直接加载 + dataSourceRef.value = resultInfo; + } else { + // 分次加载 + dataSourceRef.value = []; + insert(resultInfo, once, resultInfo.length, 0); + } + }, 0); + + setPagination({ + [pageField]: currentPage, + [totalField]: resultTotal, + prefix: () => { + return `共 ${itemCount} 条`; + }, + }); + if (opt && opt[pageField]) { + setPagination({ + [pageField]: opt[pageField] || 1, + }); + } + emit('fetch-success', { + items: unref(resultInfo), + resultTotal, + }); + } catch (error) { + console.error(error); + emit('fetch-error', error); + dataSourceRef.value = []; + // setPagination({ + // pageCount: 0, + // }); + } finally { + setLoading(false); + } + } + + onMounted(() => { + setTimeout(() => { + fetch(); + }, 16); + }); + + function insert(resultInfo, once, curTotal, curIndex) { + if (curTotal <= 0) { + return; + } + if (once > curTotal) { + once = curTotal; + } + window.requestAnimationFrame(() => { + for (let i = 0; i < once; i++) { + dataSourceRef.value.push(resultInfo[curIndex + i]); + } + insert(resultInfo, once, curTotal - once, curIndex + once); + }); + } + + function setTableData(values) { + dataSourceRef.value = values; + } + + function getDataSource(): any[] { + return getDataSourceRef.value; + } + + async function reload(opt?) { + await fetch(opt); + } + + return { + fetch, + getRowKey, + getDataSourceRef, + getDataSource, + setTableData, + reload, + }; +} diff --git a/web/src/components/Table/src/hooks/useLoading.ts b/web/src/components/Table/src/hooks/useLoading.ts new file mode 100644 index 0000000..0a670b0 --- /dev/null +++ b/web/src/components/Table/src/hooks/useLoading.ts @@ -0,0 +1,21 @@ +import { ref, ComputedRef, unref, computed, watch } from 'vue'; +import type { BasicTableProps } from '../types/table'; + +export function useLoading(props: ComputedRef) { + const loadingRef = ref(unref(props).loading); + + watch( + () => unref(props).loading, + (loading) => { + loadingRef.value = loading; + } + ); + + const getLoading = computed(() => unref(loadingRef)); + + function setLoading(loading: boolean) { + loadingRef.value = loading; + } + + return { getLoading, setLoading }; +} diff --git a/web/src/components/Table/src/hooks/usePagination.ts b/web/src/components/Table/src/hooks/usePagination.ts new file mode 100644 index 0000000..cf37b98 --- /dev/null +++ b/web/src/components/Table/src/hooks/usePagination.ts @@ -0,0 +1,50 @@ +import type { PaginationProps } from '../types/pagination'; +import type { BasicTableProps } from '../types/table'; +import { computed, unref, ref, ComputedRef } from 'vue'; + +import { isBoolean } from '@/utils/is'; +import { APISETTING, DEFAULTPAGESIZE, PAGESIZES } from '../const'; + +export function usePagination(refProps: ComputedRef) { + const configRef = ref({}); + const show = ref(true); + + const getPaginationInfo = computed((): PaginationProps | boolean => { + const { pagination } = unref(refProps); + if (!unref(show) || (isBoolean(pagination) && !pagination)) { + return false; + } + const { totalField } = APISETTING; + return { + pageSize: DEFAULTPAGESIZE, + pageSizes: PAGESIZES, + showSizePicker: true, + showQuickJumper: true, + ...(isBoolean(pagination) ? {} : pagination), + ...unref(configRef), + pageCount: unref(configRef)[totalField], + }; + }); + + function setPagination(info: Partial) { + const paginationInfo = unref(getPaginationInfo); + configRef.value = { + ...(!isBoolean(paginationInfo) ? paginationInfo : {}), + ...info, + }; + } + + function getPagination() { + return unref(getPaginationInfo); + } + + function getShowPagination() { + return unref(show); + } + + async function setShowPagination(flag: boolean) { + show.value = flag; + } + + return { getPagination, getPaginationInfo, setShowPagination, getShowPagination, setPagination }; +} diff --git a/web/src/components/Table/src/hooks/useTableContext.ts b/web/src/components/Table/src/hooks/useTableContext.ts new file mode 100644 index 0000000..e975ac3 --- /dev/null +++ b/web/src/components/Table/src/hooks/useTableContext.ts @@ -0,0 +1,22 @@ +import type { Ref } from 'vue'; +import type { BasicTableProps, TableActionType } from '../types/table'; +import { provide, inject, ComputedRef } from 'vue'; + +const key = Symbol('s-table'); + +type Instance = TableActionType & { + wrapRef: Ref>; + getBindValues: ComputedRef; +}; + +type RetInstance = Omit & { + getBindValues: ComputedRef; +}; + +export function createTableContext(instance: Instance) { + provide(key, instance); +} + +export function useTableContext(): RetInstance { + return inject(key) as RetInstance; +} diff --git a/web/src/components/Table/src/props.ts b/web/src/components/Table/src/props.ts new file mode 100644 index 0000000..87a2026 --- /dev/null +++ b/web/src/components/Table/src/props.ts @@ -0,0 +1,67 @@ +import type { PropType } from 'vue'; +import { propTypes } from '@/utils/propTypes'; +import { BasicColumn } from './types/table'; +import { NDataTable } from 'naive-ui'; +export const basicProps = { + ...NDataTable.props, // 这里继承原 UI 组件的 props + title: { + type: String, + default: null, + }, + titleTooltip: { + type: String, + default: null, + }, + showTopRight: { + type: Boolean, + default: true, + }, + openChecked: { + type: Boolean, + default: false, + }, + size: { + type: String, + default: 'medium', + }, + dataSource: { + type: [Object], + default: () => [], + }, + columns: { + type: [Array] as PropType, + default: () => [], + required: true, + }, + beforeRequest: { + type: Function as PropType<(...arg: any[]) => void | Promise>, + default: null, + }, + request: { + type: Function as PropType<(...arg: any[]) => Promise>, + default: null, + }, + afterRequest: { + type: Function as PropType<(...arg: any[]) => void | Promise>, + default: null, + }, + rowKey: { + type: [String, Function] as PropType string)>, + default: undefined, + }, + pagination: { + type: [Object, Boolean], + default: () => {}, + }, + //废弃 + showPagination: { + type: [String, Boolean], + default: 'auto', + }, + actionColumn: { + type: Object as PropType, + default: null, + }, + canResize: propTypes.bool.def(true), + resizeHeightOffset: propTypes.number.def(0), +}; diff --git a/web/src/components/Table/src/types/componentType.ts b/web/src/components/Table/src/types/componentType.ts new file mode 100644 index 0000000..e926748 --- /dev/null +++ b/web/src/components/Table/src/types/componentType.ts @@ -0,0 +1,8 @@ +export type ComponentType = + | 'NInput' + | 'NInputNumber' + | 'NSelect' + | 'NCheckbox' + | 'NSwitch' + | 'NDatePicker' + | 'NTimePicker'; diff --git a/web/src/components/Table/src/types/pagination.ts b/web/src/components/Table/src/types/pagination.ts new file mode 100644 index 0000000..e4ab053 --- /dev/null +++ b/web/src/components/Table/src/types/pagination.ts @@ -0,0 +1,8 @@ +export interface PaginationProps { + page?: number; + pageCount?: number; + pageSize?: number; + pageSizes?: number[]; + showSizePicker?: boolean; + showQuickJumper?: boolean; +} diff --git a/web/src/components/Table/src/types/table.ts b/web/src/components/Table/src/types/table.ts new file mode 100644 index 0000000..edbb48f --- /dev/null +++ b/web/src/components/Table/src/types/table.ts @@ -0,0 +1,38 @@ +import type { TableBaseColumn } from 'naive-ui/lib/data-table/src/interface'; +import { ComponentType } from './componentType'; +export interface BasicColumn extends TableBaseColumn { + //编辑表格 + edit?: boolean; + editRow?: boolean; + editable?: boolean; + editComponent?: ComponentType; + editComponentProps?: Recordable; + editRule?: boolean | ((text: string, record: Recordable) => Promise); + editValueMap?: (value: any) => string; + onEditRow?: () => void; + // 权限编码控制是否显示 + auth?: string[]; + // 业务控制是否显示 + ifShow?: boolean | ((column: BasicColumn) => boolean); + // 控制是否支持拖拽,默认支持 + draggable?: boolean; +} + +export interface TableActionType { + reload: (opt) => Promise; + emit?: any; + getColumns: (opt?) => BasicColumn[]; + setColumns: (columns: BasicColumn[] | string[]) => void; +} + +export interface BasicTableProps { + title?: string; + dataSource: Function; + columns: any[]; + pagination: object; + showPagination: boolean; + actionColumn: any[]; + canResize: boolean; + resizeHeightOffset: number; + loading: boolean; +} diff --git a/web/src/components/Table/src/types/tableAction.ts b/web/src/components/Table/src/types/tableAction.ts new file mode 100644 index 0000000..27d483f --- /dev/null +++ b/web/src/components/Table/src/types/tableAction.ts @@ -0,0 +1,27 @@ +import { NButton } from 'naive-ui'; +import type { Component } from 'vue'; +import { PermissionsEnum } from '@/enums/permissionsEnum'; +export interface ActionItem extends Partial> { + onClick?: Fn; + label?: string; + type?: 'success' | 'error' | 'warning' | 'info' | 'primary' | 'default'; + // 设定 color 后会覆盖 type 的样式 + color?: string; + icon?: Component; + popConfirm?: PopConfirm; + disabled?: boolean; + divider?: boolean; + // 权限编码控制是否显示 + auth?: PermissionsEnum | PermissionsEnum[] | string | string[]; + // 业务控制是否显示 + ifShow?: boolean | ((action: ActionItem) => boolean); +} + +export interface PopConfirm { + title: string; + okText?: string; + cancelText?: string; + confirm: Fn; + cancel?: Fn; + icon?: Component; +} diff --git a/web/src/components/Upload/index.ts b/web/src/components/Upload/index.ts new file mode 100644 index 0000000..10a2c43 --- /dev/null +++ b/web/src/components/Upload/index.ts @@ -0,0 +1 @@ +export { default as BasicUpload } from './src/BasicUpload.vue'; diff --git a/web/src/components/Upload/multipartUpload.vue b/web/src/components/Upload/multipartUpload.vue new file mode 100644 index 0000000..3937b6f --- /dev/null +++ b/web/src/components/Upload/multipartUpload.vue @@ -0,0 +1,197 @@ + + + diff --git a/web/src/components/Upload/src/BasicUpload.vue b/web/src/components/Upload/src/BasicUpload.vue new file mode 100644 index 0000000..2936ad5 --- /dev/null +++ b/web/src/components/Upload/src/BasicUpload.vue @@ -0,0 +1,412 @@ + + + + + diff --git a/web/src/components/Upload/src/props.ts b/web/src/components/Upload/src/props.ts new file mode 100644 index 0000000..ba14647 --- /dev/null +++ b/web/src/components/Upload/src/props.ts @@ -0,0 +1,42 @@ +import type { PropType } from 'vue'; +import { NUpload } from 'naive-ui'; + +export const basicProps = { + ...NUpload.props, + fileType: { + type: String, + default: 'image', + }, + accept: { + type: String, + default: '.jpg,.png,.jpeg,.svg,.gif,.webp', + }, + helpText: { + type: String as PropType, + default: '', + }, + maxSize: { + type: Number as PropType, + default: 2, + }, + maxNumber: { + type: Number as PropType, + default: Infinity, + }, + value: { + type: String as PropType, + default: () => '', + }, + values: { + type: (Array as PropType) || (Object as PropType), + default: () => [], + }, + width: { + type: Number as PropType, + default: 104, + }, + height: { + type: Number as PropType, + default: 104, //建议不小于这个尺寸 太小页面可能显示有异常 + }, +}; diff --git a/web/src/components/Upload/src/type/index.ts b/web/src/components/Upload/src/type/index.ts new file mode 100644 index 0000000..b2ea97f --- /dev/null +++ b/web/src/components/Upload/src/type/index.ts @@ -0,0 +1,7 @@ +export interface BasicProps { + title?: string; + dataSource: Function; + columns: any[]; + pagination: object; + showPagination: boolean; +} diff --git a/web/src/components/Upload/uploadFile.vue b/web/src/components/Upload/uploadFile.vue new file mode 100644 index 0000000..a20ff2f --- /dev/null +++ b/web/src/components/Upload/uploadFile.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/web/src/components/Upload/uploadImage.vue b/web/src/components/Upload/uploadImage.vue new file mode 100644 index 0000000..0279e9c --- /dev/null +++ b/web/src/components/Upload/uploadImage.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/web/src/components/YamlEditor/index.vue b/web/src/components/YamlEditor/index.vue new file mode 100644 index 0000000..9075f62 --- /dev/null +++ b/web/src/components/YamlEditor/index.vue @@ -0,0 +1,445 @@ + + + + + \ No newline at end of file diff --git a/web/src/debug/account.ts b/web/src/debug/account.ts new file mode 100644 index 0000000..df545ee --- /dev/null +++ b/web/src/debug/account.ts @@ -0,0 +1,43 @@ +import { isArray } from "@/utils/is"; + +interface Account { + /** + * 账号名称 + */ + name: string; + /** + * 用户名 + */ + username: string; + /** + * 密码 + */ + password: string; +} +/** + * 获取配置的账号信息 + * @returns {[]Account} 返回账号信息数组 + */ +export function getDemoAccounts() { + + let envConf = import.meta.env.VITE_APP_DEMO_ACCOUNT || ""; + // 帐号密码一样 + // [["username"],["username","password"],["username","password","name"]] + try { + let accounts = JSON.parse(envConf); + if (accounts && isArray(accounts)) { + return accounts.map((item: String[]) => { + let [username = "", password = "", name = ""] = item; + username = username; + password = password || username; + name = name || username; + return { + name, + username, + password, + } as Account; + }); + } + } catch (error) {} + return [] as Account[]; +} diff --git a/web/src/directives/clickOutside.ts b/web/src/directives/clickOutside.ts new file mode 100644 index 0000000..2758a9d --- /dev/null +++ b/web/src/directives/clickOutside.ts @@ -0,0 +1,86 @@ +import { on } from '@/utils/domUtils'; +import { isServer } from '@/utils/is'; +import type { ComponentPublicInstance, DirectiveBinding, ObjectDirective } from 'vue'; + +type DocumentHandler = (mouseup: T, mousedown: T) => void; + +type FlushList = Map< + HTMLElement, + { + documentHandler: DocumentHandler; + bindingFn: (...args: unknown[]) => unknown; + } +>; + +const nodeList: FlushList = new Map(); + +let startClick: MouseEvent; + +if (!isServer) { + on(document, 'mousedown', (e: MouseEvent) => (startClick = e)); + on(document, 'mouseup', (e: MouseEvent) => { + for (const { documentHandler } of nodeList.values()) { + documentHandler(e, startClick); + } + }); +} + +function createDocumentHandler(el: HTMLElement, binding: DirectiveBinding): DocumentHandler { + let excludes: HTMLElement[] = []; + if (Array.isArray(binding.arg)) { + excludes = binding.arg; + } else { + // due to current implementation on binding type is wrong the type casting is necessary here + excludes.push(binding.arg as unknown as HTMLElement); + } + return function (mouseup, mousedown) { + const popperRef = ( + binding.instance as ComponentPublicInstance<{ + popperRef: Nullable; + }> + ).popperRef; + const mouseUpTarget = mouseup.target as Node; + const mouseDownTarget = mousedown.target as Node; + const isBound = !binding || !binding.instance; + const isTargetExists = !mouseUpTarget || !mouseDownTarget; + const isContainedByEl = el.contains(mouseUpTarget) || el.contains(mouseDownTarget); + const isSelf = el === mouseUpTarget; + + const isTargetExcluded = + (excludes.length && excludes.some((item) => item?.contains(mouseUpTarget))) || + (excludes.length && excludes.includes(mouseDownTarget as HTMLElement)); + const isContainedByPopper = + popperRef && (popperRef.contains(mouseUpTarget) || popperRef.contains(mouseDownTarget)); + if ( + isBound || + isTargetExists || + isContainedByEl || + isSelf || + isTargetExcluded || + isContainedByPopper + ) { + return; + } + binding.value(); + }; +} + +const ClickOutside: ObjectDirective = { + beforeMount(el, binding) { + nodeList.set(el, { + documentHandler: createDocumentHandler(el, binding), + bindingFn: binding.value, + }); + }, + updated(el, binding) { + nodeList.set(el, { + documentHandler: createDocumentHandler(el, binding), + bindingFn: binding.value, + }); + }, + unmounted(el) { + nodeList.delete(el); + }, +}; + +export default ClickOutside; diff --git a/web/src/directives/copy.ts b/web/src/directives/copy.ts new file mode 100644 index 0000000..d5f0b9f --- /dev/null +++ b/web/src/directives/copy.ts @@ -0,0 +1,33 @@ +/** + * v-copy + * 复制某个值至剪贴板 + * 接收参数:string类型/Ref类型/Reactive类型 + */ +import type { Directive, DirectiveBinding } from 'vue'; +interface ElType extends HTMLElement { + copyData: string | number; + __handleClick__: any; +} +const copy: Directive = { + mounted(el: ElType, binding: DirectiveBinding) { + el.copyData = binding.value; + el.addEventListener('click', handleClick); + }, + updated(el: ElType, binding: DirectiveBinding) { + el.copyData = binding.value; + }, + beforeUnmount(el: ElType) { + el.removeEventListener('click', el.__handleClick__); + }, +}; +function handleClick(this: any) { + const input = document.createElement('input'); + input.value = this.copyData.toLocaleString(); + document.body.appendChild(input); + input.select(); + document.execCommand('Copy'); + document.body.removeChild(input); + console.log('复制成功', this.copyData); +} + +export default copy; diff --git a/web/src/directives/debounce.ts b/web/src/directives/debounce.ts new file mode 100644 index 0000000..a1889c7 --- /dev/null +++ b/web/src/directives/debounce.ts @@ -0,0 +1,31 @@ +/** + * v-debounce + * 按钮防抖指令,可自行扩展至input + * 接收参数:function类型 + */ +import type { Directive, DirectiveBinding } from 'vue'; +interface ElType extends HTMLElement { + __handleClick__: () => any; +} +const debounce: Directive = { + mounted(el: ElType, binding: DirectiveBinding) { + if (typeof binding.value !== 'function') { + throw 'callback must be a function'; + } + let timer: NodeJS.Timeout | null = null; + el.__handleClick__ = function () { + if (timer) { + clearInterval(timer); + } + timer = setTimeout(() => { + binding.value(); + }, 500); + }; + el.addEventListener('click', el.__handleClick__); + }, + beforeUnmount(el: ElType) { + el.removeEventListener('click', el.__handleClick__); + }, +}; + +export default debounce; diff --git a/web/src/directives/draggable.ts b/web/src/directives/draggable.ts new file mode 100644 index 0000000..1c05c76 --- /dev/null +++ b/web/src/directives/draggable.ts @@ -0,0 +1,49 @@ +/* + 需求:实现一个拖拽指令,可在父元素区域任意拖拽元素。 + + 思路: + 1、设置需要拖拽的元素为absolute,其父元素为relative。 + 2、鼠标按下(onmousedown)时记录目标元素当前的 left 和 top 值。 + 3、鼠标移动(onmousemove)时计算每次移动的横向距离和纵向距离的变化值,并改变元素的 left 和 top 值 + 4、鼠标松开(onmouseup)时完成一次拖拽 + + 使用:在 Dom 上加上 v-draggable 即可 +
+*/ +import type { Directive } from 'vue'; +interface ElType extends HTMLElement { + parentNode: any; +} +const draggable: Directive = { + mounted: function (el: ElType) { + el.style.cursor = 'move'; + el.style.position = 'absolute'; + el.onmousedown = function (e) { + const disX = e.pageX - el.offsetLeft; + const disY = e.pageY - el.offsetTop; + document.onmousemove = function (e) { + let x = e.pageX - disX; + let y = e.pageY - disY; + const maxX = el.parentNode.offsetWidth - el.offsetWidth; + const maxY = el.parentNode.offsetHeight - el.offsetHeight; + if (x < 0) { + x = 0; + } else if (x > maxX) { + x = maxX; + } + + if (y < 0) { + y = 0; + } else if (y > maxY) { + y = maxY; + } + el.style.left = x + 'px'; + el.style.top = y + 'px'; + }; + document.onmouseup = function () { + document.onmousemove = document.onmouseup = null; + }; + }; + }, +}; +export default draggable; diff --git a/web/src/directives/permission.ts b/web/src/directives/permission.ts new file mode 100644 index 0000000..d41c46a --- /dev/null +++ b/web/src/directives/permission.ts @@ -0,0 +1,19 @@ +import { ObjectDirective } from 'vue'; +import { usePermission } from '@/hooks/web/usePermission'; + +export const permission: ObjectDirective = { + mounted(el: HTMLButtonElement, binding) { + if (binding.value == undefined) return; + const { action, effect } = binding.value; + const { hasPermission } = usePermission(); + if (!hasPermission(action)) { + if (effect == 'disabled') { + el.disabled = true; + el.style['disabled'] = 'disabled'; + el.classList.add('n-button--disabled'); + } else { + el.remove(); + } + } + }, +}; diff --git a/web/src/directives/throttle.ts b/web/src/directives/throttle.ts new file mode 100644 index 0000000..d407f8e --- /dev/null +++ b/web/src/directives/throttle.ts @@ -0,0 +1,41 @@ +/* + 需求:防止按钮在短时间内被多次点击,使用节流函数限制规定时间内只能点击一次。 + + 思路: + 1、第一次点击,立即调用方法并禁用按钮,等延迟结束再次激活按钮 + 2、将需要触发的方法绑定在指令上 + + 使用:给 Dom 加上 v-throttle 及回调函数即可 + +*/ +import type { Directive, DirectiveBinding } from 'vue'; +interface ElType extends HTMLElement { + __handleClick__: () => any; + disabled: boolean; +} +const throttle: Directive = { + mounted(el: ElType, binding: DirectiveBinding) { + if (typeof binding.value !== 'function') { + throw 'callback must be a function'; + } + let timer: NodeJS.Timeout | null = null; + el.__handleClick__ = function () { + if (timer) { + clearTimeout(timer); + } + if (!el.disabled) { + el.disabled = true; + binding.value(); + timer = setTimeout(() => { + el.disabled = false; + }, 1000); + } + }; + el.addEventListener('click', el.__handleClick__); + }, + beforeUnmount(el: ElType) { + el.removeEventListener('click', el.__handleClick__); + }, +}; + +export default throttle; diff --git a/web/src/enums/apiEnum.ts b/web/src/enums/apiEnum.ts new file mode 100644 index 0000000..13aa3a9 --- /dev/null +++ b/web/src/enums/apiEnum.ts @@ -0,0 +1,18 @@ +export enum ApiEnum { + // api前缀 + Prefix = '/api', + + // 基础 + SiteRegister = '/site/register', // 账号注册 + SiteAccountLogin = '/site/accountLogin', // 账号登录 + SiteMobileLogin = '/site/mobileLogin', // 手机号登录 + SiteLoginConfig = '/site/loginConfig', // 登录配置 + SiteLogout = '/site/logout', // 注销 + SiteConfig = '/site/config', // 配置信息 + + // 用户 + MemberInfo = '/member/info', // 登录用户信息 + + // 角色 + RoleDynamic = '/role/dynamic', // 动态路由 +} diff --git a/web/src/enums/breakpointEnum.ts b/web/src/enums/breakpointEnum.ts new file mode 100644 index 0000000..93acc1a --- /dev/null +++ b/web/src/enums/breakpointEnum.ts @@ -0,0 +1,28 @@ +export enum sizeEnum { + XS = 'XS', + SM = 'SM', + MD = 'MD', + LG = 'LG', + XL = 'XL', + XXL = 'XXL', +} + +export enum screenEnum { + XS = 480, + SM = 576, + MD = 768, + LG = 992, + XL = 1200, + XXL = 1600, +} + +const screenMap = new Map(); + +screenMap.set(sizeEnum.XS, screenEnum.XS); +screenMap.set(sizeEnum.SM, screenEnum.SM); +screenMap.set(sizeEnum.MD, screenEnum.MD); +screenMap.set(sizeEnum.LG, screenEnum.LG); +screenMap.set(sizeEnum.XL, screenEnum.XL); +screenMap.set(sizeEnum.XXL, screenEnum.XXL); + +export { screenMap }; diff --git a/web/src/enums/cacheEnum.ts b/web/src/enums/cacheEnum.ts new file mode 100644 index 0000000..db4d1d0 --- /dev/null +++ b/web/src/enums/cacheEnum.ts @@ -0,0 +1,20 @@ +// token key +export const TOKEN_KEY = 'TOKEN'; + +// member info key +export const USER_INFO_KEY = 'USER__INFO__'; + +// role info key +export const ROLES_KEY = 'ROLES__KEY__'; + +// project config key +export const PROJ_CFG_KEY = 'PROJ__CFG__KEY__'; + +// lock info +export const LOCK_INFO_KEY = 'LOCK__INFO__KEY__'; + +// base global local key +export const BASE_LOCAL_CACHE_KEY = 'LOCAL__CACHE__KEY__'; + +// base global session key +export const BASE_SESSION_CACHE_KEY = 'SESSION__CACHE__KEY__'; diff --git a/web/src/enums/deptEnum.ts b/web/src/enums/deptEnum.ts new file mode 100644 index 0000000..9beb4c7 --- /dev/null +++ b/web/src/enums/deptEnum.ts @@ -0,0 +1,14 @@ +// 部门类型 +export enum DeptTypeEnum { + // 公司 + Company = 'company', + + // 租户 + Tenant = 'tenant', + + // 商户 + Merchant = 'merchant', + + // 用户 + User = 'user', +} diff --git a/web/src/enums/httpEnum.ts b/web/src/enums/httpEnum.ts new file mode 100644 index 0000000..891c2be --- /dev/null +++ b/web/src/enums/httpEnum.ts @@ -0,0 +1,34 @@ +/** + * @description: 请求结果集 + */ +export enum ResultEnum { + SUCCESS = 0, + ERROR = -1, + TIMEOUT = 61, + TYPE = 'success', +} + +/** + * @description: 请求方法 + */ +export enum RequestEnum { + GET = 'GET', + POST = 'POST', + PATCH = 'PATCH', + PUT = 'PUT', + DELETE = 'DELETE', +} + +/** + * @description: 常用的contentTyp类型 + */ +export enum ContentTypeEnum { + // json + JSON = 'application/json;charset=UTF-8', + // json + TEXT = 'text/plain;charset=UTF-8', + // form-data 一般配合qs + FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8', + // form-data 上传 + FORM_DATA = 'multipart/form-data;charset=UTF-8', +} diff --git a/web/src/enums/localDictEnum.ts b/web/src/enums/localDictEnum.ts new file mode 100644 index 0000000..681be7e --- /dev/null +++ b/web/src/enums/localDictEnum.ts @@ -0,0 +1,124 @@ +// 本地字典 +// 一些不需要放在服务端注册的字典,也可以在这里进行注册 + +import { DictOptions, Option } from '@/store/modules/dict'; + +export const noticeTypeOptions: Option[] = [ + { + key: 1, + value: 1, + label: '通知', + listClass: 'warning', + extra: null, + }, + { + key: 2, + value: 2, + label: '公告', + listClass: 'error', + extra: null, + }, + { + key: 3, + value: 3, + label: '私信', + listClass: 'info', + extra: null, + }, +]; + +export const noticeTagOptions: Option[] = [ + { + value: 0, + label: '无标签', + key: 0, + listClass: 'default', + extra: null, + }, + { + value: 1, + label: '一般', + key: 1, + listClass: 'info', + extra: null, + }, + { + value: 2, + label: '紧急', + key: 2, + listClass: 'error', + extra: null, + }, + { + value: 3, + label: '重要', + key: 3, + listClass: 'warning', + extra: null, + }, + { + value: 4, + label: '提醒', + key: 4, + listClass: 'success', + extra: null, + }, + { + value: 5, + label: '次要', + key: 5, + listClass: 'default', + extra: null, + }, +]; + +export const tagTypeOptions: Option[] = [ + { + value: 'success', + label: '绿色', + key: 'success', + listClass: 'success', + extra: null, + }, + { + value: 'warning', + label: '橙色', + key: 'warning', + listClass: 'warning', + extra: null, + }, + { + value: 'error', + label: '红色', + key: 'error', + listClass: 'error', + extra: null, + }, + { + value: 'info', + label: '蓝色', + key: 'info', + listClass: 'info', + extra: null, + }, + { + value: 'default', + label: '灰色', + key: 'default', + listClass: 'default', + extra: null, + }, + { + value: 'primary', + label: '主题色', + key: 'primary', + listClass: 'primary', + extra: null, + }, +]; + +export const localDict: DictOptions = { + noticeTypeOptions: noticeTypeOptions, // 消息通知类型 + noticeTagOptions: noticeTagOptions, // 消息标签类型 + tagTypeOptions: tagTypeOptions, // 标签类型选项 +}; diff --git a/web/src/enums/optionsiEnum.ts b/web/src/enums/optionsiEnum.ts new file mode 100644 index 0000000..e2d89be --- /dev/null +++ b/web/src/enums/optionsiEnum.ts @@ -0,0 +1,22 @@ +export const statusOptions = [ + { + value: 1, + label: '正常', + }, + { + value: 2, + label: '停用', + }, +]; + +// 操作类 +export const statusActions = [ + { + label: '设为启用', + key: 1, + }, + { + label: '设为禁用', + key: 2, + }, +]; diff --git a/web/src/enums/pageEnum.ts b/web/src/enums/pageEnum.ts new file mode 100644 index 0000000..d08ada9 --- /dev/null +++ b/web/src/enums/pageEnum.ts @@ -0,0 +1,14 @@ +export enum PageEnum { + // 登录 + BASE_LOGIN = '/login', + BASE_LOGIN_NAME = 'Login', + //重定向 + REDIRECT = '/redirect', + REDIRECT_NAME = 'Redirect', + // 首页 + BASE_HOME = '/dashboard', + //首页跳转默认路由 + BASE_HOME_REDIRECT = '/dashboard/console', + // 错误 + ERROR_PAGE_NAME = 'ErrorPage', +} diff --git a/web/src/enums/permissionsEnum.ts b/web/src/enums/permissionsEnum.ts new file mode 100644 index 0000000..bf3f53a --- /dev/null +++ b/web/src/enums/permissionsEnum.ts @@ -0,0 +1,4 @@ +export interface PermissionsEnum { + value: string; + label: string; +} diff --git a/web/src/enums/socketEnum.ts b/web/src/enums/socketEnum.ts new file mode 100644 index 0000000..d3baddc --- /dev/null +++ b/web/src/enums/socketEnum.ts @@ -0,0 +1,12 @@ +export enum SocketEnum { + EventPing = 'ping', + EventKick = 'kick', + EventNotice = 'notice', + EventConnected = 'connected', + EventAdminMonitorTrends = 'admin/monitor/trends', + EventAdminMonitorRunInfo = 'admin/monitor/runInfo', + EventAdminOrderNotify = 'admin/order/notify', + HeartBeatInterval = 1000, + CodeSuc = 0, + CodeErr = -1, +} diff --git a/web/src/enums/systemMessageEnum.ts b/web/src/enums/systemMessageEnum.ts new file mode 100644 index 0000000..221e88c --- /dev/null +++ b/web/src/enums/systemMessageEnum.ts @@ -0,0 +1,179 @@ +import { NAvatar, NTag, NText, SelectRenderLabel, SelectRenderTag } from 'naive-ui'; +import { Component, h } from 'vue'; +import { BellOutlined, NotificationOutlined, SendOutlined } from '@vicons/antd'; +import { useDictStore } from '@/store/modules/dict'; + +export interface personOption { + value: number; + label: string; + username: string; + avatar: string; +} + +export const renderMultipleSelectTag: SelectRenderTag = ({ option, handleClose }) => { + // @ts-ignore + return h( + NTag, + { + style: { + padding: '0 6px 0 4px', + }, + round: true, + closable: true, + onClose: (e) => { + e.stopPropagation(); + handleClose(); + }, + }, + { + default: () => + h( + 'div', + { + style: { + display: 'flex', + alignItems: 'center', + }, + }, + [ + option.avatar !== '' + ? h(NAvatar, { + src: option.avatar as string, + round: true, + size: 22, + style: { + marginRight: '4px', + }, + }) + : h( + NAvatar, + { + round: true, + size: 22, + style: { + marginRight: '4px', + }, + }, + { + default: () => + option.label !== '' + ? ((option?.label as string).substring(0, 1) as string) + : ((option?.username as string).substring(0, 1) as string), + } + ), + option.label as string, + ] + ), + } + ); +}; + +export const renderLabel: SelectRenderLabel = (option) => { + return h( + 'div', + { + style: { + display: 'flex', + alignItems: 'center', + }, + }, + [ + option.avatar !== '' + ? h(NAvatar, { + src: option.avatar as string, + round: true, + size: 'small', + }) + : h( + NAvatar, + { + round: true, + size: 'small', + }, + { + default: () => + option.label !== '' + ? ((option?.label as string).substring(0, 1) as string) + : ((option?.username as string).substring(0, 2) as string), + } + ), + h( + 'div', + { + style: { + marginLeft: '12px', + padding: '4px 0', + }, + }, + [ + h('div', null, [option.label as string]), + h( + NText, + { depth: 3, tag: 'div' }, + { + default: () => option.username, + } + ), + ] + ), + ] + ); +}; + +export interface MessageTab { + /** tab的key */ + key: number; + /** tab名称 */ + name: string; + /** badge类型 */ + badgeProps?: import('naive-ui').BadgeProps; + /** 消息数据 */ + list: MessageRow[]; +} + +export interface MessageRow { + /** 消息ID */ + id: number; + /** 消息类型 */ + type: number; + /** 消息标题 */ + title: string; + /** 消息内容 */ + content: string; + /** 发送时间 */ + createdAt: string; + /** 是否已读 */ + isRead: boolean; + /** 发送者头像 */ + senderAvatar: string; + /** 标签ID */ + tag: number; + /** 标签名称 */ + tagTitle?: string; + /** 标签props */ + tagProps?: import('naive-ui').TagProps; +} + +// 获取消息的展示图标 +export function getIcon(row: MessageRow): Component { + if (row.type === 1) { + return NotificationOutlined; + } + if (row.type === 2) { + return BellOutlined; + } + return SendOutlined; +} + +// 解析消息 +export function parseMessage(row): MessageRow { + row = row as MessageRow; + if (row.tag <= 0) { + return row; + } + const dict = useDictStore(); + const label = dict.getLabel('noticeTagOptions', row.tag); + row.tagTitle = label; + row.tagProps = { type: label }; + return row; +} diff --git a/web/src/hooks/common/index.ts b/web/src/hooks/common/index.ts new file mode 100644 index 0000000..438ede1 --- /dev/null +++ b/web/src/hooks/common/index.ts @@ -0,0 +1,8 @@ +import useContext from './useContext'; +import useBoolean from './useBoolean'; +import useLoading from './useLoading'; +import useLoadingEmpty from './useLoadingEmpty'; +import useSendCode from './useSendCode'; +import useSorter from './useSorter'; + +export { useContext, useBoolean, useLoading, useLoadingEmpty, useSendCode, useSorter }; diff --git a/web/src/hooks/common/useBoolean.ts b/web/src/hooks/common/useBoolean.ts new file mode 100644 index 0000000..645d471 --- /dev/null +++ b/web/src/hooks/common/useBoolean.ts @@ -0,0 +1,26 @@ +import { ref } from 'vue'; + +export default function useBoolean(initValue = false) { + const bool = ref(initValue); + + function setBool(value: boolean) { + bool.value = value; + } + function setTrue() { + setBool(true); + } + function setFalse() { + setBool(false); + } + function toggle() { + setBool(!bool.value); + } + + return { + bool, + setBool, + setTrue, + setFalse, + toggle, + }; +} diff --git a/web/src/hooks/common/useContext.ts b/web/src/hooks/common/useContext.ts new file mode 100644 index 0000000..cc826b9 --- /dev/null +++ b/web/src/hooks/common/useContext.ts @@ -0,0 +1,20 @@ +import { inject, provide } from 'vue'; +import type { InjectionKey } from 'vue'; + +export default function useContext(contextName = 'context') { + const injectKey: InjectionKey = Symbol(contextName); + + function useProvide(context: T) { + provide(injectKey, context); + return context; + } + + function useInject() { + return inject(injectKey) as T; + } + + return { + useProvide, + useInject, + }; +} diff --git a/web/src/hooks/common/useCountDown.ts b/web/src/hooks/common/useCountDown.ts new file mode 100644 index 0000000..6a58845 --- /dev/null +++ b/web/src/hooks/common/useCountDown.ts @@ -0,0 +1,54 @@ +import { computed, onScopeDispose, ref } from 'vue'; +import useBoolean from './useBoolean'; + +/** + * 倒计时 + * @param second - 倒计时的时间(s) + */ +export default function useCountDown(second: number) { + if (second <= 0 && second % 1 !== 0) { + throw new Error('倒计时的时间应该为一个正整数!'); + } + const { bool: isComplete, setTrue, setFalse } = useBoolean(false); + + const counts = ref(0); + const isCounting = computed(() => Boolean(counts.value)); + + let intervalId: any; + + /** + * 开始计时 + * @param updateSecond - 更改初时传入的倒计时时间 + */ + function start(updateSecond: number = second) { + if (!counts.value) { + setFalse(); + counts.value = updateSecond; + intervalId = setInterval(() => { + counts.value -= 1; + if (counts.value <= 0) { + clearInterval(intervalId); + setTrue(); + } + }, 1000); + } + } + + /** + * 停止计时 + */ + function stop() { + intervalId = clearInterval(intervalId); + counts.value = 0; + } + + onScopeDispose(stop); + + return { + counts, + isCounting, + start, + stop, + isComplete, + }; +} diff --git a/web/src/hooks/common/useLoading.ts b/web/src/hooks/common/useLoading.ts new file mode 100644 index 0000000..86c2242 --- /dev/null +++ b/web/src/hooks/common/useLoading.ts @@ -0,0 +1,11 @@ +import useBoolean from './useBoolean'; + +export default function useLoading(initValue = false) { + const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(initValue); + + return { + loading, + startLoading, + endLoading, + }; +} diff --git a/web/src/hooks/common/useLoadingEmpty.ts b/web/src/hooks/common/useLoadingEmpty.ts new file mode 100644 index 0000000..1397fad --- /dev/null +++ b/web/src/hooks/common/useLoadingEmpty.ts @@ -0,0 +1,14 @@ +import useBoolean from './useBoolean'; + +export default function useLoadingEmpty(initLoading = false, initEmpty = false) { + const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(initLoading); + const { bool: empty, setBool: setEmpty } = useBoolean(initEmpty); + + return { + loading, + startLoading, + endLoading, + empty, + setEmpty, + }; +} diff --git a/web/src/hooks/common/useSendCode.ts b/web/src/hooks/common/useSendCode.ts new file mode 100644 index 0000000..2314f0e --- /dev/null +++ b/web/src/hooks/common/useSendCode.ts @@ -0,0 +1,43 @@ +import { computed } from 'vue'; +import useLoading from './useLoading'; +import useCountDown from './useCountDown'; + +export default function useSmsCode() { + const { loading, startLoading, endLoading } = useLoading(); + const { counts, start, isCounting } = useCountDown(60); + const initLabel = '获取验证码'; + const countingLabel = (second: number) => `重新获取(${second})`; + const sendLabel = computed(() => { + let text = initLabel; + if (loading.value) { + text = ''; + } + if (isCounting.value) { + text = countingLabel(counts.value); + } + return text; + }); + + /** + * 激活发送 + */ + function activateSend(request: Promise) { + startLoading(); + request + .then((_res) => { + window['$message']?.success('验证码发送成功!'); + start(); + }) + .finally(() => { + endLoading(); + }); + } + + return { + sendLabel, + start, + isCounting, + activateSend, + loading, + }; +} diff --git a/web/src/hooks/common/useSorter.ts b/web/src/hooks/common/useSorter.ts new file mode 100644 index 0000000..6cdd733 --- /dev/null +++ b/web/src/hooks/common/useSorter.ts @@ -0,0 +1,40 @@ +import { ref } from 'vue'; +import { DataTableSortState } from 'naive-ui'; +import { SorterMultiple } from 'naive-ui/es/data-table/src/interface'; + +export default function useSorter(callback: Function = function () {}) { + const sortStatesRef = ref([]); + + function updateSorter(sorter: DataTableSortState | DataTableSortState[] | null) { + if (sorter === null) { + // 默认排序 + sortStatesRef.value = []; + } else if (Array.isArray(sorter)) { + // 多字段排序 + sortStatesRef.value = filterMultipleSorters(sorter); + } else { + // 单字段排序 + sortStatesRef.value = []; + sortStatesRef.value.push(sorter); + } + callback(); + } + + // 多字段排序,将不参与排序的字段过滤掉,再根据设置的优先级对排序器重新排列 + function filterMultipleSorters(sorters: DataTableSortState[]): DataTableSortState[] { + const filter = sorters.filter((item) => item.order !== false); + return filter.sort((a, b) => { + if (typeof a.sorter === 'object' && typeof b.sorter === 'object') { + const aSorter = a.sorter as SorterMultiple; + const bSorter = b.sorter as SorterMultiple; + return bSorter.multiple - aSorter.multiple; + } + return 0; + }); + } + + return { + updateSorter, + sortStatesRef, + }; +} diff --git a/web/src/hooks/core/useTimeout.ts b/web/src/hooks/core/useTimeout.ts new file mode 100644 index 0000000..dc352c9 --- /dev/null +++ b/web/src/hooks/core/useTimeout.ts @@ -0,0 +1,47 @@ +import { ref, watch } from 'vue'; +import { tryOnUnmounted } from '@vueuse/core'; +import { isFunction } from '@/utils/is'; + +export function useTimeoutFn(handle: Fn, wait: number, native = false) { + if (!isFunction(handle)) { + throw new Error('handle is not Function!'); + } + + const { readyRef, stop, start } = useTimeoutRef(wait); + if (native) { + handle(); + } else { + watch( + readyRef, + (maturity) => { + maturity && handle(); + }, + { immediate: false } + ); + } + return { readyRef, stop, start }; +} + +export function useTimeoutRef(wait: number) { + const readyRef = ref(false); + + let timer: TimeoutHandle; + + function stop(): void { + readyRef.value = false; + timer && window.clearTimeout(timer); + } + + function start(): void { + stop(); + timer = setTimeout(() => { + readyRef.value = true; + }, wait); + } + + start(); + + tryOnUnmounted(stop); + + return { readyRef, stop, start }; +} diff --git a/web/src/hooks/event/useBreakpoint.ts b/web/src/hooks/event/useBreakpoint.ts new file mode 100644 index 0000000..4b2d29c --- /dev/null +++ b/web/src/hooks/event/useBreakpoint.ts @@ -0,0 +1,89 @@ +import { ref, computed, ComputedRef, unref } from 'vue'; +import { useEventListener } from '@/hooks/event/useEventListener'; +import { screenMap, sizeEnum, screenEnum } from '@/enums/breakpointEnum'; + +let globalScreenRef: ComputedRef; +let globalWidthRef: ComputedRef; +let globalRealWidthRef: ComputedRef; + +export interface CreateCallbackParams { + screen: ComputedRef; + width: ComputedRef; + realWidth: ComputedRef; + screenEnum: typeof screenEnum; + screenMap: Map; + sizeEnum: typeof sizeEnum; +} + +export function useBreakpoint() { + return { + screenRef: computed(() => unref(globalScreenRef)), + widthRef: globalWidthRef, + screenEnum, + realWidthRef: globalRealWidthRef, + }; +} + +// Just call it once +export function createBreakpointListen(fn?: (opt: CreateCallbackParams) => void) { + const screenRef = ref(sizeEnum.XL); + const realWidthRef = ref(window.innerWidth); + + function getWindowWidth() { + const width = document.body.clientWidth; + const xs = screenMap.get(sizeEnum.XS)!; + const sm = screenMap.get(sizeEnum.SM)!; + const md = screenMap.get(sizeEnum.MD)!; + const lg = screenMap.get(sizeEnum.LG)!; + const xl = screenMap.get(sizeEnum.XL)!; + if (width < xs) { + screenRef.value = sizeEnum.XS; + } else if (width < sm) { + screenRef.value = sizeEnum.SM; + } else if (width < md) { + screenRef.value = sizeEnum.MD; + } else if (width < lg) { + screenRef.value = sizeEnum.LG; + } else if (width < xl) { + screenRef.value = sizeEnum.XL; + } else { + screenRef.value = sizeEnum.XXL; + } + realWidthRef.value = width; + } + + useEventListener({ + el: window, + name: 'resize', + + listener: () => { + getWindowWidth(); + resizeFn(); + }, + // wait: 100, + }); + + getWindowWidth(); + globalScreenRef = computed(() => unref(screenRef)); + globalWidthRef = computed((): number => screenMap.get(unref(screenRef)!)!); + globalRealWidthRef = computed((): number => unref(realWidthRef)); + + function resizeFn() { + fn?.({ + screen: globalScreenRef, + width: globalWidthRef, + realWidth: globalRealWidthRef, + screenEnum, + screenMap, + sizeEnum, + }); + } + + resizeFn(); + return { + screenRef: globalScreenRef, + screenEnum, + widthRef: globalWidthRef, + realWidthRef: globalRealWidthRef, + }; +} diff --git a/web/src/hooks/event/useEventListener.ts b/web/src/hooks/event/useEventListener.ts new file mode 100644 index 0000000..600afab --- /dev/null +++ b/web/src/hooks/event/useEventListener.ts @@ -0,0 +1,62 @@ +import type { Ref } from 'vue'; + +import { ref, watch, unref } from 'vue'; +import { useThrottleFn, useDebounceFn } from '@vueuse/core'; + +export type RemoveEventFn = () => void; + +export interface UseEventParams { + el?: Element | Ref | Window | any; + name: string; + listener: EventListener; + options?: boolean | AddEventListenerOptions; + autoRemove?: boolean; + isDebounce?: boolean; + wait?: number; +} + +export function useEventListener({ + el = window, + name, + listener, + options, + autoRemove = true, + isDebounce = true, + wait = 80, +}: UseEventParams): { removeEvent: RemoveEventFn } { + /* eslint-disable-next-line */ + let remove: RemoveEventFn = () => { + }; + const isAddRef = ref(false); + + if (el) { + const element: Ref = ref(el as Element); + + const handler = isDebounce ? useDebounceFn(listener, wait) : useThrottleFn(listener, wait); + const realHandler = wait ? handler : listener; + const removeEventListener = (e: Element) => { + isAddRef.value = true; + e.removeEventListener(name, realHandler, options); + }; + const addEventListener = (e: Element) => e.addEventListener(name, realHandler, options); + + const removeWatch = watch( + element, + (v, _ov, cleanUp) => { + if (v) { + !unref(isAddRef) && addEventListener(v); + cleanUp(() => { + autoRemove && removeEventListener(v); + }); + } + }, + { immediate: true } + ); + + remove = () => { + removeEventListener(element.value); + removeWatch(); + }; + } + return { removeEvent: remove }; +} diff --git a/web/src/hooks/event/useWindowSizeFn.ts b/web/src/hooks/event/useWindowSizeFn.ts new file mode 100644 index 0000000..7b18ca0 --- /dev/null +++ b/web/src/hooks/event/useWindowSizeFn.ts @@ -0,0 +1,36 @@ +import { tryOnMounted, tryOnUnmounted } from '@vueuse/core'; +import { useDebounceFn } from '@vueuse/core'; + +interface WindowSizeOptions { + once?: boolean; + immediate?: boolean; + listenerOptions?: AddEventListenerOptions | boolean; +} + +export function useWindowSizeFn(fn: Fn, wait = 150, options?: WindowSizeOptions) { + let handler = () => { + fn(); + }; + const handleSize = useDebounceFn(handler, wait); + handler = handleSize; + + const start = () => { + if (options && options.immediate) { + handler(); + } + window.addEventListener('resize', handler); + }; + + const stop = () => { + window.removeEventListener('resize', handler); + }; + + tryOnMounted(() => { + start(); + }); + + tryOnUnmounted(() => { + stop(); + }); + return [start, stop]; +} diff --git a/web/src/hooks/index.ts b/web/src/hooks/index.ts new file mode 100644 index 0000000..81c4556 --- /dev/null +++ b/web/src/hooks/index.ts @@ -0,0 +1,3 @@ +import { useAsync } from './use-async'; + +export { useAsync }; diff --git a/web/src/hooks/setting/index.ts b/web/src/hooks/setting/index.ts new file mode 100644 index 0000000..0c27b3f --- /dev/null +++ b/web/src/hooks/setting/index.ts @@ -0,0 +1,31 @@ +import type { GlobConfig } from '/#/config'; +import { warn } from '@/utils/log'; +import { getAppEnvConfig } from '@/utils/env'; + +export const useGlobSetting = (): Readonly => { + const { + VITE_GLOB_APP_TITLE, + VITE_GLOB_API_URL, + VITE_GLOB_APP_SHORT_NAME, + VITE_GLOB_API_URL_PREFIX, + VITE_GLOB_UPLOAD_URL, + VITE_GLOB_IMG_URL, + } = getAppEnvConfig(); + + if (!/[a-zA-Z\_]*/.test(VITE_GLOB_APP_SHORT_NAME)) { + warn( + `VITE_GLOB_APP_SHORT_NAME Variables can only be characters/underscores, please modify in the environment variables and re-running.` + ); + } + + // Take global configuration + const glob: Readonly = { + title: VITE_GLOB_APP_TITLE, + apiUrl: VITE_GLOB_API_URL, + shortName: VITE_GLOB_APP_SHORT_NAME, + urlPrefix: VITE_GLOB_API_URL_PREFIX, + uploadUrl: VITE_GLOB_UPLOAD_URL, + imgUrl: VITE_GLOB_IMG_URL, + }; + return glob as Readonly; +}; diff --git a/web/src/hooks/setting/useDesignSetting.ts b/web/src/hooks/setting/useDesignSetting.ts new file mode 100644 index 0000000..0a873b5 --- /dev/null +++ b/web/src/hooks/setting/useDesignSetting.ts @@ -0,0 +1,18 @@ +import { computed } from 'vue'; +import { useDesignSettingStore } from '@/store/modules/designSetting'; + +export function useDesignSetting() { + const designStore = useDesignSettingStore(); + + const getDarkTheme = computed(() => designStore.darkTheme); + + const getAppTheme = computed(() => designStore.appTheme); + + const getAppThemeList = computed(() => designStore.appThemeList); + + return { + getDarkTheme, + getAppTheme, + getAppThemeList, + }; +} diff --git a/web/src/hooks/setting/useProjectSetting.ts b/web/src/hooks/setting/useProjectSetting.ts new file mode 100644 index 0000000..1a08980 --- /dev/null +++ b/web/src/hooks/setting/useProjectSetting.ts @@ -0,0 +1,42 @@ +import { computed } from 'vue'; +import { useProjectSettingStore } from '@/store/modules/projectSetting'; + +export function useProjectSetting() { + const projectStore = useProjectSettingStore(); + + const getNavMode = computed(() => projectStore.navMode); + + const getNavTheme = computed(() => projectStore.navTheme); + + const getIsMobile = computed(() => projectStore.isMobile); + + const getHeaderSetting = computed(() => projectStore.headerSetting); + + const getMultiTabsSetting = computed(() => projectStore.multiTabsSetting); + + const getMenuSetting = computed(() => projectStore.menuSetting); + + const getCrumbsSetting = computed(() => projectStore.crumbsSetting); + + const getPermissionMode = computed(() => projectStore.permissionMode); + + const getShowFooter = computed(() => projectStore.showFooter); + + const getIsPageAnimate = computed(() => projectStore.isPageAnimate); + + const getPageAnimateType = computed(() => projectStore.pageAnimateType); + + return { + getNavMode, + getNavTheme, + getIsMobile, + getHeaderSetting, + getMultiTabsSetting, + getMenuSetting, + getCrumbsSetting, + getPermissionMode, + getShowFooter, + getIsPageAnimate, + getPageAnimateType, + }; +} diff --git a/web/src/hooks/use-async.ts b/web/src/hooks/use-async.ts new file mode 100644 index 0000000..277e086 --- /dev/null +++ b/web/src/hooks/use-async.ts @@ -0,0 +1,15 @@ +import { isReactive, isRef } from 'vue'; + +function setLoading(loading, val) { + if (loading != undefined && isRef(loading)) { + loading.value = val; + } else if (loading != undefined && isReactive(loading)) { + loading.loading = val; + } +} + +export const useAsync = async (func: Promise, loading: any): Promise => { + setLoading(loading, true); + + return await func.finally(() => setLoading(loading, false)); +}; diff --git a/web/src/hooks/useBattery.ts b/web/src/hooks/useBattery.ts new file mode 100644 index 0000000..1728a73 --- /dev/null +++ b/web/src/hooks/useBattery.ts @@ -0,0 +1,93 @@ +import { computed, onMounted, reactive, toRefs } from 'vue'; + +interface Battery { + charging: boolean; // 当前电池是否正在充电 + chargingTime: number; // 距离充电完毕还需多少秒,如果为0则充电完毕 + dischargingTime: number; // 代表距离电池耗电至空且挂起需要多少秒 + level: number; // 代表电量的放大等级,这个值在 0.0 至 1.0 之间 + [key: string]: any; +} + +export const useBattery = () => { + const state = reactive({ + battery: { + charging: false, + chargingTime: 0, + dischargingTime: 0, + level: 100, + }, + }); + + // 更新电池使用状态 + const updateBattery = (target) => { + for (const key in state.battery) { + state.battery[key] = target[key]; + } + state.battery.level = state.battery.level * 100; + }; + + // 计算电池剩余可用时间 + const calcDischargingTime = computed(() => { + const hour = state.battery.dischargingTime / 3600; + const minute = (state.battery.dischargingTime / 60) % 60; + return `${~~hour}小时${~~minute}分钟`; + }); + + // 计算电池充满剩余时间 + const calcChargingTime = computed(() => { + console.log(state.battery); + const hour = state.battery.chargingTime / 3600; + const minute = (state.battery.chargingTime / 60) % 60; + return `${~~hour}小时${~~minute}分钟`; + }); + + // 电池状态 + const batteryStatus = computed(() => { + if (state.battery.charging && state.battery.level >= 100) { + return '已充满'; + } else if (state.battery.charging) { + return '充电中'; + } else { + return '已断开电源'; + } + }); + + onMounted(async () => { + const BatteryManager: Battery = await (window.navigator as any).getBattery(); + updateBattery(BatteryManager); + + // 电池充电状态更新时被调用 + BatteryManager.onchargingchange = ({ target }) => { + updateBattery(target); + }; + // 电池充电时间更新时被调用 + BatteryManager.onchargingtimechange = ({ target }) => { + updateBattery(target); + }; + // 电池断开充电时间更新时被调用 + BatteryManager.ondischargingtimechange = ({ target }) => { + updateBattery(target); + }; + // 电池电量更新时被调用 + BatteryManager.onlevelchange = ({ target }) => { + updateBattery(target); + }; + + // new Intl.DateTimeFormat('zh', { + // year: 'numeric', + // month: '2-digit', + // day: '2-digit', + // hour: '2-digit', + // minute: '2-digit', + // second: '2-digit', + // hour12: false + // }).format(new Date()) + }); + + return { + ...toRefs(state), + batteryStatus, + calcDischargingTime, + calcChargingTime, + }; +}; diff --git a/web/src/hooks/useBoolean.ts b/web/src/hooks/useBoolean.ts new file mode 100644 index 0000000..1646ffa --- /dev/null +++ b/web/src/hooks/useBoolean.ts @@ -0,0 +1,49 @@ +import { ref, Ref } from "vue"; + +type Resettable = + /** + * 基础形式为一个元组,包含: + * 1. `Ref`:状态引用,可以直接访问和修改状态值 + * 2. `() => void`:切换真假 + * 3. `(value: boolean) => void`:设置状态 + * 4.`() => void`:设置为`true`方法 + * 5.`() => void`:设置为`false`方法 + */ + [Ref, () => void, () => void] & /** + * 同时扩展为一个对象形式,提供以下属性和方法: + * - `loading: Ref`:状态引用,与元组中的第一个元素相同 + * - `startLoading: () => void`:开始加载方法,与元组中的第二个元素相同 + * - `endLoading: () => void`:结束加载方法,与元组中的第三个元素相同 + */ { + /** 加载状态引用 */ + bool: Ref; + toggle: () => void; + setBool: (value: boolean) => void; + setTrue: () => void; + setFalse: () => void; + }; +export default function useBoolean(initValue = false): Resettable { + const bool = ref(initValue); + + function setBool(value: boolean) { + bool.value = value; + } + function setTrue() { + setBool(true); + } + function setFalse() { + setBool(false); + } + function toggle() { + setBool(!bool.value); + } + + // 返回带加载状态引用、开始加载和结束加载方法的扩展数组 + return (Object.assign([bool, toggle, setBool, setTrue, setFalse], { + bool, + toggle, + setBool, + setTrue, + setFalse, + }) as unknown) as Resettable; +} diff --git a/web/src/hooks/useCreateScript.ts b/web/src/hooks/useCreateScript.ts new file mode 100644 index 0000000..a8c6632 --- /dev/null +++ b/web/src/hooks/useCreateScript.ts @@ -0,0 +1,21 @@ +import { onMounted } from 'vue'; + +export default function userCreateScript(src: string) { + const createScriptPromise = new Promise((resolve, reject) => { + onMounted(() => { + const script = document.createElement('script'); + script.type = 'text/javascript'; + script.onload = () => { + resolve(''); + }; + script.onerror = (error) => { + reject(error); + }; + script.src = src; + document.head.appendChild(script); + }); + }); + return { + createScriptPromise, + }; +} diff --git a/web/src/hooks/useDomWidth.ts b/web/src/hooks/useDomWidth.ts new file mode 100644 index 0000000..1a0bc5a --- /dev/null +++ b/web/src/hooks/useDomWidth.ts @@ -0,0 +1,23 @@ +import { ref, onMounted, onUnmounted } from 'vue'; +import { debounce } from 'lodash-es'; + +/** + * description: 获取页面宽度 + */ + +export function useDomWidth() { + const domWidth = ref(window.innerWidth); + + function resize() { + domWidth.value = document.body.clientWidth; + } + + onMounted(() => { + window.addEventListener('resize', debounce(resize, 80)); + }); + onUnmounted(() => { + window.removeEventListener('resize', resize); + }); + + return domWidth; +} diff --git a/web/src/hooks/useEcharts.ts b/web/src/hooks/useEcharts.ts new file mode 100644 index 0000000..91d0022 --- /dev/null +++ b/web/src/hooks/useEcharts.ts @@ -0,0 +1,8 @@ +import * as echarts from 'echarts'; +export default function useEcharts(dom: HTMLElement, theme?: string) { + let instance = echarts.getInstanceByDom(dom); + if (!instance) { + instance = echarts.init(dom, theme); + } + return instance; +} diff --git a/web/src/hooks/useOnline.ts b/web/src/hooks/useOnline.ts new file mode 100644 index 0000000..f55f31f --- /dev/null +++ b/web/src/hooks/useOnline.ts @@ -0,0 +1,30 @@ +import { ref, onMounted, onUnmounted } from 'vue'; + +/** + * @description 用户网络是否可用 + * */ +export function useOnline() { + const online = ref(true); + + const showStatus = (val) => { + online.value = typeof val == 'boolean' ? val : val.target.online; + }; + + // 在页面加载后,设置正确的网络状态 + navigator.onLine ? showStatus(true) : showStatus(false); + + onMounted(() => { + // 开始监听网络状态的变化 + window.addEventListener('online', showStatus); + + window.addEventListener('offline', showStatus); + }); + onUnmounted(() => { + // 移除监听网络状态的变化 + window.removeEventListener('online', showStatus); + + window.removeEventListener('offline', showStatus); + }); + + return { online }; +} diff --git a/web/src/hooks/useTime.ts b/web/src/hooks/useTime.ts new file mode 100644 index 0000000..9ff66c7 --- /dev/null +++ b/web/src/hooks/useTime.ts @@ -0,0 +1,44 @@ +import { ref, onMounted, onUnmounted } from 'vue'; + +/** + * @description 获取本地时间 + */ +export function useTime() { + let timer; // 定时器 + const year = ref(0); // 年份 + const month = ref(0); // 月份 + const week = ref(''); // 星期几 + const day = ref(0); // 天数 + const hour = ref(0); // 小时 + const minute = ref(0); // 分钟 + const second = ref(0); // 秒 + + // 更新时间 + const updateTime = () => { + const date = new Date(); + year.value = date.getFullYear(); + month.value = date.getMonth() + 1; + week.value = '日一二三四五六'.charAt(date.getDay()); + day.value = date.getDate(); + hour.value = + (date.getHours() + '').padStart(2, '0') || + new Intl.NumberFormat(undefined, { minimumIntegerDigits: 2 }).format(date.getHours()); + minute.value = + (date.getMinutes() + '').padStart(2, '0') || + new Intl.NumberFormat(undefined, { minimumIntegerDigits: 2 }).format(date.getMinutes()); + second.value = date.getSeconds(); + }; + + updateTime(); + + onMounted(() => { + clearInterval(timer); + timer = setInterval(() => updateTime(), 1000); + }); + + onUnmounted(() => { + clearInterval(timer); + }); + + return { month, day, hour, minute, second, week }; +} diff --git a/web/src/hooks/web/useECharts.ts b/web/src/hooks/web/useECharts.ts new file mode 100644 index 0000000..bd008a8 --- /dev/null +++ b/web/src/hooks/web/useECharts.ts @@ -0,0 +1,117 @@ +import type { EChartsOption } from 'echarts'; +import type { Ref } from 'vue'; +import { useTimeoutFn } from '@/hooks/core/useTimeout'; +import { tryOnUnmounted } from '@vueuse/core'; +import { unref, nextTick, watch, computed, ref } from 'vue'; +import { useDebounceFn } from '@vueuse/core'; +import { useEventListener } from '@/hooks/event/useEventListener'; +import { useBreakpoint } from '@/hooks/event/useBreakpoint'; +import echarts from '@/utils/lib/echarts'; +import { useDesignSetting } from '@/hooks/setting/useDesignSetting'; + +export function useECharts( + elRef: Ref, + theme: 'light' | 'dark' | 'default' = 'default' +) { + const { getDarkTheme: getSysDarkTheme } = useDesignSetting(); + + const getDarkTheme = computed(() => { + const sysTheme: string = getSysDarkTheme.value ? 'dark' : 'light'; + return theme === 'default' ? sysTheme : theme; + }); + + let chartInstance: echarts.ECharts | null = null; + let resizeFn: Fn = resize; + const cacheOptions = ref({}); + let removeResizeFn: Fn = () => {}; + resizeFn = useDebounceFn(resize, 200); + + const getOptions = computed((): EChartsOption => { + if (getDarkTheme.value !== 'dark') { + return cacheOptions.value; + } + return { + backgroundColor: 'transparent', + ...cacheOptions.value, + }; + }); + + function initCharts(t = theme) { + const el = unref(elRef); + if (!el || !unref(el)) { + return; + } + + chartInstance = echarts.init(el, t); + const { removeEvent } = useEventListener({ + el: window, + name: 'resize', + listener: resizeFn, + }); + removeResizeFn = removeEvent; + const { widthRef, screenEnum } = useBreakpoint(); + if (unref(widthRef) <= screenEnum.MD || el.offsetHeight === 0) { + useTimeoutFn(() => { + resizeFn(); + }, 30); + } + } + + function setOptions(options: EChartsOption, clear = true) { + cacheOptions.value = options; + if (unref(elRef)?.offsetHeight === 0) { + useTimeoutFn(() => { + setOptions(unref(getOptions)); + }, 30); + return; + } + nextTick(() => { + useTimeoutFn(() => { + if (!chartInstance) { + initCharts(getDarkTheme.value as 'default'); + + if (!chartInstance) return; + } + clear && chartInstance?.clear(); + + chartInstance?.setOption(unref(getOptions)); + }, 30); + }); + } + + function resize() { + chartInstance?.resize(); + } + + watch( + () => getDarkTheme.value, + (theme) => { + if (chartInstance) { + chartInstance.dispose(); + initCharts(theme as 'default'); + setOptions(cacheOptions.value); + } + } + ); + + tryOnUnmounted(() => { + if (!chartInstance) return; + removeResizeFn(); + chartInstance.dispose(); + chartInstance = null; + }); + + function getInstance(): echarts.ECharts | null { + if (!chartInstance) { + initCharts(getDarkTheme.value as 'default'); + } + return chartInstance; + } + + return { + setOptions, + resize, + echarts, + getInstance, + }; +} diff --git a/web/src/hooks/web/usePage.ts b/web/src/hooks/web/usePage.ts new file mode 100644 index 0000000..c11b1d8 --- /dev/null +++ b/web/src/hooks/web/usePage.ts @@ -0,0 +1,62 @@ +import type { RouteLocationRaw, Router } from 'vue-router'; + +import { PageEnum } from '@/enums/pageEnum'; +import { RedirectName } from '@/router/constant'; + +import { useRouter } from 'vue-router'; +import { isString } from '@/utils/is'; +import { unref } from 'vue'; + +export type RouteLocationRawEx = Omit & { path: PageEnum }; + +function handleError(e: Error) { + console.error(e); +} + +/** + * 页面切换 + */ +export function useGo(_router?: Router) { + let router; + if (!_router) { + router = useRouter(); + } + const { push, replace } = _router || router; + function go(opt: PageEnum | RouteLocationRawEx | string = PageEnum.BASE_HOME, isReplace = false) { + if (!opt) { + return; + } + if (isString(opt)) { + isReplace ? replace(opt).catch(handleError) : push(opt).catch(handleError); + } else { + const o = opt as RouteLocationRaw; + isReplace ? replace(o).catch(handleError) : push(o).catch(handleError); + } + } + return go; +} + +/** + * 重做当前页面 + */ +export const useRedo = (_router?: Router) => { + const { push, currentRoute } = _router || useRouter(); + const { query, params = {}, name, fullPath } = unref(currentRoute.value); + function redo(): Promise { + return new Promise((resolve) => { + if (name === RedirectName) { + resolve(false); + return; + } + if (name && Object.keys(params).length > 0) { + params['_redirect_type'] = 'name'; + params['path'] = String(name); + } else { + params['_redirect_type'] = 'path'; + params['path'] = fullPath; + } + push({ name: RedirectName, params, query }).then(() => resolve(true)); + }); + } + return redo; +}; diff --git a/web/src/hooks/web/usePermission.ts b/web/src/hooks/web/usePermission.ts new file mode 100644 index 0000000..3a03e74 --- /dev/null +++ b/web/src/hooks/web/usePermission.ts @@ -0,0 +1,52 @@ +import { useUserStore } from '@/store/modules/user'; + +export function usePermission() { + const userStore = useUserStore(); + + /** + * 检查权限 + * @param accesses + */ + function _somePermissions(accesses: string[]) { + return userStore.getPermissions.some((item) => { + // @ts-ignore + return accesses.includes(item); + }); + } + + /** + * 判断是否存在权限 + * 可用于 v-if 显示逻辑 + * */ + function hasPermission(accesses: string[]): boolean { + if (!accesses || !accesses.length) return true; + return _somePermissions(accesses); + } + + /** + * 是否包含指定的所有权限 + * @param accesses + */ + function hasEveryPermission(accesses: string[]): boolean { + const permissionsList = userStore.getPermissions; + if (Array.isArray(accesses)) { + return permissionsList.every((access: any) => accesses.includes(access)); + } + throw new Error(`[hasEveryPermission]: ${accesses} should be a array !`); + } + + /** + * 是否包含其中某个权限 + * @param accesses + * @param accessMap + */ + function hasSomePermission(accesses: string[]): boolean { + const permissionsList = userStore.getPermissions; + if (Array.isArray(accesses)) { + return permissionsList.some((access: any) => accesses.includes(access)); + } + throw new Error(`[hasSomePermission]: ${accesses} should be a array !`); + } + + return { hasPermission, hasEveryPermission, hasSomePermission }; +} diff --git a/web/src/layout/components/Header/MessageList.vue b/web/src/layout/components/Header/MessageList.vue new file mode 100644 index 0000000..2bb638b --- /dev/null +++ b/web/src/layout/components/Header/MessageList.vue @@ -0,0 +1,74 @@ + + + + diff --git a/web/src/layout/components/Header/ProjectSetting.vue b/web/src/layout/components/Header/ProjectSetting.vue new file mode 100644 index 0000000..e6aaf7a --- /dev/null +++ b/web/src/layout/components/Header/ProjectSetting.vue @@ -0,0 +1,384 @@ + + + + + diff --git a/web/src/layout/components/Header/Search.vue b/web/src/layout/components/Header/Search.vue new file mode 100644 index 0000000..05cdee1 --- /dev/null +++ b/web/src/layout/components/Header/Search.vue @@ -0,0 +1,241 @@ + + + diff --git a/web/src/layout/components/Header/SystemMessage.vue b/web/src/layout/components/Header/SystemMessage.vue new file mode 100644 index 0000000..943bbf2 --- /dev/null +++ b/web/src/layout/components/Header/SystemMessage.vue @@ -0,0 +1,129 @@ + + + + diff --git a/web/src/layout/components/Header/components.ts b/web/src/layout/components/Header/components.ts new file mode 100644 index 0000000..6014cbb --- /dev/null +++ b/web/src/layout/components/Header/components.ts @@ -0,0 +1,37 @@ +import { + SettingOutlined, + SearchOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + FullscreenOutlined, + FullscreenExitOutlined, + PoweroffOutlined, + GithubOutlined, + LockOutlined, + ReloadOutlined, + LogoutOutlined, + UserOutlined, + CheckOutlined, + BellOutlined, +} from '@vicons/antd'; + +import { Refresh, LanguageOutline } from '@vicons/ionicons5'; + +export default { + SettingOutlined, + LockOutlined, + GithubOutlined, + SearchOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + FullscreenOutlined, + FullscreenExitOutlined, + PoweroffOutlined, + ReloadOutlined, + LogoutOutlined, + UserOutlined, + CheckOutlined, + BellOutlined, + Refresh, + LanguageOutline, +}; diff --git a/web/src/layout/components/Header/index.ts b/web/src/layout/components/Header/index.ts new file mode 100644 index 0000000..6a454ed --- /dev/null +++ b/web/src/layout/components/Header/index.ts @@ -0,0 +1,3 @@ +import PageHeader from './index.vue'; + +export { PageHeader }; diff --git a/web/src/layout/components/Header/index.vue b/web/src/layout/components/Header/index.vue new file mode 100644 index 0000000..8fd072a --- /dev/null +++ b/web/src/layout/components/Header/index.vue @@ -0,0 +1,764 @@ + + + + + diff --git a/web/src/layout/components/Logo/index.ts b/web/src/layout/components/Logo/index.ts new file mode 100644 index 0000000..1a63b71 --- /dev/null +++ b/web/src/layout/components/Logo/index.ts @@ -0,0 +1,3 @@ +import Logo from './index.vue'; + +export { Logo }; diff --git a/web/src/layout/components/Logo/index.vue b/web/src/layout/components/Logo/index.vue new file mode 100644 index 0000000..06b0ad6 --- /dev/null +++ b/web/src/layout/components/Logo/index.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/web/src/layout/components/Main/index.ts b/web/src/layout/components/Main/index.ts new file mode 100644 index 0000000..f63218e --- /dev/null +++ b/web/src/layout/components/Main/index.ts @@ -0,0 +1,3 @@ +import MainView from './index.vue'; + +export { MainView }; diff --git a/web/src/layout/components/Main/index.vue b/web/src/layout/components/Main/index.vue new file mode 100644 index 0000000..5600c9a --- /dev/null +++ b/web/src/layout/components/Main/index.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/web/src/layout/components/Menu/index.ts b/web/src/layout/components/Menu/index.ts new file mode 100644 index 0000000..0e4f00f --- /dev/null +++ b/web/src/layout/components/Menu/index.ts @@ -0,0 +1,3 @@ +import AsideMenu from './index.vue'; + +export { AsideMenu }; diff --git a/web/src/layout/components/Menu/index.vue b/web/src/layout/components/Menu/index.vue new file mode 100644 index 0000000..3b52932 --- /dev/null +++ b/web/src/layout/components/Menu/index.vue @@ -0,0 +1,173 @@ + + + diff --git a/web/src/layout/components/TagsView/index.ts b/web/src/layout/components/TagsView/index.ts new file mode 100644 index 0000000..66ff085 --- /dev/null +++ b/web/src/layout/components/TagsView/index.ts @@ -0,0 +1,3 @@ +import TabsView from './index.vue'; + +export { TabsView }; diff --git a/web/src/layout/components/TagsView/index.vue b/web/src/layout/components/TagsView/index.vue new file mode 100644 index 0000000..23e037a --- /dev/null +++ b/web/src/layout/components/TagsView/index.vue @@ -0,0 +1,686 @@ + + + + + diff --git a/web/src/layout/index.vue b/web/src/layout/index.vue new file mode 100644 index 0000000..b6f4352 --- /dev/null +++ b/web/src/layout/index.vue @@ -0,0 +1,278 @@ + + + + + + diff --git a/web/src/layout/parentLayout.vue b/web/src/layout/parentLayout.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/web/src/layout/parentLayout.vue @@ -0,0 +1,3 @@ + diff --git a/web/src/locale/en.json b/web/src/locale/en.json new file mode 100644 index 0000000..e1cb0d2 --- /dev/null +++ b/web/src/locale/en.json @@ -0,0 +1,28 @@ +{ + "你好,美丽世界": "Hello, Beautiful World", + "剩余{num}余额": "Remaining {num} Balance", + "卡板量": "Total Card", + "日": "Day", + "日同比": "Than Yesterday", + "周同比": "Than LastWeek", + "总卡板量:": "ALL Card:", + "激活卡板": "Activate Card", + "周": "Week", + "总激活卡板:": "Total Activate Card:", + "代理商": "Agent", + "总代理商量:": "Total Agent:", + "提现佣金": "Withdrawal Commission", + "月": "Month", + "月同比": "Than LastMonth", + "总提现额:": "Total Withdrawal Amount:", + "用户": "Users", + "分析": "Analysis", + "商品": "Goods", + "订单": "Order", + "票据": "Receipt", + "消息": "Message", + "标签": "Label", + "配置": "Configure", + "流量消耗趋势": "Trend Of Traffic Consumption", + "客户端访问量": "Client Traffic" +} diff --git a/web/src/locale/index.ts b/web/src/locale/index.ts new file mode 100644 index 0000000..01dac82 --- /dev/null +++ b/web/src/locale/index.ts @@ -0,0 +1,37 @@ +import { createI18n } from 'vue-i18n'; +import type { I18nOptions } from 'vue-i18n'; +import en from './en.json'; +import zhHant from './zh-Hant.json'; +import zhHans from './zh-Hans.json'; + +const messages = { + en, + 'zh-TW': zhHant, + 'zh-CN': zhHans, +}; + +// 创建 i18n 实例配置 +const i18nConfig: I18nOptions = { + legacy: false, // 使用 Composition API 模式 + locale: 'zh-CN', // 默认语言 + fallbackLocale: 'zh-CN', // 回退语言 + messages, // 语言包 + missingWarn: false, // 关闭找不到 key 的警告 + fallbackWarn: false, // 关闭回退警告 + // 当找不到翻译时,返回 key 本身 + missing: (_locale, key) => { + return key; + }, +}; + +const i18n = createI18n(i18nConfig); + +export default i18n; + +// 导出 t 函数,支持多种参数形式 +export const t = (key: string, named?: Record, options?: any): string => { + if (named !== undefined) { + return i18n.global.t(key, named, options) as string; + } + return i18n.global.t(key) as string; +}; diff --git a/web/src/locale/zh-Hans.json b/web/src/locale/zh-Hans.json new file mode 100644 index 0000000..2192606 --- /dev/null +++ b/web/src/locale/zh-Hans.json @@ -0,0 +1,3 @@ +{ + "剩余{num}余额": "剩余{num}余额" +} diff --git a/web/src/locale/zh-Hant.json b/web/src/locale/zh-Hant.json new file mode 100644 index 0000000..7a3a4a2 --- /dev/null +++ b/web/src/locale/zh-Hant.json @@ -0,0 +1,28 @@ +{ + "你好,美丽世界": "你好,美麗世界", + "剩余{num}余额": "剩餘{num}餘額", + "卡板量": "卡板量", + "日": "日", + "日同比": "日同比", + "周同比": "週同比", + "总卡板量:": "總卡板量:", + "激活卡板": "激活卡板", + "周": "週", + "总激活卡板:": "總激活卡板:", + "代理商": "代理商", + "总代理商量:": "總代理商數:", + "提现佣金": "提現傭金", + "月": "月", + "月同比": "月同比", + "总提现额:": "總提現額:", + "用户": "使用者", + "分析": "分析", + "商品": "商品", + "订单": "訂單", + "票据": "票據", + "消息": "訊息", + "标签": "標籤", + "配置": "設定", + "流量消耗趋势": "流量消耗趨勢", + "客户端访问量": "用戶端訪問量" +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..47f12de --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,49 @@ +import './styles/tailwind.css'; +import { createApp } from 'vue'; +import App from './App.vue'; +import router, { setupRouter } from './router'; +import { setupStore } from '@/store'; +import { setupNaive, setupDirectives } from '@/plugins'; +import { AppProvider } from '@/components/Application'; +import setupWebsocket from '@/utils/websocket/index'; +import i18n from '@/locale/index'; + +async function bootstrap() { + const appProvider = createApp(AppProvider); + + const app = createApp(App); + + // 国际化 + app.use(i18n); + app.config.globalProperties.t = i18n.global.t; + + // 注册全局常用的 naive-ui 组件 + setupNaive(app); + + // 注册全局自定义组件 + //setupCustomComponents(); + + // 注册全局自定义指令,如:v-permission权限指令 + setupDirectives(app); + + // 注册全局方法,如:app.config.globalProperties.$message = message + //setupGlobalMethods(app); + + // 挂载状态管理 + setupStore(app); + + //优先挂载一下 Provider 解决路由守卫,Axios中可使用,Dialog,Message 等之类组件 + appProvider.mount('#appProvider', true); + + // 挂载路由 + await setupRouter(app); + + // 路由准备就绪后挂载APP实例 + await router.isReady(); + + setupWebsocket(); + + app.mount('#app', true); +} + +void bootstrap(); diff --git a/web/src/plugins/customComponents.ts b/web/src/plugins/customComponents.ts new file mode 100644 index 0000000..496717c --- /dev/null +++ b/web/src/plugins/customComponents.ts @@ -0,0 +1,7 @@ +/** + * 全局注册自定义组件 待完善 + * @param app + */ +export function setupCustomComponents() { + // app.component() +} diff --git a/web/src/plugins/directives.ts b/web/src/plugins/directives.ts new file mode 100644 index 0000000..6323b1b --- /dev/null +++ b/web/src/plugins/directives.ts @@ -0,0 +1,24 @@ +import { App } from 'vue'; + +import { permission } from '@/directives/permission'; +import copy from '@/directives/copy'; +import debounce from '@/directives/debounce'; +import throttle from '@/directives/throttle'; +import draggable from '@/directives/draggable'; + +/** + * 注册全局自定义指令 + * @param app + */ +export function setupDirectives(app: App) { + // 权限控制指令(演示) + app.directive('permission', permission); + // 复制指令 + app.directive('copy', copy); + // 防抖指令 + app.directive('debounce', debounce); + // 节流指令 + app.directive('throttle', throttle); + // 拖拽指令 + app.directive('draggable', draggable); +} diff --git a/web/src/plugins/globalMethods.ts b/web/src/plugins/globalMethods.ts new file mode 100644 index 0000000..4cc3a9b --- /dev/null +++ b/web/src/plugins/globalMethods.ts @@ -0,0 +1,5 @@ +/** + * 注册全局方法 待完善 + * @param app + */ +export function setupGlobalMethods() {} diff --git a/web/src/plugins/index.ts b/web/src/plugins/index.ts new file mode 100644 index 0000000..e5e4ec9 --- /dev/null +++ b/web/src/plugins/index.ts @@ -0,0 +1,4 @@ +export { setupNaive } from '@/plugins/naive'; +export { setupDirectives } from '@/plugins/directives'; +export { setupCustomComponents } from '@/plugins/customComponents'; +export { setupGlobalMethods } from '@/plugins/globalMethods'; diff --git a/web/src/plugins/naive.ts b/web/src/plugins/naive.ts new file mode 100644 index 0000000..bdbbce9 --- /dev/null +++ b/web/src/plugins/naive.ts @@ -0,0 +1,143 @@ +import type { App } from 'vue'; +import { + create, + NConfigProvider, + NMessageProvider, + NDialogProvider, + NInput, + NButton, + NForm, + NFormItem, + NCheckboxGroup, + NCheckbox, + NIcon, + NLayout, + NLayoutHeader, + NLayoutContent, + NLayoutFooter, + NLayoutSider, + NMenu, + NBreadcrumb, + NBreadcrumbItem, + NDropdown, + NSpace, + NTooltip, + NAvatar, + NTabs, + NTabPane, + NCard, + NRow, + NCol, + NDrawer, + NDrawerContent, + NDivider, + NSwitch, + NBadge, + NAlert, + NElement, + NTag, + NNotificationProvider, + NProgress, + NDatePicker, + NGrid, + NGridItem, + NList, + NListItem, + NThing, + NDataTable, + NPopover, + NPagination, + NSelect, + NRadioGroup, + NRadio, + NSteps, + NStep, + NInputGroup, + NResult, + NDescriptions, + NDescriptionsItem, + NTable, + NInputNumber, + NLoadingBarProvider, + NModal, + NUpload, + NTree, + NSpin, + NTimePicker, + NBackTop, + NSkeleton, +} from 'naive-ui'; + +const naive = create({ + components: [ + NMessageProvider, + NDialogProvider, + NConfigProvider, + NInput, + NButton, + NForm, + NFormItem, + NCheckboxGroup, + NCheckbox, + NIcon, + NLayout, + NLayoutHeader, + NLayoutContent, + NLayoutFooter, + NLayoutSider, + NMenu, + NBreadcrumb, + NBreadcrumbItem, + NDropdown, + NSpace, + NTooltip, + NAvatar, + NTabs, + NTabPane, + NCard, + NRow, + NCol, + NDrawer, + NDrawerContent, + NDivider, + NSwitch, + NBadge, + NAlert, + NElement, + NTag, + NNotificationProvider, + NProgress, + NDatePicker, + NGrid, + NGridItem, + NList, + NListItem, + NThing, + NDataTable, + NPopover, + NPagination, + NSelect, + NRadioGroup, + NRadio, + NSteps, + NStep, + NInputGroup, + NResult, + NDescriptions, + NDescriptionsItem, + NTable, + NInputNumber, + NLoadingBarProvider, + NModal, + NUpload, + NTree, + NSpin, + NTimePicker, + NBackTop, + NSkeleton, + ], +}); + +export function setupNaive(app: App) { + app.use(naive); +} diff --git a/web/src/router/base.ts b/web/src/router/base.ts new file mode 100644 index 0000000..4e5e8c4 --- /dev/null +++ b/web/src/router/base.ts @@ -0,0 +1,45 @@ +import type { AppRouteRecordRaw } from '@/router/types'; +import { ErrorPage, RedirectName, Layout } from '@/router/constant'; + +// 404 on a page +export const ErrorPageRoute: AppRouteRecordRaw = { + path: '/:path(.*)*', + name: 'ErrorPage', + component: Layout, + meta: { + title: 'ErrorPage', + hideBreadcrumb: true, + }, + children: [ + { + path: '/:path(.*)*', + name: 'ErrorPageSon', + component: ErrorPage, + meta: { + title: 'ErrorPage', + hideBreadcrumb: true, + }, + }, + ], +}; + +export const RedirectRoute: AppRouteRecordRaw = { + path: '/redirect', + name: RedirectName, + component: Layout, + meta: { + title: RedirectName, + hideBreadcrumb: true, + }, + children: [ + { + path: '/redirect/:path(.*)', + name: `${RedirectName}Son`, + component: () => import('@/views/redirect/index.vue'), + meta: { + title: RedirectName, + hideBreadcrumb: true, + }, + }, + ], +}; diff --git a/web/src/router/constant.ts b/web/src/router/constant.ts new file mode 100644 index 0000000..ead009d --- /dev/null +++ b/web/src/router/constant.ts @@ -0,0 +1,4 @@ +export const RedirectName = 'Redirect'; +export const ErrorPage = () => import('@/views/exception/404.vue'); +export const Layout = () => import('@/layout/index.vue'); +export const ParentLayout = () => import('@/layout/parentLayout.vue'); diff --git a/web/src/router/generator-routers.ts b/web/src/router/generator-routers.ts new file mode 100644 index 0000000..1f107ee --- /dev/null +++ b/web/src/router/generator-routers.ts @@ -0,0 +1,151 @@ +import { adminMenus } from '@/api/system/menu'; +import { constantRouterIcon } from './router-icons'; +import { RouteRecordRaw } from 'vue-router'; +import { Layout, ParentLayout } from '@/router/constant'; +import type { AppRouteRecordRaw } from '@/router/types'; + +const Iframe = () => import('@/views/iframe/index.vue'); +const LayoutMap = new Map Promise>(); + +LayoutMap.set('LAYOUT', Layout); +LayoutMap.set('IFRAME', Iframe); + +/** + * 格式化 后端 结构信息并递归生成层级路由表 + * @param routerMap + * @param parent + * @returns {*} + */ +export const routerGenerator = (routerMap, parent?): any[] => { + return routerMap.map((item) => { + const currentRouter: any = { + // 路由地址 动态拼接生成如 /dashboard/workplace + path: `${(parent && parent.path) || ''}/${item.path}`, + // 路由名称,建议唯一 + name: item.name || '', + // 该路由对应页面的 组件 + component: item.component, + // meta: 页面标题, 菜单图标, 页面权限(供指令权限用,可去掉) + meta: { + ...item.meta, + label: item.meta.title, + icon: constantRouterIcon[item.meta.icon] || null, + permissions: item.meta.permissions || null, + }, + }; + + // 为了防止出现后端返回结果不规范,处理有可能出现拼接出两个 反斜杠 + currentRouter.path = currentRouter.path.replace('//', '/'); + // 重定向 ,菜单类型为目录默认默认跳转 + if(item.meta.type === 1){ + item.redirect && (currentRouter.redirect = item.redirect); + } + // 是否有子菜单,并递归处理 + if (item.children && item.children.length > 0) { + //如果未定义 redirect 默认第一个子路由为 redirect + if(item.meta.type === 1) { + !item.redirect && (currentRouter.redirect = `${item.path}/${item.children[0].path}`); + } + // Recursion + currentRouter.children = routerGenerator(item.children, currentRouter); + } + + return currentRouter; + }); +}; + +/** + * 动态生成菜单 + */ +export const generatorDynamicRouter = (): Promise => { + return new Promise((resolve, reject) => { + adminMenus() + .then((result) => { + const routeList = routerGenerator(result.list); + asyncImportRoute(routeList); + + resolve(routeList); + }) + .catch((err) => { + reject(err); + }); + }); +}; + +/** + * 查找views中对应的组件文件 + * */ +let viewsModules: Record Promise>; +export const asyncImportRoute = (routes: AppRouteRecordRaw[] | undefined): void => { + viewsModules = viewsModules || import.meta.glob('../views/**/*.{vue,tsx}'); + if (!routes) return; + routes.forEach((item) => { + if (!item.component && item.meta?.frameSrc) { + item.component = 'IFRAME'; + } + const { component, name } = item; + const { children } = item; + if (component) { + const layoutFound = LayoutMap.get(component as string); + if (layoutFound) { + item.component = layoutFound; + } else { + item.component = dynamicImport(viewsModules, component as string); + } + } else if (name) { + item.component = ParentLayout; + } + children && asyncImportRoute(children); + }); +}; + +/** + * 动态导入 + * */ +export const dynamicImport = ( + viewsModules: Record Promise>, + component: string +) => { + const keys = Object.keys(viewsModules); + const matchKeys = keys.filter((key) => { + let k = key.replace('../views', ''); + const lastIndex = k.lastIndexOf('.'); + k = k.substring(0, lastIndex); + return k === component; + }); + if (matchKeys?.length === 1) { + const matchKey = matchKeys[0]; + return viewsModules[matchKey]; + } + if (matchKeys?.length > 1) { + console.warn( + 'Please do not create `.vue` and `.TSX` files with the same file name in the same hierarchical directory under the views folder. This will cause dynamic introduction failure' + ); + return; + } +}; + +/** + * 移除隐藏的菜单 + * @param menus + */ +export const removeHiddenMenus = (menus: any[]) => { + const arr: any[] = []; + for (let j = 0; j < menus.length; j++) { + if (menus[j].meta?.type === 3) { + continue; + } + if (menus[j].meta?.hidden === true) { + continue; + } + + if (menus[j].children?.length > 0) { + menus[j].children = removeHiddenMenus(menus[j].children); + if (menus[j].children?.length === 0) { + delete menus[j].children; + } + } + arr.push(menus[j]); + } + return arr; +}; diff --git a/web/src/router/index.ts b/web/src/router/index.ts new file mode 100644 index 0000000..bfae36f --- /dev/null +++ b/web/src/router/index.ts @@ -0,0 +1,62 @@ +import { App } from 'vue'; +import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'; +import { RedirectRoute } from '@/router/base'; +import { PageEnum } from '@/enums/pageEnum'; +import { createRouterGuards } from './router-guards'; +import { createRouterIcon } from './router-icons'; + +// @ts-ignore +const modules = import.meta.glob('./modules/**/*.ts'); +const routeModuleList: RouteRecordRaw[] = []; + +Object.keys(modules).forEach((key) => { + const mod = modules[key].default || {}; + const modList = Array.isArray(mod) ? [...mod] : [mod]; + routeModuleList.push(...modList); +}); + +function sortRoute(a, b) { + return (a.meta?.sort || 0) - (b.meta?.sort || 0); +} + +routeModuleList.sort(sortRoute); + +export const RootRoute: RouteRecordRaw = { + path: '/', + name: 'Root', + redirect: PageEnum.BASE_HOME, + meta: { + title: 'Root', + }, +}; + +export const LoginRoute: RouteRecordRaw = { + path: '/login', + name: 'Login', + component: () => import('@/views/login/index.vue'), + meta: { + title: '登录', + }, +}; + +//需要验证权限 +export const asyncRoutes = [...routeModuleList]; + +//普通路由 无需验证权限 +export const constantRouter: any[] = [LoginRoute, RootRoute, RedirectRoute]; + +const router = createRouter({ + history: createWebHashHistory(''), + routes: constantRouter, + strict: true, + scrollBehavior: () => ({ left: 0, top: 0 }), +}); + +export function setupRouter(app: App) { + app.use(router); + // 创建路由守卫 + createRouterGuards(router); + createRouterIcon(); +} + +export default router; diff --git a/web/src/router/router-guards.ts b/web/src/router/router-guards.ts new file mode 100644 index 0000000..d1cd778 --- /dev/null +++ b/web/src/router/router-guards.ts @@ -0,0 +1,112 @@ +import type { RouteRecordRaw } from 'vue-router'; +import { isNavigationFailure, Router } from 'vue-router'; +import { useUserStoreWidthOut } from '@/store/modules/user'; +import { useAsyncRouteStoreWidthOut } from '@/store/modules/asyncRoute'; +import { ACCESS_TOKEN } from '@/store/mutation-types'; +import { storage } from '@/utils/Storage'; +import { PageEnum } from '@/enums/pageEnum'; +import { ErrorPageRoute } from '@/router/base'; +import { jump } from '@/utils/http/axios'; +import { getNowUrl } from '@/utils/urlUtils'; + +const LOGIN_PATH = PageEnum.BASE_LOGIN; +const whitePathList = [LOGIN_PATH]; // no redirect whitelist + +export function createRouterGuards(router: Router) { + const userStore = useUserStoreWidthOut(); + const asyncRouteStore = useAsyncRouteStoreWidthOut(); + router.beforeEach(async (to, from, next) => { + const Loading = window['$loading'] || null; + Loading && Loading.start(); + + if (from.path === LOGIN_PATH && to.name === 'errorPage') { + next(PageEnum.BASE_HOME); + return; + } + + // Whitelist can be directly entered + if (whitePathList.includes(to.path as PageEnum)) { + await userStore.LoadLoginConfig(); + next(); + return; + } + + const token = storage.get(ACCESS_TOKEN); + + if (!token) { + // You can access without permissions. You need to set the routing meta.ignoreAuth to true + if (to.meta.ignoreAuth) { + next(); + return; + } + + // redirect login page + const redirectData: { path: string; replace: boolean; query?: Recordable } = { + path: LOGIN_PATH, + replace: true, + }; + if (to.path) { + redirectData.query = { + ...redirectData.query, + redirect: to.path, + }; + } + next(redirectData); + return; + } + + if (asyncRouteStore.getIsDynamicAddedRoute) { + next(); + return; + } + + const redirectPath = (from.query.redirect || to.path) as string; + const redirect = decodeURIComponent(redirectPath); + const nextData = to.path === redirect ? { ...to, replace: true } : { path: redirect }; + const userInfo = await userStore.GetInfo(); + await userStore.LoadLoginConfig(); + + // 是否允许获取微信openid + if (userStore.allowWxOpenId()) { + let path = nextData.path; + if (path === LOGIN_PATH) { + path = PageEnum.BASE_HOME_REDIRECT; + } + + const URI = getNowUrl() + '#' + path; + jump('/wechat/authorize', { type: 'openId', syncRedirect: URI }); + return; + } + + await userStore.GetConfig(); + const routes = await asyncRouteStore.generateRoutes(userInfo); + + // 动态添加可访问路由表 + routes.forEach((item) => { + router.addRoute(item as unknown as RouteRecordRaw); + }); + + //添加404 + const isErrorPage = router.getRoutes().findIndex((item) => item.name === ErrorPageRoute.name); + if (isErrorPage === -1) { + router.addRoute(ErrorPageRoute as unknown as RouteRecordRaw); + } + + asyncRouteStore.setDynamicAddedRoute(true); + next(nextData); + Loading && Loading.finish(); + }); + + router.afterEach((to, _, failure) => { + document.title = (to?.meta?.title as string) || document.title; + if (isNavigationFailure(failure)) { + //console.log('failed navigation', failure) + } + const Loading = window['$loading'] || null; + Loading && Loading.finish(); + }); + + router.onError((error) => { + console.log(error, '路由错误'); + }); +} diff --git a/web/src/router/router-icons.ts b/web/src/router/router-icons.ts new file mode 100644 index 0000000..a72e4d1 --- /dev/null +++ b/web/src/router/router-icons.ts @@ -0,0 +1,15 @@ +import { renderIcon } from '@/utils'; +import * as antdIcons from '@vicons/antd'; +// import * as x5Icons from '@vicons/ionicons5'; + +export const constantRouterIcon = {}; + +export function createRouterIcon() { + for (const element of Object.keys(antdIcons)) { + constantRouterIcon[element] = renderIcon(antdIcons[element]); + } + + // for (const element of Object.keys(x5Icons)) { + // constantRouterIcon[element] = renderIcon(x5Icons[element]); + // } +} diff --git a/web/src/router/types.ts b/web/src/router/types.ts new file mode 100644 index 0000000..2fc8aad --- /dev/null +++ b/web/src/router/types.ts @@ -0,0 +1,56 @@ +import type { RouteRecordRaw, RouteMeta } from 'vue-router'; +import { defineComponent } from 'vue'; + +export type Component = + | ReturnType + | (() => Promise) + | (() => Promise); + +// @ts-ignore +export interface AppRouteRecordRaw extends Omit { + name: string; + meta: RouteMeta; + component?: Component | string; + components?: Component; + children?: AppRouteRecordRaw[]; + props?: Recordable; + fullPath?: string; +} + +export interface Meta { + // 名称 + title: string; + // 是否忽略权限 + ignoreAuth?: boolean; + permissions?: string[]; + // 是否不缓存 + noKeepAlive?: boolean; + // 是否固定在tab上 + affix?: boolean; + // tab上的图标 + icon?: string; + // 跳转地址 + frameSrc?: string; + // 外链跳转地址 + externalLink?: string; + //隐藏 + hidden?: boolean; +} + +export interface Menu { + title: string; + label: string; + key: string; + meta: RouteMeta; + name: string; + component?: Component | string; + components?: Component; + children?: AppRouteRecordRaw[]; + props?: Recordable; + fullPath?: string; + icon?: any; + path: string; + permissions?: string[]; + redirect?: string; + sort?: number; +} diff --git a/web/src/settings/animateSetting.ts b/web/src/settings/animateSetting.ts new file mode 100644 index 0000000..05dc0be --- /dev/null +++ b/web/src/settings/animateSetting.ts @@ -0,0 +1,8 @@ +export const animates = [ + { value: 'zoom-fade', label: '渐变' }, + { value: 'zoom-out', label: '闪现' }, + { value: 'fade-slide', label: '滑动' }, + { value: 'fade', label: '消退' }, + { value: 'fade-bottom', label: '底部消退' }, + { value: 'fade-scale', label: '缩放消退' }, +]; diff --git a/web/src/settings/componentSetting.ts b/web/src/settings/componentSetting.ts new file mode 100644 index 0000000..ce282e2 --- /dev/null +++ b/web/src/settings/componentSetting.ts @@ -0,0 +1,57 @@ +export default { + table: { + apiSetting: { + // 当前页的字段名 + pageField: 'page', + // 每页数量字段名 + sizeField: 'pageSize', + // 接口返回的数据字段名 + listField: 'list', + // 接口返回总页数字段名 + totalField: 'pageCount', + // 接口返回总行数字段名 + itemCountField: 'totalCount', + }, + //默认分页数量 + defaultPageSize: 10, + //可切换每页数量集合 + pageSizes: [10, 15, 20, 30, 50, 100], + }, + upload: { + //考虑接口规范不同 + apiSetting: { + // 集合字段名 + infoField: 'data', + // 图片地址字段名 + imgField: 'fileUrl', + }, + //最大上传图片大小 + maxSize: 10, + //图片上传类型 + imageType: ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'image/svg+xml', 'image/webp'], + //文件上传类型 + fileType: [ + // 图片 + 'image/png', + 'image/jpg', + 'image/jpeg', + 'image/gif', + 'image/svg+xml', + 'image/webp', + // 文档 + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + // 音频 + 'audio/mpeg', + 'audio/midi', + // 视频 + 'audio/mp4', + 'video/webm', + 'video/x-flv', + ], + }, +}; diff --git a/web/src/settings/designSetting.ts b/web/src/settings/designSetting.ts new file mode 100644 index 0000000..566934d --- /dev/null +++ b/web/src/settings/designSetting.ts @@ -0,0 +1,32 @@ +// app theme preset color +export const appThemeList: string[] = [ + '#2d8cf0', + '#0960bd', + '#0084f4', + '#009688', + '#536dfe', + '#ff5c93', + '#ee4f12', + '#0096c7', + '#9c27b0', + '#ff9800', + '#FF3D68', + '#00C1D4', + '#71EFA3', + '#171010', + '#78DEC7', + '#1768AC', + '#FB9300', + '#FC5404', +]; + +const setting = { + //深色主题 + darkTheme: false, + //系统主题色 + appTheme: '#2d8cf0', + //系统内置主题色列表 + appThemeList, +}; + +export default setting; diff --git a/web/src/settings/projectSetting.ts b/web/src/settings/projectSetting.ts new file mode 100644 index 0000000..5ac1e5b --- /dev/null +++ b/web/src/settings/projectSetting.ts @@ -0,0 +1,57 @@ +const setting = { + //导航模式 vertical 左侧菜单模式 horizontal 顶部菜单模式 + navMode: 'vertical', + //导航风格 dark 暗色侧边栏 light 白色侧边栏 header-dark 暗色顶栏 + navTheme: 'dark', + // 是否处于移动端模式 + isMobile: false, + //顶部 + headerSetting: { + //背景色 + bgColor: '#fff', + //固定顶部 + fixed: true, + //显示重载按钮 + isReload: true, + }, + //页脚 + showFooter: true, + //多标签 + multiTabsSetting: { + //背景色 + bgColor: '#fff', + //是否显示 + show: true, + //固定多标签 + fixed: true, + }, + //菜单 + menuSetting: { + //最小宽度 + minMenuWidth: 64, + //菜单宽度 + menuWidth: 200, + //固定菜单 + fixed: true, + //分割菜单 + mixMenu: false, + //触发移动端侧边栏的宽度 + mobileWidth: 800, + // 折叠菜单 + collapsed: false, + }, + //面包屑 + crumbsSetting: { + //是否显示 + show: true, + //显示图标 + showIcon: false, + }, + //菜单权限模式 FIXED 前端固定路由 BACK 动态获取 + permissionMode: 'BACK', + //是否开启路由动画 + isPageAnimate: true, + //路由动画类型 + pageAnimateType: 'zoom-fade', +}; +export default setting; diff --git a/web/src/store/index.ts b/web/src/store/index.ts new file mode 100644 index 0000000..efaf6c9 --- /dev/null +++ b/web/src/store/index.ts @@ -0,0 +1,10 @@ +import type { App } from 'vue'; +import { createPinia } from 'pinia'; + +const store = createPinia(); + +export function setupStore(app: App) { + app.use(store); +} + +export { store }; diff --git a/web/src/store/modules/asyncRoute.ts b/web/src/store/modules/asyncRoute.ts new file mode 100644 index 0000000..ac625ec --- /dev/null +++ b/web/src/store/modules/asyncRoute.ts @@ -0,0 +1,141 @@ +import { toRaw, unref } from 'vue'; +import { defineStore } from 'pinia'; +import { RouteRecordRaw } from 'vue-router'; +import { store } from '@/store'; +import { asyncRoutes, constantRouter } from '@/router'; +import { generatorDynamicRouter } from '@/router/generator-routers'; +import { useProjectSetting } from '@/hooks/setting/useProjectSetting'; + +interface TreeHelperConfig { + id: string; + children: string; + pid: string; +} + +const DEFAULT_CONFIG: TreeHelperConfig = { + id: 'id', + children: 'children', + pid: 'pid', +}; + +const getConfig = (config: Partial) => Object.assign({}, DEFAULT_CONFIG, config); + +export interface IAsyncRouteState { + menus: RouteRecordRaw[]; + routers: any[]; + addRouters: any[]; + keepAliveComponents: string[]; + isDynamicAddedRoute: boolean; +} + +function filter( + tree: T[], + func: (n: T) => boolean, + config: Partial = {} +): T[] { + config = getConfig(config); + const children = config.children as string; + + function listFilter(list: T[]) { + return list + .map((node: any) => ({ ...node })) + .filter((node) => { + node[children] = node[children] && listFilter(node[children]); + return func(node) || (node[children] && node[children].length); + }); + } + + return listFilter(tree); +} + +export const useAsyncRouteStore = defineStore({ + id: 'app-async-route', + state: (): IAsyncRouteState => ({ + menus: [], + routers: constantRouter, + addRouters: [], + keepAliveComponents: [], + // Whether the route has been dynamically added + isDynamicAddedRoute: false, + }), + getters: { + getMenus(): RouteRecordRaw[] { + return this.menus; + }, + getIsDynamicAddedRoute(): boolean { + return this.isDynamicAddedRoute; + }, + }, + actions: { + getRouters() { + return toRaw(this.addRouters); + }, + setDynamicAddedRoute(added: boolean) { + this.isDynamicAddedRoute = added; + }, + // 设置动态路由 + setRouters(routers) { + this.addRouters = routers; + this.routers = constantRouter.concat(routers); + }, + setMenus(menus) { + // 设置动态路由 + this.menus = menus; + }, + setKeepAliveComponents(compNames) { + // 设置需要缓存的组件 + this.keepAliveComponents = compNames; + }, + async generateRoutes(data) { + let accessedRouters; + const permissionsList = data.permissions || []; + const routeFilter = (route) => { + const { meta } = route; + const { permissions } = meta || {}; + if (!permissions) return true; + return permissionsList.some((item) => permissions.includes(item)); + }; + const { getPermissionMode } = useProjectSetting(); + const permissionMode = unref(getPermissionMode); + const $dialog = window['$dialog']; + if (permissionMode === 'BACK') { + // 动态获取菜单 + try { + accessedRouters = await generatorDynamicRouter(); + } catch (error) { + console.log(error); + $dialog.info({ + title: '提示', + content: '获取动态路由失败,管理员请确认是否为角色分配菜单权限?', + positiveText: '确定', + onPositiveClick: () => {}, + }); + } + } else { + try { + //过滤账户是否拥有某一个权限,并将菜单从加载列表移除 + accessedRouters = filter(asyncRoutes, routeFilter); + } catch (error) { + console.log(error); + $dialog.info({ + title: '提示', + content: '过滤动态路由失败,请联系管理员解决!', + positiveText: '确定', + onPositiveClick: () => {}, + }); + } + } + + accessedRouters = accessedRouters.filter(routeFilter); + + this.setRouters(accessedRouters); + this.setMenus(accessedRouters); + return toRaw(accessedRouters); + }, + }, +}); + +// Need to be used outside the setup +export function useAsyncRouteStoreWidthOut() { + return useAsyncRouteStore(store); +} diff --git a/web/src/store/modules/designSetting.ts b/web/src/store/modules/designSetting.ts new file mode 100644 index 0000000..b0b5555 --- /dev/null +++ b/web/src/store/modules/designSetting.ts @@ -0,0 +1,40 @@ +import { defineStore } from 'pinia'; +import { store } from '@/store'; +import designSetting from '@/settings/designSetting'; + +const { darkTheme, appTheme, appThemeList } = designSetting; + +interface DesignSettingState { + //深色主题 + darkTheme: boolean; + //系统风格 + appTheme: string; + //系统内置风格 + appThemeList: string[]; +} + +export const useDesignSettingStore = defineStore({ + id: 'app-design-setting', + state: (): DesignSettingState => ({ + darkTheme, + appTheme, + appThemeList, + }), + getters: { + getDarkTheme(): boolean { + return this.darkTheme; + }, + getAppTheme(): string { + return this.appTheme; + }, + getAppThemeList(): string[] { + return this.appThemeList; + }, + }, + actions: {}, +}); + +// Need to be used outside the setup +export function useDesignSettingWithOut() { + return useDesignSettingStore(store); +} diff --git a/web/src/store/modules/dict.ts b/web/src/store/modules/dict.ts new file mode 100644 index 0000000..9bbc403 --- /dev/null +++ b/web/src/store/modules/dict.ts @@ -0,0 +1,192 @@ +import { computed, ComputedRef } from 'vue'; +import { defineStore } from 'pinia'; +import { createStorage, storage } from '@/utils/Storage'; +import { store } from '@/store'; +import { CURRENT_DICT } from '@/store/mutation-types'; +import { Dicts } from '@/api/dict/dict'; +import { SelectMixedOption } from 'naive-ui/es/select/src/interface'; +import { localDict } from '@/enums/localDictEnum'; + +const Storage = createStorage({ storage: localStorage }); + +// 默认字典类型,通过后台字典管理添加的 +export type DefaultDictType = 'sys_normal_disable' | 'sys_switch' | 'sys_user_sex'; + +// 枚举字典类型 +export type EnumsDictType = 'creditType' | 'creditGroup' | 'deptType'; + +// 方法字典类型 +export type FuncDictType = 'testCategoryOption'; + +// 可以把常用的字典类型加入进来,这样会有IDE提示 +export type DictType = DefaultDictType | EnumsDictType | FuncDictType | string; + +export type DictListClass = 'default' | 'error' | 'primary' | 'info' | 'success' | 'warning'; + +export interface Option { + label: string; + value: string | number; + key: string | number; + listClass: DictListClass; + extra: any; +} + +export class DictOptions { + [name: DictType]: Option[]; +} + +export type IDictState = { + options: DictOptions; +}; + +export const useDictStore = defineStore({ + id: 'app-dict', + state: (): IDictState => ({ + options: Storage.get(CURRENT_DICT, new DictOptions()), + }), + getters: {}, + actions: { + // 加载字典数据选项 + loadOptions(types: DictType[]) { + Dicts({ + types: types, + }).then((res) => { + if (!res) { + return; + } + Object.keys(res).forEach((key) => { + if (res[key]) { + this.options[key] = res[key] as unknown as Option[]; + } + }); + storage.set(CURRENT_DICT, this.options); + }); + }, + + // 设置指定类型的字典选项 + setOption(type: DictType, opts: Option[]) { + this.options[type] = opts; + storage.set(CURRENT_DICT, this.options); + }, + + // 获取指定类型的字典选项 + getOption(type: DictType): ComputedRef { + return computed(() => { + const opts = this.checkOptionValue(type) ?? []; + return opts as unknown as SelectMixedOption[]; + }); + }, + + // 获取指定类型的字典选项 + getOptionUnRef(type: DictType): any[] { + return this.checkOptionValue(type) ?? []; + }, + + // 是否存在选项,不存在返回null,存在就返回选项 + checkOptionValue(type: DictType): null | Option[] { + // 本地字典 + const local = localDict[type] ?? []; + if (local && local.length > 0) { + return local; + } + const opts = this.options[type] ?? []; + if (!opts || opts?.length === 0) { + return null; + } + return opts; + }, + + // 获取选项名称 + getLabel(type: DictType, value: any) { + if (value === null || value === undefined) { + return ``; + } + const opts = this.checkOptionValue(type); + if (!opts) { + return ``; + } + for (const item of opts) { + if (item.value.toString() === value.toString()) { + return item.label; + } + } + return ``; + }, + + // 获取选项标签类型 + getType(type: DictType, value: any): DictListClass { + if (value === null || value === undefined) { + return `default`; + } + const opts = this.checkOptionValue(type); + if (!opts) { + return `default`; + } + for (const item of opts) { + if (item.value.toString() === value.toString()) { + return item.listClass; + } + } + return `default`; + }, + + // 获取选项额外的数据配置 + getExtra(type: DictType, value: any): any { + if (value === null || value === undefined) { + return null; + } + const opts = this.checkOptionValue(type); + if (!opts) { + return null; + } + for (const item of opts) { + if (item.value.toString() === value.toString()) { + return item?.extra; + } + } + return null; + }, + + // 是否存在指定选项值 + hasValue(type: DictType, value: any): boolean { + if (value === null || value === undefined) { + return false; + } + const opts = this.checkOptionValue(type); + if (!opts) { + return false; + } + for (const item of opts) { + if (item.value.toString() === value.toString()) { + return true; + } + } + return false; + }, + + // 获取指定类型的可选项数量 + getOptionLength(type: DictType): number { + const opts = this.checkOptionValue(type); + if (!opts) { + return 0; + } + return opts?.length; + }, + + // 生成单个选项 + genOption(key: any, label: string, type = 'default', extra: any = null): Option { + return