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 @@ + + + {{ value }} + + 0 + + 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 @@ + + + + 选择{{ buttonText }} + + + + + + + + + + + + {{ getFileExt(item) }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 上传文件 + + + + + + + + + 取消 + 确定 + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + {{ getFileExt(item.fileUrl) }} + + + + + {{ item.name }} + + + + + + + + + + + + + {{ item.kind === 'image' ? '预览' : '下载' }} + + + + + + + + 删除 + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + 点击或者拖动{{ typeTag }}到该区域来上传 + 单次最多允许{{ maxUpload }}个{{ typeTag }} + + + + + + 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 @@ + + + + + + + + {{ schema.label }} + + + + + + + {{ schema.labelMessage }} + + + + + + + + + + + + + + + + + + + + + + {{ item.label }} + + + + + + + + + + + + + + + + + + + + + + + + {{ getProps.submitButtonText }} + {{ getProps.resetButtonText }} + + + + + + + + + + {{ overflow ? '展开' : '收起' }} + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + {{ hour }}:{{ minute }} + {{ month }}月{{ day }}号,星期{{ week }} + + + + + + + + + + + + + + + + + + {{ loginParams.username }} + + + + + + + + + + + {{ errorMsg }} + + + + 返回 + 重新登录 + 进入系统 + + + + + + + + + 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 @@ + + + {{ battery.level }}% + + + + + + + + {{ batteryStatus }} + + 剩余可使用时间:{{ calcDischargingTime }} + + + 距离电池充满需要:{{ calcChargingTime }} + + + + + + + + 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 @@ + + + + {{ getBindValue.title }} + + + + + + + 取消 + {{ + subBtuText + }} + + + + + + + + + + + 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 @@ + + + + + + + {{ title }} + + + + + + + {{ titleTooltip }} + + + + + + + + + + + + + + + + + + 表格斑马纹 + + + + + + + + + + + + + 刷新 + + + + + + + + + + + + + + 密度 + + + + + + + + + + + + + + + + + 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 @@ + + + + + + {{ action.label }} + + + + + + + + + + 更多 + + + + + + + + + + + + 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 @@ + + + + {{ getValues }} + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + 点击或者拖动{{ typeTag }}到该区域来上传 + 支持大文件分片上传,支持断点续传 + + + 解析中,请稍候... + + + 正在上传({{ progress }}%)... + 文件大小:{{ sizeFormat }} + + + + + + + + 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 @@ + + + + + + + + + + + + + + {{ getFileExt(item) }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ uploadTitle }} + + + + + + + + + + {{ helpText }} + + + + + + + + + + + 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 @@ + + + + + + {{ errorMessage }} + + + + + + 行数: {{ lineCount }} + + + ✓ 语法正确 + + + ✗ 语法错误 + + + + + + + + + \ 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 @@ + + + + + + + + + + + + + + {{ item.title }} + + {{ item.title }} + + + + + {{ item.tagTitle }} + + + + + + + {{ item.createdAt }} + + + + + + + + + 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 @@ + + + + + 主题 + + + + + + + + + + + + + + + + + + {{ designStore.darkTheme ? '深' : '浅' }}色主题 + + + + 系统主题 + + + + + + + + + + 导航栏模式 + + + + + + + + 左侧菜单模式 + + + + + + + + + + 顶部菜单模式 + + + + + + + + + + 顶部菜单混合模式 + + + + + + 导航栏风格 + + + + + + + + 暗色侧边栏 + + + + + + + + + + 白色侧边栏 + + + + + + + + + + 暗色顶栏 + + + + + 界面功能 + + + 分割菜单 + + + + + + + 固定顶栏 + + + + + + + + + + + + + + + + 固定多页签 + + + + + + 界面显示 + + + 显示重载页面按钮 + + + + + + + 显示面包屑导航 + + + + + + + 显示面包屑显示图标 + + + + + + + 显示多页签 + + + + + + + + + + + + + 动画 + + + 禁用动画 + + + + + + + 动画类型 + + + + + + + + {{ alertText }} + + + + + + + + + + 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 @@ + + + + + + + Ctrl K + + + + + + + + + + + + + + + + + {{ option.label }} + + {{ option.value }} + + + + + + + + + + + + 选择 + + + + + 切换 + + + + 关闭 + + + + + 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 @@ + + + + + + {{ item.name }} + + + + + + + 查看更多 + + + + + + + + + 清空 + 全部已读 + 查看更多 + + + + + 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 @@ + + + + + + + {{ projectName }} + + + + + + + $emit('update:collapsed', !collapsed)" + > + + + + + + + + + + + + + + + + + + + + + {{ routeItem.meta.title }} + + + + + {{ routeItem.meta.title }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ item.tips }} + + + + + + + + + + + + + + {{ item.tips }} + + + + + + + + + + + + 全屏 + + + + + + + + + + + + + 切换语言 + + + + + + + + + + {{ userStore.realName }} + + + + + + + + + + + + 项目配置 + + + + + + + + + + + + + + + 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 @@ + + + + {{ projectName }} + + + + + + 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 @@ + + + + {{ setKeepAlive(route) }} + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + {{ element.meta.title }} + + + + + + + + + + + + + + + + + + + + + + + + + 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 { + label: label, + value: key, + key: key, + listClass: type, + extra: extra, + }; + }, + }, +}); + +// Need to be used outside the setup +export function useDictStoreWidthOut() { + return useDictStore(store); +} diff --git a/web/src/store/modules/i18n.ts b/web/src/store/modules/i18n.ts new file mode 100644 index 0000000..0d6b5e0 --- /dev/null +++ b/web/src/store/modules/i18n.ts @@ -0,0 +1,68 @@ +import { defineStore } from 'pinia'; +import { createStorage } from '@/utils/Storage'; +import { CurrentLocale } from '@/store/mutation-types'; +import i18n from '@/locale/index'; +import { useUserStore } from '@/store/modules/user'; + +export const availableLocales = [ + { + label: '简体中文', + key: 'zh-CN', + }, + { + label: '繁體中文', + key: 'zh-TW', + }, + { + label: 'English', + key: 'en', + }, +]; + +export interface II18nStore { + currentLocale: string; +} + +const Storage = createStorage({ storage: localStorage }); + +export const useI18nStore = defineStore({ + id: 'I18nStore', + state: (): II18nStore => ({ + currentLocale: Storage.get(CurrentLocale, 'zh-CN'), + }), + getters: {}, + actions: { + getLocale(): string { + return this.currentLocale; + }, + + setLocale(locale: string) { + if (availableLocales.some((l) => l.key === locale)) { + (i18n.global.locale as any).value = locale; + this.currentLocale = locale; + Storage.set(CurrentLocale, locale); + } + }, + + initLocale(): void { + const userStore = useUserStore(); + const defaultLanguage = userStore.loginConfig?.defaultLanguage || 'zh-CN'; + + // 未开启国际化功能,仅设置语言不持久化 + if (!userStore.loginConfig?.i18nSwitch) { + this.setLocale(defaultLanguage); + return; + } + + // 优先使用本地存储的语言设置 + const savedLocale = Storage.get(CurrentLocale, defaultLanguage); + if (savedLocale && availableLocales.some((l) => l.key === savedLocale)) { + this.setLocale(savedLocale); + return; + } + + // 使用系统配置的默认语言 + this.setLocale(defaultLanguage); + }, + }, +}); diff --git a/web/src/store/modules/index.ts b/web/src/store/modules/index.ts new file mode 100644 index 0000000..b1c9dbf --- /dev/null +++ b/web/src/store/modules/index.ts @@ -0,0 +1,30 @@ +// @ts-ignore +const allModules = import.meta.globEager('./*/index.ts'); +const modules = {} as any; +Object.keys(allModules).forEach((path) => { + const fileName = path.split('/')[1]; + modules[fileName] = allModules[path][fileName] || allModules[path].default || allModules[path]; +}); + +// export default modules +// @ts-ignore +import asyncRoute from './async-route'; +// @ts-ignore +import user from './user'; +// @ts-ignore +import tabsView from './tabs-view'; +// @ts-ignore +import lockscreen from './lockscreen'; +// @ts-ignore +import dict from './dict'; +// @ts-ignore +import i18n from './i18n'; + +export default { + asyncRoute, + user, + tabsView, + lockscreen, + dict, + i18n, +}; diff --git a/web/src/store/modules/lockscreen.ts b/web/src/store/modules/lockscreen.ts new file mode 100644 index 0000000..c9bf4a2 --- /dev/null +++ b/web/src/store/modules/lockscreen.ts @@ -0,0 +1,31 @@ +import { defineStore } from 'pinia'; +import { IS_LOCKSCREEN } from '@/store/mutation-types'; +import { storage } from '@/utils/Storage'; + +// 长时间不操作默认锁屏时间 +const initTime = 60 * 60; + +const isLock = storage.get(IS_LOCKSCREEN, false); + +export type ILockscreenState = { + isLock: boolean; // 是否锁屏 + lockTime: number; +}; + +export const useLockscreenStore = defineStore({ + id: 'app-lockscreen', + state: (): ILockscreenState => ({ + isLock: isLock === true, // 是否锁屏 + lockTime: isLock == 'true' ? initTime : 0, + }), + getters: {}, + actions: { + setLock(payload) { + this.isLock = payload; + storage.set(IS_LOCKSCREEN, this.isLock); + }, + setLockTime(payload = initTime) { + this.lockTime = payload; + }, + }, +}); diff --git a/web/src/store/modules/notification.ts b/web/src/store/modules/notification.ts new file mode 100644 index 0000000..9af36f7 --- /dev/null +++ b/web/src/store/modules/notification.ts @@ -0,0 +1,106 @@ +import { defineStore } from 'pinia'; +import { store } from '@/store'; +import { PullMessages } from '@/api/apply/notice'; +import { MessageRow, MessageTab, parseMessage } from '@/enums/systemMessageEnum'; + +export interface INotificationStore { + messages: MessageTab[]; + notifyUnread: number; + noticeUnread: number; + letterUnread: number; + newMessage: MessageRow | null; +} + +export const notificationStore = defineStore({ + id: 'notificationStore', + state: (): INotificationStore => ({ + messages: [ + { + key: 1, + name: '通知', + badgeProps: { type: 'warning' }, + list: [], + }, + { + key: 2, + name: '公告', + badgeProps: { type: 'error' }, + list: [], + }, + { + key: 3, + name: '私信', + badgeProps: { type: 'info' }, + list: [], + }, + ], + notifyUnread: 0, + noticeUnread: 0, + letterUnread: 0, + newMessage: null, + }), + getters: { + getMessages(): MessageTab[] { + return this.messages; + }, + }, + actions: { + setMessages(messages) { + this.messages = messages; + }, + triggerNewMessages(message) { + message = parseMessage(message); + this.addMessages(message); + this.newMessage = message; + }, + addMessages(message: MessageRow) { + switch (message.type) { + case 1: + this.messages[0].list.push(message); + this.notifyUnread++; + break; + case 2: + this.messages[1].list.push(message); + this.noticeUnread++; + break; + case 3: + this.messages[2].list.push(message); + this.letterUnread++; + break; + } + }, + pullMessages() { + PullMessages().then((res) => { + if (res.list === undefined) { + return; + } + + this.messages[0].list = []; + this.messages[1].list = []; + this.messages[2].list = []; + + if (res.list?.length > 0) { + for (let i = 0; i < res.list.length; i++) { + this.addMessages(parseMessage(res.list[i])); + } + } + + this.notifyUnread = res.notifyCount; + this.noticeUnread = res.noticeCount; + this.letterUnread = res.letterCount; + }); + }, + getUnreadCount() { + const count = this.notifyUnread + this.noticeUnread + this.letterUnread; + if (count > 0) { + return count; + } + return 0; + }, + }, +}); + +// Need to be used outside the setup +export function notificationStoreWidthOut() { + return notificationStore(store); +} diff --git a/web/src/store/modules/projectSetting.ts b/web/src/store/modules/projectSetting.ts new file mode 100644 index 0000000..9c2c3cd --- /dev/null +++ b/web/src/store/modules/projectSetting.ts @@ -0,0 +1,97 @@ +import { defineStore } from 'pinia'; +import { store } from '@/store'; +import projectSetting from '@/settings/projectSetting'; +import type { IheaderSetting, ImenuSetting, ImultiTabsSetting, IcrumbsSetting } from '/#/config'; + +const { + navMode, + navTheme, + isMobile, + headerSetting, + showFooter, + menuSetting, + multiTabsSetting, + crumbsSetting, + permissionMode, + isPageAnimate, + pageAnimateType, +} = projectSetting; + +interface ProjectSettingState { + navMode: string; //导航模式 + navTheme: string; //导航风格 + headerSetting: IheaderSetting; //顶部设置 + showFooter: boolean; //页脚 + menuSetting: ImenuSetting; //多标签 + multiTabsSetting: ImultiTabsSetting; //多标签 + crumbsSetting: IcrumbsSetting; //面包屑 + permissionMode: string; //权限模式 + isPageAnimate: boolean; //是否开启路由动画 + pageAnimateType: string; //路由动画类型 + isMobile: boolean; // 是否处于移动端模式 +} + +export const useProjectSettingStore = defineStore({ + id: 'app-project-setting', + state: (): ProjectSettingState => ({ + navMode: navMode, + navTheme, + isMobile, + headerSetting, + showFooter, + menuSetting, + multiTabsSetting, + crumbsSetting, + permissionMode, + isPageAnimate, + pageAnimateType, + }), + getters: { + getNavMode(): string { + return this.navMode; + }, + getNavTheme(): string { + return this.navTheme; + }, + getIsMobile(): boolean { + return this.isMobile; + }, + getHeaderSetting(): object { + return this.headerSetting; + }, + getShowFooter(): boolean { + return this.showFooter; + }, + getMenuSetting(): object { + return this.menuSetting; + }, + getMultiTabsSetting(): object { + return this.multiTabsSetting; + }, + getCrumbsSetting(): object { + return this.multiTabsSetting; + }, + getPermissionMode(): string { + return this.permissionMode; + }, + getIsPageAnimate(): boolean { + return this.isPageAnimate; + }, + getPageAnimateType(): string { + return this.pageAnimateType; + }, + }, + actions: { + setNavTheme(value: string): void { + this.navTheme = value; + }, + setIsMobile(value: boolean): void { + this.isMobile = value; + }, + }, +}); + +// Need to be used outside the setup +export function useProjectSettingStoreWithOut() { + return useProjectSettingStore(store); +} diff --git a/web/src/store/modules/tabsView.ts b/web/src/store/modules/tabsView.ts new file mode 100644 index 0000000..0838015 --- /dev/null +++ b/web/src/store/modules/tabsView.ts @@ -0,0 +1,79 @@ +import { defineStore } from 'pinia'; +import { RouteLocationNormalized } from 'vue-router'; + +// 不需要出现在标签页中的路由 +const whiteList = ['Redirect', 'login']; + +export type RouteItem = Partial & { + fullPath: string; + path: string; + name: string; + hash: string; + meta: object; + params: object; + query: object; +}; + +export type ITabsViewState = { + tabsList: RouteItem[]; // 标签页 + signal: string; // 关闭信号 +}; + +//保留固定路由 +function retainAffixRoute(list: any[]) { + return list.filter((item) => item?.meta?.affix ?? false); +} + +export const useTabsViewStore = defineStore({ + id: 'app-tabs-view', + state: (): ITabsViewState => ({ + tabsList: [], + signal: '', + }), + getters: {}, + actions: { + initTabs(routes) { + // 初始化标签页 + this.tabsList = routes; + }, + addTabs(route): boolean { + // 添加标签页 + if (whiteList.includes(route.name)) return false; + const isExists = this.tabsList.some((item) => item.fullPath == route.fullPath); + if (!isExists) { + this.tabsList.push(route); + } + return true; + }, + closeLeftTabs(route) { + // 关闭左侧 + const index = this.tabsList.findIndex((item) => item.fullPath == route.fullPath); + this.tabsList = this.tabsList.filter((item, i) => i >= index || (item?.meta?.affix ?? false)); + }, + closeRightTabs(route) { + // 关闭右侧 + const index = this.tabsList.findIndex((item) => item.fullPath == route.fullPath); + this.tabsList = this.tabsList.filter((item, i) => i <= index || (item?.meta?.affix ?? false)); + }, + closeOtherTabs(route) { + // 关闭其他 + this.tabsList = this.tabsList.filter( + (item) => item.fullPath == route.fullPath || (item?.meta?.affix ?? false) + ); + }, + closeCurrentTab(route) { + // 关闭当前页 + const index = this.tabsList.findIndex((item) => item.fullPath == route.fullPath); + this.tabsList.splice(index, 1); + }, + closeAllTabs() { + // 关闭全部 + console.log(retainAffixRoute(this.tabsList)); + this.tabsList = retainAffixRoute(this.tabsList); + }, + closeSignal(signal: string) { + // 设置tab关闭信号, 1刷新 2关闭当前 3关闭其他 4关闭全部 + this.signal = signal; + }, + }, +}); diff --git a/web/src/store/modules/user.ts b/web/src/store/modules/user.ts new file mode 100644 index 0000000..b5aef1d --- /dev/null +++ b/web/src/store/modules/user.ts @@ -0,0 +1,276 @@ +import { defineStore } from 'pinia'; +import { createStorage, storage } from '@/utils/Storage'; +import { store } from '@/store'; +import { + ACCESS_TOKEN, + CURRENT_CONFIG, + CURRENT_DICT, + CURRENT_LOGIN_CONFIG, + CURRENT_USER, + IS_LOCKSCREEN, +} from '@/store/mutation-types'; +import { ResultEnum } from '@/enums/httpEnum'; +import { + getConfig, + getLoginConfig, + getUserInfo, + login, + logout, + mobileLogin, +} from '@/api/system/user'; +import { isWechatBrowser } from '@/utils/is'; +import { DeptTypeEnum } from '@/enums/deptEnum'; +const Storage = createStorage({ storage: localStorage }); + +export interface UserInfoState { + id: number; + deptName: string; + deptType: string; + roleName: string; + cityLabel: string; + permissions: string[]; + username: string; + realName: string; + avatar: string; + balance: number; + integral: number; + sex: number; + qq: string; + email: string; + mobile: string; + birthday: string; + cityId: number; + address: string; + cash: { + name: string; + account: string; + payeeCode: string; + }; + createdAt: string; + loginCount: number; + lastLoginAt: string; + lastLoginIp: string; + openId: string; + inviteCode: string; +} + +export interface ConfigState { + domain: string; + version: string; + wsAddr: string; + mode: string; +} + +export interface LoginConfigState { + loginRegisterSwitch: number; + loginCaptchaSwitch: number; + loginAutoOpenId: number; + loginProtocol: string; + loginPolicy: string; + i18nSwitch: boolean; + defaultLanguage: string; + projectName: string; +} + +export interface IUserState { + token: string; + username: string; + realName: string; + avatar: string; + permissions: any[]; + info: UserInfoState | null; + config: ConfigState | null; + loginConfig: LoginConfigState | null; +} + +export const useUserStore = defineStore({ + id: 'app-member', + state: (): IUserState => ({ + token: Storage.get(ACCESS_TOKEN, ''), + username: '', + realName: '', + avatar: '', + permissions: [], + info: Storage.get(CURRENT_USER, null), + config: Storage.get(CURRENT_CONFIG, null), + loginConfig: Storage.get(CURRENT_LOGIN_CONFIG, null), + }), + getters: { + getToken(): string { + return this.token; + }, + getAvatar(): string { + return this.avatar; + }, + getUsername(): string { + return this.username; + }, + getRealName(): string { + return this.realName; + }, + getPermissions(): [any][] { + return this.permissions; + }, + getUserInfo(): UserInfoState | null { + return this.info; + }, + getConfig(): ConfigState | null { + return this.config; + }, + getLoginConfig(): LoginConfigState | null { + return this.loginConfig; + }, + isCompanyDept(): boolean { + return this.info?.deptType == DeptTypeEnum.Company; + }, + isTenantDept(): boolean { + return this.info?.deptType == DeptTypeEnum.Tenant; + }, + isMerchantDept(): boolean { + return this.info?.deptType == DeptTypeEnum.Merchant; + }, + isUserDept(): boolean { + return this.info?.deptType == DeptTypeEnum.User; + }, + }, + actions: { + setToken(token: string) { + this.token = token; + }, + setAvatar(avatar: string) { + this.avatar = avatar; + }, + setUsername(username: string) { + this.username = username; + }, + setRealName(realName: string) { + this.realName = realName; + }, + setPermissions(permissions: string[]) { + this.permissions = permissions; + }, + setUserInfo(info: UserInfoState | null) { + this.info = info; + }, + setConfig(config: ConfigState | null) { + this.config = config; + }, + setLoginConfig(config: LoginConfigState | null) { + this.loginConfig = config; + }, + // 账号登录 + async login(userInfo) { + return await this.handleLogin(login(userInfo)); + }, + // 手机号登录 + async mobileLogin(userInfo) { + return await this.handleLogin(mobileLogin(userInfo)); + }, + async handleLogin(request: Promise) { + try { + const response = await request; + const { data, code } = response; + if (code === ResultEnum.SUCCESS) { + const ex = 30 * 24 * 60 * 60 * 1000; + storage.set(ACCESS_TOKEN, data.token, ex); + storage.set(CURRENT_USER, data, ex); + storage.set(IS_LOCKSCREEN, false); + this.setToken(data.token); + this.setUserInfo(data); + } + return Promise.resolve(response); + } catch (e) { + return Promise.reject(e); + } + }, + // 获取用户信息 + GetInfo() { + const that: any = this; + return new Promise((resolve, reject) => { + getUserInfo() + .then((res) => { + const result = res as UserInfoState; + if (result.permissions && result.permissions.length) { + const permissionsList = result.permissions; + that.setPermissions(permissionsList); + that.setUserInfo(result); + that.setAvatar(result.avatar); + that.setUsername(result.username); + that.setRealName(result.realName); + } else { + reject(new Error('getInfo: permissionsList must be a non-null array !')); + } + resolve(result); + }) + .catch((error) => { + reject(error); + }); + }); + }, + // 获取基础配置 + GetConfig() { + const that = this; + return new Promise((resolve, reject) => { + getConfig() + .then((res) => { + const result = res; + that.setConfig(result); + storage.set(CURRENT_CONFIG, result); + resolve(res); + }) + .catch((error) => { + reject(error); + }); + }); + }, + // 获取登录配置 + LoadLoginConfig: function () { + const that = this; + return new Promise((resolve, reject) => { + getLoginConfig() + .then((res) => { + const result = res as unknown as LoginConfigState; + that.setLoginConfig(result); + storage.set(CURRENT_LOGIN_CONFIG, result); + resolve(res); + }) + .catch((error) => { + reject(error); + }); + }); + }, + // 是否允许获取微信openid + allowWxOpenId(): boolean { + if (!isWechatBrowser()) { + return false; + } + + if (this.loginConfig !== null && this.loginConfig.loginAutoOpenId !== 1) { + return false; + } + return this.info === null || this.info.openId === ''; + }, + // 登出 + async logout() { + try { + const response = await logout(); + const { code } = response; + if (code === ResultEnum.SUCCESS) { + this.setPermissions([]); + this.setUserInfo(null); + storage.remove(ACCESS_TOKEN); + storage.remove(CURRENT_USER); + storage.remove(CURRENT_DICT); + } + return Promise.resolve(response); + } catch (e) { + return Promise.reject(e); + } + }, + }, +}); + +// Need to be used outside the setup +export function useUserStoreWidthOut() { + return useUserStore(store); +} diff --git a/web/src/store/mutation-types.ts b/web/src/store/mutation-types.ts new file mode 100644 index 0000000..15d17a2 --- /dev/null +++ b/web/src/store/mutation-types.ts @@ -0,0 +1,8 @@ +export const ACCESS_TOKEN = 'ACCESS-TOKEN'; // 用户token +export const CURRENT_USER = 'CURRENT-USER'; // 当前用户信息 +export const CURRENT_CONFIG = 'CURRENT-CONFIG'; // 当前基础配置 +export const CURRENT_LOGIN_CONFIG = 'CURRENT-LOGIN-CONFIG'; // 当前登录配置 +export const IS_LOCKSCREEN = 'IS-LOCKSCREEN'; // 是否锁屏 +export const TABS_ROUTES = 'TABS-ROUTES'; // 标签页 +export const CURRENT_DICT = 'CURRENT-DICT'; // 当前用户字典配置 +export const CurrentLocale = 'Current-Locale'; // 当前语言 diff --git a/web/src/store/types.ts b/web/src/store/types.ts new file mode 100644 index 0000000..65cef51 --- /dev/null +++ b/web/src/store/types.ts @@ -0,0 +1,16 @@ +import { IAsyncRouteState } from '@/store/modules/asyncRoute'; +import { IUserState } from '@/store/modules/user'; +import { ILockscreenState } from '@/store/modules/lockscreen'; +import { ITabsViewState } from '@/store/modules/tabsView'; +import { INotificationStore } from '@/store/modules/notification'; +import { IDictState } from '@/store/modules/dict'; + +export interface IStore { + asyncRoute: IAsyncRouteState; + user: IUserState; + lockscreen: ILockscreenState; + tabsView: ITabsViewState; + dict: IDictState; + notification: INotificationStore; + count: number; +} diff --git a/web/src/styles/common.less b/web/src/styles/common.less new file mode 100644 index 0000000..355d698 --- /dev/null +++ b/web/src/styles/common.less @@ -0,0 +1,134 @@ +#app, +body, +html { + height: 100%; +} + +body { + font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, + '\5FAE\8F6F\96C5\9ED1', Arial, sans-serif; + line-height: 1.5; + color: #515a6e; + font-size: 14px; + background-color: #f7f7f7; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +//重置样式 +.anticon { + svg { + vertical-align: initial; + } +} + +a { + color: #2d8cf0; + background: transparent; + text-decoration: none; + outline: none; + cursor: pointer; + transition: color 0.2s ease; +} + +a:active, +a:hover { + outline-width: 0; +} + +a:hover { + color: #57a3f3; +} + +a:active { + color: #2b85e4; +} + +a:active, +a:hover { + outline: 0; + text-decoration: none; +} + +/* 滚动条凹槽的颜色,还可以设置边框属性 */ +*::-webkit-scrollbar-track-piece { + background-color: #f8f8f8; + -webkit-border-radius: 2em; + -moz-border-radius: 2em; + border-radius: 2em; +} + +/* 滚动条的宽度 */ +*::-webkit-scrollbar { + width: 9px; + height: 9px; +} + +/* 滚动条的设置 */ +*::-webkit-scrollbar-thumb { + background-color: #ddd; + background-clip: padding-box; + -webkit-border-radius: 2em; + -moz-border-radius: 2em; + border-radius: 2em; +} + +/* 滚动条鼠标移上去 */ +*::-webkit-scrollbar-thumb:hover { + background-color: #bbb; +} + +/* router view transition */ +.zoom-fade-enter-active, +.zoom-fade-leave-active { + transition: transform 0.35s, opacity 0.28s ease-in-out; +} + +.zoom-fade-enter-from { + opacity: 0; + transform: scale(0.97); +} + +.zoom-fade-leave-to { + opacity: 0; + transform: scale(1.03); +} + +//antd 卡片样式定制 +body .n-card { + transition: all 0.2s ease-in-out; +} + +body .n-icon { + vertical-align: middle; +} + +body .proCard { + border-radius: 12px; + margin-top: 12px; + + .n-card-content { + padding: 16px; + + &:first-child { + padding-top: 16px; + } + } +} + +body .n-modal { + border-radius: 6px; +} + +.n-layout-page-header .n-card { + border-radius: 12px; +} + +//body .proCardTabs{ +// .n-card-content{ padding-top: 3px} +// .n-card-content:first-child{ padding-top: 3px} +//} + +.n-layout-page-header { + margin: 8px 0; +} diff --git a/web/src/styles/hotgo.less b/web/src/styles/hotgo.less new file mode 100644 index 0000000..4875404 --- /dev/null +++ b/web/src/styles/hotgo.less @@ -0,0 +1,3 @@ +.min-left-space { + margin-left: 5px; +} diff --git a/web/src/styles/index.less b/web/src/styles/index.less new file mode 100644 index 0000000..c55041e --- /dev/null +++ b/web/src/styles/index.less @@ -0,0 +1,4 @@ +@import 'transition/index.less'; +@import './var.less'; +@import './common.less'; +@import './hotgo.less'; diff --git a/web/src/styles/tailwind.css b/web/src/styles/tailwind.css new file mode 100644 index 0000000..c187af7 --- /dev/null +++ b/web/src/styles/tailwind.css @@ -0,0 +1,4 @@ +/*! @import */ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/web/src/styles/transition/base.less b/web/src/styles/transition/base.less new file mode 100644 index 0000000..7944c8b --- /dev/null +++ b/web/src/styles/transition/base.less @@ -0,0 +1,18 @@ +.transition-default() { + &-enter-active, + &-leave-active { + transition: 0.3s cubic-bezier(0.25, 0.8, 0.5, 1) !important; + } + + &-move { + transition: transform 0.4s; + } +} + +.expand-transition { + .transition-default(); +} + +.expand-x-transition { + .transition-default(); +} diff --git a/web/src/styles/transition/fade.less b/web/src/styles/transition/fade.less new file mode 100644 index 0000000..1f8e63e --- /dev/null +++ b/web/src/styles/transition/fade.less @@ -0,0 +1,81 @@ +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.2s ease-in-out; +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +/* fade-slide */ +.fade-slide-leave-active, +.fade-slide-enter-active { + transition: all 0.3s; +} + +.fade-slide-enter-from { + opacity: 0; + transform: translateX(-30px); +} + +.fade-slide-leave-to { + opacity: 0; + transform: translateX(30px); +} + +// /////////////////////////////////////////////// +// Fade Bottom +// /////////////////////////////////////////////// + +// Speed: 1x +.fade-bottom-enter-active, +.fade-bottom-leave-active { + transition: opacity 0.25s, transform 0.3s; +} + +.fade-bottom-enter-from { + opacity: 0; + transform: translateY(-10%); +} + +.fade-bottom-leave-to { + opacity: 0; + transform: translateY(10%); +} + +// fade-scale +.fade-scale-leave-active, +.fade-scale-enter-active { + transition: all 0.28s; +} + +.fade-scale-enter-from { + opacity: 0; + transform: scale(1.2); +} + +.fade-scale-leave-to { + opacity: 0; + transform: scale(0.8); +} + +// /////////////////////////////////////////////// +// Fade Top +// /////////////////////////////////////////////// + +// Speed: 1x +.fade-top-enter-active, +.fade-top-leave-active { + transition: opacity 0.2s, transform 0.25s; +} + +.fade-top-enter-from { + opacity: 0; + transform: translateY(8%); +} + +.fade-top-leave-to { + opacity: 0; + transform: translateY(-8%); +} diff --git a/web/src/styles/transition/index.less b/web/src/styles/transition/index.less new file mode 100644 index 0000000..e372b25 --- /dev/null +++ b/web/src/styles/transition/index.less @@ -0,0 +1,10 @@ +@import './base.less'; +@import './fade.less'; +@import './scale.less'; +@import './slide.less'; +@import './scroll.less'; +@import './zoom.less'; + +.collapse-transition { + transition: 0.2s height ease-in-out, 0.2s padding-top ease-in-out, 0.2s padding-bottom ease-in-out; +} diff --git a/web/src/styles/transition/scale.less b/web/src/styles/transition/scale.less new file mode 100644 index 0000000..c965493 --- /dev/null +++ b/web/src/styles/transition/scale.less @@ -0,0 +1,21 @@ +.scale-transition { + .transition-default(); + + &-enter-from, + &-leave, + &-leave-to { + opacity: 0; + transform: scale(0); + } +} + +.scale-rotate-transition { + .transition-default(); + + &-enter-from, + &-leave, + &-leave-to { + opacity: 0; + transform: scale(0) rotate(-45deg); + } +} diff --git a/web/src/styles/transition/scroll.less b/web/src/styles/transition/scroll.less new file mode 100644 index 0000000..a5f45e4 --- /dev/null +++ b/web/src/styles/transition/scroll.less @@ -0,0 +1,67 @@ +.scroll-y-transition { + .transition-default(); + + &-enter-from, + &-leave-to { + opacity: 0; + } + + &-enter-from { + transform: translateY(-15px); + } + + &-leave-to { + transform: translateY(15px); + } +} + +.scroll-y-reverse-transition { + .transition-default(); + + &-enter-from, + &-leave-to { + opacity: 0; + } + + &-enter-from { + transform: translateY(15px); + } + + &-leave-to { + transform: translateY(-15px); + } +} + +.scroll-x-transition { + .transition-default(); + + &-enter-from, + &-leave-to { + opacity: 0; + } + + &-enter-from { + transform: translateX(-15px); + } + + &-leave-to { + transform: translateX(15px); + } +} + +.scroll-x-reverse-transition { + .transition-default(); + + &-enter-from, + &-leave-to { + opacity: 0; + } + + &-enter-from { + transform: translateX(15px); + } + + &-leave-to { + transform: translateX(-15px); + } +} diff --git a/web/src/styles/transition/slide.less b/web/src/styles/transition/slide.less new file mode 100644 index 0000000..79b00df --- /dev/null +++ b/web/src/styles/transition/slide.less @@ -0,0 +1,39 @@ +.slide-y-transition { + .transition-default(); + + &-enter-from, + &-leave-to { + opacity: 0; + transform: translateY(-15px); + } +} + +.slide-y-reverse-transition { + .transition-default(); + + &-enter-from, + &-leave-to { + opacity: 0; + transform: translateY(15px); + } +} + +.slide-x-transition { + .transition-default(); + + &-enter-from, + &-leave-to { + opacity: 0; + transform: translateX(-15px); + } +} + +.slide-x-reverse-transition { + .transition-default(); + + &-enter-from, + &-leave-to { + opacity: 0; + transform: translateX(15px); + } +} diff --git a/web/src/styles/transition/zoom.less b/web/src/styles/transition/zoom.less new file mode 100644 index 0000000..2ea378c --- /dev/null +++ b/web/src/styles/transition/zoom.less @@ -0,0 +1,27 @@ +// zoom-out +.zoom-out-enter-active, +.zoom-out-leave-active { + transition: opacity 0.1 ease-in-out, transform 0.15s ease-out; +} + +.zoom-out-enter-from, +.zoom-out-leave-to { + opacity: 0; + transform: scale(0); +} + +// zoom-fade +.zoom-fade-enter-active, +.zoom-fade-leave-active { + transition: transform 0.2s, opacity 0.3s ease-out; +} + +.zoom-fade-enter-from { + opacity: 0; + transform: scale(0.92); +} + +.zoom-fade-leave-to { + opacity: 0; + transform: scale(1.06); +} diff --git a/web/src/styles/var.less b/web/src/styles/var.less new file mode 100644 index 0000000..e0fb0cb --- /dev/null +++ b/web/src/styles/var.less @@ -0,0 +1,4 @@ +@primaryColor: #2d8cf0; +@primaryColorHover: #57a3f3; +@header-height: 64px; +@footer-height: 70px; diff --git a/web/src/utils/Drag.ts b/web/src/utils/Drag.ts new file mode 100644 index 0000000..d41fedb --- /dev/null +++ b/web/src/utils/Drag.ts @@ -0,0 +1,98 @@ +//获取相关CSS属性 +const getCss = function (o, key) { + return o.currentStyle + ? o.currentStyle[key] + : document.defaultView?.getComputedStyle(o, null)[key]; +}; + +const params = { + left: 0, + top: 0, + currentX: 0, + currentY: 0, + flag: false, +}; + +const startDrag = function (bar, target, callback?) { + const screenWidth = document.body.clientWidth; // body当前宽度 + const screenHeight = document.documentElement.clientHeight; // 可见区域高度 + + const dragDomW = target.offsetWidth; // 对话框宽度 + const dragDomH = target.offsetHeight; // 对话框高度 + + const minDomLeft = target.offsetLeft; + const minDomTop = target.offsetTop; + + const maxDragDomLeft = screenWidth - minDomLeft - dragDomW; + const maxDragDomTop = screenHeight - minDomTop - dragDomH; + + if (getCss(target, 'left') !== 'auto') { + params.left = getCss(target, 'left'); + } + if (getCss(target, 'top') !== 'auto') { + params.top = getCss(target, 'top'); + } + + //o是移动对象 + bar.onmousedown = function (event) { + params.flag = true; + if (!event) { + event = window.event; + //防止IE文字选中 + bar.onselectstart = function () { + return false; + }; + } + const e = event; + params.currentX = e.clientX; + params.currentY = e.clientY; + }; + document.onmouseup = function () { + params.flag = false; + if (getCss(target, 'left') !== 'auto') { + params.left = getCss(target, 'left'); + } + if (getCss(target, 'top') !== 'auto') { + params.top = getCss(target, 'top'); + } + }; + document.onmousemove = function (event) { + const e: any = event ? event : window.event; + if (params.flag) { + const nowX = e.clientX, + nowY = e.clientY; + const disX = nowX - params.currentX, + disY = nowY - params.currentY; + + let left = parseInt(params.left) + disX; + let top = parseInt(params.top) + disY; + + // 拖出屏幕边缘 + if (-left > minDomLeft) { + left = -minDomLeft; + } else if (left > maxDragDomLeft) { + left = maxDragDomLeft; + } + + if (-top > minDomTop) { + top = -minDomTop; + } else if (top > maxDragDomTop) { + top = maxDragDomTop; + } + + target.style.left = left + 'px'; + target.style.top = top + 'px'; + + if (typeof callback == 'function') { + callback((parseInt(params.left) || 0) + disX, (parseInt(params.top) || 0) + disY); + } + + if (event.preventDefault) { + event.preventDefault(); + } + return false; + } + }; +}; + +export default startDrag; diff --git a/web/src/utils/Storage.ts b/web/src/utils/Storage.ts new file mode 100644 index 0000000..1a3459d --- /dev/null +++ b/web/src/utils/Storage.ts @@ -0,0 +1,127 @@ +// 默认缓存期限为7天 +const DEFAULT_CACHE_TIME = 60 * 60 * 24 * 7; + +/** + * 创建本地缓存对象 + * @param {string=} prefixKey - + * @param {Object} [storage=localStorage] - sessionStorage | localStorage + */ +export const createStorage = ({ prefixKey = '', storage = localStorage } = {}) => { + /** + * 本地缓存类 + * @class Storage + */ + const Storage = class { + private storage = storage; + private prefixKey?: string = prefixKey; + + private getKey(key: string) { + return `${this.prefixKey}${key}`.toUpperCase(); + } + + /** + * @description 设置缓存 + * @param {string} key 缓存键 + * @param {*} value 缓存值 + * @param expire + */ + set(key: string, value: any, expire: number | null = DEFAULT_CACHE_TIME) { + const stringData = JSON.stringify({ + value, + expire: expire !== null ? new Date().getTime() + expire * 1000 : null, + }); + this.storage.setItem(this.getKey(key), stringData); + } + + /** + * 读取缓存 + * @param {string} key 缓存键 + * @param {*=} def 默认值 + */ + get(key: string, def: any = null) { + const item = this.storage.getItem(this.getKey(key)); + if (item) { + try { + const data = JSON.parse(item); + const { value, expire } = data; + // 在有效期内直接返回 + if (expire === null || expire >= Date.now()) { + return value; + } + this.remove(key); + } catch (e) { + return def; + } + } + return def; + } + + /** + * 从缓存删除某项 + * @param {string} key + */ + remove(key: string) { + this.storage.removeItem(this.getKey(key)); + } + + /** + * 清空所有缓存 + * @memberOf Cache + */ + clear(): void { + this.storage.clear(); + } + + /** + * 设置cookie + * @param {string} name cookie 名称 + * @param {*} value cookie 值 + * @param {number=} expire 过期时间 + * 如果过期时间为设置,默认关闭浏览器自动删除 + * @example + */ + setCookie(name: string, value: any, expire: number | null = DEFAULT_CACHE_TIME) { + document.cookie = `${this.getKey(name)}=${value}; Max-Age=${expire}`; + } + + /** + * 根据名字获取cookie值 + * @param name + */ + getCookie(name: string): string { + const cookieArr = document.cookie.split('; '); + for (let i = 0, length = cookieArr.length; i < length; i++) { + const kv = cookieArr[i].split('='); + if (kv[0] === this.getKey(name)) { + return kv[1]; + } + } + return ''; + } + + /** + * 根据名字删除指定的cookie + * @param {string} key + */ + removeCookie(key: string) { + this.setCookie(key, 1, -1); + } + + /** + * 清空cookie,使所有cookie失效 + */ + clearCookie(): void { + const keys = document.cookie.match(/[^ =;]+(?==)/g); + if (keys) { + for (let i = keys.length; i--; ) { + document.cookie = keys[i] + '=0;expire=' + new Date(0).toUTCString(); + } + } + } + }; + return new Storage(); +}; + +export const storage = createStorage(); + +export default Storage; diff --git a/web/src/utils/array.ts b/web/src/utils/array.ts new file mode 100644 index 0000000..7559db1 --- /dev/null +++ b/web/src/utils/array.ts @@ -0,0 +1,64 @@ + +export function delNullProperty(obj) { + for (const i in obj) { + if (obj[i] === undefined || obj[i] === null || obj[i] === '') { + delete obj[i]; + } else if (obj[i].constructor === Object) { + if (Object.keys(obj[i]).length === 0) delete obj[i]; + delNullProperty(obj[i]); + } else if (obj[i].constructor === Array) { + if (Array.prototype.isPrototypeOf(obj[i]) && obj[i].length === 0) { + delete obj[i]; + } else { + for (let index = 0; index < obj[i].length; index++) { + if ( + obj[i][index] === undefined || + obj[i][index] === null || + obj[i][index] === '' || + JSON.stringify(obj[i][index]) === '{}' + ) { + obj[i].splice(index, 1); + index--; + } + if (obj[i][index] === undefined || obj[i][index].constructor !== undefined) { + continue; + } + if (obj[i][index].constructor === Object || obj[i][index].constructor === Array) { + delNullProperty(obj[i][index]); + } + } + } + } + } + return obj; +} + +export function reverse(array) { + if (array !== undefined && array !== null && array.length > 0) { + return array.reverse(); + } + return array; +} + +export function encodeParams(obj) { + const arr = []; + for (const p in obj) { + // @ts-ignore + arr.push(encodeURIComponent(p) + '=' + encodeURIComponent(obj[p])); + } + return arr.join('&'); +} + +export function copyObj(obj2: any, obj1: any) { + for (const key in obj1) { + if (obj2[key] !== undefined) { + obj2[key] = obj1[key]; + } + } + return obj2; +} + +// 返回两个数组的差集 +export function findArrayDifference(arr1: number[], arr2: number[]): number[] { + return arr1.filter((num) => !arr2.includes(num)); +} diff --git a/web/src/utils/browser-type.ts b/web/src/utils/browser-type.ts new file mode 100644 index 0000000..7fe8722 --- /dev/null +++ b/web/src/utils/browser-type.ts @@ -0,0 +1,142 @@ +/** + * @description 获取用户浏览器版本及系统信息 + * @param {string='zh-cn' | 'en'} lang 返回中文的信息还是英文的 + * @constructor + */ +export default function BrowserType(lang: 'zh-cn' | 'en' = 'en') { + // 权重:系统 + 系统版本 > 平台 > 内核 + 载体 + 内核版本 + 载体版本 > 外壳 + 外壳版本 + const ua = navigator.userAgent.toLowerCase(); + const testUa = (regexp) => regexp.test(ua); + const testVs = (regexp) => + ua + .match(regexp) + ?.toString() + .replace(/[^0-9|_.]/g, '') + .replace(/_/g, '.'); + // 系统 + const system = + new Map([ + [testUa(/windows|win32|win64|wow32|wow64/g), 'windows'], // windows系统 + [testUa(/macintosh|macintel/g), 'macos'], // macos系统 + [testUa(/x11/g), 'linux'], // linux系统 + [testUa(/android|adr/g), 'android'], // android系统 + [testUa(/ios|iphone|ipad|ipod|iwatch/g), 'ios'], // ios系统 + ]).get(true) || 'unknow'; + + // 系统版本 + const systemVs = + new Map([ + [ + 'windows', + new Map([ + [testUa(/windows nt 5.0|windows 2000/g), '2000'], + [testUa(/windows nt 5.1|windows xp/g), 'xp'], + [testUa(/windows nt 5.2|windows 2003/g), '2003'], + [testUa(/windows nt 6.0|windows vista/g), 'vista'], + [testUa(/windows nt 6.1|windows 7/g), '7'], + [testUa(/windows nt 6.2|windows 8/g), '8'], + [testUa(/windows nt 6.3|windows 8.1/g), '8.1'], + [testUa(/windows nt 10.0|windows 10/g), '10'], + ]).get(true), + ], + ['macos', testVs(/os x [\d._]+/g)], + ['android', testVs(/android [\d._]+/g)], + ['ios', testVs(/os [\d._]+/g)], + ]).get(system) || 'unknow'; + + // 平台 + let platform = 'unknow'; + if (system === 'windows' || system === 'macos' || system === 'linux') { + platform = 'desktop'; // 桌面端 + } else if (system === 'android' || system === 'ios' || testUa(/mobile/g)) { + platform = 'mobile'; // 移动端 + } + // 内核和载体 + const [engine = 'unknow', supporter = 'unknow'] = new Map([ + [ + testUa(/applewebkit/g), + [ + 'webkit', + new Map([ + // webkit内核 + [testUa(/safari/g), 'safari'], // safari浏览器 + [testUa(/chrome/g), 'chrome'], // chrome浏览器 + [testUa(/opr/g), 'opera'], // opera浏览器 + [testUa(/edge/g), 'edge'], // edge浏览器 + ]).get(true), + ] || 'unknow', + ], // [webkit内核, xxx浏览器] + [testUa(/gecko/g) && testUa(/firefox/g), ['gecko', 'firefox']], // [gecko内核,firefox浏览器] + [testUa(/presto/g), ['presto', 'opera']], // [presto内核,opera浏览器] + [testUa(/trident|compatible|msie/g), ['trident', 'iexplore']], // [trident内核,iexplore浏览器] + ]).get(true) || ['unknow', 'unknow']; + + // 内核版本 + const engineVs = + new Map([ + ['webkit', testVs(/applewebkit\/[\d._]+/g)], + ['gecko', testVs(/gecko\/[\d._]+/g)], + ['presto', testVs(/presto\/[\d._]+/g)], + ['trident', testVs(/trident\/[\d._]+/g)], + ]).get(engine) || 'unknow'; + + // 载体版本 + const supporterVs = + new Map([ + ['firefox', testVs(/firefox\/[\d._]+/g)], + ['opera', testVs(/opr\/[\d._]+/g)], + ['iexplore', testVs(/(msie [\d._]+)|(rv:[\d._]+)/g)], + ['edge', testVs(/edge\/[\d._]+/g)], + ['safari', testVs(/version\/[\d._]+/g)], + ['chrome', testVs(/chrome\/[\d._]+/g)], + ]).get(supporter) || 'unknow'; + + // 外壳和外壳版本 + const [shell = 'none', shellVs = 'unknow'] = new Map([ + [testUa(/micromessenger/g), ['wechat', testVs(/micromessenger\/[\d._]+/g)]], // [微信浏览器,] + [testUa(/qqbrowser/g), ['qq', testVs(/qqbrowser\/[\d._]+/g)]], // [QQ浏览器,] + [testUa(/ucbrowser/g), ['uc', testVs(/ucbrowser\/[\d._]+/g)]], // [UC浏览器,] + [testUa(/qihu 360se/g), ['360', 'unknow']], // [360浏览器(无版本),] + [testUa(/2345explorer/g), ['2345', testVs(/2345explorer\/[\d._]+/g)]], // [2345浏览器,] + [testUa(/metasr/g), ['sougou', 'unknow']], // [搜狗浏览器(无版本),] + [testUa(/lbbrowser/g), ['liebao', 'unknow']], // [猎豹浏览器(无版本),] + [testUa(/maxthon/g), ['maxthon', testVs(/maxthon\/[\d._]+/g)]], // [遨游浏览器,] + ]).get(true) || ['none', 'unknow']; + + return { + 'zh-cn': Object.assign( + { + 内核: engine, // 内核: webkit gecko presto trident + 内核版本: engineVs, // 内核版本 + 平台: platform, // 平台: desktop mobile + 载体: supporter, // 载体: chrome safari firefox opera iexplore edge + 载体版本: supporterVs, // 载体版本 + 系统: system, // 系统: windows macos linux android ios + 系统版本: systemVs, // 系统版本 + }, + shell === 'none' + ? {} + : { + 外壳: shell, // 外壳: wechat qq uc 360 2345 sougou liebao maxthon + 外壳版本: shellVs, // 外壳版本 + } + ), + en: Object.assign( + { + engine, // 内核: webkit gecko presto trident + engineVs, // 内核版本 + platform, // 平台: desktop mobile + supporter, // 载体: chrome safari firefox opera iexplore edge + supporterVs, // 载体版本 + system, // 系统: windows macos linux android ios + systemVs, // 系统版本 + }, + shell === 'none' + ? {} + : { + shell, // 外壳: wechat qq uc 360 2345 sougou liebao maxthon + shellVs, // 外壳版本 + } + ), + }[lang]; +} diff --git a/web/src/utils/charset.ts b/web/src/utils/charset.ts new file mode 100644 index 0000000..869ed48 --- /dev/null +++ b/web/src/utils/charset.ts @@ -0,0 +1,74 @@ +/** + * 随机生成字符串 + */ +export function getRandomString(len = 12, isSmall = false) { + const _charStr = 'abacdefghjklmnopqrstuvwxyzABCDEFGHJKLMNOPQRSTUVWXYZ0123456789'; + const _smallCharStr = 'abacdefghjklmnopqrstuvwxyz0123456789'; + const charStr = isSmall ? _smallCharStr : _charStr; + const min = 0; + const max = charStr.length - 1; + let _str = ''; + //循环生成字符串 + for (let i = 0, index; i < len; i++) { + index = (function (randomIndexFunc, i) { + return randomIndexFunc(min, max, i, randomIndexFunc); + })(function (min, max, i, _self) { + const numStart = _charStr.length - 10; + let indexTemp = Math.floor(Math.random() * (max - min + 1) + min); + if (i == 0 && indexTemp >= numStart) { + indexTemp = _self(min, max, i, _self); + } + return indexTemp; + }, i); + _str += _charStr[index]; + } + return _str; +} + +/** + * 隐藏中间几位字符 + */ +export function structure(array) { + // 将字符串转化成数组 + const arrBox = [...array]; + const count = arrBox.length; + if (count == 1) { + return '*'; + } + let min = 1; + let max = count; + // 两位姓名 + if (count == 2) { + min = 0; + max = count; + } + // 三位姓名 + if (count == 3) { + min = 0; + max = count - 1; + } + // if (count >= 2 && count <= 8) { + // min = 1; + // } + + // 手机号 + if (count == 11) { + min = 3; + max = 7; + } + // 身份证号码 + if (count >= 15) { + min = 9; + max = count - 4; + } + // 2.将数组中的4-7位变成* + let str = ''; + arrBox.map((res, index) => { + if (index > min && index < max) { + str += '*'; + } else { + str += res; + } + }); + return str; +} diff --git a/web/src/utils/dateUtil.ts b/web/src/utils/dateUtil.ts new file mode 100644 index 0000000..779664f --- /dev/null +++ b/web/src/utils/dateUtil.ts @@ -0,0 +1,206 @@ +import { + endOfMonth, + endOfToday, + endOfWeek, + endOfYesterday, + format, + startOfMonth, + startOfToday, + startOfTomorrow, + startOfWeek, + startOfYesterday, + subMonths, +} from 'date-fns'; + +const DATE_TIME_FORMAT = 'yyyy-MM-dd HH:mm:ss'; +const DATE_FORMAT = 'yyyy-MM-dd'; + +export function formatToDateTime(date: string, formatStr = DATE_TIME_FORMAT): string { + if (date === null || date === undefined || date === '') { + return ``; + } + return format(new Date(Date.parse(date)), formatStr); +} + +export function formatToDate(date: string, formatStr = DATE_FORMAT): string { + if (date === null || date === undefined || date === '') { + return ``; + } + return format(new Date(Date.parse(date)), formatStr); +} + +export function timestampToTime(timestamp) { + const date = new Date(timestamp * 1000); + const Y = date.getFullYear() + '-'; + const M = (date.getMonth() + 1 < 10 ? '0' + (date.getMonth() + 1) : date.getMonth() + 1) + '-'; + const D = (date.getDate() + 1 <= 10 ? '0' + date.getDate() : date.getDate()) + ' '; + const h = (date.getHours() + 1 <= 10 ? '0' + date.getHours() : date.getHours()) + ':'; + const m = (date.getMinutes() + 1 <= 10 ? '0' + date.getMinutes() : date.getMinutes()) + ':'; + const s = date.getSeconds() + 1 <= 10 ? '0' + date.getSeconds() : date.getSeconds(); + return Y + M + D + h + m + s; +} + +export function timestampToTimeNF(timestamp) { + const date = new Date(timestamp); + const Y = date.getFullYear(); + const M = date.getMonth() + 1 < 10 ? '0' + (date.getMonth() + 1) : date.getMonth() + 1; + const D = date.getDate() + 1 <= 10 ? '0' + date.getDate() : date.getDate(); + const h = date.getHours() + 1 <= 10 ? '0' + date.getHours() : date.getHours(); + const m = date.getMinutes() + 1 <= 10 ? '0' + date.getMinutes() : date.getMinutes(); + const s = date.getSeconds() + 1 <= 10 ? '0' + date.getSeconds() : date.getSeconds(); + return Y.toString() + M.toString() + D.toString() + h.toString() + m.toString() + s.toString(); +} + +export function dateToTimestamp(date: string) { + if (date === null || date === undefined || date === '') { + return 0; + } + return new Date(date).getTime(); +} + +export function timestampToDate(timestamp) { + const date = new Date(timestamp); + const Y = date.getFullYear() + '-'; + const M = (date.getMonth() + 1 <= 10 ? '0' + (date.getMonth() + 1) : date.getMonth() + 1) + '-'; + const D = date.getDate() + 1 <= 10 ? '0' + date.getDate() : date.getDate(); + return Y + M + D; +} + +export function getTime() { + const myDate = new Date(); + const hour = myDate.getHours().toString().padStart(2, '0'); + const minutes = myDate.getMinutes().toString().padStart(2, '0'); + const seconed = myDate.getSeconds().toString().padStart(2, '0'); + return hour + ':' + minutes + ':' + seconed; +} + +export function getDate() { + const myDate = new Date(); + const month = (myDate.getMonth() + 1).toString().padStart(2, '0'); + const day = myDate.getDate().toString().padStart(2, '0'); + return myDate.getFullYear() + '-' + month + '-' + day; +} + +export function defaultStatisticsTimeOptions() { + return new Date().getTime() - 86400 * 1000; +} + +export function formatBefore(oldDate) { + //当前时间 + const newDate = new Date(); + const newDateTime1 = newDate.getTime(); //含有时分秒 + newDate.setHours(0); + newDate.setMinutes(0); + newDate.setSeconds(0); + newDate.setMilliseconds(0); + const newDateTime2 = newDate.getTime(); //当前时间,不含有时分秒 + + //传递时间 + const oldDateTime1 = oldDate.getTime(); //含有时分秒 + oldDate.setHours(0); + oldDate.setMinutes(0); + oldDate.setSeconds(0); + oldDate.setMilliseconds(0); + const oldDateTime2 = oldDate.getTime(); //不含有时分秒 + + const d1 = (newDateTime1 - oldDateTime1) / 1000; + const d2 = (newDateTime2 - oldDateTime2) / 1000; + + let res = ''; + if (d2 > 0) { + //是几天前 + const days = parseInt(d2 / 86400); + if (days === 1) { + res = '昨天'; + } else if (days === 2) { + res = '前天'; + } else { + res = days + '天前'; + } + } else { + //是今天 + const hours = parseInt(d1 / 3600); + if (hours > 0) { + res = hours + '小时前'; + } else { + const minutes = parseInt(d1 / 60); + if (minutes > 0) { + res = minutes + '分钟前'; + } else { + const seconds = parseInt(d1); + if (seconds > 10) { + res = seconds + '秒前'; + } else { + res = '刚刚'; + } + } + } + } + return res; +} + +// @ts-ignore +export function formatAfter(end): string { + const start = new Date(); + let sjc = start.getTime() - end.getTime(); //时间差的毫秒数 + if (end.getTime() - start.getTime() > 0) { + sjc = end.getTime() - start.getTime(); //时间差的毫秒数 + } + const days = Math.floor(sjc / (24 * 3600 * 1000)); //计算出相差天数 + const leave1 = sjc % (24 * 3600 * 1000); //计算天数后剩余的毫秒数 + const hours = Math.floor(leave1 / (3600 * 1000)); //计算出小时数 + const leave2 = leave1 % (3600 * 1000); //计算小时数后剩余的毫秒数 + const minutes = Math.floor(leave2 / (60 * 1000)); //计算相差分钟数 + const leave3 = leave2 % (60 * 1000); //计算分钟数后剩余的毫秒数 + const seconds = Math.round(leave3 / 1000); //计算相差秒数 + if (days > 0) { + return days + '天后'; + } + if (hours > 0) { + return hours + '小时后'; + } + if (minutes > 0) { + return minutes + '分钟后'; + } + if (seconds > 0) { + return seconds + '秒后'; + } + return '刚刚'; +} + +export function defShortcuts() { + return { + 今天: startOfToday().getTime(), + 昨天: startOfYesterday().getTime(), + 明天: startOfTomorrow().getTime(), + }; +} + +export function defRangeShortcuts() { + const nowDate = new Date(); + const dayBase = 86400 * 1000; + return { + 今天: [startOfToday().getTime(), endOfToday().getTime()] as const, + 昨天: () => { + return [startOfYesterday().getTime(), endOfYesterday().getTime()] as const; + }, + 近7天: [startOfToday().getTime() - dayBase * 6, endOfToday().getTime()] as const, + 近30天: [startOfToday().getTime() - dayBase * 29, endOfToday().getTime()] as const, + 近90天: [startOfToday().getTime() - dayBase * 89, endOfToday().getTime()] as const, + 本周: () => { + return [ + startOfWeek(nowDate, { weekStartsOn: 1 }).getTime(), + endOfWeek(nowDate, { weekStartsOn: 1 }).getTime(), + ] as const; + }, + 本月: () => { + return [startOfMonth(nowDate).getTime(), endOfMonth(nowDate).getTime()] as const; + }, + 上月: () => { + return [ + startOfMonth(subMonths(nowDate, 1)).getTime(), + endOfMonth(subMonths(nowDate, 1)).getTime(), + ] as const; + }, + }; +} diff --git a/web/src/utils/domUtils.ts b/web/src/utils/domUtils.ts new file mode 100644 index 0000000..80a77a5 --- /dev/null +++ b/web/src/utils/domUtils.ts @@ -0,0 +1,165 @@ +import { upperFirst } from 'lodash-es'; + +export interface ViewportOffsetResult { + left: number; + top: number; + right: number; + bottom: number; + rightIncludeBody: number; + bottomIncludeBody: number; +} + +export function getBoundingClientRect(element: Element): DOMRect | number { + if (!element || !element.getBoundingClientRect) { + return 0; + } + return element.getBoundingClientRect(); +} + +function trim(string: string) { + return (string || '').replace(/^[\s\uFEFF]+|[\s\uFEFF]+$/g, ''); +} + +/* istanbul ignore next */ +export function hasClass(el: Element, cls: string) { + if (!el || !cls) return false; + if (cls.indexOf(' ') !== -1) throw new Error('className should not contain space.'); + if (el.classList) { + return el.classList.contains(cls); + } else { + return (' ' + el.className + ' ').indexOf(' ' + cls + ' ') > -1; + } +} + +/* istanbul ignore next */ +export function addClass(el: Element, cls: string) { + if (!el) return; + let curClass = el.className; + const classes = (cls || '').split(' '); + + for (let i = 0, j = classes.length; i < j; i++) { + const clsName = classes[i]; + if (!clsName) continue; + + if (el.classList) { + el.classList.add(clsName); + } else if (!hasClass(el, clsName)) { + curClass += ' ' + clsName; + } + } + if (!el.classList) { + el.className = curClass; + } +} + +/* istanbul ignore next */ +export function removeClass(el: Element, cls: string) { + if (!el || !cls) return; + const classes = cls.split(' '); + let curClass = ' ' + el.className + ' '; + + for (let i = 0, j = classes.length; i < j; i++) { + const clsName = classes[i]; + if (!clsName) continue; + + if (el.classList) { + el.classList.remove(clsName); + } else if (hasClass(el, clsName)) { + curClass = curClass.replace(' ' + clsName + ' ', ' '); + } + } + if (!el.classList) { + el.className = trim(curClass); + } +} +/** + * Get the left and top offset of the current element + * left: the distance between the leftmost element and the left side of the document + * top: the distance from the top of the element to the top of the document + * right: the distance from the far right of the element to the right of the document + * bottom: the distance from the bottom of the element to the bottom of the document + * rightIncludeBody: the distance between the leftmost element and the right side of the document + * bottomIncludeBody: the distance from the bottom of the element to the bottom of the document + * + * @description: + */ +export function getViewportOffset(element: Element): ViewportOffsetResult { + const doc = document.documentElement; + + const docScrollLeft = doc.scrollLeft; + const docScrollTop = doc.scrollTop; + const docClientLeft = doc.clientLeft; + const docClientTop = doc.clientTop; + + const pageXOffset = window.pageXOffset; + const pageYOffset = window.pageYOffset; + + const box = getBoundingClientRect(element); + + const { left: retLeft, top: rectTop, width: rectWidth, height: rectHeight } = box as DOMRect; + + const scrollLeft = (pageXOffset || docScrollLeft) - (docClientLeft || 0); + const scrollTop = (pageYOffset || docScrollTop) - (docClientTop || 0); + const offsetLeft = retLeft + pageXOffset; + const offsetTop = rectTop + pageYOffset; + + const left = offsetLeft - scrollLeft; + const top = offsetTop - scrollTop; + + const clientWidth = window.document.documentElement.clientWidth; + const clientHeight = window.document.documentElement.clientHeight; + return { + left: left, + top: top, + right: clientWidth - rectWidth - left, + bottom: clientHeight - rectHeight - top, + rightIncludeBody: clientWidth - left, + bottomIncludeBody: clientHeight - top, + }; +} + +export function hackCss(attr: string, value: string) { + const prefix: string[] = ['webkit', 'Moz', 'ms', 'OT']; + + const styleObj: any = {}; + prefix.forEach((item) => { + styleObj[`${item}${upperFirst(attr)}`] = value; + }); + return { + ...styleObj, + [attr]: value, + }; +} + +/* istanbul ignore next */ +export function on( + element: Element | HTMLElement | Document | Window, + event: string, + handler: EventListenerOrEventListenerObject +): void { + if (element && event && handler) { + element.addEventListener(event, handler, false); + } +} + +/* istanbul ignore next */ +export function off( + element: Element | HTMLElement | Document | Window, + event: string, + handler: Fn +): void { + if (element && event && handler) { + element.removeEventListener(event, handler, false); + } +} + +/* istanbul ignore next */ +export function once(el: HTMLElement, event: string, fn: EventListener): void { + const listener = function (this: any, ...args: unknown[]) { + if (fn) { + fn.apply(this, args); + } + off(el, event, listener); + }; + on(el, event, listener); +} diff --git a/web/src/utils/downloadFile.ts b/web/src/utils/downloadFile.ts new file mode 100644 index 0000000..8e4f74f --- /dev/null +++ b/web/src/utils/downloadFile.ts @@ -0,0 +1,76 @@ +/** + * 根据文件url获取文件名 + * @param url 文件url + */ +function getFileName(url) { + const num = url.lastIndexOf('/') + 1; + let fileName = url.substring(num); + //把参数和文件名分割开 + fileName = decodeURI(fileName.split('?')[0]); + return fileName; +} + +/** + * 根据文件地址下载文件 + * @param {*} sUrl + */ +export function downloadByUrl({ + url, + target = '_blank', + fileName, +}: { + url: string; + target?: '_self' | '_blank'; + fileName?: string; +}): Promise { + // 是否同源 + const isSameHost = new URL(url).host == location.host; + return new Promise((resolve, reject) => { + if (isSameHost) { + const link = document.createElement('a'); + link.href = url; + link.target = target; + + if (link.download !== undefined) { + link.download = fileName || getFileName(url); + } + + if (document.createEvent) { + const e = document.createEvent('MouseEvents'); + e.initEvent('click', true, true); + link.dispatchEvent(e); + return resolve(true); + } + + if (url.indexOf('?') === -1) { + url += '?download'; + } + + window.open(url, target); + return resolve(true); + } else { + const canvas = document.createElement('canvas'); + const img = document.createElement('img'); + img.setAttribute('crossOrigin', 'Anonymous'); + img.src = url; + img.onload = () => { + canvas.width = img.width; + canvas.height = img.height; + const context = canvas.getContext('2d')!; + context.drawImage(img, 0, 0, img.width, img.height); + // window.navigator.msSaveBlob(canvas.msToBlob(),'image.jpg'); + // saveAs(imageDataUrl, '附件'); + canvas.toBlob((blob) => { + const link = document.createElement('a'); + // @ts-ignore + link.href = window.URL.createObjectURL(blob); + link.download = getFileName(url); + link.click(); + URL.revokeObjectURL(link.href); + resolve(true); + }, 'image/jpeg'); + }; + img.onerror = (e) => reject(e); + } + }); +} diff --git a/web/src/utils/encrypt.ts b/web/src/utils/encrypt.ts new file mode 100644 index 0000000..f176abc --- /dev/null +++ b/web/src/utils/encrypt.ts @@ -0,0 +1,25 @@ +import CryptoJS from 'crypto-js'; + +const defaultKey = 'f080a463654b2279'; + +export const aesEcb = { + // 加密 + encrypt(word: string, keyStr: string = defaultKey): string { + const key = CryptoJS.enc.Utf8.parse(keyStr); + const src = CryptoJS.enc.Utf8.parse(word); + const encrypted = CryptoJS.AES.encrypt(src, key, { + mode: CryptoJS.mode.ECB, + padding: CryptoJS.pad.Pkcs7, + }); + return encrypted.toString(); + }, + // 解密 + decrypt(word: string, keyStr: string = defaultKey): string { + const key = CryptoJS.enc.Utf8.parse(keyStr); + const decrypt = CryptoJS.AES.decrypt(word, key, { + mode: CryptoJS.mode.ECB, + padding: CryptoJS.pad.Pkcs7, + }); + return CryptoJS.enc.Utf8.stringify(decrypt).toString(); + }, +}; diff --git a/web/src/utils/env.ts b/web/src/utils/env.ts new file mode 100644 index 0000000..67fe2bb --- /dev/null +++ b/web/src/utils/env.ts @@ -0,0 +1,85 @@ +import type { GlobEnvConfig } from '/#/config'; + +import { warn } from '@/utils/log'; +import pkg from '../../package.json'; +import { getConfigFileName } from '../../build/getConfigFileName'; + +export function getCommonStoragePrefix() { + const { VITE_GLOB_APP_SHORT_NAME } = getAppEnvConfig(); + return `${VITE_GLOB_APP_SHORT_NAME}__${getEnv()}`.toUpperCase(); +} + +// Generate cache key according to version +export function getStorageShortName() { + return `${getCommonStoragePrefix()}${`__${pkg.version}`}__`.toUpperCase(); +} + +export function getAppEnvConfig() { + const ENV_NAME = getConfigFileName(import.meta.env); + + const ENV = (import.meta.env.DEV + ? // Get the global configuration (the configuration will be extracted independently when packaging) + (import.meta.env as unknown as GlobEnvConfig) + : window[ENV_NAME as any]) as unknown as GlobEnvConfig; + + 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, + } = ENV; + + 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.` + ); + } + + return { + 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, + }; +} + +/** + * @description: Development model + */ +export const devMode = 'development'; + +/** + * @description: Production mode + */ +export const prodMode = 'production'; + +/** + * @description: Get environment variables + * @returns: + * @example: + */ +export function getEnv(): string { + return import.meta.env.MODE; +} + +/** + * @description: Is it a development mode + * @returns: + * @example: + */ +export function isDevMode(): boolean { + return import.meta.env.DEV; +} + +/** + * @description: Is it a production mode + * @returns: + * @example: + */ +export function isProdMode(): boolean { + return import.meta.env.PROD; +} diff --git a/web/src/utils/highHtml.ts b/web/src/utils/highHtml.ts new file mode 100644 index 0000000..d15c2cb --- /dev/null +++ b/web/src/utils/highHtml.ts @@ -0,0 +1,186 @@ +// 关键词配置 +interface IKeywordOption { + keyword: string | RegExp; + color?: string; + bgColor?: string; + style?: Record; + // 高亮标签名 + tagName?: string; + // 忽略大小写 + caseSensitive?: boolean; + // 自定义渲染高亮html + // eslint-disable-next-line no-unused-vars + renderHighlightKeyword?: (content: string) => any; +} + +type IKeyword = string | IKeywordOption; + +export interface IMatchIndex { + index: number; + subString: string; +} + +// 关键词索引 +export interface IKeywordParseIndex { + keyword: string | RegExp; + indexList: IMatchIndex[]; + option?: IKeywordOption; +} + +// 关键词 +export interface IKeywordParseResult { + start: number; + end: number; + subString?: string; + option?: IKeywordOption; +} + +// 计算 +const getKeywordIndexList = (content: string, keyword: string | RegExp, flags = 'ig') => { + const reg = new RegExp(keyword, flags); + const res = (content as any).matchAll(reg); + const arr = [...res]; + const allIndexArr: IMatchIndex[] = arr.map((e) => ({ + index: e.index, + subString: e['0'], + })); + return allIndexArr; +}; + +// 驼峰转换横线 +function humpToLine(name: string) { + return name.replace(/([A-Z])/g, '-$1').toLowerCase(); +} + +const renderNodeTag = (subStr: string, option: IKeywordOption) => { + const s = subStr; + if (!option) { + return s; + } + const { tagName = 'mark', bgColor, color, style = {}, renderHighlightKeyword } = option; + if (typeof renderHighlightKeyword === 'function') { + return renderHighlightKeyword(subStr); + } + style.backgroundColor = bgColor; + style.color = color; + + const styleContent = Object.keys(style) + .map((k) => `${humpToLine(k)}:${style[k]}`) + .join(';'); + const styleStr = `style="${styleContent}"`; + return `<${tagName} ${styleStr}>${s}${tagName}>`; +}; + +const renderHighlightHtml = (content: string, list: any[]) => { + let str = ''; + list.forEach((item) => { + const { start, end, option } = item; + const s = content.slice(start, end); + const subStr = renderNodeTag(s, option); + str += subStr; + item.subString = subStr; + }); + return str; +}; + +// 解析关键词为索引 +const parseHighlightIndex = (content: string, keywords: IKeyword[]) => { + const result: IKeywordParseIndex[] = []; + keywords.forEach((keywordOption: IKeyword) => { + let option: IKeywordOption = { keyword: '' }; + if (typeof keywordOption === 'string') { + option = { keyword: keywordOption }; + } else { + option = keywordOption; + } + const { keyword, caseSensitive = true } = option; + const indexList = getKeywordIndexList(content, keyword, caseSensitive ? 'g' : 'gi'); + const res = { + keyword, + indexList, + option, + }; + result.push(res); + }); + return result; +}; + +const parseHighlightString = (content: string, keywords: IKeyword[]) => { + const result = parseHighlightIndex(content, keywords); + const splitList: IKeywordParseResult[] = []; + const findSplitIndex = (index: number, len: number) => { + for (let i = 0; i < splitList.length; i++) { + const cur = splitList[i]; + // 有交集 + if ( + (index > cur.start && index < cur.end) || + (index + len > cur.start && index + len < cur.end) || + (cur.start > index && cur.start < index + len) || + (cur.end > index && cur.end < index + len) || + (index === cur.start && index + len === cur.end) + ) { + return -1; + } + // 没有交集,且在当前的前面 + if (index + len <= cur.start) { + return i; + } + // 没有交集,且在当前的后面的,放在下个迭代处理 + } + return splitList.length; + }; + result.forEach(({ indexList, option }: IKeywordParseIndex) => { + indexList.forEach((e) => { + const { index, subString } = e; + const item = { + start: index, + end: index + subString.length, + option, + }; + const splitIndex = findSplitIndex(index, subString.length); + if (splitIndex !== -1) { + splitList.splice(splitIndex, 0, item); + } + }); + }); + + // 补上没有匹配关键词的部分 + const list: IKeywordParseResult[] = []; + splitList.forEach((cur, i) => { + const { start, end } = cur; + const next = splitList[i + 1]; + // 第一个前面补一个 + if (i === 0 && start > 0) { + list.push({ start: 0, end: start, subString: content.slice(0, start) }); + } + list.push({ ...cur, subString: content.slice(start, end) }); + // 当前和下一个中间补一个 + if (next?.start > end) { + list.push({ + start: end, + end: next.start, + subString: content.slice(end, next.start), + }); + } + // 最后一个后面补一个 + if (i === splitList.length - 1 && end < content.length - 1) { + list.push({ + start: end, + end: content.length - 1, + subString: content.slice(end, content.length), + }); + } + }); + return list; +}; + +// 生成关键词高亮的html字符串 +const highHtml = (content: string, keywords: IKeyword[]) => { + const splitList = parseHighlightString(content, keywords); + return { + highText: renderHighlightHtml(content, splitList), + highList: splitList, + }; +}; + +export default highHtml; diff --git a/web/src/utils/hotgo.ts b/web/src/utils/hotgo.ts new file mode 100644 index 0000000..5ad5f6f --- /dev/null +++ b/web/src/utils/hotgo.ts @@ -0,0 +1,186 @@ +// @ts-ignore +import onerrorImg from '@/assets/images/onerror.png'; +import { usePermission } from '@/hooks/web/usePermission'; +import { ActionItem } from '@/components/Table'; +import { isBoolean, isFunction } from '@/utils/is'; +import { PermissionsEnum } from '@/enums/permissionsEnum'; + +/** + * @deprecated 这个方法将来未来版本完全弃用,请统一使用useDictStore()来维护字典选项 + */ +export interface Option { + label: string; + value: string | number; + key: string | number; + // type: string; + listClass: 'default' | 'error' | 'primary' | 'info' | 'success' | 'warning'; +} + +/** + * @deprecated 这个方法将来未来版本完全弃用,请统一使用useDictStore()来维护字典选项 + */ +export interface Options { + [name: string]: Option[]; +} + +/** + * 获取选项名称 + * @deprecated 这个方法将来未来版本完全弃用,请统一使用useDictStore()来维护字典选项 + */ +export function getOptionLabel(options: Option[], value) { + if (options === undefined || options?.length === 0) { + return `unknown`; + } + for (const item of options) { + if (item.value == value) { + return item.label; + } + } + + return `unknown`; +} + +/** + * 获取选项标签 + * @deprecated 这个方法将来未来版本完全弃用,请统一使用useDictStore()来维护字典选项 + */ +export function getOptionTag(options: Option[], value) { + if (options === undefined || options?.length === 0) { + return 'default'; + } + for (const item of options) { + if (item.value == value) { + return item.listClass; + } + } + + return 'default'; +} + +// 自适应模板宽度 +export function adaModalWidth(def = 840) { + const val = document.body.clientWidth; + if (val <= def) { + return '100%'; + } else { + return def + 'px'; + } +} + +export interface TableColumn { + width?: number | string; + auth?: PermissionsEnum | PermissionsEnum[] | string | string[]; + ifShow?: boolean | ((action: ActionItem) => boolean); +} + +// 自适应表格组件横向滑动可见宽度 +export function adaTableScrollX(columns: TableColumn[] = [], actionWidth: number) { + const { hasPermission } = usePermission(); + + let x = 50; // 勾选列宽度 + columns = columns.filter((column) => { + return hasPermission(column.auth as string[]) && isIfShow(column); + }); + for (const column of columns) { + if (column.width && Number(column.width) >= 1) { + x += Number(column.width); + } else { + x += 100; // 默认列宽度 + } + } + x += actionWidth; + return x; +} + +export function isIfShow(action: ActionItem): boolean { + let isIfShow = true; + const ifShow = action.ifShow; + if (isBoolean(ifShow)) { + isIfShow = ifShow; + } + if (isFunction(ifShow)) { + isIfShow = ifShow(action); + } + return isIfShow; +} + +// 图片加载失败显示自定义默认图片(缺省图) img标签使用 +export function errorImg(e: any): void { + e.target.src = onerrorImg; + e.target.onerror = null; +} + +// 图片加载失败显示自定义默认图片(缺省图) NImage组件使用 +export function fallbackSrc(): string { + return onerrorImg; +} + +export function timeFix() { + const time = new Date(); + const hour = time.getHours(); + return hour < 9 + ? '早上好' + : hour <= 11 + ? '上午好' + : hour <= 13 + ? '中午好' + : hour < 20 + ? '下午好' + : '晚上好'; +} + +// 随机浅色 +export function rdmLightRgbColor(): string { + const letters = '456789ABCDEF'; + let color = '#'; + for (let i = 0; i < 6; i++) { + if (i === 0) { + color += 'F'; // 确保第一个字符较亮 + } else { + color += letters[Math.floor(Math.random() * letters.length)]; + } + } + return color; +} + +// 将列表数据转为树形数据 +export function convertListToTree(list: any[], idField = 'id', pidField = 'pid') { + if (!list || list.length === 0) { + return []; + } + const min = list.reduce((prev, current) => (prev[pidField] < current[pidField] ? prev : current)); + + const map = list.reduce((acc, item) => { + acc[item[idField]] = { ...item, children: [] }; + return acc; + }, {}); + + list.forEach((item) => { + if (item[pidField] !== min[pidField]) { + if (!map[item[pidField]]) { + map[item[pidField]] = {}; + } + + if (!map[item[pidField]].children) { + map[item[pidField]].children = []; + } + map[item[pidField]].children.push(map[item[idField]]); + } + }); + return list.filter((item) => item[pidField] === min[pidField]).map((item) => map[item[idField]]); +} + +// 从树选项中获取所有key +export function getTreeKeys(data: any[], idField = 'id') { + const keys: any = []; + if (!data || data.length === 0) { + return keys; + } + data.map((item: any) => { + keys.push(item[idField]); + if (item.children && item.children.length) { + keys.push(...getTreeKeys(item.children)); + } + }); + return keys; +} diff --git a/web/src/utils/http/axios/Axios.ts b/web/src/utils/http/axios/Axios.ts new file mode 100644 index 0000000..f8580bd --- /dev/null +++ b/web/src/utils/http/axios/Axios.ts @@ -0,0 +1,233 @@ +import type { AxiosRequestConfig, AxiosInstance, AxiosResponse } from 'axios'; +import axios from 'axios'; +import { AxiosCanceler } from './axiosCancel'; +import { isFunction, isString, isUrl } from '@/utils/is'; +import { cloneDeep } from 'lodash-es'; +import type { RequestOptions, CreateAxiosOptions, Result, UploadFileParams } from './types'; +import { ContentTypeEnum } from '@/enums/httpEnum'; +import { useGlobSetting } from '@/hooks/setting'; + +export * from './axiosTransform'; + +/** + * @description: axios模块 + */ +export class VAxios { + private axiosInstance: AxiosInstance; + private options: CreateAxiosOptions; + + constructor(options: CreateAxiosOptions) { + this.options = options; + this.axiosInstance = axios.create(options); + this.setupInterceptors(); + } + + getAxios(): AxiosInstance { + return this.axiosInstance; + } + + /** + * @description: 重新配置axios + */ + configAxios(config: CreateAxiosOptions) { + if (!this.axiosInstance) { + return; + } + this.createAxios(config); + } + + /** + * @description: 设置通用header + */ + setHeader(headers: any): void { + if (!this.axiosInstance) { + return; + } + Object.assign(this.axiosInstance.defaults.headers, headers); + } + + /** + * @description: 请求方法 + */ + request(config: AxiosRequestConfig, options?: RequestOptions): Promise { + let conf: AxiosRequestConfig = cloneDeep(config); + const transform = this.getTransform(); + + const { requestOptions } = this.options; + + const opt: RequestOptions = Object.assign({}, requestOptions, options); + + const { beforeRequestHook, requestCatch, transformRequestData } = transform || {}; + if (beforeRequestHook && isFunction(beforeRequestHook)) { + conf = beforeRequestHook(conf, opt); + } + + //这里重新 赋值成最新的配置 + // @ts-ignore + conf.requestOptions = opt; + + return new Promise((resolve, reject) => { + this.axiosInstance + .request>(conf) + .then((res: AxiosResponse) => { + // 请求是否被取消 + const isCancel = axios.isCancel(res); + if (transformRequestData && isFunction(transformRequestData) && !isCancel) { + try { + const ret = transformRequestData(res, opt); + resolve(ret); + } catch (err) { + reject(err || new Error('request error!')); + } + return; + } + resolve(res as unknown as Promise); + }) + .catch((e: Error) => { + if (requestCatch && isFunction(requestCatch)) { + reject(requestCatch(e)); + return; + } + reject(e); + }); + }); + } + + /** + * @description: 创建axios实例 + */ + private createAxios(config: CreateAxiosOptions): void { + this.axiosInstance = axios.create(config); + } + + private getTransform() { + const { transform } = this.options; + return transform; + } + + /** + * @description: 文件上传 + */ + uploadFile(config: AxiosRequestConfig, params: UploadFileParams, options?: RequestOptions) { + const transform = this.getTransform(); + const { requestCatch, transformRequestData } = transform || {}; + const { requestOptions } = this.options; + const opt: RequestOptions = Object.assign({}, requestOptions, options); + + const globSetting = useGlobSetting(); + const urlPrefix = globSetting.urlPrefix || ''; + const apiUrl = globSetting.apiUrl || ''; + const isUrlStr = isUrl(config.url as string); + + if (!isUrlStr) { + config.url = `${urlPrefix}${config.url}`; + } + + if (!isUrlStr && apiUrl && isString(apiUrl)) { + config.url = `${apiUrl}${config.url}`; + } + + const formData = new window.FormData(); + if (params) { + Object.keys(params).forEach((key) => { + const value = params![key]; + if (Array.isArray(value)) { + value.forEach((item) => { + formData.append(`${key}[]`, item); + }); + return; + } + + formData.append(key, params![key]); + }); + } + + return new Promise((resolve, reject) => { + this.axiosInstance + .request({ + method: 'POST', + data: formData, + headers: { + 'Content-type': ContentTypeEnum.FORM_DATA, + ignoreCancelToken: true, + }, + ...config, + }) + .then((res: AxiosResponse) => { + // 请求是否被取消 + const isCancel = axios.isCancel(res); + if (transformRequestData && isFunction(transformRequestData) && !isCancel) { + try { + const ret = transformRequestData(res, opt); + resolve(ret); + } catch (err) { + reject(err || new Error('request error!')); + } + return; + } + resolve(res as unknown as Promise); + }) + .catch((e: Error) => { + if (requestCatch && isFunction(requestCatch)) { + reject(requestCatch(e)); + return; + } + reject(e); + }); + }); + } + + /** + * @description: 拦截器配置 + */ + private setupInterceptors() { + const transform = this.getTransform(); + if (!transform) { + return; + } + const { + requestInterceptors, + requestInterceptorsCatch, + responseInterceptors, + responseInterceptorsCatch, + } = transform; + + const axiosCanceler = new AxiosCanceler(); + + // 请求拦截器配置处理 + this.axiosInstance.interceptors.request.use((config: AxiosRequestConfig) => { + const { + headers: { ignoreCancelToken }, + } = config; + const ignoreCancel = + ignoreCancelToken !== undefined + ? ignoreCancelToken + : this.options.requestOptions?.ignoreCancelToken; + + !ignoreCancel && axiosCanceler.addPending(config); + if (requestInterceptors && isFunction(requestInterceptors)) { + config = requestInterceptors(config, this.options); + } + return config; + }, undefined); + + // 请求拦截器错误捕获 + requestInterceptorsCatch && + isFunction(requestInterceptorsCatch) && + this.axiosInstance.interceptors.request.use(undefined, requestInterceptorsCatch); + + // 响应结果拦截器处理 + this.axiosInstance.interceptors.response.use((res: AxiosResponse) => { + res && axiosCanceler.removePending(res.config); + if (responseInterceptors && isFunction(responseInterceptors)) { + res = responseInterceptors(res); + } + return res; + }, undefined); + + // 响应结果拦截器错误捕获 + responseInterceptorsCatch && + isFunction(responseInterceptorsCatch) && + this.axiosInstance.interceptors.response.use(undefined, responseInterceptorsCatch); + } +} diff --git a/web/src/utils/http/axios/axiosCancel.ts b/web/src/utils/http/axios/axiosCancel.ts new file mode 100644 index 0000000..be517c0 --- /dev/null +++ b/web/src/utils/http/axios/axiosCancel.ts @@ -0,0 +1,61 @@ +import axios, { AxiosRequestConfig, Canceler } from 'axios'; +import qs from 'qs'; + +import { isFunction } from '@/utils/is/index'; + +// 声明一个 Map 用于存储每个请求的标识 和 取消函数 +let pendingMap = new Map(); + +export const getPendingUrl = (config: AxiosRequestConfig) => + [config.method, config.url, qs.stringify(config.data), qs.stringify(config.params)].join('&'); + +export class AxiosCanceler { + /** + * 添加请求 + * @param {Object} config + */ + addPending(config: AxiosRequestConfig) { + this.removePending(config); + const url = getPendingUrl(config); + config.cancelToken = + config.cancelToken || + new axios.CancelToken((cancel) => { + if (!pendingMap.has(url)) { + // 如果 pending 中不存在当前请求,则添加进去 + pendingMap.set(url, cancel); + } + }); + } + + /** + * @description: 清空所有pending + */ + removeAllPending() { + pendingMap.forEach((cancel) => { + cancel && isFunction(cancel) && cancel(); + }); + pendingMap.clear(); + } + + /** + * 移除请求 + * @param {Object} config + */ + removePending(config: AxiosRequestConfig) { + const url = getPendingUrl(config); + + if (pendingMap.has(url)) { + // 如果在 pending 中存在当前请求标识,需要取消当前请求,并且移除 + const cancel = pendingMap.get(url); + cancel && cancel(url); + pendingMap.delete(url); + } + } + + /** + * @description: 重置 + */ + reset(): void { + pendingMap = new Map(); + } +} diff --git a/web/src/utils/http/axios/axiosTransform.ts b/web/src/utils/http/axios/axiosTransform.ts new file mode 100644 index 0000000..c966a0b --- /dev/null +++ b/web/src/utils/http/axios/axiosTransform.ts @@ -0,0 +1,52 @@ +/** + * 数据处理类,可以根据项目自行配置 + */ +import type { AxiosRequestConfig, AxiosResponse } from 'axios'; +import type { RequestOptions, Result } from './types'; + +export interface CreateAxiosOptions extends AxiosRequestConfig { + authenticationScheme?: string; + transform?: AxiosTransform; + requestOptions?: RequestOptions; +} + +export abstract class AxiosTransform { + /** + * @description: 请求之前处理配置 + * @description: Process configuration before request + */ + beforeRequestHook?: (config: AxiosRequestConfig, options: RequestOptions) => AxiosRequestConfig; + + /** + * @description: 请求成功处理 + */ + transformRequestData?: (res: AxiosResponse, options: RequestOptions) => any; + + /** + * @description: 请求失败处理 + */ + requestCatch?: (e: Error) => Promise; + + /** + * @description: 请求之前的拦截器 + */ + requestInterceptors?: ( + config: AxiosRequestConfig, + options: CreateAxiosOptions + ) => AxiosRequestConfig; + + /** + * @description: 请求之后的拦截器 + */ + responseInterceptors?: (res: AxiosResponse) => AxiosResponse; + + /** + * @description: 请求之前的拦截器错误处理 + */ + requestInterceptorsCatch?: (error: Error) => void; + + /** + * @description: 请求之后的拦截器错误处理 + */ + responseInterceptorsCatch?: (error: Error) => void; +} diff --git a/web/src/utils/http/axios/checkStatus.ts b/web/src/utils/http/axios/checkStatus.ts new file mode 100644 index 0000000..061fa7f --- /dev/null +++ b/web/src/utils/http/axios/checkStatus.ts @@ -0,0 +1,47 @@ +export function checkStatus(status: number, msg: string): void { + const $message = window['$message']; + switch (status) { + case 400: + $message.error(msg); + break; + // 401: 未登录 + // 未登录则跳转登录页面,并携带当前页面的路径 + // 在登录成功后返回当前页面,这一步需要在登录页操作。 + case 61: + $message.error('用户没有权限(令牌、用户名、密码错误)!'); + break; + case 403: + $message.error('用户得到授权,但是访问是被禁止的。!'); + break; + // 404请求不存在 + case 404: + $message.error('网络请求错误,未找到该资源!'); + break; + case 405: + $message.error('网络请求错误,请求方法未允许!'); + break; + case 408: + $message.error('网络请求超时'); + break; + case 500: + $message.error('服务器错误,请稍候重试!'); + break; + case 501: + $message.error('网络未实现'); + break; + case 502: + $message.error('网络错误'); + break; + case 503: + $message.error('服务不可用,服务器暂时过载或维护!'); + break; + case 504: + $message.error('网络超时'); + break; + case 505: + $message.error('http版本不支持该请求!'); + break; + default: + $message.error(msg); + } +} diff --git a/web/src/utils/http/axios/helper.ts b/web/src/utils/http/axios/helper.ts new file mode 100644 index 0000000..af540d4 --- /dev/null +++ b/web/src/utils/http/axios/helper.ts @@ -0,0 +1,47 @@ +import { isObject, isString } from '@/utils/is'; + +const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm'; + +export function joinTimestamp( + join: boolean, + restful: T +): T extends true ? string : object; + +export function joinTimestamp(join: boolean, restful = false): string | object { + if (!join) { + return restful ? '' : {}; + } + const now = new Date().getTime(); + if (restful) { + return `?_t=${now}`; + } + return { _t: now }; +} + +/** + * @description: Format request parameter time + */ +export function formatRequestDate(params: Recordable) { + if (Object.prototype.toString.call(params) !== '[object Object]') { + return; + } + + for (const key in params) { + if (params[key] && params[key]._isAMomentObject) { + params[key] = params[key].format(DATE_TIME_FORMAT); + } + if (isString(key)) { + const value = params[key]; + if (value) { + try { + params[key] = isString(value) ? value.trim() : value; + } catch (error) { + throw new Error(error as any); + } + } + } + if (isObject(params[key])) { + formatRequestDate(params[key]); + } + } +} diff --git a/web/src/utils/http/axios/index.ts b/web/src/utils/http/axios/index.ts new file mode 100644 index 0000000..b871c5a --- /dev/null +++ b/web/src/utils/http/axios/index.ts @@ -0,0 +1,309 @@ +// axios配置 可自行根据项目进行更改,只需更改该文件即可,其他文件可以不动 +import { VAxios } from './Axios'; +import { AxiosTransform } from './axiosTransform'; +import axios, { AxiosResponse } from 'axios'; +import { checkStatus } from './checkStatus'; +import { formatRequestDate, joinTimestamp } from './helper'; +import { ContentTypeEnum, RequestEnum, ResultEnum } from '@/enums/httpEnum'; +import { PageEnum } from '@/enums/pageEnum'; +import { useGlobSetting } from '@/hooks/setting'; +import { isString, isUrl } from '@/utils/is/'; +import { deepMerge } from '@/utils'; +import { setObjToUrlParams } from '@/utils/urlUtils'; +import { CreateAxiosOptions, RequestOptions, Result } from './types'; +import { useUserStoreWidthOut } from '@/store/modules/user'; +import router from '@/router'; +import { storage } from '@/utils/Storage'; +import { encodeParams } from '@/utils/urlUtils'; +import { delNullProperty } from '@/utils/array'; +import { useI18nStore } from '@/store/modules/i18n'; + +const globSetting = useGlobSetting(); +const urlPrefix = globSetting.urlPrefix || ''; + +/** + * @description: 数据处理,方便区分多种处理方式 + */ +const transform: AxiosTransform = { + /** + * @description: 处理请求数据 + */ + transformRequestData: (res: AxiosResponse, options: RequestOptions) => { + const { + isShowMessage = true, + isShowErrorMessage, + isShowSuccessMessage, + successMessageText, + errorMessageText, + isTransformResponse, + isReturnNativeResponse, + } = options; + + // 是否返回原生响应头 比如:需要获取响应头时使用该属性 + if (isReturnNativeResponse) { + return res; + } + // 不进行任何处理,直接返回 + // 用于页面代码可能需要直接获取code,data,message这些信息时开启 + if (!isTransformResponse) { + return res.data; + } + + const response = res.data; + const $dialog = window['$dialog']; + const $message = window['$message']; + + if (!response) { + // return '[HTTP] Request has no return value'; + throw new Error('请求出错,请稍候重试'); + } + + // 这里 code,result,message为 后台统一的字段,需要修改为项目自己的接口返回格式 + const { code, data, message } = response; + + // 请求成功 + const hasSuccess = response && Reflect.has(response, 'code') && code === ResultEnum.SUCCESS; + // 是否显示提示信息 + if (isShowMessage) { + if (hasSuccess && (successMessageText || isShowSuccessMessage)) { + // 是否显示自定义信息提示 + $dialog.success({ + type: 'success', + content: successMessageText || message || '操作成功!', + }); + } else if (!hasSuccess && (errorMessageText || isShowErrorMessage)) { + // 是否显示自定义信息提示 + $message.error(message || errorMessageText || '操作失败!'); + } else if (!hasSuccess && options.errorMessageMode === 'modal') { + // errorMessageMode=‘custom-modal’的时候会显示modal错误弹窗,而不是消息提示,用于一些比较重要的错误 + $dialog.info({ + title: '提示', + content: message, + positiveText: '确定', + onPositiveClick: () => {}, + }); + } + } + + // 接口请求成功,直接返回结果 + if (code === ResultEnum.SUCCESS) { + return data; + } + // 接口请求错误,统一提示错误信息 这里逻辑可以根据项目进行修改 + let errorMsg = message; + switch (code) { + // 请求失败 + case ResultEnum.ERROR: + $message.error(errorMsg); + break; + // 登录超时 + case ResultEnum.TIMEOUT: + const LoginName = PageEnum.BASE_LOGIN_NAME; + const LoginPath = PageEnum.BASE_LOGIN; + if (router.currentRoute.value.name === LoginName) return; + // 到登录页 + errorMsg = message ?? '登录超时,请重新登录!'; + $dialog.warning({ + title: '提示', + content: errorMsg, // '登录身份已失效,请重新登录!', + positiveText: '确定', + //negativeText: '取消', + closable: false, + maskClosable: false, + onPositiveClick: () => { + storage.clear(); + window.location.href = LoginPath; + }, + onNegativeClick: () => {}, + }); + break; + default: + console.log('unknown status code:' + code); + $message.error(errorMsg); + } + + throw new Error(errorMsg); + }, + + // 请求之前处理config + beforeRequestHook: (config, options) => { + const { apiUrl, joinPrefix, joinParamsToUrl, formatDate, joinTime = true, urlPrefix } = options; + + const isUrlStr = isUrl(config.url as string); + + if (!isUrlStr && joinPrefix) { + config.url = `${urlPrefix}${config.url}`; + } + + if (!isUrlStr && apiUrl && isString(apiUrl)) { + config.url = `${apiUrl}${config.url}`; + } + const params = config.params || {}; + const data = config.data || false; + if (config.method?.toUpperCase() === RequestEnum.GET) { + if (!isString(params)) { + // 给 get 请求加上时间戳参数,避免从缓存中拿数据。 + config.params = Object.assign(params || {}, joinTimestamp(joinTime, false)); + } else { + // 兼容restful风格 + config.url = config.url + params + `${joinTimestamp(joinTime, true)}`; + config.params = undefined; + } + } else { + if (!isString(params)) { + formatDate && formatRequestDate(params); + if (Reflect.has(config, 'data') && config.data && Object.keys(config.data).length > 0) { + config.data = data; + config.params = params; + } else { + config.data = params; + config.params = undefined; + } + if (joinParamsToUrl) { + config.url = setObjToUrlParams( + config.url as string, + Object.assign({}, config.params, config.data) + ); + } + } else { + // 兼容restful风格 + config.url = config.url + params; + config.params = undefined; + } + } + return config; + }, + + /** + * @description: 请求拦截器处理 + */ + requestInterceptors: (config, options) => { + // 请求之前处理config + const i18nStore = useI18nStore(); + const userStore = useUserStoreWidthOut(); + const token = userStore.getToken; + if (token && (config as Recordable)?.requestOptions?.withToken !== false) { + // jwt token + (config as Recordable).headers.Authorization = options.authenticationScheme + ? `${options.authenticationScheme} ${token}` + : token; + } + config.headers.Locale = i18nStore.getLocale(); + return config; + }, + + /** + * @description: 响应错误处理 + */ + responseInterceptorsCatch: (error: any) => { + const $dialog = window['$dialog']; + const $message = window['$message']; + const { response, code, message } = error || {}; + // TODO 此处要根据后端接口返回格式修改 + const msg: string = + response && response.data && response.data.message ? response.data.message : ''; + const err: string = error.toString(); + try { + if (code === 'ECONNABORTED' && message.indexOf('timeout') !== -1) { + $message.error('接口请求超时,请刷新页面重试!'); + return; + } + if (err && err.includes('Network Error')) { + $dialog.info({ + title: '网络异常', + content: '请检查您的网络连接是否正常', + positiveText: '确定', + //negativeText: '取消', + closable: false, + maskClosable: false, + onPositiveClick: () => {}, + onNegativeClick: () => {}, + }); + return Promise.reject(error); + } + } catch (error) { + throw new Error(error as any); + } + // 请求是否被取消 + const isCancel = axios.isCancel(error); + if (!isCancel) { + checkStatus(error.response && error.response.status, msg); + } else { + console.warn(error, '请求被取消!'); + } + //return Promise.reject(error); + return Promise.reject(response?.data); + }, +}; + +function createAxios(opt?: Partial) { + return new VAxios( + deepMerge( + { + timeout: 30 * 1000, + authenticationScheme: '', + // 接口前缀 + prefixUrl: urlPrefix, + headers: { 'Content-Type': ContentTypeEnum.JSON }, + // 数据处理方式 + transform, + // 配置项,下面的选项都可以在独立的接口请求中覆盖 + requestOptions: { + // 默认将prefix 添加到url + joinPrefix: true, + // 是否返回原生响应头 比如:需要获取响应头时使用该属性 + isReturnNativeResponse: false, + // 需要对返回数据进行处理 + isTransformResponse: true, + // post请求的时候添加参数到url + joinParamsToUrl: false, + // 格式化提交参数时间 + formatDate: true, + // 消息提示类型 + errorMessageMode: 'none', + // 接口地址 + apiUrl: globSetting.apiUrl, + // 接口拼接地址 + urlPrefix: urlPrefix, + // 是否加入时间戳 + joinTime: false, + // 忽略重复请求 + ignoreCancelToken: true, + // 是否携带token + withToken: true, + }, + withCredentials: false, + }, + opt || {} + ) + ); +} + +export const http = createAxios(); + +// 导出 +export const jumpExport = function (url, params) { + jump(url, params); +}; + +// 跳转 +export const jump = function (url, params) { + window.location.href = + urlPrefix + + url + + '?' + + encodeParams({ + ...delNullProperty(params), + ...{ authorization: useUserStoreWidthOut().token }, + }); +}; + +// 项目,多个不同 api 地址,直接在这里导出多个 +// src/api ts 里面接口,就可以单独使用这个请求, +// import { httpTwo } from '@/utils/http/axios' +// export const httpTwo = createAxios({ +// requestOptions: { +// apiUrl: 'http://localhost:9001', +// urlPrefix: 'api', +// }, +// }); diff --git a/web/src/utils/http/axios/types.ts b/web/src/utils/http/axios/types.ts new file mode 100644 index 0000000..e1e3423 --- /dev/null +++ b/web/src/utils/http/axios/types.ts @@ -0,0 +1,65 @@ +import { AxiosRequestConfig } from 'axios'; +import { AxiosTransform } from './axiosTransform'; + +export interface CreateAxiosOptions extends AxiosRequestConfig { + transform?: AxiosTransform; + requestOptions?: RequestOptions; + authenticationScheme?: string; +} + +// 上传文件 +export interface UploadFileParams { + // 其他参数 + data?: Recordable; + // 文件参数接口字段名 + name?: string; + // 文件 + file: File | Blob; + // 文件名称 + filename?: string; + [key: string]: any; +} + +export interface RequestOptions { + // 请求参数拼接到url + joinParamsToUrl?: boolean; + // 格式化请求参数时间 + formatDate?: boolean; + // 是否显示提示信息 + isShowMessage?: boolean; + // 是否解析成JSON + isParseToJson?: boolean; + // 成功的文本信息 + successMessageText?: string; + // 是否显示成功信息 + isShowSuccessMessage?: boolean; + // 是否显示失败信息 + isShowErrorMessage?: boolean; + // 错误的文本信息 + errorMessageText?: string; + // 是否加入url + joinPrefix?: boolean; + // 接口地址, 不填则使用默认apiUrl + apiUrl?: string; + // 请求拼接路径 + urlPrefix?: string; + // 错误消息提示类型 + errorMessageMode?: 'none' | 'modal'; + // 是否添加时间戳 + joinTime?: boolean; + // 不进行任何处理,直接返回 + isTransformResponse?: boolean; + // 是否返回原生响应头 + isReturnNativeResponse?: boolean; + //忽略重复请求 + ignoreCancelToken?: boolean; + // 是否携带token + withToken?: boolean; +} + +export interface Result { + code: number; + type?: 'success' | 'error' | 'warning'; + message: string; + data?: T; +} diff --git a/web/src/utils/icons.ts b/web/src/utils/icons.ts new file mode 100644 index 0000000..bceaabe --- /dev/null +++ b/web/src/utils/icons.ts @@ -0,0 +1,17 @@ +import { Component } from 'vue'; +import { renderIcon } from '@/utils/index'; +import * as antdIcons from '@vicons/antd'; +// import * as x5Icons from '@vicons/ionicons5'; + +export const Icons = {}; +for (const element of Object.keys(antdIcons)) { + Icons[element] = renderIcon(antdIcons[element]); +} + +// for (const element of Object.keys(x5Icons)) { +// constantRouterIcon[element] = renderIcon(x5Icons[element]); +// } + +export function getIconComponent(name: string): Component { + return Icons[name]; +} diff --git a/web/src/utils/index.ts b/web/src/utils/index.ts new file mode 100644 index 0000000..15507b5 --- /dev/null +++ b/web/src/utils/index.ts @@ -0,0 +1,453 @@ +import { h, unref } from 'vue'; +import type { App, Plugin } from 'vue'; +import { + NAvatar, + NBadge, + NButton, + NIcon, + NImage, + NPopover, + NTable, + NTag, + NTooltip, + SelectRenderTag, +} from 'naive-ui'; +import { EllipsisHorizontalCircleOutline } from '@vicons/ionicons5'; +import { PageEnum } from '@/enums/pageEnum'; +import { isArray, isJsonString, isNullObject, isObject } from './is/index'; +import { cloneDeep } from 'lodash-es'; +import { VNode } from 'vue'; +import { DictType, useDictStore } from '@/store/modules/dict'; +import { fallbackSrc } from '@/utils/hotgo'; +import { getFileExt } from '@/utils/urlUtils'; + +export const renderTooltip = (trigger, content) => { + return h(NTooltip, null, { + trigger: () => trigger, + default: () => content, + }); +}; + +/** + * render 图标 + * */ +export function renderIcon(icon, props = null) { + return () => h(NIcon, props, { default: () => h(icon) }); +} + +/** + * render new Tag + * */ +const newTagColors = { color: '#f90', textColor: '#fff', borderColor: '#f90' }; + +export function renderNew(type = 'warning', text = 'New', color: object = newTagColors) { + return () => + h( + NTag as any, + { + type, + round: true, + size: 'small', + color, + }, + { default: () => text } + ); +} + +// render 标记 +export function renderBadge(node: VNode) { + return h( + NBadge, + { + dot: true, + type: 'info', + }, + { default: () => node } + ); +} + +// render 标签 +export const renderTag: SelectRenderTag = ({ option }) => { + return h( + NTag, + { + type: option.listClass as 'success' | 'warning' | 'error' | 'info' | 'primary' | 'default', + }, + { default: () => option.label } + ); +}; + +// renderOptionTag 选项标签 +export const renderOptionTag = (type: DictType, value: any) => { + if (isNullObject(value)) { + return ``; + } + const dict = useDictStore(); + return h( + NTag, + { + style: { + marginRight: '6px', + }, + type: dict.getType(type, value), + bordered: false, + }, + { + default: () => dict.getLabel(type, value), + } + ); +}; + +// render 图片 +export const renderImage = (image: string) => { + if (!image || image === '') { + return ``; + } + return h(NImage, { + width: 32, + height: 32, + src: image, + fallbackSrc: fallbackSrc(), + style: { + width: '32px', + height: '32px', + 'max-width': '100%', + 'max-height': '100%', + 'margin-left': '2px', + }, + }); +}; + +// render 图片组 +export const renderImageGroup = (images: any) => { + if (isJsonString(images)) { + images = JSON.parse(images); + } + if (isNullObject(images) || !isArray(images)) { + return ``; + } + return images.map((image: string) => { + return renderImage(image); + }); +}; + +// render 文件 +export const renderFile = (file: string) => { + if (!file || file === '') { + return ``; + } + return h( + NAvatar, + { + size: 'small', + style: { + 'margin-left': '2px', + }, + }, + { + default: () => getFileExt(file), + } + ); +}; + +// render 文件组 +export const renderFileGroup = (files: any) => { + if (isJsonString(files)) { + files = JSON.parse(files); + } + if (isNullObject(files) || !isArray(files)) { + return ``; + } + return files.map((file: string) => { + return renderFile(file); + }); +}; + +export interface MemberSumma { + id: number; // 用户ID + realName: string; // 真实姓名 + username: string; // 用户名 + avatar: string; // 头像 +} + +// render 操作人摘要 +export const renderPopoverMemberSumma = (member: MemberSumma | null | undefined) => { + if (!member) { + return ''; + } + return h( + NPopover, + { trigger: 'hover' }, + { + trigger: () => + h( + NButton, + { + size: 'small', + text: true, + iconPlacement: 'right', + }, + { default: () => member.realName, icon: renderIcon(EllipsisHorizontalCircleOutline) } + ), + default: () => + h( + NTable, + { + props: { + bordered: false, + 'single-line': false, + size: 'small', + }, + }, + [ + h('thead', [ + h('tr', { align: 'center' }, [ + h('th', '用户ID'), + h('th', '头像'), + h('th', '姓名'), + h('th', '用户名'), + ]), + ]), + h('tbody', [ + h('tr', { align: 'center' }, [ + h('td', member.id), + h('td', h(NAvatar, { src: member.avatar, round: true, size: 'small' })), + h('td', member.realName), + h('td', member.username), + ]), + ]), + ] + ), + } + ); +}; + +// render html +export function renderHtmlTooltip(content: string) { + content = content.replace(/\n/g, ''); + const html = h('p', { id: 'app' }, [ + h('div', { + innerHTML: content, + }), + ]); + return renderTooltip(html, html); +} + +/** + * 递归组装菜单格式 + */ +export function generatorMenu(routerMap: Array) { + return filterRouter(routerMap).map((item) => { + const isRoot = isRootRouter(item); + const info = isRoot ? item.children[0] : item; + const currentMenu = { + ...info, + ...info.meta, + label: info.meta?.title, + key: info.name, + icon: isRoot ? item.meta?.icon : info.meta?.icon, + }; + // 是否有子菜单,并递归处理 + if (info.children && info.children.length > 0) { + // Recursion + currentMenu.children = generatorMenu(info.children); + + // 当生成后子集为空,则删除子集空数组,否则加载时仍为目录格式! + if (currentMenu.children.length === 0) { + delete currentMenu.children; + } + } + return currentMenu; + }); +} + +/** + * 混合菜单 + * */ +export function generatorMenuMix(routerMap: Array, routerName: string, location: string) { + const cloneRouterMap = cloneDeep(routerMap); + const newRouter = filterRouter(cloneRouterMap); + if (location === 'header') { + const firstRouter: any[] = []; + newRouter.forEach((item) => { + const isRoot = isRootRouter(item); + const info = isRoot ? item.children[0] : item; + info.children = undefined; + const currentMenu = { + ...info, + ...info.meta, + label: info.meta?.title, + key: info.name, + }; + firstRouter.push(currentMenu); + }); + return firstRouter; + } else { + return getChildrenRouter(newRouter.filter((item) => item.name === routerName)); + } +} + +/** + * 递归组装子菜单 + * */ +export function getChildrenRouter(routerMap: Array) { + return filterRouter(routerMap).map((item) => { + const isRoot = isRootRouter(item); + const info = isRoot ? item.children[0] : item; + const currentMenu = { + ...info, + ...info.meta, + label: info.meta?.title, + key: info.name, + }; + // 是否有子菜单,并递归处理 + if (info.children && info.children.length > 0) { + // Recursion + currentMenu.children = getChildrenRouter(info.children); + } + return currentMenu; + }); +} + +/** + * 判断根路由 Router + * */ +export function isRootRouter(item) { + if (item.meta?.alwaysShow != true && item.children?.length === 0) { + return true; + } + + // if (item.meta?.alwaysShow != true) { + // if (item.children?.length > 0) { + // // 如果存在子级。且只要有一个不是隐藏状态的,则判断不是跟路由 + // for (let i = 0; i < item.children.length; i++) { + // if (item.children[i]?.hidden == false) { + // return false; + // } + // } + // + // return true; + // } + // } + + return false; +} + +/** + * 强制根路由转换 + * @param item + */ +export function mandatoryRootConvert(item) { + if (item.meta?.isRoot === true) { + } + + // 默认 + return item.children[0]; +} + +/** + * 排除Router + * */ +export function filterRouter(routerMap: Array) { + return routerMap.filter((item) => { + return ( + (item.meta?.hidden || false) != true && + !['/:path(.*)*', '/', PageEnum.REDIRECT, PageEnum.BASE_LOGIN].includes(item.path) + ); + }); +} + +export const withInstall = (component: T, alias?: string) => { + const comp = component as any; + comp.install = (app: App) => { + // @ts-ignore + app.component(comp.name || comp.displayName, component); + if (alias) { + app.config.globalProperties[alias] = component; + } + }; + return component as T & Plugin; +}; + +// dynamic use hook props +export function getDynamicProps(props: T): Partial { + const ret: Recordable = {}; + + // @ts-ignore + Object.keys(props).map((key) => { + ret[key] = unref((props as Recordable)[key]); + }); + + return ret as Partial; +} + +export function deepMerge(src: any = {}, target: any = {}): T { + let key: string; + for (key in target) { + src[key] = isObject(src[key]) ? deepMerge(src[key], target[key]) : (src[key] = target[key]); + } + return src; +} + +/** + * Sums the passed percentage to the R, G or B of a HEX color + * @param {string} color The color to change + * @param {number} amount The amount to change the color by + * @returns {string} The processed part of the color + */ +function addLight(color: string, amount: number) { + const cc = parseInt(color, 16) + amount; + const c = cc > 255 ? 255 : cc; + return c.toString(16).length > 1 ? c.toString(16) : `0${c.toString(16)}`; +} + +/** + * Lightens a 6 char HEX color according to the passed percentage + * @param {string} color The color to change + * @param {number} amount The amount to change the color by + * @returns {string} The processed color represented as HEX + */ +export function lighten(color: string, amount: number) { + color = color.indexOf('#') >= 0 ? color.substring(1, color.length) : color; + amount = Math.trunc((255 * amount) / 100); + return `#${addLight(color.substring(0, 2), amount)}${addLight( + color.substring(2, 4), + amount + )}${addLight(color.substring(4, 6), amount)}`; +} + +// 获取树的所有节点key +export function getAllExpandKeys(treeData: any): any[] { + let expandedKeys: any = []; + const expandKeys = (items: any[]) => { + items.forEach((item: any) => { + expandedKeys.push(item.key); + if (item.children && item.children.length > 0) { + expandKeys(item.children); + } + }); + }; + + expandKeys(unref(treeData)); + + // 去重并转换为数组 + expandedKeys = Array.from(new Set(expandedKeys)); + return expandedKeys; +} + +// 从树中查找指定节点 +export function findTreeNode(data: any, key?: string | number, keyField = 'key'): any { + for (const item of data) { + if (item[keyField] == key) { + return item; + } else { + if (item.children && item.children.length) { + const foundItem = findTreeNode(item.children, key); + if (foundItem) { + return foundItem; + } + } + } + } + return null; +} diff --git a/web/src/utils/is/index.ts b/web/src/utils/is/index.ts new file mode 100644 index 0000000..49ff4cf --- /dev/null +++ b/web/src/utils/is/index.ts @@ -0,0 +1,165 @@ +const toString = Object.prototype.toString; + +/** + * @description: 判断值是否未某个类型 + */ +export function is(val: unknown, type: string) { + return toString.call(val) === `[object ${type}]`; +} + +/** + * @description: 是否为函数 + */ +export function isFunction(val: unknown): val is T { + return is(val, 'Function') || is(val, 'AsyncFunction'); +} + +/** + * @description: 是否已定义 + */ +export const isDef = (val?: T): val is T => { + return typeof val !== 'undefined'; +}; + +export const isUnDef = (val?: T): val is T => { + return !isDef(val); +}; +/** + * @description: 是否为对象 + */ +export const isObject = (val: any): val is Record => { + return val !== null && is(val, 'Object'); +}; + +/** + * @description: 是否为时间 + */ +export function isDate(val: unknown): val is Date { + return is(val, 'Date'); +} + +/** + * @description: 是否为数值 + */ +export function isNumber(val: unknown): val is number { + return is(val, 'Number'); +} + +/** + * @description: 是否为AsyncFunction + */ +export function isAsyncFunction(val: unknown): val is Promise { + return is(val, 'AsyncFunction'); +} + +/** + * @description: 是否为promise + */ +export function isPromise(val: unknown): val is Promise { + return is(val, 'Promise') && isObject(val) && isFunction(val.then) && isFunction(val.catch); +} + +/** + * @description: 是否为字符串 + */ +export function isString(val: unknown): val is string { + return is(val, 'String'); +} + +/** + * @description: 是否为boolean类型 + */ +export function isBoolean(val: unknown): val is boolean { + return is(val, 'Boolean'); +} + +/** + * @description: 是否为数组 + */ +export function isArray(val: any): val is Array { + return val && Array.isArray(val); +} + +/** + * @description: 是否为字符串数组 + */ +export function isArrayString(val: any): val is Array { + return Array.isArray(val) && val.every((item: any) => typeof item === 'string'); +} + +/** + * @description: 是否为转为string的json + */ +export function isJsonString(value: any) { + try { + const toObj = JSON.parse(value); + if (toObj && typeof toObj === 'object') { + return true; + } + } catch {} + return false; +} + +export function isNullObject(value: any) { + return ( + isNullOrUnDef(value) || + JSON.stringify(value) === '{}' || + JSON.stringify(value) === '[]' || + value === '' + ); +} + +/** + * @description: 是否客户端 + */ +export const isClient = () => { + return typeof window !== 'undefined'; +}; + +/** + * @description: 是否为浏览器 + */ +export const isWindow = (val: any): val is Window => { + return typeof window !== 'undefined' && is(val, 'Window'); +}; + +export const isElement = (val: unknown): val is Element => { + return isObject(val) && !!val.tagName; +}; + +export const isServer = typeof window === 'undefined'; + +// 是否为图片节点 +export function isImageDom(o: Element) { + return o && ['IMAGE', 'IMG'].includes(o.tagName); +} + +export function isNull(val: unknown): val is null { + return val === null; +} + +export function isNullAndUnDef(val: unknown): val is null | undefined { + return isUnDef(val) && isNull(val); +} + +export function isNullOrUnDef(val: unknown): val is null | undefined { + return isUnDef(val) || isNull(val); +} + +export function isEmpty(value: any): boolean { + return value === undefined || value === null || value === ''; +} + +// 判断字串符是否以字母开头 +export function isLetterBegin(str) { + return /^[A-z]/.test(str); +} + +export function isUrl(url: string): boolean { + return /(^http|https:\/\/)/g.test(url); +} + +// 判断是否为微信浏览器 +export function isWechatBrowser(): boolean { + return /micromessenger/.test(navigator.userAgent.toLowerCase()) ? true : false; +} diff --git a/web/src/utils/lib/echarts.ts b/web/src/utils/lib/echarts.ts new file mode 100644 index 0000000..a52f48b --- /dev/null +++ b/web/src/utils/lib/echarts.ts @@ -0,0 +1,53 @@ +import * as echarts from 'echarts/core'; + +import { + BarChart, + LineChart, + PieChart, + MapChart, + PictorialBarChart, + RadarChart, +} from 'echarts/charts'; + +import { + TitleComponent, + TooltipComponent, + GridComponent, + PolarComponent, + AriaComponent, + ParallelComponent, + LegendComponent, + RadarComponent, + ToolboxComponent, + DataZoomComponent, + VisualMapComponent, + TimelineComponent, + CalendarComponent, +} from 'echarts/components'; + +import { SVGRenderer } from 'echarts/renderers'; + +echarts.use([ + LegendComponent, + TitleComponent, + TooltipComponent, + GridComponent, + PolarComponent, + AriaComponent, + ParallelComponent, + BarChart, + LineChart, + PieChart, + MapChart, + RadarChart, + SVGRenderer, + PictorialBarChart, + RadarComponent, + ToolboxComponent, + DataZoomComponent, + VisualMapComponent, + TimelineComponent, + CalendarComponent, +]); + +export default echarts; diff --git a/web/src/utils/log.ts b/web/src/utils/log.ts new file mode 100644 index 0000000..8f79800 --- /dev/null +++ b/web/src/utils/log.ts @@ -0,0 +1,9 @@ +const projectName = import.meta.env.VITE_GLOB_APP_TITLE; + +export function warn(message: string) { + console.warn(`[${projectName} warn]:${message}`); +} + +export function error(message: string) { + throw new Error(`[${projectName} error]:${message}`); +} diff --git a/web/src/utils/propTypes.ts b/web/src/utils/propTypes.ts new file mode 100644 index 0000000..6c4173e --- /dev/null +++ b/web/src/utils/propTypes.ts @@ -0,0 +1,50 @@ +import { CSSProperties, VNodeChild } from 'vue'; +import { + createTypes, + VueTypeExtendCallback, + VueTypeValidableDef, + VueTypesInterface, +} from 'vue-types'; + +export type VueNode = VNodeChild | JSX.Element; + +type ExtendedPropTypes = VueTypesInterface & { + readonly style: VueTypeValidableDef; + readonly VNodeChild: VueTypeValidableDef; +}; + +class CustomVueTypes + extends (createTypes({ + func: undefined, + bool: undefined, + string: undefined, + number: undefined, + object: undefined, + integer: undefined, + }) as VueTypesInterface) + implements ExtendedPropTypes +{ + static extend(types: VueTypeExtendCallback[]): CustomVueTypes { + return types.reduce((result, { name, ...callbacks }) => { + result[name] = { getter: true, ...callbacks }; + return result; + }, this); + } + + readonly style!: VueTypeValidableDef; + readonly VNodeChild!: VueTypeValidableDef; +} + +const propTypes = CustomVueTypes.extend([ + { + name: 'style', + type: [String, Object], + default: undefined, + }, + { + name: 'VNodeChild', + type: undefined, + }, +]); + +export { propTypes }; diff --git a/web/src/utils/tree.ts b/web/src/utils/tree.ts new file mode 100644 index 0000000..b67e076 --- /dev/null +++ b/web/src/utils/tree.ts @@ -0,0 +1,97 @@ +/** + * 树形数组转平级 + * @date 2023-01-09 + * @returns {array} + */ +export const tree2FlatArray = (tree: any[], childrenKey: string = "children") => { + return tree.reduce((arr, item) => { + var children = item?.[childrenKey] ?? []; + var obj = item; + delete obj[childrenKey]; + //解构赋值+默认值 + return arr.concat([obj], tree2FlatArray(children)); //children部分进行递归 + }, []); +}; +/** + * 树形数组转对象 + * @date 2023-01-09 + * @returns {object} + */ +export const tree2Objects = ( + tree: any[], + key: string = "id", + childrenKey: string = "children" +) => { + let rows = tree2FlatArray(tree, childrenKey); + let objects = {}; + rows.forEach((item) => { + objects[item[key]] = item; + }); + return objects; +}; + +/** + * 树形菜单数据输出 + * @date 2022-04-20 + * @param {object} data - 数据 + * @param {string} pid="parent_id" - 父类名称 + * @param {string} children="children" - 子类名称 + * @returns {object} + */ +export const treesBy = ( + data: any[], + pid: string = "parent_id", + children: string = "children" +) => { + let map = {}, + val = []; + data.forEach((item) => { + item[children] = []; + map[item.id] = item; + }); + data.forEach((item) => { + const parent = map[item[pid]]; + if (!!parent) { + parent[children].push(item); + } else { + val.push(item); + } + }); + return val; +}; +/** + * 格式化树形菜单,省市区 + * @param {object} org + * @returns {object} + */ +export const mapTree = (org: any, keyId = "v", keyChild = "s", keyName = "n") => { + const haveChildren = + org[keyChild] && Array.isArray(org[keyChild]) && org[keyChild].length > 0; + return { + key: org[keyId], + value: String(org[keyId]), + label: org[keyName], + isLeaf: true, + children: haveChildren ? org[keyChild].map((i) => mapTree(i)) : [], + }; +}; + +/** + * 格式化树形菜单,省市区 + * @param {object} org + * @returns {object} + */ +export const toFlatArray = ( + tree: any[], + parentId: string | number, + keyId: string | number = "key" +) => { + return tree.reduce((t, _) => { + const child = _["children"]; + return [ + ...t, + { [keyId]: _[keyId], parentId }, + ...(child && child.length ? toFlatArray(child, _[keyId]) : []), + ]; + }, []); +}; diff --git a/web/src/utils/urlUtils.ts b/web/src/utils/urlUtils.ts new file mode 100644 index 0000000..3a678bf --- /dev/null +++ b/web/src/utils/urlUtils.ts @@ -0,0 +1,81 @@ +import { RouteLocationRaw } from 'vue-router'; +import router from '@/router'; + +/** + * 将对象添加当作参数拼接到URL上面 + * @param baseUrl 需要拼接的url + * @param obj 参数对象 + * @returns {string} 拼接后的对象 + * 例子: + * let obj = {a: '3', b: '4'} + * setObjToUrlParams('www.baidu.com', obj) + * ==>www.baidu.com?a=3&b=4 + */ +export function setObjToUrlParams(baseUrl: string, obj: object): string { + let parameters = ''; + let url = ''; + for (const key in obj) { + parameters += key + '=' + encodeURIComponent(obj[key]) + '&'; + } + parameters = parameters.replace(/&$/, ''); + if (/\?$/.test(baseUrl)) { + url = baseUrl + parameters; + } else { + url = baseUrl.replace(/\/?$/, '?') + parameters; + } + return url; +} + +export function encodeParams(obj) { + const arr = []; + for (const p in obj) { + // @ts-ignore + arr.push(encodeURIComponent(p) + '=' + encodeURIComponent(obj[p])); + } + return arr.join('&'); +} + +/** + * 获取文件后缀 + */ +export function getFileExt(fileName: string) { + if (fileName === undefined || fileName === '') { + return ``; + } + return fileName.substring(fileName.lastIndexOf('.') + 1); +} + +/** + * 获取当访问的url,不含参数 + */ +export function getNowUrl(): string { + const w = window.location; + return w.protocol + '//' + w.host + w.pathname; +} + +// 返回上一页 +export function goBackOrToPage(to: RouteLocationRaw): void { + if (router.currentRoute.value.matched.length > 0) { + router.go(-1); + } else { + router.push(to).catch((e) => { + console.log('返回上一页失败:', e); + }); + } +} + +// 更新hash路由get参数,不刷新页面 +export function pushHashRouterParameter(url, key, value) { + const urlParts = url.split('?'); + let newUrl = ''; + if (urlParts.length >= 2) { + const baseUrl = urlParts[0]; + const queryString = urlParts[1]; + const searchParams = new URLSearchParams(queryString); + searchParams.set(key, value); + newUrl = `${baseUrl}?${searchParams.toString()}`; + } else { + newUrl = `${url}?${key}=${value}`; + } + window.history.pushState({ path: newUrl }, '', newUrl); +} diff --git a/web/src/utils/validateUtil.ts b/web/src/utils/validateUtil.ts new file mode 100644 index 0000000..dbbcb42 --- /dev/null +++ b/web/src/utils/validateUtil.ts @@ -0,0 +1,251 @@ +import { FormItemRule } from 'naive-ui'; +import * as yaml from 'js-yaml'; +/** + * @description 表单验证封装 + */ +export const validate = { + // 验证ipv4 + ip(rule: FormItemRule, value: any, callback: Function): boolean | Error { + // 支持通配符的ipv4正则 + const ipv4Regex = + /^(?:[1-9]?[0-9]|1[0-9]{2}|2(?:[0-4][0-9]|5[0-5]))(?!.*?\.\*\.[*\d])(?:\.(?:(?:[1-9]?[0-9]|1[0-9]{2}|2(?:[0-4][0-9]|5[0-5]))|\*)){1,3}$/; + // Ipv6: + const ipv6Regex = + /^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$/; + if (!value && !rule.required) { + callback(); + } + if (!value) { + callback(new Error('请输入IP')); + } else if (!ipv4Regex.test(value) && !ipv6Regex.test(value)) { + callback(new Error('请输入正确的IP')); + } else { + callback(); + } + return true; + }, + //0-100百分比验证 + percentage(rule: FormItemRule, value: any, callback: Function): boolean | Error { + const reg = /^([1-9]{1,2}$)|(^[0-9]{1,2}\.[0-9]{1,2}$)|100$/; + if (!value && !rule.required) { + callback(new Error('请输入比例')); + } else if (!reg.test(value)) { + callback(new Error('请输入0-100的数字')); + } else { + callback(); + } + return true; + }, + // 手机号 (eg:138********,159********) + phone(rule: FormItemRule, value: any, callback: Function): boolean | Error { + const regPhone = /^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\d{8}$/; + if (!value && !rule.required) { + callback(); + } else if (!value) { + callback(new Error('请输入手机号码')); + } else if (!regPhone.test(value)) { + callback(new Error('手机号格式错误')); + } else { + callback(); + } + return true; + }, + // 用户名 (eg:a123456) + userName(rule: FormItemRule, value: any, callback: Function): boolean | Error { + const regUserName = /^[0-9a-zA-Z]{6,16}$/; + if (!value && !rule.required) { + callback(new Error('请输入登录账号')); + } else if (!regUserName.test(value)) { + callback(new Error('请输入6-16位由字母和数字组成的登录账号')); + } else { + callback(); + } + return true; + }, + // 账号 + account(rule: FormItemRule, value: any, callback: Function): boolean | Error { + const regex = /^[\w_\d]{6,16}$/; + if (!value && !rule.required) { + callback(); + } else if (!value) { + callback(new Error('请输入账号')); + } else if (!regex.test(value)) { + callback(new Error('请输入6-16位由字母、数字或下划线组成的账号')); + } else { + callback(); + } + return true; + }, + // 密码 + password(rule: FormItemRule, value: any, callback: Function): boolean | Error { + const regPassword = /^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,18}$/; + if (!value && !rule.required) { + callback(new Error('请输入密码')); + } else if (!regPassword.test(value)) { + callback(new Error('密码格式错误!必须包含6-18为字母和数字')); + } else { + callback(); + } + return true; + }, + // 邮箱 + email(rule: FormItemRule, value: any, callback: Function): boolean | Error { + const regEmails = /^([A-Za-z0-9_\-\.])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/; + // console.log('isRequired is: ', JSON.stringify(isRequired)) + if (!value && !rule.required) { + callback(); + } else if (!value) { + callback(new Error('请输入邮箱')); + } else if (!regEmails.test(value)) { + callback(new Error('邮箱格式错误')); + } else { + callback(); + } + return true; + }, + // 金额验证 + amount(rule: FormItemRule, value: any, callback: Function): boolean | Error { + const regAmount = /(^[0-9]{1,10}$)|(^[0-9]{1,10}[\.]{1}[0-9]{1,2}$)/; + if (!value && !rule.required) { + callback(); + } else if (!value) { + callback(new Error('请输入金额')); + } else if (!regAmount.test(value)) { + callback(new Error('金额格式错误,最多允许输入10位整数及2位小数')); + } else { + callback(); + } + return true; + }, + // 身份证验证 + idCard(rule: FormItemRule, value: any, callback: Function, isEnabled = true): boolean | Error { + const regIdCard = + /^[1-9]\d{7}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}$|^[1-9]\d{5}[1-9]\d{3}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}([0-9]|X|x)$/; + if (!isEnabled) { + callback(); + } else if (!value && !rule.required) { + callback(); + } else if (!value) { + callback(new Error('请输入身份证号')); + } else if (!regIdCard.test(value)) { + callback(new Error('身份证号码格式错误')); + } else { + callback(); + } + return true; + }, + // 银行卡验证 + bank(rule: FormItemRule, value: any, callback: Function): boolean | Error { + const regBank = /^([1-9]{1})(\d{15}|\d{16}|\d{18})$/; + if (!value && !rule.required) { + callback(); + } else if (!value) { + callback(); + } else if (!regBank.test(value)) { + callback(new Error('银行卡号码格式错误')); + } else { + callback(); + } + return true; + }, + // 非零正整数验证 + num(rule: FormItemRule, value: any, callback: Function): boolean | Error { + const reg = /^\+?[1-9][0-9]*$/; + if (!value && !rule.required) { + callback(new Error('请填写非零正整数')); + } else { + if (!reg.test(value)) { + callback(new Error('请输入非零的正整数')); + } else { + callback(); + } + } + return true; + }, + // 银行卡 + bankCard(rule: FormItemRule, value: any, callback: Function): boolean | Error { + const regBankCard = /^(\d{16}|\d{19})$/; + if (value == '' && !rule.required) { + callback(new Error('请输入银行卡号')); + } else { + if (!regBankCard.test(value)) { + callback(new Error('银行卡号格式错误')); + } else { + callback(); + } + } + return true; + }, + // 固话格式 + tel(rule: FormItemRule, value: any, callback: Function): boolean | Error { + const regTel = /^(0\d{2,3}-?)?\d{7,8}$/; + if (value == '' && !rule.required) { + callback(new Error('请输入座机号码')); + } else { + if (!regTel.test(value)) { + callback(new Error('座机号码格式错误')); + } else { + callback(); + } + } + return true; + }, + // QQ号码 + qq(rule: FormItemRule, value: any, callback: Function): boolean | Error { + const regex = /^[1-9][0-9]{4,}$/; + if (!value && !rule.required) { + callback(); + } else if (!value) { + callback(new Error('请输入QQ号码')); + } else { + if (!regex.test(value)) { + callback(new Error('QQ号码格式错误')); + } else { + callback(); + } + } + return true; + }, + // weibo号 + weibo(rule: FormItemRule, value: any, callback: Function): boolean | Error { + const regex = /^[0-9a-zA-Z\u4e00-\u9fa5_-]*$/; + if (!value && !rule.required) { + callback(); + } else if (!value) { + callback(new Error('请输入微博账号')); + } else { + if (!regex.test(value)) { + callback(new Error('微博号码格式错误')); + } else { + callback(); + } + } + return true; + }, + // YAML格式验证 + yaml(rule: FormItemRule, value: any, callback: Function): boolean | Error { + if (!value && !rule.required) { + callback(); + return true; + } + if (!value) { + callback(new Error('请输入YAML配置')); + return false; + } + + try { + // 使用 js-yaml 进行同步验证 + yaml.load(value); + callback(); + return true; + } catch (error: any) { + callback(new Error(`YAML格式错误: ${error.message}`)); + return false; + } + }, + // 不验证 + none(_rule: FormItemRule, _value: any, callback: Function): boolean { + callback(); + return true; + }, +}; diff --git a/web/src/utils/websocket/index.ts b/web/src/utils/websocket/index.ts new file mode 100644 index 0000000..ca8b1e2 --- /dev/null +++ b/web/src/utils/websocket/index.ts @@ -0,0 +1,188 @@ +import { SocketEnum } from '@/enums/socketEnum'; +import { useUserStoreWidthOut } from '@/store/modules/user'; +import { isJsonString } from '@/utils/is'; +import { registerGlobalMessage } from '@/utils/websocket/registerMessage'; + +// WebSocket消息格式 +export interface WebSocketMessage { + event: string; + data: any; + code: number; + timestamp: number; +} + +let socket: WebSocket; +let isActive: boolean; +const messageHandler: Map = new Map(); + +export default () => { + const heartCheck = { + timeout: 5000, + timeoutObj: setTimeout(() => {}), + serverTimeoutObj: setInterval(() => {}), + reset: function () { + clearTimeout(this.timeoutObj); + clearTimeout(this.serverTimeoutObj); + return this; + }, + start: function () { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + clearTimeout(this.timeoutObj); + clearTimeout(this.serverTimeoutObj); + this.timeoutObj = setTimeout(function () { + socket.send( + JSON.stringify({ + event: SocketEnum.EventPing, + }) + ); + self.serverTimeoutObj = setTimeout(function () { + console.log('[WebSocket] 关闭服务'); + socket.close(); + }, self.timeout); + }, this.timeout); + }, + }; + + const useUserStore = useUserStoreWidthOut(); + let lockReconnect = false; + let timer: ReturnType; + const createSocket = () => { + console.log('[WebSocket] createSocket...'); + if (useUserStore.token === '' || useUserStore.config?.wsAddr == '') { + console.error('[WebSocket] 用户未登录,稍后重试...'); + resetReconnect(); + return; + } + try { + socket = new WebSocket(`${useUserStore.config?.wsAddr}?authorization=${useUserStore.token}`); + init(); + if (lockReconnect) { + lockReconnect = false; + } + } catch (e) { + console.error(`[WebSocket] createSocket err: ${e}`); + resetReconnect(); + return; + } + }; + + const resetReconnect = () => { + if (lockReconnect) { + lockReconnect = false; + } + reconnect(); + }; + + const reconnect = () => { + console.log('[WebSocket] lockReconnect:' + lockReconnect); + if (lockReconnect) return; + lockReconnect = true; + clearTimeout(timer); + timer = setTimeout(() => { + createSocket(); + }, 1000 * 10); + }; + + const init = () => { + socket.onopen = function (_) { + console.log('[WebSocket] 已连接'); + heartCheck.reset().start(); + isActive = true; + }; + + socket.onmessage = function (event) { + isActive = true; + // console.log('WebSocket:收到一条消息', event.data); + + if (!isJsonString(event.data)) { + console.log('[WebSocket] message incorrect format:' + JSON.stringify(event)); + return; + } + + heartCheck.reset().start(); + + const message = JSON.parse(event.data) as WebSocketMessage; + onMessage(message); + }; + + socket.onerror = function (_) { + console.log('[WebSocket] 发生错误'); + reconnect(); + isActive = false; + }; + + socket.onclose = function (_) { + console.log('[WebSocket] 已关闭'); + heartCheck.reset(); + reconnect(); + isActive = false; + }; + + window.onbeforeunload = function () { + socket.close(); + isActive = false; + }; + }; + + createSocket(); + registerGlobalMessage(); +}; + +function onMessage(message: WebSocketMessage) { + let handled = false; + messageHandler.forEach((value: Function, key: string) => { + if (message.event === key || key === '*') { + handled = true; + value.call(null, message); + } + }); + + if (!handled) { + console.log('[WebSocket] messageHandler not registered. message:' + JSON.stringify(message)); + } +} + +// 发送消息 +export function sendMsg(event: string, data: any = null, isRetry = true) { + if (socket === undefined || !isActive) { + if (!isRetry) { + console.log('[WebSocket] 连接异常,发送失败!'); + return; + } + console.log('[WebSocket] 连接异常,等待重试..'); + setTimeout(() => { + sendMsg(event, data); + }, 200); + return; + } + + try { + socket.send(JSON.stringify({ event, data })); + } catch (err: any) { + console.log('[WebSocket] 发送消息失败,err:', err.message); + if (!isRetry) { + return; + } + + console.log('[WebSocket] 等待重试..'); + setTimeout(() => { + sendMsg(event, data); + }, 100); + } +} + +// 添加消息处理 +export function addOnMessage(key: string, value: Function): void { + messageHandler.set(key, value); +} + +// 移除消息处理 +export function removeOnMessage(key: string): boolean { + return messageHandler.delete(key); +} + +// 查看所有消息处理 +export function getAllOnMessage(): Map { + return messageHandler; +} diff --git a/web/src/utils/websocket/registerMessage.ts b/web/src/utils/websocket/registerMessage.ts new file mode 100644 index 0000000..0a9d27c --- /dev/null +++ b/web/src/utils/websocket/registerMessage.ts @@ -0,0 +1,32 @@ +import { TABS_ROUTES } from '@/store/mutation-types'; +import { SocketEnum } from '@/enums/socketEnum'; +import { useUserStoreWidthOut } from '@/store/modules/user'; +import { notificationStoreWidthOut } from '@/store/modules/notification'; +import { addOnMessage, WebSocketMessage } from '@/utils/websocket/index'; + +// 注册全局消息监听 +export function registerGlobalMessage() { + // 心跳 + addOnMessage(SocketEnum.EventPing, function (_message: WebSocketMessage) { + // console.log('ping..'); + }); + + // 强制退出 + addOnMessage(SocketEnum.EventKick, function (_message: WebSocketMessage) { + const useUserStore = useUserStoreWidthOut(); + useUserStore.logout().then(() => { + // 移除标签页 + localStorage.removeItem(TABS_ROUTES); + location.reload(); + }); + }); + + // 消息通知 + addOnMessage(SocketEnum.EventNotice, function (message: WebSocketMessage) { + const notificationStore = notificationStoreWidthOut(); + notificationStore.triggerNewMessages(message.data); + }); + + // 更多全局消息处理都可以在这里注册 + // ... +} diff --git a/web/src/views/about/index.vue b/web/src/views/about/index.vue new file mode 100644 index 0000000..77fd041 --- /dev/null +++ b/web/src/views/about/index.vue @@ -0,0 +1,111 @@ + + + + + {{ name }} 是一个基于 goframe2,vue3,vite2,TypeScript,uniapp + 的中后台解决方案,它可以帮助你快速搭建企业级中后台项目,相信不管是从新技术使用还是其他方面,都能帮助到你,持续更新中。 + + + + + + {{ config?.version }} + + + {{ lastBuildTime }} + + + + 查看文档地址 + + + + + 查看预览地址 + + + + + 查看Github地址 + + + + + 点击链接加入群聊【HotGo 交流群】 + + + + + + + + + {{ item.label }} + + + + + + + + {{ item.label }} + + + + + + + + + diff --git a/web/src/views/addons/.gitkeep b/web/src/views/addons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/src/views/addons/hgexample/comp/calendar/index.vue b/web/src/views/addons/hgexample/comp/calendar/index.vue new file mode 100644 index 0000000..325e44f --- /dev/null +++ b/web/src/views/addons/hgexample/comp/calendar/index.vue @@ -0,0 +1,42 @@ + + + 一个普通的日历 + + + + {{ year }}-{{ month }}-{{ date }} + + + + + diff --git a/web/src/views/addons/hgexample/comp/des/index.vue b/web/src/views/addons/hgexample/comp/des/index.vue new file mode 100644 index 0000000..2031fca --- /dev/null +++ b/web/src/views/addons/hgexample/comp/des/index.vue @@ -0,0 +1,89 @@ + + + + 基础用法 + + + 早餐 + 苹果 + + 苹果 + 苹果 + + 两个 + 苹果 + + 苹果 + + + + 每个描述项都可以设定跨度 + + + 早餐 + 苹果 + + 苹果 + 苹果 + 苹果 + 苹果 + 苹果 + 苹果 + 苹果 + 苹果 + + + + 边框 + 如果有很多多行的信息,你可以把它设为 bordered + + + 早餐 + 苹果 + + 苹果 + 苹果 + + 为什么长长长长长 + + + 为什么长长长长长 + + + 为什么长长长长长 + + + + + + + + + + 暑夜 + 晚春 + + + 奋勇呀然后休息呀 + 完成你伟大的人生 + + + + + + + 环形公路 + 潜水艇司机 + + + 最新的打印机 + 复制着彩色傀儡 + 早上好 + 让他带你去被工厂敲击 + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/directive/index.vue b/web/src/views/addons/hgexample/comp/directive/index.vue new file mode 100644 index 0000000..5f264d8 --- /dev/null +++ b/web/src/views/addons/hgexample/comp/directive/index.vue @@ -0,0 +1,58 @@ + + + + + + 复 制 + + + + 防抖测试 + + + 节流测试 + + + 鼠标放到矩形上面拖拽试试 + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/drag/index.vue b/web/src/views/addons/hgexample/comp/drag/index.vue new file mode 100644 index 0000000..c113ebe --- /dev/null +++ b/web/src/views/addons/hgexample/comp/drag/index.vue @@ -0,0 +1,162 @@ + + + + 常用于卡片,事项,预约,流程,计划等, + + + + 每个卡片,都可以上下拖拽顺序,另外不同卡片,也可以拖拽过去,拖拽过来,都不在话下呢,快试试O(∩_∩)O哈哈~ + + + + + + + 月 + + + + + + 需求{{ element.name }} + + + + + + + + + + 月 + + + + + + 开发中{{ element.name }} + + + + + + + + + + 月 + + + + + 已完成{{ element.name }} + + + + + + + + + + 月 + + + + + 已验收{{ element.name }} + + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/fingerprintjs/index.vue b/web/src/views/addons/hgexample/comp/fingerprintjs/index.vue new file mode 100644 index 0000000..081e497 --- /dev/null +++ b/web/src/views/addons/hgexample/comp/fingerprintjs/index.vue @@ -0,0 +1,38 @@ + + + + 通过设备浏览器信息获取浏览器指纹的插件(官方宣称其识别精度达到99.5%) + + + 指纹ID: + + {{ compData.murmur }} + + + + + diff --git a/web/src/views/addons/hgexample/comp/form/basic.vue b/web/src/views/addons/hgexample/comp/form/basic.vue new file mode 100644 index 0000000..3236daf --- /dev/null +++ b/web/src/views/addons/hgexample/comp/form/basic.vue @@ -0,0 +1,175 @@ + + + + 基础表单,用于向用户收集表单信息 + + + + + + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/form/useForm.vue b/web/src/views/addons/hgexample/comp/form/useForm.vue new file mode 100644 index 0000000..9d8af3f --- /dev/null +++ b/web/src/views/addons/hgexample/comp/form/useForm.vue @@ -0,0 +1,198 @@ + + + + useForm 表单,用于向用户收集表单信息 + + + + + + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/icons/antd.vue b/web/src/views/addons/hgexample/comp/icons/antd.vue new file mode 100644 index 0000000..4f63bfd --- /dev/null +++ b/web/src/views/addons/hgexample/comp/icons/antd.vue @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/icons/icons.vue b/web/src/views/addons/hgexample/comp/icons/icons.vue new file mode 100644 index 0000000..c9ee2dd --- /dev/null +++ b/web/src/views/addons/hgexample/comp/icons/icons.vue @@ -0,0 +1,136 @@ + + + + + + + + 图标大小 + + + + + + + 图标颜色 {{ compData.color }} + + + + + + + + + + + + + + 复制 + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/icons/ionicons5.vue b/web/src/views/addons/hgexample/comp/icons/ionicons5.vue new file mode 100644 index 0000000..5387e24 --- /dev/null +++ b/web/src/views/addons/hgexample/comp/icons/ionicons5.vue @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/icons/selector.vue b/web/src/views/addons/hgexample/comp/icons/selector.vue new file mode 100644 index 0000000..9d5a33e --- /dev/null +++ b/web/src/views/addons/hgexample/comp/icons/selector.vue @@ -0,0 +1,29 @@ + + + + + + 图标选择器,用于表单选择图标,支持直接选定图标和输入图标名称来选择 + + + + + + + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/import/excel.vue b/web/src/views/addons/hgexample/comp/import/excel.vue new file mode 100644 index 0000000..ba87758 --- /dev/null +++ b/web/src/views/addons/hgexample/comp/import/excel.vue @@ -0,0 +1,68 @@ + + + + + 将excel表格数据导入,可解析出完整的表格内容,包括所有的sheet和行列数据 + + + + + + + + + + + 点击或者拖动.xlsx文件到该区域来上传 + 单次上传数据最大不建议超过5000条 + + + + + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/index.vue b/web/src/views/addons/hgexample/comp/index.vue new file mode 100644 index 0000000..b380813 --- /dev/null +++ b/web/src/views/addons/hgexample/comp/index.vue @@ -0,0 +1,369 @@ + + + + 组件示例 + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/map/baidu.vue b/web/src/views/addons/hgexample/comp/map/baidu.vue new file mode 100644 index 0000000..92d81e5 --- /dev/null +++ b/web/src/views/addons/hgexample/comp/map/baidu.vue @@ -0,0 +1,33 @@ + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/map/gaode.vue b/web/src/views/addons/hgexample/comp/map/gaode.vue new file mode 100644 index 0000000..8fc3943 --- /dev/null +++ b/web/src/views/addons/hgexample/comp/map/gaode.vue @@ -0,0 +1,34 @@ + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/modal/index.vue b/web/src/views/addons/hgexample/comp/modal/index.vue new file mode 100644 index 0000000..461ca0a --- /dev/null +++ b/web/src/views/addons/hgexample/comp/modal/index.vue @@ -0,0 +1,307 @@ + + + + + 模态框,用于向用户收集或展示信息,Modal 采用 Dialog 预设,扩展拖拽效果 + + 以下是 useModal + 方式,ref方式,也支持,使用方式和其他组件一致,如:modalRef.value.closeModal() + + + + + 使用 useModal 进行弹窗展示和操作,并演示了在Modal内和Form组件,组合使用方法 + + + + 打开Modal嵌套Form例子 + + + + 使用 useModal 进行弹窗展示和操作,自定义配置,实现轻量级效果,更多配置,请参考文档 + + + + 轻量级确认 + + + + 组件暴露了,setProps 方法,用于修改组件内部 + Props,比如标题,等,具体参考UI框架文档,DialogReactive Properties + + + + + + + + + + + + + + + + 一些对话框内容 + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/moreComponents/index.vue b/web/src/views/addons/hgexample/comp/moreComponents/index.vue new file mode 100644 index 0000000..1aa5bfb --- /dev/null +++ b/web/src/views/addons/hgexample/comp/moreComponents/index.vue @@ -0,0 +1,15 @@ + + + + + + diff --git a/web/src/views/addons/hgexample/comp/notice/index.vue b/web/src/views/addons/hgexample/comp/notice/index.vue new file mode 100644 index 0000000..8d94bfd --- /dev/null +++ b/web/src/views/addons/hgexample/comp/notice/index.vue @@ -0,0 +1,322 @@ + + + + + + + 万事开头难,只能一直努力!!! + + 成功就是眼前,过了黑夜就是白天,加油~~ + + + 想想昨天,看看今天,望望明天;一切都会好起来 + + 有人欺负人,请不犹豫,请还回去 + Gee it's good to be back home + + Gee it's good to be back home + + + + + + + + Well the Ukraine girls really knock me out + They leave the West behind + And Moscow girls make me sing and shout + That Georgia's always on my mind + Aw come on! + + + + Yeah I'm back in the U.S.S.R. + You don't know how lucky you are boys + + + + + + + + + 警告 + 成功 + 错误 + + + + + + 信息 + 错误 + 警告 + 成功 + 加载中 + + + + + 信息 + 错误 + 警告 + 成功 + + + + + + + + 引用 + + 一切都将一去杳然,任何人都无法将其捕获。 + + + + 只有确定 + + 一切都将一去杳然,任何人都无法将其捕获。 + + + + 只有取消 + + 一切都将一去杳然,任何人都无法将其捕获。 + + + + 什么也没有 + + 一切都将一去杳然,任何人都无法将其捕获。 + + + + 自定义 action + + 自定义 action + 一切都将一去杳然,任何人都无法将其捕获。 + + + + + + + + + 悬浮 + + I wish they all could be California girls + + + + 悬浮(忽略主体) + + I wish they all could be California girls + + + + 点击 + + I wish they all could be California girls + + + + 聚焦 + + I wish they all could be California girls + + + + 手动 + + I wish they all could be California girls + + + + 延迟 500ms, 持续 500ms + + + Lately did you ever feel the pain In the morning rain as it soaks it to the bone + + + + + + + + + 鸭子 + + 如果它长得像鸭子,走起来像鸭子,叫起来也像鸭子,那它一定是个鸭子。 + + + + 悬浮 + + I wish they all could be California girls + + + + 点击 + + I wish they all could be California girls + + + + 手动 + + I wish they all could be California girls + + + + 默认有箭头 + + 和 Popover 一样 + + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/print/index.vue b/web/src/views/addons/hgexample/comp/print/index.vue new file mode 100644 index 0000000..adcc33f --- /dev/null +++ b/web/src/views/addons/hgexample/comp/print/index.vue @@ -0,0 +1,109 @@ + + + + + 打印 + + + + + + + + 打印 + + + + + + 姓名 + 年龄 + 性别 + 职业 + + + + + {{ item.name }} + {{ item.age }} + {{ item.gender }} + {{ item.career }} + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/result/fail.vue b/web/src/views/addons/hgexample/comp/result/fail.vue new file mode 100644 index 0000000..aa049c1 --- /dev/null +++ b/web/src/views/addons/hgexample/comp/result/fail.vue @@ -0,0 +1,70 @@ + + + + + + 您提交的内容有如下错误: + + + + + + 认证照片不够清晰 + 立即修改 + + + + + + + + 备注包含敏感字符,并且不能包含政治相关 + 立即修改 + + + + + + + 回到首页 + 查看详情 + 打印 + + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/result/info.vue b/web/src/views/addons/hgexample/comp/result/info.vue new file mode 100644 index 0000000..a9d59be --- /dev/null +++ b/web/src/views/addons/hgexample/comp/result/info.vue @@ -0,0 +1,74 @@ + + + + + + 您提交的内容如下: + + + + + + 转入支付宝账户(189****5426):¥1980元 + 立即撤回 + + + + + + + + 转入支付宝账户(187****5426):¥2980元 + 立即撤回 + + + + + + + 回到首页 + 查看详情 + 全部撤回 + + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/result/success.vue b/web/src/views/addons/hgexample/comp/result/success.vue new file mode 100644 index 0000000..dd09f6c --- /dev/null +++ b/web/src/views/addons/hgexample/comp/result/success.vue @@ -0,0 +1,55 @@ + + + + + + 已提交申请,等待财务部门审核。 + + + + + 回到首页 + 查看详情 + 打印 + + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/tagsView/index.vue b/web/src/views/addons/hgexample/comp/tagsView/index.vue new file mode 100644 index 0000000..d01f398 --- /dev/null +++ b/web/src/views/addons/hgexample/comp/tagsView/index.vue @@ -0,0 +1,26 @@ + + + + 标签页操作项 + + + + 刷新页面路由 + 关闭当前页面 + 关闭其他页面 + 关闭全部页面 + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/text/gradient/index.vue b/web/src/views/addons/hgexample/comp/text/gradient/index.vue new file mode 100644 index 0000000..3051748 --- /dev/null +++ b/web/src/views/addons/hgexample/comp/text/gradient/index.vue @@ -0,0 +1,70 @@ + + + + + 渐变文字 + + 渐变文字成了 + + + + 苏轼,(1037年1月8日-1101年8月24日)字子瞻、和仲,号铁冠道人、东坡居士,世称苏东坡、苏仙,汉族,眉州眉山(四川省眉山市)人,祖籍河北栾城,北宋著名文学家、书法家、画家,历史治水名人。苏轼是北宋中期文坛领袖,在诗、词、散文、书、画等方面取得很高成就。文纵横恣肆;诗题材广阔,清新豪健,善用夸张比喻,独具风格,与黄庭坚并称“苏黄”;词开豪放一派,与辛弃疾同是豪放派代表,并称“苏辛”;散文著述宏富,豪放自如,与欧阳修并称“欧苏”,为“唐宋八大家”之一。苏轼善书,“宋四家”之一;擅长文人画,尤擅墨竹、怪石、枯木等。与韩愈、柳宗元和欧阳修合称“千古文章四大家”。作品有《东坡七集》《东坡易传》《东坡乐府》《潇湘竹石图卷》《古木怪石图卷》等 + + + + + 渐变文字 + + 渐变文字成了 + + + + 白居易(772年-846年),字乐天,号香山居士,又号醉吟先生,祖籍太原,到其曾祖父时迁居下邽,生于河南新郑。是唐代伟大的现实主义诗人,唐代三大诗人之一。白居易与元稹共同倡导新乐府运动,世称“元白”,与刘禹锡并称“刘白”。白居易的诗歌题材广泛,形式多样,语言平易通俗,有“诗魔”和“诗王”之称。官至翰林学士、左赞善大夫。公元846年,白居易在洛阳逝世,葬于香山。有《白氏长庆集》传世,代表诗作有《长恨歌》《卖炭翁》《琵琶行》等。 + + + + + 自定义颜色 + + + 渐变文字成了 + + + + + 杜甫(712-770),字子美,自号少陵野老,世称“杜工部”、“杜少陵”等,汉族,河南府巩县(今河南省巩义市)人,唐代伟大的现实主义诗人,杜甫被世人尊为“诗圣”,其诗被称为“诗史”。杜甫与李白合称“李杜”,为了跟另外两位诗人李商隐与杜牧即“小李杜”区别开来,杜甫与李白又合称“大李杜”。他忧国忧民,人格高尚,他的约1400余首诗被保留了下来,诗艺精湛,在中国古典诗歌中备受推崇,影响深远。759-766年间曾居成都,后世有杜甫草堂纪念 + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/text/high/index.vue b/web/src/views/addons/hgexample/comp/text/high/index.vue new file mode 100644 index 0000000..8da12e8 --- /dev/null +++ b/web/src/views/addons/hgexample/comp/text/high/index.vue @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/text/mint/index.vue b/web/src/views/addons/hgexample/comp/text/mint/index.vue new file mode 100644 index 0000000..e78c15d --- /dev/null +++ b/web/src/views/addons/hgexample/comp/text/mint/index.vue @@ -0,0 +1,50 @@ + + + 敏感词汇验证 + + + {{ item }} + + + + {{ item }} + + + + + diff --git a/web/src/views/addons/hgexample/comp/text/pinyin/index.vue b/web/src/views/addons/hgexample/comp/text/pinyin/index.vue new file mode 100644 index 0000000..828a418 --- /dev/null +++ b/web/src/views/addons/hgexample/comp/text/pinyin/index.vue @@ -0,0 +1,102 @@ + + + + + 字符拼音 + 陋室铭,唐代:刘禹锡 + + + + {{ todo.py }} + {{ todo.hz }} + + + + + 字符拼音 + 李贺小传,李商隐〔唐代〕 + + + + {{ todo.py }} + {{ todo.hz }} + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/timeline/index.vue b/web/src/views/addons/hgexample/comp/timeline/index.vue new file mode 100644 index 0000000..4d24f34 --- /dev/null +++ b/web/src/views/addons/hgexample/comp/timeline/index.vue @@ -0,0 +1,97 @@ + + + 时间线 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/vis/echarts.vue b/web/src/views/addons/hgexample/comp/vis/echarts.vue new file mode 100644 index 0000000..76f0f07 --- /dev/null +++ b/web/src/views/addons/hgexample/comp/vis/echarts.vue @@ -0,0 +1,15 @@ + + + + + + diff --git a/web/src/views/addons/hgexample/comp/vis/madeapie.vue b/web/src/views/addons/hgexample/comp/vis/madeapie.vue new file mode 100644 index 0000000..279a3ae --- /dev/null +++ b/web/src/views/addons/hgexample/comp/vis/madeapie.vue @@ -0,0 +1,15 @@ + + + + + + diff --git a/web/src/views/addons/hgexample/comp/vis/ppchart.vue b/web/src/views/addons/hgexample/comp/vis/ppchart.vue new file mode 100644 index 0000000..e8bf7bd --- /dev/null +++ b/web/src/views/addons/hgexample/comp/vis/ppchart.vue @@ -0,0 +1,15 @@ + + + + + + diff --git a/web/src/views/addons/hgexample/comp/waterfall/index.vue b/web/src/views/addons/hgexample/comp/waterfall/index.vue new file mode 100644 index 0000000..9bbbd25 --- /dev/null +++ b/web/src/views/addons/hgexample/comp/waterfall/index.vue @@ -0,0 +1,106 @@ + + + 瀑布流组件 + + vue-waterfall-plugin-next + + + + + 君问归期未有期,巴山夜雨涨秋池。何当共剪西窗烛,却话巴山夜雨时。 + + + + + + + + + diff --git a/web/src/views/addons/hgexample/comp/watermark/index.vue b/web/src/views/addons/hgexample/comp/watermark/index.vue new file mode 100644 index 0000000..3f6db90 --- /dev/null +++ b/web/src/views/addons/hgexample/comp/watermark/index.vue @@ -0,0 +1,171 @@ + + + + 文字水印 + 核心机密 + + + + + 复盘 + 赋能 + 协同 + ... + 串联 + + + + + 拉通 + 打通 + 树立 + ... + 履约 + + + ... + ... + ... + ... + ... + + + + + + + 图片水印 + + + + + + + + 复盘 + 赋能 + 协同 + ... + 串联 + + + + + 拉通 + 打通 + 树立 + ... + 履约 + + + ... + ... + ... + ... + ... + + + + + + + 自定义水印 + + + + 添加 + + + + + + + 复盘 + 赋能 + 协同 + ... + 串联 + + + + + 拉通 + 打通 + 树立 + ... + 履约 + + + ... + ... + ... + ... + ... + + + + + + + + diff --git a/web/src/views/addons/hgexample/config/BasicSetting.vue b/web/src/views/addons/hgexample/config/BasicSetting.vue new file mode 100644 index 0000000..1877934 --- /dev/null +++ b/web/src/views/addons/hgexample/config/BasicSetting.vue @@ -0,0 +1,74 @@ + + + + + + + + 这是一个测试参数,每个插件都可以有独立的配置项,可以按需添加 + + + + + 保存更新 + + + + + + + + diff --git a/web/src/views/addons/hgexample/config/system.vue b/web/src/views/addons/hgexample/config/system.vue new file mode 100644 index 0000000..863a69e --- /dev/null +++ b/web/src/views/addons/hgexample/config/system.vue @@ -0,0 +1,82 @@ + + + + + + + {{ item.name }} + {{ item.desc }} + + + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/portal/form.vue b/web/src/views/addons/hgexample/portal/form.vue new file mode 100644 index 0000000..cfac5e1 --- /dev/null +++ b/web/src/views/addons/hgexample/portal/form.vue @@ -0,0 +1,47 @@ + + + + + + 复制链接 + + + + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/portal/index.vue b/web/src/views/addons/hgexample/portal/index.vue new file mode 100644 index 0000000..31fe0dd --- /dev/null +++ b/web/src/views/addons/hgexample/portal/index.vue @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/portal/websocketTest.vue b/web/src/views/addons/hgexample/portal/websocketTest.vue new file mode 100644 index 0000000..cee6676 --- /dev/null +++ b/web/src/views/addons/hgexample/portal/websocketTest.vue @@ -0,0 +1,134 @@ + + + + + 尝试在下方输入框中输入任意文字消息内容,发送后websocket服务器收到会原样返回 + + + + + + + 发送消息 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/table/edit.vue b/web/src/views/addons/hgexample/table/edit.vue new file mode 100644 index 0000000..149fcc0 --- /dev/null +++ b/web/src/views/addons/hgexample/table/edit.vue @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 取消 + 确定 + + + + + + + + + diff --git a/web/src/views/addons/hgexample/table/index.vue b/web/src/views/addons/hgexample/table/index.vue new file mode 100644 index 0000000..e5e8fc2 --- /dev/null +++ b/web/src/views/addons/hgexample/table/index.vue @@ -0,0 +1,262 @@ + + + + + 这里提供了一些常用的普通表格组件的用法和表单组件的例子,你可能会需要 + + + + + + + + + + + + + + + + + + 添加 + + + + + + + + 批量删除 + + + + + + + + 导出 + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/table/model.ts b/web/src/views/addons/hgexample/table/model.ts new file mode 100644 index 0000000..f3f9184 --- /dev/null +++ b/web/src/views/addons/hgexample/table/model.ts @@ -0,0 +1,466 @@ +import { h, ref } from 'vue'; +import { NAvatar, NImage, NTag, NSwitch, NRate } from 'naive-ui'; +import { cloneDeep } from 'lodash-es'; +import { FormSchema } from '@/components/Form'; +import { Switch } from '@/api/addons/hgexample/table'; +import { isNullObject } from '@/utils/is'; +import { getFileExt } from '@/utils/urlUtils'; +import { defRangeShortcuts, defShortcuts, formatToDate } from '@/utils/dateUtil'; +import { validate } from '@/utils/validateUtil'; +import { fallbackSrc } from '@/utils/hotgo'; +import { useDictStore } from '@/store/modules/dict'; +import { renderOptionTag } from '@/utils'; +import type { FormRules } from 'naive-ui/es/form/src/interface'; + +const dict = useDictStore(); +const $message = window['$message']; + +export interface State { + id: number; + memberId: number; + categoryId: number; + flag: number[] | null; + title: string; + content: string; + image: string; + images: string[] | null; + attachfile: string; + attachfiles: string[] | null; + map: unknown[] | null; + star: number; + description: string; + price: number; + views: number; + activityAt: string; + startAt: null; + endAt: null; + switch: number; + sort: number; + avatar: string; + sex: number; + qq: string; + email: string; + mobile: string; + channel: number; + cityId: number; + hobby: string[] | null; + pid: number; + level: number; + tree: string; + remark: string; + status: number; + createdBy: number; + createdAt: string; + updatedAt: string; +} + +export const defaultState = { + id: 0, + memberId: 0, + categoryId: 0, + flag: [1], + title: '', + content: '', + image: '', + images: null, + attachfile: '', + attachfiles: null, + map: null, + star: 0, + description: '', + price: 0, + views: 0, + activityAt: '', + startAt: null, + endAt: null, + switch: 0, + sort: 0, + avatar: '', + sex: 0, + qq: '', + email: '', + mobile: '', + channel: 0, + cityId: 0, + hobby: null, + pid: 0, + level: 1, + tree: '', + remark: '', + status: 1, + createdBy: 0, + createdAt: '', + updatedAt: '', +}; + +export function newState(state: State | null): State { + if (state !== null) { + return cloneDeep(state); + } + return cloneDeep(defaultState); +} + +export const rules: FormRules = { + title: { + required: true, + trigger: ['blur', 'input'], + message: '请输入标题', + }, + price: { + required: true, + trigger: ['blur', 'input'], + validator: validate.amount, + }, + qq: { + required: false, + trigger: ['blur', 'input'], + validator: validate.qq, + }, + email: { + required: true, + trigger: ['blur', 'input'], + validator: validate.email, + }, + mobile: { + required: true, + trigger: ['blur', 'input'], + validator: validate.phone, + }, +}; + +export const schemas = ref([ + { + field: 'title', + component: 'NInput', + label: '标题', + componentProps: { + placeholder: '请输入标题', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + rules: [{ message: '请输入标题', trigger: ['blur'] }], + }, + { + field: 'content', + component: 'NInput', + label: '内容', + componentProps: { + placeholder: '请输入内容关键词', + showButton: false, + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'price', + labelMessage: '我是自定义提示', + component: 'NInput', + label: '价格', + componentProps: { + pair: true, + separator: '-', + clearable: true, + placeholder: ['从', '到'], + onInput: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'activityAt', + component: 'NDatePicker', + label: '活动时间', + componentProps: { + type: 'date', + clearable: true, + shortcuts: defShortcuts(), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'createdAt', + component: 'NDatePicker', + label: '创建时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'flag', + component: 'NCheckbox', + label: '标签', + giProps: { + span: 1, + }, + componentProps: { + placeholder: '请选择标签', + options: dict.getOption('sys_notice_type'), + onUpdateChecked: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'switch', + component: 'NRadioGroup', + label: '开关', + giProps: { + //span: 24, + }, + componentProps: { + options: dict.getOption('sys_switch'), + onUpdateChecked: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'hobby', + component: 'NSelect', + label: '爱好', + defaultValue: null, + componentProps: { + multiple: true, + placeholder: '请选择爱好', + options: dict.getOption('sys_user_hobby'), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'status', + component: 'NSelect', + label: '状态', + defaultValue: null, + componentProps: { + placeholder: '请选择类型', + options: dict.getOption('sys_normal_disable'), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, +]); + +export const columns = [ + { + title: 'ID', + key: 'id', + sorter: true, // 单列排序 + }, + { + title: '标题', + key: 'title', + render(row) { + return row.title; + }, + sorter: { + multiple: 1, // 为多列排序的优先级,越高优先级越高 + }, + }, + { + title: '标签', + key: 'flag', + render(row) { + if (isNullObject(row.flag)) { + return ``; + } + return row.flag.map((tagKey) => { + return renderOptionTag('sys_notice_type', tagKey); + }); + }, + }, + { + title: '单图', + key: 'image', + render(row) { + return h(NImage, { + width: 32, + height: 32, + src: row.image, + fallbackSrc: fallbackSrc(), + style: { + width: '32px', + height: '32px', + 'max-width': '100%', + 'max-height': '100%', + }, + }); + }, + }, + { + title: '多图', + key: 'images', + render(row) { + if (isNullObject(row.images)) { + return ``; + } + return row.images.map((image) => { + return h(NImage, { + width: 32, + height: 32, + src: image, + fallbackSrc: fallbackSrc(), + style: { + width: '32px', + height: '32px', + 'max-width': '100%', + 'max-height': '100%', + 'margin-left': '2px', + }, + }); + }); + }, + }, + { + title: '附件', + key: 'attachfile', + render(row) { + if (row.attachfile === '') { + return ``; + } + return h( + NAvatar, + { + size: 'small', + }, + { + default: () => getFileExt(row.attachfile), + } + ); + }, + }, + { + title: '多附件', + key: 'attachfiles', + render(row) { + if (isNullObject(row.attachfiles)) { + return ``; + } + return row.attachfiles.map((attachfile) => { + return h( + NAvatar, + { + size: 'small', + style: { + 'margin-left': '2px', + }, + }, + { + default: () => getFileExt(attachfile), + } + ); + }); + }, + }, + { + title: '推荐星', + key: 'star', + // width: 180, + render(row) { + return h(NRate, { + allowHalf: true, + readonly: true, + defaultValue: row.star, + }); + }, + }, + { + title: '描述', + key: 'description', + }, + { + title: '价格', + key: 'price', + sorter: { + multiple: 2, // 为多列排序的优先级,越高优先级越高 + }, + render(row) { + return h( + NTag, + { + style: { + marginRight: '6px', + }, + type: 'success', + bordered: false, + }, + { + default: () => row.price.toFixed(2), + } + ); + }, + }, + { + title: '开关', + key: 'switch', + width: 100, + render(row) { + return h(NSwitch, { + value: row.switch === 1, + checked: '开启', + unchecked: '关闭', + onUpdateValue: function (e) { + row.switch = e ? 1 : 2; + Switch({ id: row.id, key: 'switch', value: row.switch }).then((_res) => { + $message.success('操作成功'); + }); + }, + }); + }, + }, + // { + // title: '排序', + // key: 'sort', + // }, + { + title: '状态', + key: 'status', + render(row) { + return renderOptionTag('sys_normal_disable', row.status); + }, + }, + { + title: '爱好', + key: 'hobby', + render(row) { + if (isNullObject(row.hobby)) { + return ``; + } + return row.hobby.map((tagKey) => { + return renderOptionTag('sys_user_hobby', tagKey); + }); + }, + }, + { + title: '活动时间', + key: 'activityAt', + render(row) { + return formatToDate(row.activityAt); + }, + sorter: { + multiple: 3, // 为多列排序的优先级,越高优先级越高 + }, + }, +]; + +export function loadOptions() { + dict.loadOptions([ + 'sys_normal_disable', + 'sys_user_sex', + 'sys_notice_type', + 'sys_switch', + 'sys_user_hobby', + 'sys_user_channel', + ]); +} diff --git a/web/src/views/addons/hgexample/table/view.vue b/web/src/views/addons/hgexample/table/view.vue new file mode 100644 index 0000000..2ce3618 --- /dev/null +++ b/web/src/views/addons/hgexample/table/view.vue @@ -0,0 +1,143 @@ + + + + 基础详情,有时也用于显示只读信息。 + + + + + 分类ID + {{ formValue.categoryId }} + + + + + {{ dict.getLabel('sys_notice_type', item) }} + + + {{ formValue.title }} + {{ formValue.description }} + + {{ formValue.price }} + {{ formValue.views }} + {{ formValue.activityAt }} + + + {{ formValue.createdBy }} + {{ formValue.createdAt }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ getFileExt(formValue.attachfile) }} + + + + + + + + + + + + + + + {{ + getFileExt(item) + }} + + + + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/tenantOrder/alert.vue b/web/src/views/addons/hgexample/tenantOrder/alert.vue new file mode 100644 index 0000000..e04b54e --- /dev/null +++ b/web/src/views/addons/hgexample/tenantOrder/alert.vue @@ -0,0 +1,76 @@ + + + 这里主要演示多租户业务下,不同用户身份如何在同一页面下展示不同的表格功能和字段数据,以及添加/编辑购买订单时服务端如何自动维护多租户关系 + 不同身份的测试账号 + + + + 身份 + ID + 账号 + 密码 + 身份描述 + + + + + {{ account.type }} + {{ account.id }} + {{ account.username }} + {{ account.password }} + {{ account.dc }} + + + + + + + + + diff --git a/web/src/views/addons/hgexample/tenantOrder/edit.vue b/web/src/views/addons/hgexample/tenantOrder/edit.vue new file mode 100644 index 0000000..82e9b08 --- /dev/null +++ b/web/src/views/addons/hgexample/tenantOrder/edit.vue @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 元 + + + + + + + + + + + + + + + + + + + + 取消 + 确定 + + + + + + + + + diff --git a/web/src/views/addons/hgexample/tenantOrder/index.vue b/web/src/views/addons/hgexample/tenantOrder/index.vue new file mode 100644 index 0000000..d46f580 --- /dev/null +++ b/web/src/views/addons/hgexample/tenantOrder/index.vue @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + 添加购买订单 + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/tenantOrder/model.ts b/web/src/views/addons/hgexample/tenantOrder/model.ts new file mode 100644 index 0000000..9e4651b --- /dev/null +++ b/web/src/views/addons/hgexample/tenantOrder/model.ts @@ -0,0 +1,212 @@ +import { ref } from 'vue'; +import { cloneDeep } from 'lodash-es'; +import { FormSchema } from '@/components/Form'; +import { defRangeShortcuts } from '@/utils/dateUtil'; +import { useUserStore } from '@/store/modules/user'; +import { useDictStore } from '@/store/modules/dict'; +import { renderOptionTag } from '@/utils'; +import type { FormRules } from 'naive-ui/es/form/src/interface'; + +const dict = useDictStore(); +const userStore = useUserStore(); + +export class State { + public id = 0; // 主键 + public tenantId = null; // 租户ID + public merchantId = null; // 商户ID + public userId = null; // 用户ID + public productName = ''; // 购买产品 + public orderSn = ''; // 关联订单号 + public money = null; // 充值金额 + public remark = ''; // 备注 + public status = 1; // 订单状态 + public createdAt = ''; // 创建时间 + public updatedAt = ''; // 修改时间 + + constructor(state?: Partial) { + if (state) { + Object.assign(this, state); + } + } +} + +export function newState(state: State | Record | null): State { + if (state !== null) { + if (state instanceof State) { + return cloneDeep(state); + } + return new State(state); + } + return new State(); +} + +// 表单验证规则 +export const rules: FormRules = { + money: { + required: true, + trigger: ['blur', 'input'], + type: 'number', + message: '请输入充值金额', + }, +}; + +// 表格搜索表单 +export const schemas = ref([ + { + field: 'tenantId', + component: 'NInput', + label: '租户ID', + componentProps: { + placeholder: '请输入租户ID', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + ifShow: () => { + return userStore.isCompanyDept; + }, + }, + { + field: 'merchantId', + component: 'NInput', + label: '商户ID', + componentProps: { + placeholder: '请输入商户ID', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + ifShow: () => { + return userStore.isCompanyDept || userStore.isTenantDept; + }, + }, + { + field: 'userId', + component: 'NInput', + label: '用户ID', + componentProps: { + placeholder: '请输入用户ID', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + ifShow: () => { + return userStore.isCompanyDept || userStore.isTenantDept || userStore.isMerchantDept; + }, + }, + { + field: 'orderSn', + component: 'NInput', + label: '订单号', + componentProps: { + placeholder: '请输入订单号', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'status', + component: 'NSelect', + label: '订单状态', + defaultValue: null, + componentProps: { + placeholder: '请选择订单状态', + options: [], + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'createdAt', + component: 'NDatePicker', + label: '创建时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, +]); + +// 表格列 +export const columns = [ + { + title: '订单ID', + key: 'id', + align: 'left', + width: 100, + }, + { + title: '租户ID', + key: 'tenantId', + align: 'left', + width: 100, + ifShow: () => { + return userStore.isCompanyDept; + }, + }, + { + title: '商户ID', + key: 'merchantId', + align: 'left', + width: 100, + ifShow: () => { + return userStore.isCompanyDept || userStore.isTenantDept; + }, + }, + { + title: '用户ID', + key: 'userId', + align: 'left', + width: 100, + ifShow: () => { + return userStore.isCompanyDept || userStore.isTenantDept || userStore.isMerchantDept; + }, + }, + { + title: '购买产品', + key: 'productName', + align: 'left', + width: 150, + }, + { + title: '订单号', + key: 'orderSn', + align: 'left', + width: 200, + }, + { + title: '充值金额', + key: 'money', + align: 'left', + width: 100, + render(row) { + return row.money + ' 元'; + }, + }, + { + title: '订单状态', + key: 'status', + align: 'left', + width: 100, + render(row) { + return renderOptionTag('payStatus', row.status); + }, + }, + { + title: '创建时间', + key: 'createdAt', + align: 'left', + width: 180, + }, +]; + +// 加载字典数据选项 +export function loadOptions() { + dict.loadOptions(['payStatus']); +} diff --git a/web/src/views/addons/hgexample/treeTable/edit.vue b/web/src/views/addons/hgexample/treeTable/edit.vue new file mode 100644 index 0000000..cb1f134 --- /dev/null +++ b/web/src/views/addons/hgexample/treeTable/edit.vue @@ -0,0 +1,256 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 取消 + 确定 + + + + + + + + + diff --git a/web/src/views/addons/hgexample/treeTable/index.vue b/web/src/views/addons/hgexample/treeTable/index.vue new file mode 100644 index 0000000..416828d --- /dev/null +++ b/web/src/views/addons/hgexample/treeTable/index.vue @@ -0,0 +1,235 @@ + + + + 一个树形表格例子 + + + + + + + + + + + + + + + 添加 + + + + + + + + + + 编辑 + + + + + + + + + + 删除 + + + {{ expandedKeys.length ? '收起' : '展开' }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 正在编辑 {{ treeItemTitle ? `:${treeItemTitle}` : '' }} + {{ + treeItemTitle ? '' : '从列表选择一项后,进行编辑' + }} + + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/treeTable/list.vue b/web/src/views/addons/hgexample/treeTable/list.vue new file mode 100644 index 0000000..943730e --- /dev/null +++ b/web/src/views/addons/hgexample/treeTable/list.vue @@ -0,0 +1,256 @@ + + + + + + + + + + + + + + + + + + + + + 添加 + + + + + + + + 批量删除 + + + + + + + + 导出 + + + + + + + + + + + + + diff --git a/web/src/views/addons/hgexample/treeTable/model.ts b/web/src/views/addons/hgexample/treeTable/model.ts new file mode 100644 index 0000000..541f471 --- /dev/null +++ b/web/src/views/addons/hgexample/treeTable/model.ts @@ -0,0 +1,460 @@ +import { h, ref } from 'vue'; +import { NAvatar, NImage, NTag, NSwitch, NRate } from 'naive-ui'; +import { cloneDeep } from 'lodash-es'; +import { FormSchema } from '@/components/Form'; +import { Switch } from '@/api/addons/hgexample/table'; +import { isNullObject } from '@/utils/is'; +import { getFileExt } from '@/utils/urlUtils'; +import { defRangeShortcuts, defShortcuts, formatToDate } from '@/utils/dateUtil'; +import { validate } from '@/utils/validateUtil'; +import { fallbackSrc } from '@/utils/hotgo'; +import { useDictStore } from '@/store/modules/dict'; +import { renderOptionTag } from '@/utils'; + +const dict = useDictStore(); +const $message = window['$message']; + +export interface State { + id: number; + memberId: number; + categoryId: number; + flag: number[] | null; + title: string; + content: string; + image: string; + images: string[] | null; + attachfile: string; + attachfiles: string[] | null; + map: any | null; + star: number; + description: string; + price: number; + views: number; + activityAt: string; + startAt: null; + endAt: null; + switch: number; + sort: number; + avatar: string; + sex: number; + qq: string; + email: string; + mobile: string; + channel: number; + cityId: number; + hobby: string[] | null; + pid: number; + level: number; + tree: string; + remark: string; + status: number; + createdBy: number; + createdAt: string; + updatedAt: string; +} + +export const defaultState = { + id: 0, + memberId: 0, + categoryId: 0, + flag: [1], + title: '', + content: '', + image: '', + images: null, + attachfile: '', + attachfiles: null, + map: null, + star: 0, + description: '', + price: 0, + views: 0, + activityAt: '', + startAt: null, + endAt: null, + switch: 0, + sort: 0, + avatar: '', + sex: 0, + qq: '', + email: '', + mobile: '', + channel: 0, + cityId: 0, + hobby: null, + pid: 0, + level: 1, + tree: '', + remark: '', + status: 1, + createdBy: 0, + createdAt: '', + updatedAt: '', +}; + +export function newState(state: State | null): State { + if (state !== null) { + return cloneDeep(state); + } + return cloneDeep(defaultState); +} + +export const rules = { + title: { + required: true, + trigger: ['blur', 'input'], + message: '请输入标题', + }, + price: { + required: true, + trigger: ['blur', 'input'], + validator: validate.amount, + }, + qq: { + required: false, + trigger: ['blur', 'input'], + validator: validate.qq, + }, + email: { + required: true, + trigger: ['blur', 'input'], + validator: validate.email, + }, + mobile: { + required: true, + trigger: ['blur', 'input'], + validator: validate.phone, + }, +}; + +export const schemas = ref([ + { + field: 'title', + component: 'NInput', + label: '标题', + componentProps: { + placeholder: '请输入标题', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + rules: [{ message: '请输入标题', trigger: ['blur'] }], + }, + { + field: 'content', + component: 'NInput', + label: '内容', + componentProps: { + placeholder: '请输入内容关键词', + showButton: false, + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'price', + labelMessage: '我是自定义提示', + component: 'NInput', + label: '价格', + componentProps: { + pair: true, + separator: '-', + clearable: true, + placeholder: ['从', '到'], + onInput: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'activityAt', + component: 'NDatePicker', + label: '活动时间', + componentProps: { + type: 'date', + clearable: true, + shortcuts: defShortcuts(), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'createdAt', + component: 'NDatePicker', + label: '创建时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'flag', + component: 'NCheckbox', + label: '标签', + giProps: { + span: 1, + }, + componentProps: { + placeholder: '请选择标签', + options: dict.getOption('sys_notice_type'), + onUpdateChecked: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'switch', + component: 'NRadioGroup', + label: '开关', + giProps: { + //span: 24, + }, + componentProps: { + options: dict.getOption('sys_switch'), + onUpdateChecked: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'hobby', + component: 'NSelect', + label: '爱好', + defaultValue: null, + componentProps: { + multiple: true, + placeholder: '请选择爱好', + options: dict.getOption('sys_user_hobby'), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'status', + component: 'NSelect', + label: '状态', + defaultValue: null, + componentProps: { + placeholder: '请选择类型', + options: dict.getOption('sys_normal_disable'), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, +]); + +export const columns = [ + { + title: 'ID', + key: 'id', + sorter: true, // 单列排序 + }, + { + title: '标题', + key: 'title', + render(row) { + return row.title; + }, + sorter: { + multiple: 1, // 为多列排序的优先级,越高优先级越高 + }, + }, + { + title: '标签', + key: 'flag', + render(row) { + if (isNullObject(row.flag)) { + return ``; + } + return row.flag.map((tagKey) => { + return renderOptionTag('sys_notice_type', tagKey); + }); + }, + }, + { + title: '单图', + key: 'image', + render(row) { + return h(NImage, { + width: 32, + height: 32, + src: row.image, + fallbackSrc: fallbackSrc(), + style: { + width: '32px', + height: '32px', + 'max-width': '100%', + 'max-height': '100%', + }, + }); + }, + }, + { + title: '多图', + key: 'images', + render(row) { + if (isNullObject(row.images)) { + return ``; + } + return row.images.map((image) => { + return h(NImage, { + width: 32, + height: 32, + src: image, + fallbackSrc: fallbackSrc(), + style: { + width: '32px', + height: '32px', + 'max-width': '100%', + 'max-height': '100%', + 'margin-left': '2px', + }, + }); + }); + }, + }, + { + title: '附件', + key: 'attachfile', + render(row) { + if (row.attachfile === '') { + return ``; + } + return h( + NAvatar, + { + size: 'small', + }, + { + default: () => getFileExt(row.attachfile), + } + ); + }, + }, + { + title: '多附件', + key: 'attachfiles', + render(row) { + if (isNullObject(row.attachfiles)) { + return ``; + } + return row.attachfiles.map((attachfile) => { + return h( + NAvatar, + { + size: 'small', + style: { + 'margin-left': '2px', + }, + }, + { + default: () => getFileExt(attachfile), + } + ); + }); + }, + }, + { + title: '推荐星', + key: 'star', + // width: 180, + render(row) { + return h(NRate, { + allowHalf: true, + readonly: true, + defaultValue: row.star, + }); + }, + }, + { + title: '描述', + key: 'description', + }, + { + title: '价格', + key: 'price', + sorter: { + multiple: 2, // 为多列排序的优先级,越高优先级越高 + }, + render(row) { + return h( + NTag, + { + style: { + marginRight: '6px', + }, + type: 'success', + bordered: false, + }, + { + default: () => row.price.toFixed(2), + } + ); + }, + }, + { + title: '开关', + key: 'switch', + width: 100, + render(row) { + return h(NSwitch, { + value: row.switch === 1, + checked: '开启', + unchecked: '关闭', + onUpdateValue: function (e) { + row.switch = e ? 1 : 2; + Switch({ id: row.id, key: 'switch', value: row.switch }).then((_res) => { + $message.success('操作成功'); + }); + }, + }); + }, + }, + // { + // title: '排序', + // key: 'sort', + // }, + { + title: '状态', + key: 'status', + render(row) { + return renderOptionTag('sys_normal_disable', row.status); + }, + }, + { + title: '爱好', + key: 'hobby', + render(row) { + return renderOptionTag('sys_user_hobby', row.hobby); + }, + }, + { + title: '活动时间', + key: 'activityAt', + render(row) { + return formatToDate(row.activityAt); + }, + sorter: { + multiple: 3, // 为多列排序的优先级,越高优先级越高 + }, + }, +]; + +export function loadOptions() { + dict.loadOptions([ + 'sys_normal_disable', + 'sys_user_sex', + 'sys_notice_type', + 'sys_switch', + 'sys_user_hobby', + 'sys_user_channel', + ]); +} diff --git a/web/src/views/apply/attachment/columns.ts b/web/src/views/apply/attachment/columns.ts new file mode 100644 index 0000000..ead166c --- /dev/null +++ b/web/src/views/apply/attachment/columns.ts @@ -0,0 +1,171 @@ +import { h, ref } from 'vue'; +import { NAvatar, NImage } from 'naive-ui'; +import { getFileExt } from '@/utils/urlUtils'; +import { FormSchema } from '@/components/Form'; +import { fallbackSrc } from '@/utils/hotgo'; +import { defRangeShortcuts } from '@/utils/dateUtil'; +import { useDictStore } from '@/store/modules/dict'; +import { renderOptionTag } from '@/utils'; + +const dict = useDictStore(); + +export const schemas = ref([ + { + field: 'drive', + component: 'NSelect', + label: '上传驱动', + defaultValue: null, + componentProps: { + placeholder: '请选择上传驱动', + options: dict.getOption('config_upload_drive'), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'kind', + component: 'NSelect', + label: '上传类型', + defaultValue: null, + componentProps: { + placeholder: '请选择上传类型', + options: dict.getOption('AttachmentKindOption'), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'name', + component: 'NInput', + label: '文件名称', + labelMessage: '支持模糊查询', + componentProps: { + placeholder: '请输入文件名称', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + rules: [{ message: '请输入文件名称', trigger: ['blur'] }], + }, + { + field: 'updatedAt', + component: 'NDatePicker', + label: '上传时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'member_id', + component: 'NInput', + label: '用户ID', + componentProps: { + placeholder: '请输入用户ID', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + rules: [{ message: '请输入用户ID', trigger: ['blur'] }], + }, +]); + +export const columns = [ + { + title: '附件ID', + key: 'id', + width: 80, + }, + { + title: '应用', + key: 'appId', + width: 100, + }, + { + title: '驱动', + key: 'drive', + render(row) { + return row.drive; + }, + width: 100, + }, + { + title: '上传类型', + key: 'kind', + render(row) { + return renderOptionTag('AttachmentKindOption', row.kind); + }, + width: 120, + }, + { + title: '文件名称', + key: 'name', + width: 150, + }, + { + title: '文件', + key: 'fileUrl', + width: 80, + render(row) { + if (row.fileUrl === '') { + return ``; + } + if (row.kind !== 'image') { + return h( + NAvatar, + { + width: '40px', + height: '40px', + 'max-width': '100%', + 'max-height': '100%', + }, + { + default: () => getFileExt(row.fileUrl), + } + ); + } + return h(NImage, { + width: 40, + height: 40, + src: row.fileUrl, + fallbackSrc: fallbackSrc(), + style: { + width: '40px', + height: '40px', + 'max-width': '100%', + 'max-height': '100%', + }, + }); + }, + }, + { + title: '文件大小', + key: 'sizeFormat', + width: 100, + }, + { + title: '扩展名', + key: 'ext', + width: 80, + }, + { + title: '扩展类型', + key: 'mimeType', + width: 200, + }, + { + title: '上传时间', + key: 'updatedAt', + width: 180, + }, +]; + +export function loadOptions() { + dict.loadOptions(['AttachmentKindOption', 'config_upload_drive']); +} diff --git a/web/src/views/apply/attachment/index.vue b/web/src/views/apply/attachment/index.vue new file mode 100644 index 0000000..a9b6faa --- /dev/null +++ b/web/src/views/apply/attachment/index.vue @@ -0,0 +1,239 @@ + + + + + + + + + + + + + + + + + + 上传文件 + + + + + + + + 上传大文件 + + + + + + + + 上传图片 + + + + + + + + 上传文档 + + + + + + + + 链接转图片 + + + + + + + + 批量删除 + + + + + + + + + + + + + + + + diff --git a/web/src/views/apply/attachment/urlModal.vue b/web/src/views/apply/attachment/urlModal.vue new file mode 100644 index 0000000..2d4b28a --- /dev/null +++ b/web/src/views/apply/attachment/urlModal.vue @@ -0,0 +1,90 @@ + + + + 将外部图片链接下载并转存至平台的存储驱动中 + + + + + + + + + + + + + + + 关闭 + 上传 + + + + + + + + + diff --git a/web/src/views/apply/notice/columns.ts b/web/src/views/apply/notice/columns.ts new file mode 100644 index 0000000..1db5345 --- /dev/null +++ b/web/src/views/apply/notice/columns.ts @@ -0,0 +1,106 @@ +import { h } from 'vue'; +import { NAvatar, NAvatarGroup, NTooltip } from 'naive-ui'; +import { renderOptionTag } from '@/utils'; + +export const columns = [ + { + title: 'ID', + key: 'id', + width: 80, + }, + { + title: '消息标题', + key: 'title', + render(row) { + return h('p', { id: 'app' }, [ + h('div', { + innerHTML: '' + row.title + '', + }), + ]); + }, + width: 280, + }, + { + title: '消息类型', + key: 'type', + render(row) { + return renderOptionTag('noticeTypeOptions', row.type); + }, + width: 100, + }, + { + title: '标签', + key: 'tag', + render(row) { + return renderOptionTag('noticeTagOptions', row.tag); + }, + width: 100, + }, + { + title: '接收人', + key: 'receiver', + render(row) { + if (row.type === 1 || row.type === 2) { + return '所有人'; + } + return h( + NAvatarGroup, + { + max: 4, + size: 40, + options: row.receiverGroup, + }, + { + avatar: (column) => + h(NTooltip, null, { + trigger: () => + column.option.src !== '' + ? h(NAvatar, { + src: column.option.src, + round: true, + size: 32, + style: { + marginRight: '4px', + }, + }) + : h( + NAvatar, + { + round: true, + size: 32, + style: { + marginRight: '4px', + }, + }, + { + default: () => column.option.name?.substring(0, 1) as string, + } + ), + default: () => column.option.name, + }), + } + ); + }, + width: 180, + }, + { + title: '阅读量', + key: 'readCount', + width: 80, + }, + { + title: '排序', + key: 'sort', + width: 80, + }, + { + title: '备注', + key: 'remark', + width: 150, + }, + { + title: '发送时间', + key: 'createdAt', + width: 180, + }, +]; diff --git a/web/src/views/apply/notice/edit.vue b/web/src/views/apply/notice/edit.vue new file mode 100644 index 0000000..1a1f673 --- /dev/null +++ b/web/src/views/apply/notice/edit.vue @@ -0,0 +1,215 @@ + + + + + + {{ + formValue.id > 0 + ? '编辑' + dict.getLabel('noticeTypeOptions', formValue.type) + ' #' + formValue.id + : '发送' + dict.getLabel('noticeTypeOptions', formValue.type) + }} + + + + + 消息发送成功后如果接收人在线会立即收到一条消息通知,编辑已发送的消息不会再次通知 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 取消 + + 立即发送 + + + + + + + + + + + diff --git a/web/src/views/apply/notice/index.vue b/web/src/views/apply/notice/index.vue new file mode 100644 index 0000000..4308969 --- /dev/null +++ b/web/src/views/apply/notice/index.vue @@ -0,0 +1,244 @@ + + + + + 在这里,您可以向平台中的用户发送通知、公告和私信消息 + + + + + + + + + + + + + + + + + + 发通知 + + + + + + + + + 发公告 + + + + + + + + + 发私信 + + + + + + + + + 批量删除 + + + + + + + + + + + diff --git a/web/src/views/apply/notice/model.ts b/web/src/views/apply/notice/model.ts new file mode 100644 index 0000000..a5a1c5f --- /dev/null +++ b/web/src/views/apply/notice/model.ts @@ -0,0 +1,113 @@ +import { cloneDeep } from 'lodash-es'; +import { FormSchema } from '@/components/Form'; +import { useDictStore } from '@/store/modules/dict'; + +const dict = useDictStore(); + +export class State { + id: number; + title: string; + type: number; + tag: number = 1; + content: string; + receiver: number[]; + remark: string; + sort: number; + status: number = 1; + createdBy: number; + updatedBy: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + readCount: number; + receiverGroup: Receiver[]; + + constructor(state?: Partial) { + if (state) { + Object.assign(this, state); + } + } +} + +export class Receiver { + name: string; + src: string; +} + +export function newState(state: State | Record | null): State { + if (state !== null) { + if (state instanceof State) { + return cloneDeep(state); + } + return new State(state); + } + return new State(); +} + +// 表单验证规则 +export const rules = { + title: { + required: true, + trigger: ['blur', 'input'], + message: '请输入消息标题', + }, +}; + +// 表格搜索表单 +export const schemas: FormSchema[] = [ + { + field: 'type', + component: 'NSelect', + label: '消息类型', + defaultValue: null, + componentProps: { + placeholder: '请选择消息类型', + options: dict.getOption('noticeTypeOptions'), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'title', + component: 'NInput', + label: '消息标题', + componentProps: { + placeholder: '请输入消息标题', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + rules: [{ message: '请输入消息标题', trigger: ['blur'] }], + }, + { + field: 'content', + component: 'NInput', + label: '消息内容', + componentProps: { + placeholder: '请输入消息内容关键词', + showButton: false, + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'status', + component: 'NSelect', + label: '状态', + defaultValue: null, + componentProps: { + placeholder: '请选择类型', + options: dict.getOption('sys_normal_disable'), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, +]; + +// 加载字典数据选项 +export function loadOptions() { + dict.loadOptions(['sys_normal_disable']); +} diff --git a/web/src/views/apply/provinces/edit.vue b/web/src/views/apply/provinces/edit.vue new file mode 100644 index 0000000..955f93b --- /dev/null +++ b/web/src/views/apply/provinces/edit.vue @@ -0,0 +1,191 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 取消 + 确定 + + + + + + + + + diff --git a/web/src/views/apply/provinces/index.vue b/web/src/views/apply/provinces/index.vue new file mode 100644 index 0000000..89bc16d --- /dev/null +++ b/web/src/views/apply/provinces/index.vue @@ -0,0 +1,232 @@ + + + + 中国省市区编码对照表 + + + + + + + + + + + + + + + 添加 + + + + + + + + + + 编辑 + + + + + + + + + + 删除 + + + {{ expandedKeys.length ? '收起' : '展开' }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 编辑省市区{{ treeItemTitle ? `:${treeItemTitle}` : '' }} + {{ + treeItemTitle ? '' : '从列表选择一项后,进行编辑' + }} + + + + + + + + + + + + diff --git a/web/src/views/apply/provinces/list.vue b/web/src/views/apply/provinces/list.vue new file mode 100644 index 0000000..29b121f --- /dev/null +++ b/web/src/views/apply/provinces/list.vue @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + 添加数据 + + + + + + + + + + + + diff --git a/web/src/views/apply/provinces/model.ts b/web/src/views/apply/provinces/model.ts new file mode 100644 index 0000000..e21ba37 --- /dev/null +++ b/web/src/views/apply/provinces/model.ts @@ -0,0 +1,87 @@ +import { h } from 'vue'; +import { NTag } from 'naive-ui'; +import { cloneDeep } from 'lodash-es'; +import { useDictStore } from '@/store/modules/dict'; +import { renderOptionTag } from '@/utils'; + +const dict = useDictStore(); + +export const listColumns = [ + { + title: '地区ID', + key: 'id', + }, + { + title: '地区名称', + key: 'title', + }, + { + title: '拼音', + key: 'pinyin', + render(row) { + return h( + NTag, + { + style: { + marginRight: '6px', + }, + type: 'success', + bordered: false, + }, + { + default: () => row.pinyin, + } + ); + }, + }, + { + title: '经度', + key: 'lng', + }, + { + title: '维度', + key: 'lat', + }, + { + title: '状态', + key: 'status', + render(row) { + return renderOptionTag('sys_normal_disable', row.status); + }, + }, +]; + +export interface State { + id: number | null; + title: string; + pinyin: string; + lng: string; + lat: string; + pid: number; + sort: number; + status: number; + oldId: number; +} + +export const defaultState = { + id: null, + title: '', + pinyin: '', + lng: '', + lat: '', + pid: 0, + sort: 0, + status: 1, + oldId: 0, +}; + +export function newState(state: State | null): State { + if (state !== null) { + return cloneDeep(state); + } + return cloneDeep(defaultState); +} + +export function loadOptions() { + dict.loadOptions(['sys_normal_disable']); +} diff --git a/web/src/views/asset/cash/columns.ts b/web/src/views/asset/cash/columns.ts new file mode 100644 index 0000000..2c4b30d --- /dev/null +++ b/web/src/views/asset/cash/columns.ts @@ -0,0 +1,99 @@ +import { h } from 'vue'; +import { NTag } from 'naive-ui'; +import { renderPopoverMemberSumma } from '@/utils'; + +const msgMap = { + 1: '处理中', + 2: '提现成功', + 3: ' 提现异常', +}; + +export const statusOptions = [ + { + value: 1, + label: '处理中', + }, + { + value: 2, + label: '提现成功', + }, + { + value: 3, + label: '提现异常', + }, +]; + +export const columns = [ + { + title: '提现ID', + key: 'id', + width: 100, + }, + { + title: '申请人', + key: 'memberId', + width: 100, + render(row) { + return renderPopoverMemberSumma(row.memberBySumma); + }, + }, + { + title: '提现金额', + key: 'money', + render(row) { + return row.money.toFixed(2); + }, + width: 100, + }, + { + title: '手续费', + key: 'fee', + render(row) { + return row.fee.toFixed(2); + }, + width: 100, + }, + { + title: '最终到账', + key: 'lastMoney', + render(row) { + return row.lastMoney.toFixed(2); + }, + width: 100, + }, + { + title: '处理结果', + key: 'msg', + render(row) { + return h( + NTag, + { + style: { + marginRight: '6px', + }, + type: row.status == 1 ? 'info' : row.status == 2 ? 'success' : 'warning', + bordered: false, + }, + { + default: () => (row.msg == '' ? msgMap[row.status] : row.msg), + } + ); + }, + width: 200, + }, + { + title: '申请IP', + key: 'ip', + width: 180, + }, + { + title: '处理时间', + key: 'handleAt', + width: 180, + }, + { + title: '申请时间', + key: 'createdAt', + width: 180, + }, +]; diff --git a/web/src/views/asset/cash/index.vue b/web/src/views/asset/cash/index.vue new file mode 100644 index 0000000..1e2c4ba --- /dev/null +++ b/web/src/views/asset/cash/index.vue @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + diff --git a/web/src/views/asset/cash/list.vue b/web/src/views/asset/cash/list.vue new file mode 100644 index 0000000..0629239 --- /dev/null +++ b/web/src/views/asset/cash/list.vue @@ -0,0 +1,408 @@ + + + + + + + + + + + + + + + + + + 申请提现 + + + + + + + + + 设置提现账户 + + + + + + + + + + + + {{ estimated }} + + + + + + + + + + + + + + + + + (showModal = false)">取消 + 确定 + + + + + + + + + + + + + + + + + + + + + + + + + + + + 不填默认显示提现状态 + + + + + + (showPaymentModal = false)">取消 + 确定 + + + + + + + + + + diff --git a/web/src/views/asset/creditsLog/index.vue b/web/src/views/asset/creditsLog/index.vue new file mode 100644 index 0000000..84b6131 --- /dev/null +++ b/web/src/views/asset/creditsLog/index.vue @@ -0,0 +1,46 @@ + + + + + 你和下级在平台中余额、积分的变动明细都可以在这里进行查看 + + + + + + + + + + + + + + + + diff --git a/web/src/views/asset/creditsLog/list.vue b/web/src/views/asset/creditsLog/list.vue new file mode 100644 index 0000000..d6758a0 --- /dev/null +++ b/web/src/views/asset/creditsLog/list.vue @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + 导出 + + + + + + + + + + diff --git a/web/src/views/asset/creditsLog/model.ts b/web/src/views/asset/creditsLog/model.ts new file mode 100644 index 0000000..093d923 --- /dev/null +++ b/web/src/views/asset/creditsLog/model.ts @@ -0,0 +1,166 @@ +import { ref } from 'vue'; +import { FormSchema } from '@/components/Form'; +import { defRangeShortcuts } from '@/utils/dateUtil'; +import { useDictStore } from '@/store/modules/dict'; +import { renderOptionTag, renderPopoverMemberSumma } from '@/utils'; + +const dict = useDictStore(); + +export const schemas = ref([ + { + field: 'complexMemberId', + component: 'ComplexMemberPicker', + label: '选择用户', + componentProps: { + placeholder: '请选择用户', + onInput: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'creditGroup', + component: 'NSelect', + label: '变动组别', + defaultValue: null, + componentProps: { + placeholder: '请选择变动组别', + options: dict.getOption('creditGroup'), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'remark', + component: 'NInput', + label: '备注', + componentProps: { + placeholder: '请输入备注', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'ip', + component: 'NInput', + label: '操作人IP', + componentProps: { + placeholder: '请输入操作人IP', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'id', + component: 'NInput', + label: '变动ID', + componentProps: { + placeholder: '请输入变动ID', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'createdAt', + component: 'NDatePicker', + label: '变动时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, +]); + +export const columns = [ + { + title: '变动ID', + key: 'id', + width: 100, + }, + { + title: '用户', + key: 'memberId', + width: 100, + render(row) { + return renderPopoverMemberSumma(row.memberBySumma); + }, + }, + { + title: '变动类型', + key: 'creditType', + render(row) { + return renderOptionTag('creditType', row.creditType); + }, + width: 150, + }, + { + title: '变动组别', + key: 'creditGroup', + render(row) { + return renderOptionTag('creditGroup', row.creditGroup); + }, + width: 150, + }, + { + title: '变动前', + key: 'beforeNum', + width: 100, + render(row) { + return Number(row.beforeNum).toFixed(2); + }, + }, + { + title: '变动数量', + key: 'num', + width: 100, + render(row) { + return Number(row.num).toFixed(2); + }, + }, + { + title: '变动后', + key: 'afterNum', + width: 100, + render(row) { + return Number(row.afterNum).toFixed(2); + }, + }, + { + title: '备注', + key: 'remark', + width: 200, + }, + { + title: '操作人IP', + key: 'ip', + width: 150, + }, + { + title: '关联ID', + key: 'mapId', + width: 100, + render(row) { + if (row.mapId === 0) { + return '-'; + } + return row.mapId; + }, + }, + { + title: '变动时间', + key: 'createdAt', + width: 180, + }, +]; + +export function loadOptions() { + dict.loadOptions(['creditType', 'creditGroup']); +} diff --git a/web/src/views/asset/payRefund/index.vue b/web/src/views/asset/payRefund/index.vue new file mode 100644 index 0000000..98ce65e --- /dev/null +++ b/web/src/views/asset/payRefund/index.vue @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + 导出 + + + + + + + + + + diff --git a/web/src/views/asset/payRefund/model.ts b/web/src/views/asset/payRefund/model.ts new file mode 100644 index 0000000..8c66f18 --- /dev/null +++ b/web/src/views/asset/payRefund/model.ts @@ -0,0 +1,181 @@ +import { ref } from 'vue'; +import { FormSchema } from '@/components/Form'; +import { defRangeShortcuts } from '@/utils/dateUtil'; +import { useDictStore } from '@/store/modules/dict'; +import { renderOptionTag } from '@/utils'; + +const dict = useDictStore(); + +export const schemas = ref([ + { + field: 'id', + component: 'NInputNumber', + label: '变动ID', + componentProps: { + placeholder: '请输入变动ID', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'memberId', + component: 'NInputNumber', + label: '管理员ID', + componentProps: { + placeholder: '请输入管理员ID', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'appId', + component: 'NInput', + label: '应用id', + componentProps: { + placeholder: '请输入应用id', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'creditType', + component: 'NSelect', + label: '变动类型', + defaultValue: null, + componentProps: { + placeholder: '请选择变动类型', + options: [], + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'creditGroup', + component: 'NSelect', + label: '变动的组别', + defaultValue: null, + componentProps: { + placeholder: '请选择变动的组别', + options: [], + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'remark', + component: 'NInput', + label: '备注', + componentProps: { + placeholder: '请输入备注', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'ip', + component: 'NInput', + label: '操作人IP', + componentProps: { + placeholder: '请输入操作人IP', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'status', + component: 'NSelect', + label: '状态', + defaultValue: null, + componentProps: { + placeholder: '请选择状态', + options: [], + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'createdAt', + component: 'NDatePicker', + label: '创建时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, +]); + +export const columns = [ + { + title: '变动ID', + key: 'id', + }, + { + title: '管理员ID', + key: 'memberId', + }, + { + title: '应用id', + key: 'appId', + }, + { + title: '插件名称', + key: 'addonsName', + }, + { + title: '变动前', + key: 'beforeNum', + }, + { + title: '变动数据', + key: 'num', + }, + { + title: '变动后', + key: 'afterNum', + }, + { + title: '备注', + key: 'remark', + }, + { + title: '操作人IP', + key: 'ip', + }, + { + title: '关联ID', + key: 'mapId', + }, + { + title: '状态', + key: 'status', + render(row) { + return renderOptionTag('sys_normal_disable', row.status); + }, + }, + { + title: '创建时间', + key: 'createdAt', + }, + { + title: '修改时间', + key: 'updatedAt', + }, +]; + +export function loadOptions() { + dict.loadOptions(['sys_normal_disable']); +} + +loadOptions(); diff --git a/web/src/views/asset/recharge/recharge.vue b/web/src/views/asset/recharge/recharge.vue new file mode 100644 index 0000000..7317101 --- /dev/null +++ b/web/src/views/asset/recharge/recharge.vue @@ -0,0 +1,287 @@ + + + + 余额可用于购买付费产品或商城消费 + + + + + + + 充值金额 + + + ¥{{ item }} + + + + + + ¥ + + + 支付方式 + + + + + + + {{ item.label }} + + + + + + 立即充值 + + + + + + + + + + + + + + + + + + (showQrModal = false)">关闭 + + + + + + + + + + diff --git a/web/src/views/asset/rechargeLog/acceptRefund.vue b/web/src/views/asset/rechargeLog/acceptRefund.vue new file mode 100644 index 0000000..d033e6e --- /dev/null +++ b/web/src/views/asset/rechargeLog/acceptRefund.vue @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 取消 + 确定 + + + + + + + + + + diff --git a/web/src/views/asset/rechargeLog/applyRefund.vue b/web/src/views/asset/rechargeLog/applyRefund.vue new file mode 100644 index 0000000..79fc4af --- /dev/null +++ b/web/src/views/asset/rechargeLog/applyRefund.vue @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + 取消 + 确定 + + + + + + + + + + diff --git a/web/src/views/asset/rechargeLog/index.vue b/web/src/views/asset/rechargeLog/index.vue new file mode 100644 index 0000000..72b201d --- /dev/null +++ b/web/src/views/asset/rechargeLog/index.vue @@ -0,0 +1,45 @@ + + + + + + + + + + + + + diff --git a/web/src/views/asset/rechargeLog/list.vue b/web/src/views/asset/rechargeLog/list.vue new file mode 100644 index 0000000..9bd7ff8 --- /dev/null +++ b/web/src/views/asset/rechargeLog/list.vue @@ -0,0 +1,231 @@ + + + + + + + + + + + + + + + + + + 批量删除 + + + + + + + + + 导出 + + + + + + + + + + + + + + diff --git a/web/src/views/asset/rechargeLog/model.ts b/web/src/views/asset/rechargeLog/model.ts new file mode 100644 index 0000000..209b7cc --- /dev/null +++ b/web/src/views/asset/rechargeLog/model.ts @@ -0,0 +1,154 @@ +import { ref } from 'vue'; +import { cloneDeep } from 'lodash-es'; +import { FormSchema } from '@/components/Form'; +import { defRangeShortcuts } from '@/utils/dateUtil'; +import { useDictStore } from '@/store/modules/dict'; +import { MemberSumma, renderOptionTag, renderPopoverMemberSumma } from '@/utils'; + +export interface State { + id: number; + memberId: number; + orderType: string; + productId: number; + orderSn: string; + money: number; + remark: string; + payLogOutTradeNo: string; + status: number; + createdAt: string; + updatedAt: string; + refundReason: string; + rejectRefundReason: string; + payLogPayType: string; + memberBySumma?: null | MemberSumma; +} + +export const defaultState = { + id: 0, + memberId: 0, + orderType: '', + productId: 0, + orderSn: '', + money: 0, + payLogOutTradeNo: '', + remark: '', + status: 1, + createdAt: '', + updatedAt: '', + refundReason: '', + rejectRefundReason: '', + payLogPayType: '', +}; + +export function newState(state: State | null): State { + if (state !== null) { + return cloneDeep(state); + } + return cloneDeep(defaultState); +} + +const dict = useDictStore(); +export const rules = {}; + +export const schemas = ref([ + { + field: 'complexMemberId', + component: 'ComplexMemberPicker', + label: '下单用户', + componentProps: { + placeholder: '请选择下单用户', + onInput: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'orderSn', + component: 'NInput', + label: '业务单号', + componentProps: { + placeholder: '请输入业务订单号', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'payLogOutTradeNo', + component: 'NInput', + label: '商户单号', + componentProps: { + placeholder: '请输入商户订单号', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'createdAt', + component: 'NDatePicker', + label: '创建时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, +]); + +export const columns = [ + { + title: '业务订单号', + key: 'orderSn', + width: 220, + }, + { + title: '下单用户', + key: 'memberId', + width: 100, + render(row: State) { + return renderPopoverMemberSumma(row.memberBySumma); + }, + }, + { + title: '支付方式', + key: 'payLogPayType', + render(row) { + return renderOptionTag('payType', row.payLogPayType); + }, + width: 150, + }, + { + title: '充值金额', + key: 'money', + width: 100, + render(row) { + return '¥' + Number(row.money).toFixed(2); + }, + }, + { + title: '订单状态', + key: 'status', + render(row) { + return renderOptionTag('orderStatus', row.status); + }, + width: 150, + }, + { + title: '商户订单号', + key: 'payLogOutTradeNo', + width: 220, + }, + { + title: '创建时间', + key: 'createdAt', + width: 180, + }, +]; + +export function loadOptions() { + dict.loadOptions(['payType', 'orderStatus', 'acceptRefundStatus']); +} diff --git a/web/src/views/curdDemo/edit.vue b/web/src/views/curdDemo/edit.vue new file mode 100644 index 0000000..835d564 --- /dev/null +++ b/web/src/views/curdDemo/edit.vue @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 取消 + + + 确定 + + + + + + + + + + \ No newline at end of file diff --git a/web/src/views/curdDemo/index.vue b/web/src/views/curdDemo/index.vue new file mode 100644 index 0000000..54209ff --- /dev/null +++ b/web/src/views/curdDemo/index.vue @@ -0,0 +1,207 @@ + + + + + + + + + + + + + + + + + + + + + + 添加 + + + + + + + + 批量删除 + + + + + + + + 导出 + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/views/curdDemo/model.ts b/web/src/views/curdDemo/model.ts new file mode 100644 index 0000000..0d612d7 --- /dev/null +++ b/web/src/views/curdDemo/model.ts @@ -0,0 +1,280 @@ +import { h, ref } from 'vue'; +import { NSwitch } from 'naive-ui'; +import { cloneDeep } from 'lodash-es'; +import { FormSchema } from '@/components/Form'; +import { defRangeShortcuts } from '@/utils/dateUtil'; +import { renderImage, renderFile, renderPopoverMemberSumma, MemberSumma } from '@/utils'; +import { Switch } from '@/api/curdDemo'; +import { useDictStore } from '@/store/modules/dict'; +import { usePermission } from '@/hooks/web/usePermission'; + +const dict = useDictStore(); +const { hasPermission } = usePermission(); +const $message = window['$message']; + +export class State { + + public id = 0;//ID + public categoryId = null;//测试分类 + public title = '';//标题 + public description = '';//描述 + public content = '';//内容 + public image = '';//单图 + public attachfile = '';//附件 + public cityId = null;//所在城市 + public switch = 2;//显示开关 + public sort = 0;//排序 + public status = 1;//状态 + public createdBy = 0;//创建者 + public createdBySumma?: null | MemberSumma = null;//创建者摘要信息 + public updatedBy = 0;//更新者 + public updatedBySumma?: null | MemberSumma = null;//更新者摘要信息 + public deletedBy = 0;//删除者 + public deletedBySumma?: null | MemberSumma = null;//删除者摘要信息 + public createdAt = '';//创建时间 + public updatedAt = '';//修改时间 + public deletedAt = '';//删除时间 + constructor(state?: Partial) { + if (state) { + Object.assign(this, state); + } + } +} + +export function newState(state: State | Record | null): State { + if (state !== null) { + if (state instanceof State) { + return cloneDeep(state); + } + return new State(state); + } + return new State(); +} + +// 表单验证规则 +export const rules = { + title: { + required: true, + trigger: ['blur', 'input'], + type: 'string', + message: '请输入标题', + }, + description: { + required: true, + trigger: ['blur', 'input'], + type: 'string', + message: '请输入描述', + }, + content: { + required: true, + trigger: ['blur', 'input'], + type: 'string', + message: '请输入内容', + }, + sort: { + required: true, + trigger: ['blur', 'input'], + type: 'number', + message: '请输入排序', + }, +}; + +// 表格搜索表单 +export const schemas = ref([ + { + field: 'id', + component: 'NInput', + label: 'ID', + componentProps: { + placeholder: '请输入ID', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'title', + component: 'NInput', + label: '标题', + componentProps: { + placeholder: '请输入标题', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'description', + component: 'NInput', + label: '描述', + componentProps: { + placeholder: '请输入描述', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'createdBy', + component: 'NInput', + label: '创建者', + componentProps: { + placeholder: '请输入ID|用户名|姓名|手机号', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'deletedBy', + component: 'NInput', + label: '删除者', + componentProps: { + placeholder: '请输入ID|用户名|姓名|手机号', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'createdAt', + component: 'NDatePicker', + label: '创建时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'testCategoryName', + component: 'NInput', + label: '关联分类', + componentProps: { + placeholder: '请输入关联分类', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, +]); + +// 表格列 +export const columns = [ + { + title: 'ID', + key: 'id', + align: 'left', + width: 50, + }, + { + title: '标题', + key: 'title', + align: 'left', + width: 150, + }, + { + title: '描述', + key: 'description', + align: 'left', + width: 300, + }, + { + title: '单图', + key: 'image', + align: 'left', + width: 100, + render(row: State) { + return renderImage(row.image); + }, + }, + { + title: '附件', + key: 'attachfile', + align: 'left', + width: 100, + render(row: State) { + return renderFile(row.attachfile); + }, + }, + { + title: '显示开关', + key: 'switch', + align: 'left', + width: 150, + render(row: State) { + return h(NSwitch, { + value: row.switch === 1, + checked: '开启', + unchecked: '关闭', + disabled: !hasPermission(['/curdDemo/switch']), + onUpdateValue: function (e) { + console.log('onUpdateValue e:' + JSON.stringify(e)); + row.switch = e ? 1 : 2; + Switch({ id: row.id, key: 'switch', value: row.switch }).then((_res) => { + $message.success('操作成功'); + }); + }, + }); + }, + }, + { + title: '排序', + key: 'sort', + align: 'left', + width: 100, + }, + { + title: '创建者', + key: 'createdBy', + align: 'left', + width: 150, + render(row: State) { + return renderPopoverMemberSumma(row.createdBySumma); + }, + }, + { + title: '更新者', + key: 'updatedBy', + align: 'left', + width: 150, + render(row: State) { + return renderPopoverMemberSumma(row.updatedBySumma); + }, + }, + { + title: '删除者', + key: 'deletedBy', + align: 'left', + width: -1, + render(row: State) { + return renderPopoverMemberSumma(row.deletedBySumma); + }, + }, + { + title: '创建时间', + key: 'createdAt', + align: 'left', + width: 180, + }, + { + title: '修改时间', + key: 'updatedAt', + align: 'left', + width: 180, + }, + { + title: '关联分类', + key: 'testCategoryName', + align: 'left', + width: 100, + }, +]; + +// 加载字典数据选项 +export function loadOptions() { + dict.loadOptions(['sys_normal_disable', 'testCategoryOption']); +} \ No newline at end of file diff --git a/web/src/views/dashboard/console/components/FluxTrend.vue b/web/src/views/dashboard/console/components/FluxTrend.vue new file mode 100644 index 0000000..4c3db4e --- /dev/null +++ b/web/src/views/dashboard/console/components/FluxTrend.vue @@ -0,0 +1,109 @@ + + + + diff --git a/web/src/views/dashboard/console/components/Icons.ts b/web/src/views/dashboard/console/components/Icons.ts new file mode 100644 index 0000000..ce4d411 --- /dev/null +++ b/web/src/views/dashboard/console/components/Icons.ts @@ -0,0 +1,25 @@ +import { + CaretUpOutlined, + CaretDownOutlined, + UsergroupAddOutlined, + BarChartOutlined, + ShoppingCartOutlined, + AccountBookOutlined, + CreditCardOutlined, + MailOutlined, + TagsOutlined, + SettingOutlined, +} from '@vicons/antd'; + +export default { + CaretUpOutlined, + CaretDownOutlined, + UsergroupAddOutlined, + BarChartOutlined, + ShoppingCartOutlined, + AccountBookOutlined, + CreditCardOutlined, + MailOutlined, + TagsOutlined, + SettingOutlined, +}; diff --git a/web/src/views/dashboard/console/components/VisiTab.vue b/web/src/views/dashboard/console/components/VisiTab.vue new file mode 100644 index 0000000..52d783e --- /dev/null +++ b/web/src/views/dashboard/console/components/VisiTab.vue @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + diff --git a/web/src/views/dashboard/console/components/VisitAmount.vue b/web/src/views/dashboard/console/components/VisitAmount.vue new file mode 100644 index 0000000..981284c --- /dev/null +++ b/web/src/views/dashboard/console/components/VisitAmount.vue @@ -0,0 +1,61 @@ + + + + diff --git a/web/src/views/dashboard/console/components/props.ts b/web/src/views/dashboard/console/components/props.ts new file mode 100644 index 0000000..216216b --- /dev/null +++ b/web/src/views/dashboard/console/components/props.ts @@ -0,0 +1,17 @@ +import { PropType } from 'vue'; + +export interface BasicProps { + width: string; + height: string; +} + +export const basicProps = { + width: { + type: String as PropType, + default: '100%', + }, + height: { + type: String as PropType, + default: '280px', + }, +}; diff --git a/web/src/views/dashboard/console/console.vue b/web/src/views/dashboard/console/console.vue new file mode 100644 index 0000000..f4e053d --- /dev/null +++ b/web/src/views/dashboard/console/console.vue @@ -0,0 +1,24 @@ + + + + + + + + + + 部门类型未配置首页:{{ userStore.info?.deptType }} + + + + + + diff --git a/web/src/views/dashboard/console/console_company.vue b/web/src/views/dashboard/console/console_company.vue new file mode 100644 index 0000000..1c28629 --- /dev/null +++ b/web/src/views/dashboard/console/console_company.vue @@ -0,0 +1,343 @@ + + + + + + + + {{ t('日') }} + + + + + + + + + + {{ t('日同比') }} + + + + + + + + + + {{ t('周同比') }} + + + + + + + + + + + + {{ t('总卡板量:') }} + + + + + + + + + + + + {{ t('周') }} + + + + + + + + + + + + + + + {{ t('总激活卡板:') }} + + + + + + + + + + + + {{ t('周') }} + + + + + + + + + + {{ t('日同比') }} + + + + + + + + + + {{ t('周同比') }} + + + + + + + + + + + + {{ t('总代理商量:') }} + + + + + + + + + + + + {{ t('月') }} + + + + + + + + + + {{ t('月同比') }} + + + + + + + + + + {{ t('月同比') }} + + + + + + + + + + + + {{ t('总提现额:') }} + + + + + + + + + + + + + + + + + + + + + + + + + + {{ item.title }} + + + + + + + + + + + + + + diff --git a/web/src/views/dashboard/console/console_merchant.vue b/web/src/views/dashboard/console/console_merchant.vue new file mode 100644 index 0000000..a9c7b3a --- /dev/null +++ b/web/src/views/dashboard/console/console_merchant.vue @@ -0,0 +1,11 @@ + + + + + + 这是商户的首页,如果需要,你可以定制TA + + + + + diff --git a/web/src/views/dashboard/console/console_tenant.vue b/web/src/views/dashboard/console/console_tenant.vue new file mode 100644 index 0000000..bead149 --- /dev/null +++ b/web/src/views/dashboard/console/console_tenant.vue @@ -0,0 +1,11 @@ + + + + + + 这是租户的首页,如果需要,你可以定制TA + + + + + diff --git a/web/src/views/dashboard/console/console_user.vue b/web/src/views/dashboard/console/console_user.vue new file mode 100644 index 0000000..4fc2ae3 --- /dev/null +++ b/web/src/views/dashboard/console/console_user.vue @@ -0,0 +1,13 @@ + + + + + + + 这是普通用户的首页,如果需要,你可以定制TA + + + + + + diff --git a/web/src/views/dashboard/workplace/workplace.vue b/web/src/views/dashboard/workplace/workplace.vue new file mode 100644 index 0000000..ac44a58 --- /dev/null +++ b/web/src/views/dashboard/workplace/workplace.vue @@ -0,0 +1,328 @@ + + + + + + + + + + + + 早安,{{ username }},开始您一天的工作吧! + 今日阴转大雨,15℃ - 25℃,出门记得带伞哦。 + + + + + + + 项目数 + 16 + + + 待办 + 3/15 + + + 消息 + 35 + + + + + + + + + + + + + + + + + + Github + + + 是一个面向开源及私有软件项目的托管平台。 + + 开源君,2021-07-04 + + + + + + + + + Vue + + 渐进式 JavaScript 框架 + 学不动也要学,2021-07-04 + + + + + + + + + Html5 + + HTML5是互联网的下一代标准。 + 撸码也是一种艺术 2021-04-01 + + + + + + + + + Angular + + 现代 Web 开发平台,百万粉丝热捧。 + 铁粉君 2021-07-04。 + + + + + + + + + React + + 用于构建用户界面的 JavaScript 库。 + 技术牛 2021-07-04。 + + + + + + + + + Js + + 路是走出来的,而不是空想出来的。 + 架构组 2021-07-04 + + + + + + 更多 + + + + + + + 2021-07-04 22:37:16 + + + + + + + + 2021-07-04 09:37:16 + + + + + + + + 2021-07-04 22:37:16 + + + + + + + + 2021-07-04 09:37:16 + + + + + + + + 2021-07-04 20:37:16 + + + + + + + + + + + + + + + + + + + + + + + + + + + + 主控台 + + + + + + + + + + 列表 + + + + + + + + + + 表单 + + + + + + + + + + 权限管理 + + + + + + + + + + 系统管理 + + + + + + + + + + 主控台 + + + + + + + + + + + + + + + diff --git a/web/src/views/develop/addons/index.vue b/web/src/views/develop/addons/index.vue new file mode 100644 index 0000000..08b9d74 --- /dev/null +++ b/web/src/views/develop/addons/index.vue @@ -0,0 +1,326 @@ + + + + + 在这里可以管理和创建插件模板,开发独立、临时性、工具类型的功能时推荐使用插件化开发。 + + + + + + + + + + + + + + + + + + + 创建新插件 + + + + + + + 注意:插件创建成功后会在服务端对应的项目目录中生成插件模块文件,并自动注册到当前项目中。 + + + + + 显示在插件列表中的模块名称. 不要超过20个字符 + + + + + + 系统按照此名称创建和查找插件定义, 只能英文和下划线组成,建议全部小写,例如:hgexample + + + + + + + + + + + + + + + + + + 此版本号用于插件的版本更新 + + + + + + + + + 详细介绍插件的功能和使用方法 + + + + + + + + + + (showModal = false)">取消 + 确认创建 + + + + + + + + + + diff --git a/web/src/views/develop/addons/model.ts b/web/src/views/develop/addons/model.ts new file mode 100644 index 0000000..575b9ad --- /dev/null +++ b/web/src/views/develop/addons/model.ts @@ -0,0 +1,179 @@ +import { cloneDeep } from 'lodash-es'; +import { h, ref } from 'vue'; +import { fallbackSrc } from '@/utils/hotgo'; +import { isUrl } from '@/utils/is'; +import { NIcon, NIconWrapper, NImage, NTag } from 'naive-ui'; +import { getIconComponent } from '@/utils/icons'; +import { FormSchema } from '@/components/Form'; +import { useDictStore } from '@/store/modules/dict'; + +const dict = useDictStore(); + +export const genInfoObj = { + label: '', + name: '', + group: 1, + extend: ['resourcePublic', 'resourceTemplate'], + version: 'v1.0.0', + brief: '', + description: '', + author: '', +}; + +export function newState(state) { + if (state !== null) { + return cloneDeep(state); + } + return cloneDeep(genInfoObj); +} + +export const columns = [ + { + title: '图标', + key: 'logo', + width: 80, + render(row) { + if (isUrl(row.logo)) { + return h(NImage, { + width: 48, + height: 48, + src: row.logo, + fallbackSrc: fallbackSrc(), + style: { + width: '48px', + height: '48px', + 'max-width': '100%', + 'max-height': '100%', + }, + }); + } else { + return h( + NIconWrapper, + { + size: 48, + borderRadius: 8, + }, + { + default: () => + h( + NIcon, + { + size: 36, + style: { + marginTop: '-8px', + }, + }, + { + default: () => h(getIconComponent(row.logo)), + } + ), + } + ); + } + }, + }, + { + title: '模块名称', + key: 'name', + width: 120, + render(row) { + return h('div', { + innerHTML: + '' + row.label + '' + row.name + '', + }); + }, + }, + { + title: '作者', + key: 'author', + width: 100, + }, + { + title: '分组', + key: 'groupName', + render(row) { + return h( + NTag, + { + style: { + marginRight: '6px', + }, + type: 'info', + bordered: false, + }, + { + default: () => row.groupName, + } + ); + }, + width: 120, + }, + { + title: '简介', + key: 'brief', + render(row) { + return row.brief; + }, + width: 180, + }, + { + title: '详细描述', + key: 'description', + width: 300, + render(row) { + return h('p', { id: 'app' }, [ + h('div', { + innerHTML: '' + row.description + '', + }), + ]); + }, + }, + { + title: '版本', + key: 'version', + width: 100, + }, +]; + +export const schemas = ref([ + { + field: 'name', + component: 'NInput', + label: '模块名称', + componentProps: { + placeholder: '请输入模块名称或标签', + onInput: (e: any) => { + console.log(e); + }, + }, + rules: [{ trigger: ['blur'] }], + }, + { + field: 'group', + component: 'NSelect', + label: '分组', + componentProps: { + placeholder: '请选择分组', + options: dict.getOption('addonsGroupOptions'), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'status', + component: 'NSelect', + label: '安装状态', + componentProps: { + placeholder: '请选择状态', + options: dict.getOption('addonsInstallStatus'), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, +]); + +export function loadOptions() { + dict.loadOptions(['addonsGroupOptions', 'addonsInstallStatus', 'addonsExtend']); +} diff --git a/web/src/views/develop/code/components/BaseInfo.vue b/web/src/views/develop/code/components/BaseInfo.vue new file mode 100644 index 0000000..9594812 --- /dev/null +++ b/web/src/views/develop/code/components/BaseInfo.vue @@ -0,0 +1,614 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 树名称字段 + + + + + + + + + + + 树节点的显示名称字段名, 如:`title` + + + + + + + + + + + {{ status.label }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 主表中存在`status`字段时才会生效 + + + + + + + + + + + + 系统默认会过滤权限,勾选后强制不开启权限验证 + + + + + + + + + + + + + + + + + + + 如果你选择的表已经生成过dao相关代码,取消勾选可减少生成时间 + + + + + + + + + 如果是插件模块,勾选后也会自动在对应插件下运行service相关代码生成 + + + + + + + + + 将表数据生成为数据选项,并注册到内置的方法字典 + + + + + + + + + 强制覆盖所有需要生成的代码文件,但不包含菜单SQL文件 + + + + + + + + + + 上级菜单 + 菜单管理 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 新增关联表 + + + + + 关联表数量建议在三个以下 + + + + + + + + + + + + + {{ joinAliasFeedback }} + + + + + + + + + + + + + + + + + + + + + + + + 移除 + + + + + + + + + + + + + + + + diff --git a/web/src/views/develop/code/components/EditMasterCell.vue b/web/src/views/develop/code/components/EditMasterCell.vue new file mode 100644 index 0000000..45dfad3 --- /dev/null +++ b/web/src/views/develop/code/components/EditMasterCell.vue @@ -0,0 +1,651 @@ + + + + + + + + + + + + + + + 重置字段 + + + 重置后将从数据库重新加载表,不保留当前字段配置,确定要重置吗? + + + + + + + + + + + 同步字段 + + + 同步是从数据库重新加载表,保留当前有效的字段配置,确定要同步吗? + + + + + + + + + 移动字段 + + + + + + + + + + + + diff --git a/web/src/views/develop/code/components/EditSlaveCell.vue b/web/src/views/develop/code/components/EditSlaveCell.vue new file mode 100644 index 0000000..db7e41c --- /dev/null +++ b/web/src/views/develop/code/components/EditSlaveCell.vue @@ -0,0 +1,430 @@ + + + + + + + + + + + + + + + 重置字段 + + + 重置后将从数据库重新加载该表的默认配置,确定要重置吗? + + + + + + + + + + + 同步字段 + + + 同步是从数据库重新加载表,保留当前有效的字段配置,确定要同步吗? + + + + + + + + + 移动字段 + + + + + + + + + + + + diff --git a/web/src/views/develop/code/components/Move.vue b/web/src/views/develop/code/components/Move.vue new file mode 100644 index 0000000..776c66e --- /dev/null +++ b/web/src/views/develop/code/components/Move.vue @@ -0,0 +1,82 @@ + + + + + + 请通过拖拽来移动字段的位置。 + + + + + {{ + element.name + }}{{ element.dc }} + + + + + + + + + + + + diff --git a/web/src/views/develop/code/components/PreviewTab.vue b/web/src/views/develop/code/components/PreviewTab.vue new file mode 100644 index 0000000..9247daa --- /dev/null +++ b/web/src/views/develop/code/components/PreviewTab.vue @@ -0,0 +1,161 @@ + + + + + + + + {{ view.tag.label }} + + + + {{ view.path }} + + 复制本页代码 + + + + + + + + + + + + diff --git a/web/src/views/develop/code/components/SetFuncDict.vue b/web/src/views/develop/code/components/SetFuncDict.vue new file mode 100644 index 0000000..2573335 --- /dev/null +++ b/web/src/views/develop/code/components/SetFuncDict.vue @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + 取消 + 保存 + + + + + + + diff --git a/web/src/views/develop/code/components/model.ts b/web/src/views/develop/code/components/model.ts new file mode 100644 index 0000000..f595172 --- /dev/null +++ b/web/src/views/develop/code/components/model.ts @@ -0,0 +1,142 @@ +import { cloneDeep } from 'lodash-es'; +import { isJsonString } from '@/utils/is'; + +export const genFileObj = { + meth: 1, + content: '', + path: '', + required: true, +}; + +export interface joinAttr { + uuid: string; + linkTable: string; + alias: string; + linkMode: number; + field: string; + masterField: string; + columns: any; +} + +export const genInfoObj = { + id: 0, + genType: 10, + genTemplate: null, + varName: '', + options: { + headOps: ['add', 'batchDel', 'export'], + columnOps: ['edit', 'del', 'view', 'status', 'check'], + autoOps: ['genMenuPermissions', 'runDao', 'runService'], + join: [], + menu: { + pid: 0, + icon: 'MenuOutlined', + sort: 0, + }, + tree: { + titleColumn: null, + styleType: 1, + }, + funcDict: { + valueColumn: null, + labelColumn: null, + }, + presetStep: { + formGridCols: 1, + }, + }, + dbName: '', + tableName: '', + tableComment: '', + daoName: '', + masterColumns: [], + addonName: null, + status: 2, + createdAt: '', + updatedAt: '', +}; + +export const selectListObj = { + db: [], + genType: [], + status: [], + tables: [], + formMode: [], + formRole: [], + dictMode: [], + whereMode: [], + buildMeth: [], + tableAlign: [], + treeStyleType: [], +}; + +export function newState(state) { + if (state !== null) { + return cloneDeep(state); + } + return cloneDeep(genInfoObj); +} + +export const formGridColsOptions = [ + { + value: 1, + label: '一行一列', + }, + { + value: 2, + label: '一行两列', + }, + { + value: 3, + label: '一行三列', + }, + { + value: 4, + label: '一行四列', + }, +]; + +export const formGridSpanOptions = [ + { + value: 1, + label: '占一列位置', + }, + { + value: 2, + label: '占两列位置', + }, + { + value: 3, + label: '占三列位置', + }, + { + value: 4, + label: '占四列位置', + }, +]; + +// 格式化列字段 +export function formatColumns(columns: any) { + if (columns === undefined || columns.length === 0) { + columns = []; + } + + if (isJsonString(columns)) { + columns = JSON.parse(columns); + } + + if (columns.length > 0) { + for (let i = 0; i < columns.length; i++) { + if (!columns[i].formGridSpan) { + columns[i].formGridSpan = 1; + } + if (!columns[i].align) { + columns[i].align = 'left'; + } + if (!columns[i].width || columns[i].width < 1) { + columns[i].width = null; + } + } + } + return columns; +} diff --git a/web/src/views/develop/code/deploy.vue b/web/src/views/develop/code/deploy.vue new file mode 100644 index 0000000..a394dea --- /dev/null +++ b/web/src/views/develop/code/deploy.vue @@ -0,0 +1,308 @@ + + + + + + + + + + + + + + + + + + + + + + 返回列表 + 预览代码 + 提交生成 + 仅保存配置 + + + + + + + + + (showModal = false)">关闭 + 提交生成 + + + + + + + + + + + + diff --git a/web/src/views/develop/code/index.vue b/web/src/views/develop/code/index.vue new file mode 100644 index 0000000..586b37c --- /dev/null +++ b/web/src/views/develop/code/index.vue @@ -0,0 +1,499 @@ + + + + + + + + + + + + + + + + + + 立即生成 + + + + + + + + 批量删除 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + (showModal = false)">取消 + 生成配置 + + + + + + + + + + diff --git a/web/src/views/exception/403.vue b/web/src/views/exception/403.vue new file mode 100644 index 0000000..a18c770 --- /dev/null +++ b/web/src/views/exception/403.vue @@ -0,0 +1,40 @@ + + + + + + + 抱歉,你无权访问该页面 + 回到首页 + + + + + + + diff --git a/web/src/views/exception/404.vue b/web/src/views/exception/404.vue new file mode 100644 index 0000000..8c70110 --- /dev/null +++ b/web/src/views/exception/404.vue @@ -0,0 +1,40 @@ + + + + + + + 抱歉,你访问的页面不存在 + 回到首页 + + + + + + + diff --git a/web/src/views/exception/500.vue b/web/src/views/exception/500.vue new file mode 100644 index 0000000..ee7c868 --- /dev/null +++ b/web/src/views/exception/500.vue @@ -0,0 +1,40 @@ + + + + + + + 抱歉,服务器出错了 + 回到首页 + + + + + + + diff --git a/web/src/views/home/account/BasicSetting.vue b/web/src/views/home/account/BasicSetting.vue new file mode 100644 index 0000000..adad530 --- /dev/null +++ b/web/src/views/home/account/BasicSetting.vue @@ -0,0 +1,215 @@ + + + + + + + + {{ timeFix() }},{{ formValue.realName }},今天又是充满活力的一天! + + + + + {{ formValue.id }} + {{ formValue.username }} + {{ + Number(formValue.balance).toFixed(2) + }} + + {{ Number(formValue.integral).toFixed(2) }} + + {{ + formValue.lastLoginIp + }} + {{ formValue.lastLoginAt }} + + + {{ formValue.loginCount }} 次 + + {{ formValue.createdAt }} + + + + {{ formValue.deptName }} + + + + + + + + {{ formValue.roleName }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 男 + 女 + 保密 + + + + + + + + + + + + + + + 保存更新 + 重置 + + + + + + + + diff --git a/web/src/views/home/account/CashSetting.vue b/web/src/views/home/account/CashSetting.vue new file mode 100644 index 0000000..9bffc75 --- /dev/null +++ b/web/src/views/home/account/CashSetting.vue @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + 保存更新 + + + + + + + + diff --git a/web/src/views/home/account/SafetySetting.vue b/web/src/views/home/account/SafetySetting.vue new file mode 100644 index 0000000..b9728c1 --- /dev/null +++ b/web/src/views/home/account/SafetySetting.vue @@ -0,0 +1,315 @@ + + + + + + + 修改 + + + 绑定手机和邮箱,并设置密码,帐号更安全 + + + + + 修改 + + + 已绑定手机号:+86{{ userStore.info?.mobile }} + + + + + 修改 + + + 已绑定邮箱:{{ userStore.info?.email }} + + + + + + + + + + + + + + + + + + + 取消 + 修改并重新登录 + + + + + + + + + + + + {{ sendLabel }} + + + + 接收号码:+86{{ userStore.info?.mobile }} + + + + + + + + 取消 + 保存更新 + + + + + + + + + + + + {{ sendLabel }} + + + 接收邮箱:{{ userStore.info?.email }} + + + + + + + + 取消 + 保存更新 + + + + + + + diff --git a/web/src/views/home/account/ThirdBind.vue b/web/src/views/home/account/ThirdBind.vue new file mode 100644 index 0000000..daaeb11 --- /dev/null +++ b/web/src/views/home/account/ThirdBind.vue @@ -0,0 +1,303 @@ + + + + + + + 修改 + + + 已绑定微信号:xxx + + + + + 修改 + + + 已绑定抖音号:xxx + + + + + + + + + + + + + + + + + + + 取消 + 修改并重新登录 + + + + + + + + + + + + {{ sendLabel }} + + + + 接收号码:+86{{ userStore.info?.mobile }} + + + + + + + + 取消 + 保存更新 + + + + + + + + + + + + {{ sendLabel }} + + + 接收邮箱:{{ userStore.info?.email }} + + + + + + + + 取消 + 保存更新 + + + + + + + diff --git a/web/src/views/home/account/account.vue b/web/src/views/home/account/account.vue new file mode 100644 index 0000000..2574ba9 --- /dev/null +++ b/web/src/views/home/account/account.vue @@ -0,0 +1,111 @@ + + + + + + + {{ item.name }} + {{ item.desc }} + + + + + + + + + + + + + + + + diff --git a/web/src/views/home/message/list.vue b/web/src/views/home/message/list.vue new file mode 100644 index 0000000..50f4919 --- /dev/null +++ b/web/src/views/home/message/list.vue @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + + + + + {{ item.tagTitle }} + + + + + + + + + + + + + + 共 {{ dataSource.totalCount }} 条 + + + + + + + diff --git a/web/src/views/home/message/message.vue b/web/src/views/home/message/message.vue new file mode 100644 index 0000000..547f571 --- /dev/null +++ b/web/src/views/home/message/message.vue @@ -0,0 +1,41 @@ + + + + + 在这里,您可以方便地查看平台中的通知、公告和与您相关的私信消息 + + + + + + + + + + + + + diff --git a/web/src/views/iframe/index.vue b/web/src/views/iframe/index.vue new file mode 100644 index 0000000..ad6d76b --- /dev/null +++ b/web/src/views/iframe/index.vue @@ -0,0 +1,69 @@ + + + + + + + + + + diff --git a/web/src/views/log/ems-log/columns.ts b/web/src/views/log/ems-log/columns.ts new file mode 100644 index 0000000..9e6239c --- /dev/null +++ b/web/src/views/log/ems-log/columns.ts @@ -0,0 +1,80 @@ +import { h } from 'vue'; +import { NTag } from 'naive-ui'; + +export const columns = [ + { + title: '模块', + key: 'module', + render(row) { + return h( + NTag, + { + style: { + marginRight: '6px', + }, + type: row.module == 'admin' ? 'info' : 'success', + bordered: false, + }, + { + default: () => row.module, + } + ); + }, + }, + { + title: '操作人', + key: 'memberName', + render(row) { + if (row.memberId === 0) { + return row.memberName; + } + return row.memberName + '(' + row.memberId + ')'; + }, + }, + { + title: '请求方式', + key: 'method', + }, + { + title: '请求路径', + key: 'url', + }, + { + title: '访问IP', + key: 'ip', + }, + // { + // title: 'IP地区', + // key: 'region', + // }, + { + title: '状态码', + key: 'errorCode', + render(row) { + return h( + NTag, + { + style: { + marginRight: '6px', + }, + type: row.errorCode == 0 ? 'success' : 'warning', + bordered: false, + }, + { + default: () => row.errorMsg + '(' + row.errorCode + ')', + } + ); + }, + }, + { + title: 'Goroutine耗时', + key: 'takeUpTime', + render(row) { + return row.takeUpTime + ' ms'; + }, + }, + { + title: '访问时间', + key: 'createdAt', + }, +]; diff --git a/web/src/views/log/ems-log/index.vue b/web/src/views/log/ems-log/index.vue new file mode 100644 index 0000000..b6e846a --- /dev/null +++ b/web/src/views/log/ems-log/index.vue @@ -0,0 +1,292 @@ + + + + + + + + + + + + + + + + + 批量删除 + + + + + ems-log刷新数据 + + + + + + + + diff --git a/web/src/views/log/ems-log/view.vue b/web/src/views/log/ems-log/view.vue new file mode 100644 index 0000000..d39e7c3 --- /dev/null +++ b/web/src/views/log/ems-log/view.vue @@ -0,0 +1,136 @@ + + + + + {{ data.method }} + + 请求地址 + {{ data.url }} + + {{ data.takeUpTime }} ms + {{ data.ip }} + 河南 郑州 + {{ data.reqId }} + {{ + timestampToTime(data.timestamp) + }} + + {{ data.createdAt }} + + + + {{ data.userAgent }} + + + + {{ data.errorCode }} + + {{ data.errorMsg }} + + + {{ data.errorData }} + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/views/log/log/columns.ts b/web/src/views/log/log/columns.ts new file mode 100644 index 0000000..560439f --- /dev/null +++ b/web/src/views/log/log/columns.ts @@ -0,0 +1,48 @@ +import { h } from 'vue'; +import Column from './components/Column.vue'; + +export const columns = [ + { + title: '记录ID', + key: 'id', + width: 100, + }, + { + title: '访客', + key: 'name', + width: 180, + render(row) { + return h(Column, { + state: row, + column: 'visitor', + }); + }, + }, + { + title: '请求接口', + key: 'name', + width: 260, + render(row) { + return h(Column, { + state: row, + column: 'request', + }); + }, + }, + { + title: '接口响应', + key: 'name', + width: 260, + render(row) { + return h(Column, { + state: row, + column: 'response', + }); + }, + }, + { + title: '访问时间', + key: 'createdAt', + width: 180, + }, +]; diff --git a/web/src/views/log/log/components/Column.vue b/web/src/views/log/log/components/Column.vue new file mode 100644 index 0000000..e07a58c --- /dev/null +++ b/web/src/views/log/log/components/Column.vue @@ -0,0 +1,77 @@ + + + + + + + {{ + state.memberId === 0 + ? state.memberName + : state.memberName + '(' + state.memberId + ')' + }} + + + + 访问IP: + {{ state.ip }} + + + + {{ state.cityLabel !== '' ? state.cityLabel : '局域网' }} + + + + + + + + + {{ state.method }} + + + {{ state.url }} + + + {{ state.tags }} + + + + {{ state.summary }} + + + + + + + + + {{ state.errorMsg }} + + + {{ state.errorCode }} → {{ state.errorMsg }} + + + + + 处理耗时:{{ state.takeUpTime }}ms + + + 响应时间:{{ timestampToTime(state.timestamp) }} + + + + + + + + + diff --git a/web/src/views/log/log/components/props.ts b/web/src/views/log/log/components/props.ts new file mode 100644 index 0000000..0221c96 --- /dev/null +++ b/web/src/views/log/log/components/props.ts @@ -0,0 +1,12 @@ +import { State } from '@/views/log/log/model'; + +export const basicProps = { + state: { + type: State, + default: null, + }, + column: { + type: String, + default: '', + }, +}; diff --git a/web/src/views/log/log/index.vue b/web/src/views/log/log/index.vue new file mode 100644 index 0000000..b972905 --- /dev/null +++ b/web/src/views/log/log/index.vue @@ -0,0 +1,156 @@ + + + + + 全局访问日志记录了系统中后台人员和客户端的操作记录以及服务响应情况 + + + + + + + + + + + + + + + + + + 批量删除 + + + + + + + + + + diff --git a/web/src/views/log/log/model.ts b/web/src/views/log/log/model.ts new file mode 100644 index 0000000..de23bb6 --- /dev/null +++ b/web/src/views/log/log/model.ts @@ -0,0 +1,151 @@ +import { FormSchema } from '@/components/Form'; +import { ref } from 'vue'; +import { defRangeShortcuts } from '@/utils/dateUtil'; +import { useDictStore } from '@/store/modules/dict'; + +const dict = useDictStore(); + +export class State { + id = 0; + reqId = ''; + appId = ''; + merchantId = 0; + memberId = 0; + method = ''; + module = ''; + url = ''; + getData: Record | null = null; + postData: Record | null = null; + headerData: Record | null = null; + ip = ''; + provinceId = 0; + cityId = 0; + errorCode = 0; + errorMsg = ''; + errorData: string[] = []; + userAgent = ''; + takeUpTime = 0; + timestamp = 0; + status = 0; + createdAt = ''; + updatedAt = ''; + cityLabel = ''; + tags = ''; + summary = ''; + description = ''; +} + +export const schemas = ref([ + { + field: 'reqId', + component: 'NInput', + label: '链路ID', + componentProps: { + placeholder: '请输入链路ID', + onInput: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'complexMemberId', + component: 'ComplexMemberPicker', + label: '操作人', + componentProps: { + placeholder: '请选择操作人', + onInput: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'url', + component: 'NInput', + label: '接口路径', + componentProps: { + placeholder: '请输入接口路径', + onInput: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'keyword', + component: 'NInput', + label: '关键词', + componentProps: { + placeholder: '请输入关键词', + onInput: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'ip', + component: 'NInput', + label: '访问IP', + componentProps: { + placeholder: '请输入IP地址', + onInput: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'method', + component: 'NSelect', + label: '请求方式', + componentProps: { + placeholder: '请选择请求方式', + options: dict.getOption('HTTPMethod'), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'createdAt', + component: 'NDatePicker', + label: '访问时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'takeUpTime', + component: 'NSelect', + label: '请求耗时', + componentProps: { + placeholder: '请选择请求耗时', + options: dict.getOption('HTTPHandlerTime'), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'errorCode', + component: 'NSelect', + label: '状态码', + labelMessage: '支持填入自定义状态码', + componentProps: { + placeholder: '请选择状态码', + options: dict.getOption('HTTPApiCode'), + filterable: true, + tag: true, + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, +]); + +// 加载字典数据选项 +export function loadOptions() { + dict.loadOptions(['HTTPMethod', 'HTTPHandlerTime', 'HTTPApiCode']); +} diff --git a/web/src/views/log/log/view.vue b/web/src/views/log/log/view.vue new file mode 100644 index 0000000..f32da12 --- /dev/null +++ b/web/src/views/log/log/view.vue @@ -0,0 +1,181 @@ + + + + + + + + + + + + 返回 + + + + {{ data.method }} + + 请求地址 + {{ data.url }} + + {{ data.tags }} / {{ data.summary }} + {{ data.ip }} + {{ data.cityLabel }} + {{ data.reqId }} + {{ data.takeUpTime }} ms + {{ + data.timestamp > 0 ? timestampToTime(data.timestamp) : '--' + }} + + {{ data.createdAt }} + + + + {{ data.userAgent }} + + + + {{ data.errorCode }} + + {{ data.errorMsg }} + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/views/log/login-log/index.vue b/web/src/views/log/login-log/index.vue new file mode 100644 index 0000000..7042e01 --- /dev/null +++ b/web/src/views/log/login-log/index.vue @@ -0,0 +1,179 @@ + + + + 在这里会记录管理后台所有的来访登录情况 + + + + + + + + + + + + + + + + + 批量删除 + + + + + + + + 导出 + + + + + + + + + + diff --git a/web/src/views/log/login-log/model.ts b/web/src/views/log/login-log/model.ts new file mode 100644 index 0000000..698283d --- /dev/null +++ b/web/src/views/log/login-log/model.ts @@ -0,0 +1,140 @@ +import { h, ref } from 'vue'; +import { FormSchema } from '@/components/Form'; +import { defRangeShortcuts } from '@/utils/dateUtil'; +import { NTag } from 'naive-ui'; +import { useDictStore } from '@/store/modules/dict'; +import { renderOptionTag } from '@/utils'; + +const dict = useDictStore(); + +export const schemas = ref([ + { + field: 'username', + component: 'NInput', + label: '用户名', + componentProps: { + placeholder: '请输入用户名', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'loginIp', + component: 'NInput', + label: 'IP地址', + componentProps: { + placeholder: '请输入IP地址', + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'status', + component: 'NSelect', + label: '状态', + defaultValue: null, + componentProps: { + placeholder: '请选择状态', + options: dict.getOption('sys_login_status'), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, + { + field: 'loginAt', + component: 'NDatePicker', + label: '登录时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + onUpdateValue: (e: any) => { + console.log(e); + }, + }, + }, +]); + +export const columns = [ + { + title: '记录ID', + key: 'id', + width: 80, + }, + { + title: '用户名', + key: 'username', + width: 120, + render(row) { + return h( + NTag, + { + style: { + marginRight: '6px', + }, + type: 'info', + bordered: false, + }, + { + default: () => row.username, + } + ); + }, + }, + { + title: '登录IP', + key: 'loginIp', + width: 160, + }, + { + title: 'IP归属地', + key: 'cityLabel', + width: 200, + }, + { + title: '浏览器', + key: 'browser', + width: 200, + }, + { + title: '操作系统', + key: 'os', + width: 150, + }, + { + title: '状态', + key: 'status', + render(row) { + return renderOptionTag('sys_login_status', row.status); + }, + width: 150, + }, + { + title: '提示信息', + key: 'errMsg', + render(row) { + if (row.errMsg !== '') { + return row.errMsg; + } + + if (row.status === 1) { + return '登录成功'; + } + return ``; + }, + width: 200, + }, + { + title: '登录时间', + key: 'loginAt', + width: 180, + }, +]; + +// 加载字典数据选项 +export function loadOptions() { + dict.loadOptions(['sys_login_status']); +} diff --git a/web/src/views/log/sms-log/index.vue b/web/src/views/log/sms-log/index.vue new file mode 100644 index 0000000..4b6227b --- /dev/null +++ b/web/src/views/log/sms-log/index.vue @@ -0,0 +1,278 @@ + + + + + 在这里,您可以方便地查看平台的所有短信发送记录 + + + + + + + + + + + + + + + + + + 批量删除 + + + + + + + + + + diff --git a/web/src/views/login/components/form-other.vue b/web/src/views/login/components/form-other.vue new file mode 100644 index 0000000..dbe11ea --- /dev/null +++ b/web/src/views/login/components/form-other.vue @@ -0,0 +1,67 @@ + + + + + 其它登录方式 + + + + + + + + + + + + + + + + + {{ tag }} + + + + + + + + diff --git a/web/src/views/login/components/style.less b/web/src/views/login/components/style.less new file mode 100644 index 0000000..99e17ca --- /dev/null +++ b/web/src/views/login/components/style.less @@ -0,0 +1,66 @@ + .view-account { + display: flex; + flex-direction: column; + height: 100vh; + overflow: auto; + + &-container { + flex: 1; + padding: 32px 12px; + max-width: 384px; + min-width: 320px; + margin: 0 auto; + } + + &-top { + padding: 32px 0; + text-align: center; + + &-desc { + font-size: 14px; + color: #808695; + } + } + + &-other { + width: 100%; + } + + .default-color { + color: #515a6e; + + .ant-checkbox-wrapper { + color: #515a6e; + } + } + } + + @media (min-width: 768px) { + .view-account { + background-image: url('@/assets/images/login.svg'); + background-repeat: no-repeat; + background-position: 50%; + background-size: 100%; + } + + .page-account-container { + padding: 32px 0 24px 0; + } + } + + .justify-between { + justify-content: space-between; + } + + .flex-y-center { + display: flex; + align-items: center; + } + + .w-300px { + width: 300px; + } + + .w-12px { + width: 12px; + } diff --git a/web/src/views/login/index.vue b/web/src/views/login/index.vue new file mode 100644 index 0000000..4aa5134 --- /dev/null +++ b/web/src/views/login/index.vue @@ -0,0 +1,172 @@ + + + + + + + + + + {{ projectName }} + + + + + + + + + + + + + + + + + + diff --git a/web/src/views/login/login/demo-account.vue b/web/src/views/login/login/demo-account.vue new file mode 100644 index 0000000..5dda468 --- /dev/null +++ b/web/src/views/login/login/demo-account.vue @@ -0,0 +1,37 @@ + + + + 演示角色登录 + + + {{ item.name }} + + + + SaaS系统多租户多应用设计 + + + + + + + + diff --git a/web/src/views/login/login/form.vue b/web/src/views/login/login/form.vue new file mode 100644 index 0000000..81ea181 --- /dev/null +++ b/web/src/views/login/login/form.vue @@ -0,0 +1,316 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ sendLabel }} + + + + + + + + 自动登录 + 忘记密码? + + + 登录 + + + + + + + + + +
{{ item.createdAt }}
{{ alertText }}
一些对话框内容
您提交的内容有如下错误:
+ + + + + 认证照片不够清晰 + 立即修改 + +
+ + + + + 备注包含敏感字符,并且不能包含政治相关 + 立即修改 + +
您提交的内容如下:
+ + + + + 转入支付宝账户(189****5426):¥1980元 + 立即撤回 + +
+ + + + + 转入支付宝账户(187****5426):¥2980元 + 立即撤回 + +
已提交申请,等待财务部门审核。
苏轼,(1037年1月8日-1101年8月24日)字子瞻、和仲,号铁冠道人、东坡居士,世称苏东坡、苏仙,汉族,眉州眉山(四川省眉山市)人,祖籍河北栾城,北宋著名文学家、书法家、画家,历史治水名人。苏轼是北宋中期文坛领袖,在诗、词、散文、书、画等方面取得很高成就。文纵横恣肆;诗题材广阔,清新豪健,善用夸张比喻,独具风格,与黄庭坚并称“苏黄”;词开豪放一派,与辛弃疾同是豪放派代表,并称“苏辛”;散文著述宏富,豪放自如,与欧阳修并称“欧苏”,为“唐宋八大家”之一。苏轼善书,“宋四家”之一;擅长文人画,尤擅墨竹、怪石、枯木等。与韩愈、柳宗元和欧阳修合称“千古文章四大家”。作品有《东坡七集》《东坡易传》《东坡乐府》《潇湘竹石图卷》《古木怪石图卷》等
白居易(772年-846年),字乐天,号香山居士,又号醉吟先生,祖籍太原,到其曾祖父时迁居下邽,生于河南新郑。是唐代伟大的现实主义诗人,唐代三大诗人之一。白居易与元稹共同倡导新乐府运动,世称“元白”,与刘禹锡并称“刘白”。白居易的诗歌题材广泛,形式多样,语言平易通俗,有“诗魔”和“诗王”之称。官至翰林学士、左赞善大夫。公元846年,白居易在洛阳逝世,葬于香山。有《白氏长庆集》传世,代表诗作有《长恨歌》《卖炭翁》《琵琶行》等。
杜甫(712-770),字子美,自号少陵野老,世称“杜工部”、“杜少陵”等,汉族,河南府巩县(今河南省巩义市)人,唐代伟大的现实主义诗人,杜甫被世人尊为“诗圣”,其诗被称为“诗史”。杜甫与李白合称“李杜”,为了跟另外两位诗人李商隐与杜牧即“小李杜”区别开来,杜甫与李白又合称“大李杜”。他忧国忧民,人格高尚,他的约1400余首诗被保留了下来,诗艺精湛,在中国古典诗歌中备受推崇,影响深远。759-766年间曾居成都,后世有杜甫草堂纪念
{{ estimated }}
+ + + + + +
{{ item.title }}
早安,{{ username }},开始您一天的工作吧!
今日阴转大雨,15℃ - 25℃,出门记得带伞哦。
2021-07-04 22:37:16
2021-07-04 09:37:16
2021-07-04 20:37:16
+ +