46 Commits

Author SHA1 Message Date
9c51f75404 feat(projects): 登录页新增装饰点缀 2025-09-01 17:49:51 +08:00
75cc0e4592 feat(projects): 登陆页面样式重构 2025-09-01 17:05:42 +08:00
f9afd27f4b Merge remote-tracking branch 'Soybean/v2.0' into v2.0
# Conflicts:
#	build/config/proxy.ts
#	package.json
#	packages/scripts/package.json
#	pnpm-lock.yaml
#	src/typings/components.d.ts
2025-09-01 15:28:16 +08:00
6dcc8c349a Merge branch 'dev' into v2.0
# Conflicts:
#	package.json
#	packages/scripts/package.json
#	pnpm-lock.yaml
#	src/views/system/user/modules/user-operate-drawer.vue
2025-09-01 15:18:26 +08:00
3d72f954ed fix(types): fix proxy types 2025-08-28 00:11:13 +08:00
1213531bef chore(deps): update deps 2025-08-28 00:09:07 +08:00
805c338141 chore(packages): add picomatch to fix scripts 2025-08-28 00:08:44 +08:00
3c0a52825d feat(projects): modify the default value of the reset cache policy to 'refresh'. 2025-08-27 18:17:55 +08:00
100e0ea55d style(projects): format code. 2025-08-24 14:14:50 +08:00
257f1183fc feat(projects): support theme preset function. 2025-08-24 14:14:50 +08:00
2504498eb5 fix(hooks): 修复树表全部展开问题(临时) 2025-08-19 11:00:19 +08:00
5581a4a59f Merge remote-tracking branch 'Soybean/v2.0' into v2.0
# Conflicts:
#	src/typings/components.d.ts
2025-08-14 18:14:31 +08:00
7c83ce7937 Merge branch 'dev' into v2.0
# Conflicts:
#	src/views/system/user/modules/user-import-modal.vue
2025-08-14 18:12:03 +08:00
d73111116a refactor(menu): optimize the margin on the menu 2025-08-14 17:41:57 +08:00
29a2a5c66a feat(projects): add prompt information for scrolling mode and tab bar caching. 2025-08-13 15:54:15 +08:00
4005763c00 feat(components): replace NTooltip with IconTooltip and optimize the layout of related components. 2025-08-13 15:54:15 +08:00
a55b4dc073 feat(components): add the IconTooltip component. 2025-08-13 15:54:15 +08:00
be8f915a0c chore(other): update the ESLint validation configuration to support more file types. 2025-08-13 15:54:15 +08:00
358e129765 feat(hooks): add scrollX computation for total table width in useNaiveTable 2025-08-11 10:42:17 +08:00
2af57caf44 Merge branch 'dev' into v2.0
# Conflicts:
#	src/views/system/user/modules/user-import-modal.vue
#	src/views/system/user/modules/user-operate-drawer.vue
2025-08-08 17:21:41 +08:00
4699654fc1 feat(hooks): 完成表格 Hooks 改造 2025-07-31 17:08:06 +08:00
01116c9ffa Merge remote-tracking branch 'Soybean/v2.0' into v2.0 2025-07-31 14:53:35 +08:00
9ea56c9b82 fix(packages): fix the parsing logic for stored data to ensure correct return of boolean values 2025-07-31 11:24:42 +08:00
87adc35f2e refactor(hooks): remove useSignal hook and update exports 2025-07-20 00:31:03 +08:00
ee4341457a refactor(hooks): streamline column visibility handling in useTable and table components 2025-07-20 00:30:16 +08:00
8a7cd5934b fix(hooks): correct chart rendering logic in useEcharts 2025-07-19 20:09:20 +08:00
c962f7b2c5 chore(deps): update deps 2025-07-19 19:45:49 +08:00
8cc5177cda refactor(hooks)!: refactor useTable and enhance type definitions 2025-07-19 19:43:58 +08:00
3a343eea33 optimize(projects): optimize api type file 2025-07-19 18:25:59 +08:00
f83eefbc3e refactor(request): unify response transformation methods and deprecate transformBackendResponse 2025-07-19 12:04:00 +08:00
936b834e62 optimize(hooks): optimize useEcharts 2025-07-19 02:53:46 +08:00
c965140b87 refactor(hooks): optimize useContext and update useMixMenuContext 2025-07-19 02:40:25 +08:00
32b8f99071 fix(table): add type annotations for records in useTable hook 2025-07-19 02:28:05 +08:00
abaaa4a068 optimize(packages): remove ofetch package 2025-07-19 02:27:54 +08:00
b4e125300e refactor(request)!: remove cancelRequest method and related logic from request instances 2025-07-19 02:24:14 +08:00
50a5cba088 optimize(request): enhance request options and response handling with generic types 2025-07-19 02:17:50 +08:00
d6c8142bb4 refactor(projects): remove unnecessary logic in onRouteSwitchWhenLoggedIn 2025-07-15 22:04:18 +08:00
8146858b96 optimize(projects): optimize theme drawer width 2025-07-14 00:48:17 +08:00
8b8a2083bb optimize(projects): improve robustness of second-level menu key logic 2025-07-14 00:48:16 +08:00
6207292d81 typo(projects): update description of vertical-hybrid-header-first layout mode 2025-07-14 00:48:16 +08:00
b4e5c6d990 feat(projects): add 'vertical-hybrid-header-first' layout mode 2025-07-14 00:48:16 +08:00
b6ac3106ce feat(projects)!: optimize layout mode, split horizontal mix component into two layouts, and rename the component. 2025-07-14 00:48:16 +08:00
d37ce04606 refactor(types): move Auth and Route namespaces to separate files and clean up api.d.ts 2025-07-14 00:48:16 +08:00
8439a60070 optimize(projects): improve theme drawer responsive width for mobile devices 2025-07-14 00:48:16 +08:00
f238fcbd47 feat(projects): Add current time display option for watermark (#772)
* feat(projects): Add current time display option for watermark

* perf(projects): add watermark timer controls
2025-07-14 00:48:16 +08:00
8ba71a0857 feat(projects): refactor theme drawer with tabbed layout for better UX. 2025-07-14 00:48:16 +08:00
151 changed files with 3764 additions and 14486 deletions

View File

@ -13,11 +13,12 @@ import type {
ResponseType ResponseType
} from './type'; } from './type';
function createCommonRequest<ResponseData = any>( function createCommonRequest<
axiosConfig?: CreateAxiosDefaults, ResponseData,
options?: Partial<RequestOption<ResponseData>> ApiData = ResponseData,
) { State extends Record<string, unknown> = Record<string, unknown>
const opts = createDefaultOptions<ResponseData>(options); >(axiosConfig?: CreateAxiosDefaults, options?: Partial<RequestOption<ResponseData, ApiData, State>>) {
const opts = createDefaultOptions<ResponseData, ApiData, State>(options);
const axiosConf = createAxiosConfig(axiosConfig); const axiosConf = createAxiosConfig(axiosConfig);
const instance = axios.create(axiosConf); const instance = axios.create(axiosConf);
@ -80,14 +81,6 @@ function createCommonRequest<ResponseData = any>(
} }
); );
function cancelRequest(requestId: string) {
const abortController = abortControllerMap.get(requestId);
if (abortController) {
abortController.abort();
abortControllerMap.delete(requestId);
}
}
function cancelAllRequest() { function cancelAllRequest() {
abortControllerMap.forEach(abortController => { abortControllerMap.forEach(abortController => {
abortController.abort(); abortController.abort();
@ -98,7 +91,6 @@ function createCommonRequest<ResponseData = any>(
return { return {
instance, instance,
opts, opts,
cancelRequest,
cancelAllRequest cancelAllRequest
}; };
} }
@ -109,27 +101,27 @@ function createCommonRequest<ResponseData = any>(
* @param axiosConfig axios config * @param axiosConfig axios config
* @param options request options * @param options request options
*/ */
export function createRequest<ResponseData = any, State = Record<string, unknown>>( export function createRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults, axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData>> options?: Partial<RequestOption<ResponseData, ApiData, State>>
) { ) {
const { instance, opts, cancelRequest, cancelAllRequest } = createCommonRequest<ResponseData>(axiosConfig, options); const { instance, opts, cancelAllRequest } = createCommonRequest<ResponseData, ApiData, State>(axiosConfig, options);
const request: RequestInstance<State> = async function request<T = any, R extends ResponseType = 'json'>( const request: RequestInstance<ApiData, State> = async function request<
config: CustomAxiosRequestConfig T extends ApiData = ApiData,
) { R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) {
const response: AxiosResponse<ResponseData> = await instance(config); const response: AxiosResponse<ResponseData> = await instance(config);
const responseType = response.config?.responseType || 'json'; const responseType = response.config?.responseType || 'json';
if (responseType === 'json') { if (responseType === 'json') {
return opts.transformBackendResponse(response); return opts.transform(response);
} }
return response.data as MappedType<R, T>; return response.data as MappedType<R, T>;
} as RequestInstance<State>; } as RequestInstance<ApiData, State>;
request.cancelRequest = cancelRequest;
request.cancelAllRequest = cancelAllRequest; request.cancelAllRequest = cancelAllRequest;
request.state = {} as State; request.state = {} as State;
@ -144,14 +136,14 @@ export function createRequest<ResponseData = any, State = Record<string, unknown
* @param axiosConfig axios config * @param axiosConfig axios config
* @param options request options * @param options request options
*/ */
export function createFlatRequest<ResponseData = any, State = Record<string, unknown>>( export function createFlatRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults, axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData>> options?: Partial<RequestOption<ResponseData, ApiData, State>>
) { ) {
const { instance, opts, cancelRequest, cancelAllRequest } = createCommonRequest<ResponseData>(axiosConfig, options); const { instance, opts, cancelAllRequest } = createCommonRequest<ResponseData, ApiData, State>(axiosConfig, options);
const flatRequest: FlatRequestInstance<State, ResponseData> = async function flatRequest< const flatRequest: FlatRequestInstance<ResponseData, ApiData, State> = async function flatRequest<
T = any, T extends ApiData = ApiData,
R extends ResponseType = 'json' R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) { >(config: CustomAxiosRequestConfig) {
try { try {
@ -160,20 +152,21 @@ export function createFlatRequest<ResponseData = any, State = Record<string, unk
const responseType = response.config?.responseType || 'json'; const responseType = response.config?.responseType || 'json';
if (responseType === 'json') { if (responseType === 'json') {
const data = opts.transformBackendResponse(response); const data = await opts.transform(response);
return { data, error: null, response }; return { data, error: null, response };
} }
return { data: response.data as MappedType<R, T>, error: null }; return { data: response.data as MappedType<R, T>, error: null, response };
} catch (error) { } catch (error) {
return { data: null, error, response: (error as AxiosError<ResponseData>).response }; return { data: null, error, response: (error as AxiosError<ResponseData>).response };
} }
} as FlatRequestInstance<State, ResponseData>; } as FlatRequestInstance<ResponseData, ApiData, State>;
flatRequest.cancelRequest = cancelRequest;
flatRequest.cancelAllRequest = cancelAllRequest; flatRequest.cancelAllRequest = cancelAllRequest;
flatRequest.state = {} as State; flatRequest.state = {
...opts.defaultState
} as State;
return flatRequest; return flatRequest;
} }

View File

@ -4,15 +4,27 @@ import { stringify } from 'qs';
import { isHttpSuccess } from './shared'; import { isHttpSuccess } from './shared';
import type { RequestOption } from './type'; import type { RequestOption } from './type';
export function createDefaultOptions<ResponseData = any>(options?: Partial<RequestOption<ResponseData>>) { export function createDefaultOptions<
const opts: RequestOption<ResponseData> = { ResponseData,
ApiData = ResponseData,
State extends Record<string, unknown> = Record<string, unknown>
>(options?: Partial<RequestOption<ResponseData, ApiData, State>>) {
const opts: RequestOption<ResponseData, ApiData, State> = {
defaultState: {} as State,
transform: async response => response.data as unknown as ApiData,
transformBackendResponse: async response => response.data as unknown as ApiData,
onRequest: async config => config, onRequest: async config => config,
isBackendSuccess: _response => true, isBackendSuccess: _response => true,
onBackendFail: async () => {}, onBackendFail: async () => {},
transformBackendResponse: async response => response.data,
onError: async () => {} onError: async () => {}
}; };
if (options?.transform) {
opts.transform = options.transform;
} else {
opts.transform = options?.transformBackendResponse || opts.transform;
}
Object.assign(opts, options); Object.assign(opts, options);
return opts; return opts;

View File

@ -8,7 +8,30 @@ export type ContentType =
| 'application/x-www-form-urlencoded' | 'application/x-www-form-urlencoded'
| 'application/octet-stream'; | 'application/octet-stream';
export interface RequestOption<ResponseData = any> { export type ResponseTransform<Input = any, Output = any> = (input: Input) => Output | Promise<Output>;
export interface RequestOption<
ResponseData,
ApiData = ResponseData,
State extends Record<string, unknown> = Record<string, unknown>
> {
/**
* The default state
*/
defaultState?: State;
/**
* transform the response data to the api data
*
* @param response Axios response
*/
transform: ResponseTransform<AxiosResponse<ResponseData>, ApiData>;
/**
* transform the response data to the api data
*
* @deprecated use `transform` instead, will be removed in the next major version v3
* @param response Axios response
*/
transformBackendResponse: ResponseTransform<AxiosResponse<ResponseData>, ApiData>;
/** /**
* The hook before request * The hook before request
* *
@ -35,12 +58,6 @@ export interface RequestOption<ResponseData = any> {
response: AxiosResponse<ResponseData>, response: AxiosResponse<ResponseData>,
instance: AxiosInstance instance: AxiosInstance
) => Promise<AxiosResponse | null> | Promise<void>; ) => Promise<AxiosResponse | null> | Promise<void>;
/**
* transform backend response when the responseType is json
*
* @param response Axios response
*/
transformBackendResponse(response: AxiosResponse<ResponseData>): any | Promise<any>;
/** /**
* The hook to handle error * The hook to handle error
* *
@ -68,15 +85,7 @@ export type CustomAxiosRequestConfig<R extends ResponseType = 'json'> = Omit<Axi
responseType?: R; responseType?: R;
}; };
export interface RequestInstanceCommon<T> { export interface RequestInstanceCommon<State extends Record<string, unknown>> {
/**
* cancel the request by request id
*
* if the request provide abort controller sign from config, it will not collect in the abort controller map
*
* @param requestId
*/
cancelRequest: (requestId: string) => void;
/** /**
* cancel all request * cancel all request
* *
@ -84,32 +93,35 @@ export interface RequestInstanceCommon<T> {
*/ */
cancelAllRequest: () => void; cancelAllRequest: () => void;
/** you can set custom state in the request instance */ /** you can set custom state in the request instance */
state: T; state: State;
} }
/** The request instance */ /** The request instance */
export interface RequestInstance<S = Record<string, unknown>> extends RequestInstanceCommon<S> { export interface RequestInstance<ApiData, State extends Record<string, unknown>> extends RequestInstanceCommon<State> {
<T = any, R extends ResponseType = 'json'>(config: CustomAxiosRequestConfig<R>): Promise<MappedType<R, T>>; <T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig<R>
): Promise<MappedType<R, T>>;
} }
export type FlatResponseSuccessData<T = any, ResponseData = any> = { export type FlatResponseSuccessData<ResponseData, ApiData> = {
data: T; data: ApiData;
error: null; error: null;
response: AxiosResponse<ResponseData>; response: AxiosResponse<ResponseData>;
}; };
export type FlatResponseFailData<ResponseData = any> = { export type FlatResponseFailData<ResponseData> = {
data: null; data: null;
error: AxiosError<ResponseData>; error: AxiosError<ResponseData>;
response: AxiosResponse<ResponseData>; response: AxiosResponse<ResponseData>;
}; };
export type FlatResponseData<T = any, ResponseData = any> = export type FlatResponseData<ResponseData, ApiData> =
| FlatResponseSuccessData<T, ResponseData> | FlatResponseSuccessData<ResponseData, ApiData>
| FlatResponseFailData<ResponseData>; | FlatResponseFailData<ResponseData>;
export interface FlatRequestInstance<S = Record<string, unknown>, ResponseData = any> extends RequestInstanceCommon<S> { export interface FlatRequestInstance<ResponseData, ApiData, State extends Record<string, unknown>>
<T = any, R extends ResponseType = 'json'>( extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig<R> config: CustomAxiosRequestConfig<R>
): Promise<FlatResponseData<MappedType<R, T>, ResponseData>>; ): Promise<FlatResponseData<ResponseData, MappedType<R, T>>>;
} }

View File

@ -3,9 +3,7 @@ import useLoading from './use-loading';
import useCountDown from './use-count-down'; import useCountDown from './use-count-down';
import useContext from './use-context'; import useContext from './use-context';
import useSvgIconRender from './use-svg-icon-render'; import useSvgIconRender from './use-svg-icon-render';
import useHookTable from './use-table'; import useTable from './use-table';
export { useBoolean, useLoading, useCountDown, useContext, useSvgIconRender, useHookTable }; export { useBoolean, useLoading, useCountDown, useContext, useSvgIconRender, useTable };
export type * from './use-table';
export * from './use-signal';
export * from './use-table';

View File

@ -1,5 +1,4 @@
import { inject, provide } from 'vue'; import { inject, provide } from 'vue';
import type { InjectionKey } from 'vue';
/** /**
* Use context * Use context
@ -12,7 +11,7 @@ import type { InjectionKey } from 'vue';
* import { ref } from 'vue'; * import { ref } from 'vue';
* import { useContext } from '@sa/hooks'; * import { useContext } from '@sa/hooks';
* *
* export const { setupStore, useStore } = useContext('demo', () => { * export const [provideDemoContext, useDemoContext] = useContext('demo', () => {
* const count = ref(0); * const count = ref(0);
* *
* function increment() { * function increment() {
@ -35,10 +34,10 @@ import type { InjectionKey } from 'vue';
* <div>A</div> * <div>A</div>
* </template> * </template>
* <script setup lang="ts"> * <script setup lang="ts">
* import { setupStore } from './context'; * import { provideDemoContext } from './context';
* *
* setupStore(); * provideDemoContext();
* // const { increment } = setupStore(); // also can control the store in the parent component * // const { increment } = provideDemoContext(); // also can control the store in the parent component
* </script> * </script>
* ``` // B.vue * ``` // B.vue
* ```vue * ```vue
@ -46,9 +45,9 @@ import type { InjectionKey } from 'vue';
* <div>B</div> * <div>B</div>
* </template> * </template>
* <script setup lang="ts"> * <script setup lang="ts">
* import { useStore } from './context'; * import { useDemoContext } from './context';
* *
* const { count, increment } = useStore(); * const { count, increment } = useDemoContext();
* </script> * </script>
* ```; * ```;
* *
@ -57,40 +56,41 @@ import type { InjectionKey } from 'vue';
* @param contextName Context name * @param contextName Context name
* @param fn Context function * @param fn Context function
*/ */
export default function useContext<T extends (...args: any[]) => any>(contextName: string, fn: T) { export default function useContext<Arguments extends Array<any>, T>(
type Context = ReturnType<T>; contextName: string,
composable: (...args: Arguments) => T
) {
const key = Symbol(contextName);
const { useProvide, useInject: useStore } = createContext<Context>(contextName); /**
* Injects the context value.
*
* @param consumerName - The name of the component that is consuming the context. If provided, the component must be
* used within the context provider.
* @param defaultValue - The default value to return if the context is not provided.
* @returns The context value.
*/
const useInject = <N extends string | null | undefined = undefined>(
consumerName?: N,
defaultValue?: T
): N extends null | undefined ? T | null : T => {
const value = inject(key, defaultValue);
function setupStore(...args: Parameters<T>) { if (consumerName && !value) {
const context: Context = fn(...args); throw new Error(`\`${consumerName}\` must be used within \`${contextName}\``);
return useProvide(context); }
}
return { // @ts-expect-error - we want to return null if the value is undefined or null
/** Setup store in the parent component */ return value || null;
setupStore,
/** Use store in the child component */
useStore
}; };
}
/** Create context */ const useProvide = (...args: Arguments) => {
function createContext<T>(contextName: string) { const value = composable(...args);
const injectKey: InjectionKey<T> = Symbol(contextName);
function useProvide(context: T) { provide(key, value);
provide(injectKey, context);
return context; return value;
}
function useInject() {
return inject(injectKey) as T;
}
return {
useProvide,
useInject
}; };
return [useProvide, useInject] as const;
} }

View File

@ -6,31 +6,31 @@ import type {
CreateAxiosDefaults, CreateAxiosDefaults,
CustomAxiosRequestConfig, CustomAxiosRequestConfig,
MappedType, MappedType,
RequestInstanceCommon,
RequestOption, RequestOption,
ResponseType ResponseType
} from '@sa/axios'; } from '@sa/axios';
import useLoading from './use-loading'; import useLoading from './use-loading';
export type HookRequestInstanceResponseSuccessData<T = any> = { export type HookRequestInstanceResponseSuccessData<ApiData> = {
data: Ref<T>; data: Ref<ApiData>;
error: Ref<null>; error: Ref<null>;
}; };
export type HookRequestInstanceResponseFailData<ResponseData = any> = { export type HookRequestInstanceResponseFailData<ResponseData> = {
data: Ref<null>; data: Ref<null>;
error: Ref<AxiosError<ResponseData>>; error: Ref<AxiosError<ResponseData>>;
}; };
export type HookRequestInstanceResponseData<T = any, ResponseData = any> = { export type HookRequestInstanceResponseData<ResponseData, ApiData> = {
loading: Ref<boolean>; loading: Ref<boolean>;
} & (HookRequestInstanceResponseSuccessData<T> | HookRequestInstanceResponseFailData<ResponseData>); } & (HookRequestInstanceResponseSuccessData<ApiData> | HookRequestInstanceResponseFailData<ResponseData>);
export interface HookRequestInstance<ResponseData = any> { export interface HookRequestInstance<ResponseData, ApiData, State extends Record<string, unknown>>
<T = any, R extends ResponseType = 'json'>( extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig config: CustomAxiosRequestConfig
): HookRequestInstanceResponseData<MappedType<R, T>, ResponseData>; ): HookRequestInstanceResponseData<ResponseData, MappedType<R, T>>;
cancelRequest: (requestId: string) => void;
cancelAllRequest: () => void;
} }
/** /**
@ -39,25 +39,26 @@ export interface HookRequestInstance<ResponseData = any> {
* @param axiosConfig * @param axiosConfig
* @param options * @param options
*/ */
export default function createHookRequest<ResponseData = any>( export default function createHookRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults, axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData>> options?: Partial<RequestOption<ResponseData, ApiData, State>>
) { ) {
const request = createFlatRequest<ResponseData>(axiosConfig, options); const request = createFlatRequest<ResponseData, ApiData, State>(axiosConfig, options);
const hookRequest: HookRequestInstance<ResponseData> = function hookRequest<T = any, R extends ResponseType = 'json'>( const hookRequest: HookRequestInstance<ResponseData, ApiData, State> = function hookRequest<
config: CustomAxiosRequestConfig T extends ApiData = ApiData,
) { R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) {
const { loading, startLoading, endLoading } = useLoading(); const { loading, startLoading, endLoading } = useLoading();
const data = ref<MappedType<R, T> | null>(null) as Ref<MappedType<R, T>>; const data = ref(null) as Ref<MappedType<R, T>>;
const error = ref<AxiosError<ResponseData> | null>(null) as Ref<AxiosError<ResponseData> | null>; const error = ref(null) as Ref<AxiosError<ResponseData> | null>;
startLoading(); startLoading();
request(config).then(res => { request(config).then(res => {
if (res.data) { if (res.data) {
data.value = res.data; data.value = res.data as MappedType<R, T>;
} else { } else {
error.value = res.error; error.value = res.error;
} }
@ -70,9 +71,8 @@ export default function createHookRequest<ResponseData = any>(
data, data,
error error
}; };
} as HookRequestInstance<ResponseData>; } as HookRequestInstance<ResponseData, ApiData, State>;
hookRequest.cancelRequest = request.cancelRequest;
hookRequest.cancelAllRequest = request.cancelAllRequest; hookRequest.cancelAllRequest = request.cancelAllRequest;
return hookRequest; return hookRequest;

View File

@ -1,144 +0,0 @@
import { computed, ref, shallowRef, triggerRef } from 'vue';
import type {
ComputedGetter,
DebuggerOptions,
Ref,
ShallowRef,
WritableComputedOptions,
WritableComputedRef
} from 'vue';
type Updater<T> = (value: T) => T;
type Mutator<T> = (value: T) => void;
/**
* Signal is a reactive value that can be set, updated or mutated
*
* @example
* ```ts
* const count = useSignal(0);
*
* // `watchEffect`
* watchEffect(() => {
* console.log(count());
* });
*
* // watch
* watch(count, value => {
* console.log(value);
* });
*
* // useComputed
* const double = useComputed(() => count() * 2);
* const writeableDouble = useComputed({
* get: () => count() * 2,
* set: value => count.set(value / 2)
* });
* ```
*/
export interface Signal<T> {
(): Readonly<T>;
/**
* Set the value of the signal
*
* It recommend use `set` for primitive values
*
* @param value
*/
set(value: T): void;
/**
* Update the value of the signal using an updater function
*
* It recommend use `update` for non-primitive values, only the first level of the object will be reactive.
*
* @param updater
*/
update(updater: Updater<T>): void;
/**
* Mutate the value of the signal using a mutator function
*
* this action will call `triggerRef`, so the value will be tracked on `watchEffect`.
*
* It recommend use `mutate` for non-primitive values, all levels of the object will be reactive.
*
* @param mutator
*/
mutate(mutator: Mutator<T>): void;
/**
* Get the reference of the signal
*
* Sometimes it can be useful to make `v-model` work with the signal
*
* ```vue
* <template>
* <input v-model="model.count" />
* </template>;
*
* <script setup lang="ts">
* const state = useSignal({ count: 0 }, { useRef: true });
*
* const model = state.getRef();
* </script>
* ```
*/
getRef(): Readonly<ShallowRef<Readonly<T>>>;
}
export interface ReadonlySignal<T> {
(): Readonly<T>;
}
export interface SignalOptions {
/**
* Whether to use `ref` to store the value
*
* @default false use `sharedRef` to store the value
*/
useRef?: boolean;
}
export function useSignal<T>(initialValue: T, options?: SignalOptions): Signal<T> {
const { useRef } = options || {};
const state = useRef ? (ref(initialValue) as Ref<T>) : shallowRef(initialValue);
return createSignal(state);
}
export function useComputed<T>(getter: ComputedGetter<T>, debugOptions?: DebuggerOptions): ReadonlySignal<T>;
export function useComputed<T>(options: WritableComputedOptions<T>, debugOptions?: DebuggerOptions): Signal<T>;
export function useComputed<T>(
getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,
debugOptions?: DebuggerOptions
) {
const isGetter = typeof getterOrOptions === 'function';
const computedValue = computed(getterOrOptions as any, debugOptions);
if (isGetter) {
return () => computedValue.value as ReadonlySignal<T>;
}
return createSignal(computedValue);
}
function createSignal<T>(state: ShallowRef<T> | WritableComputedRef<T>): Signal<T> {
const signal = () => state.value;
signal.set = (value: T) => {
state.value = value;
};
signal.update = (updater: Updater<T>) => {
state.value = updater(state.value);
};
signal.mutate = (mutator: Mutator<T>) => {
mutator(state.value);
triggerRef(state);
};
signal.getRef = () => state as Readonly<ShallowRef<Readonly<T>>>;
return signal;
}

View File

@ -1,12 +1,20 @@
import { computed, reactive, ref } from 'vue'; import { computed, ref } from 'vue';
import type { Ref, VNodeChild } from 'vue'; import type { Ref, VNodeChild } from 'vue';
import { jsonClone } from '@sa/utils';
import useBoolean from './use-boolean'; import useBoolean from './use-boolean';
import useLoading from './use-loading'; import useLoading from './use-loading';
export type MaybePromise<T> = T | Promise<T>; export interface PaginationData<T> {
data: T[];
pageNum: number;
pageSize: number;
total: number;
}
export type ApiFn = (args: any) => Promise<unknown>; type GetApiData<ApiData, Pagination extends boolean> = Pagination extends true ? PaginationData<ApiData> : ApiData[];
type Transform<ResponseData, ApiData, Pagination extends boolean> = (
response: ResponseData
) => GetApiData<ApiData, Pagination>;
export type TableColumnCheckTitle = string | ((...args: any) => VNodeChild); export type TableColumnCheckTitle = string | ((...args: any) => VNodeChild);
@ -14,74 +22,64 @@ export type TableColumnCheck = {
key: string; key: string;
title: TableColumnCheckTitle; title: TableColumnCheckTitle;
checked: boolean; checked: boolean;
visible: boolean;
}; };
export type TableDataWithIndex<T> = T & { index: number }; export interface UseTableOptions<ResponseData, ApiData, Column, Pagination extends boolean> {
/**
export type TransformedData<T> = { * api function to get table data
data: TableDataWithIndex<T>[]; */
total?: number; api: () => Promise<ResponseData>;
}; /**
* whether to enable pagination
export type Transformer<T, Response> = (response: Response) => TransformedData<T>; */
pagination?: Pagination;
export type TableConfig<A extends ApiFn, T, C> = { /**
/** api function to get table data */ * transform api response to table data
apiFn: A; */
/** api params */ transform: Transform<ResponseData, ApiData, Pagination>;
apiParams?: Parameters<A>[0]; /**
/** transform api response to table data */ * columns factory
transformer: Transformer<T, Awaited<ReturnType<A>>>; */
/** columns factory */ columns: () => Column[];
columns: () => C[];
/** /**
* get column checks * get column checks
*
* @param columns
*/ */
getColumnChecks: (columns: C[]) => TableColumnCheck[]; getColumnChecks: (columns: Column[]) => TableColumnCheck[];
/** /**
* get columns * get columns
*
* @param columns
*/ */
getColumns: (columns: C[], checks: TableColumnCheck[]) => C[]; getColumns: (columns: Column[], checks: TableColumnCheck[]) => Column[];
/** /**
* callback when response fetched * callback when response fetched
*
* @param transformed transformed data
*/ */
onFetched?: (transformed: TransformedData<T>) => MaybePromise<void>; onFetched?: (data: GetApiData<ApiData, Pagination>) => void | Promise<void>;
/** /**
* whether to get data immediately * whether to get data immediately
* *
* @default true * @default true
*/ */
immediate?: boolean; immediate?: boolean;
}; }
export default function useHookTable<A extends ApiFn, T, C>(config: TableConfig<A, T, C>) { export default function useTable<ResponseData, ApiData, Column, Pagination extends boolean>(
options: UseTableOptions<ResponseData, ApiData, Column, Pagination>
) {
const { loading, startLoading, endLoading } = useLoading(); const { loading, startLoading, endLoading } = useLoading();
const { bool: empty, setBool: setEmpty } = useBoolean(); const { bool: empty, setBool: setEmpty } = useBoolean();
const { apiFn, apiParams, transformer, immediate = true, getColumnChecks, getColumns } = config; const { api, pagination, transform, columns, getColumnChecks, getColumns, onFetched, immediate = true } = options;
const searchParams: NonNullable<Parameters<A>[0]> = reactive(jsonClone({ ...apiParams })); const data = ref([]) as Ref<ApiData[]>;
const allColumns = ref(config.columns()) as Ref<C[]>; const columnChecks = ref(getColumnChecks(columns())) as Ref<TableColumnCheck[]>;
const data: Ref<TableDataWithIndex<T>[]> = ref([]); const $columns = computed(() => getColumns(columns(), columnChecks.value));
const columnChecks: Ref<TableColumnCheck[]> = ref(getColumnChecks(config.columns()));
const columns = computed(() => getColumns(allColumns.value, columnChecks.value));
function reloadColumns() { function reloadColumns() {
allColumns.value = config.columns();
const checkMap = new Map(columnChecks.value.map(col => [col.key, col.checked])); const checkMap = new Map(columnChecks.value.map(col => [col.key, col.checked]));
const defaultChecks = getColumnChecks(allColumns.value); const defaultChecks = getColumnChecks(columns());
columnChecks.value = defaultChecks.map(col => ({ columnChecks.value = defaultChecks.map(col => ({
...col, ...col,
@ -90,48 +88,21 @@ export default function useHookTable<A extends ApiFn, T, C>(config: TableConfig<
} }
async function getData() { async function getData() {
startLoading(); try {
startLoading();
const formattedParams = formatSearchParams(searchParams); const response = await api();
const response = await apiFn(formattedParams); const transformed = transform(response);
const transformed = transformer(response as Awaited<ReturnType<A>>); data.value = getTableData(transformed, pagination);
data.value = transformed.data; setEmpty(data.value.length === 0);
setEmpty(transformed.data.length === 0); await onFetched?.(transformed);
} finally {
await config.onFetched?.(transformed); endLoading();
}
endLoading();
}
function formatSearchParams(params: Record<string, unknown>) {
const formattedParams: Record<string, unknown> = {};
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formattedParams[key] = value;
}
});
return formattedParams;
}
/**
* update search params
*
* @param params
*/
function updateSearchParams(params: Partial<Parameters<A>[0]>) {
Object.assign(searchParams, params);
}
/** reset search params */
function resetSearchParams() {
Object.assign(searchParams, jsonClone(apiParams));
getData();
} }
if (immediate) { if (immediate) {
@ -142,12 +113,20 @@ export default function useHookTable<A extends ApiFn, T, C>(config: TableConfig<
loading, loading,
empty, empty,
data, data,
columns, columns: $columns,
columnChecks, columnChecks,
reloadColumns, reloadColumns,
getData, getData
searchParams,
updateSearchParams,
resetSearchParams
}; };
} }
function getTableData<ApiData, Pagination extends boolean>(
data: GetApiData<ApiData, Pagination>,
pagination?: Pagination
) {
if (pagination) {
return (data as PaginationData<ApiData>).data;
}
return data as ApiData[];
}

View File

@ -127,7 +127,6 @@ function handleClickMask() {
:class="[ :class="[
style['layout-header'], style['layout-header'],
commonClass, commonClass,
headerClass,
headerLeftGapClass, headerLeftGapClass,
{ 'absolute top-0 left-0 w-full': fixedHeaderAndTab } { 'absolute top-0 left-0 w-full': fixedHeaderAndTab }
]" ]"

View File

@ -6,12 +6,6 @@ interface AdminLayoutHeaderConfig {
* @default true * @default true
*/ */
headerVisible?: boolean; headerVisible?: boolean;
/**
* Header class
*
* @default ''
*/
headerClass?: string;
/** /**
* Header height * Header height
* *

View File

@ -1,15 +0,0 @@
{
"name": "@sa/fetch",
"version": "1.3.15",
"exports": {
".": "./src/index.ts"
},
"typesVersions": {
"*": {
"*": ["./src/*"]
}
},
"dependencies": {
"ofetch": "1.4.1"
}
}

View File

@ -1,10 +0,0 @@
import { ofetch } from 'ofetch';
import type { FetchOptions } from 'ofetch';
export function createRequest(options: FetchOptions) {
const request = ofetch.create(options);
return request;
}
export default createRequest;

View File

@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": ["DOM", "ESNext"],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"types": ["node"],
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@ -22,6 +22,7 @@
"execa": "9.6.0", "execa": "9.6.0",
"kolorist": "1.8.0", "kolorist": "1.8.0",
"npm-check-updates": "18.0.3", "npm-check-updates": "18.0.3",
"picomatch": "4.0.3",
"rimraf": "6.0.1" "rimraf": "6.0.1"
} }
} }

View File

@ -32,7 +32,8 @@ export function createStorage<T extends object>(type: StorageType, storagePrefix
storageData = JSON.parse(json); storageData = JSON.parse(json);
} catch {} } catch {}
if (storageData) { // storageData may be `false` if it is boolean type
if (storageData !== null) {
return storageData as T[K]; return storageData as T[K];
} }
} }

9
pnpm-lock.yaml generated
View File

@ -258,12 +258,6 @@ importers:
specifier: 0.9.1 specifier: 0.9.1
version: 0.9.1 version: 0.9.1
packages/ofetch:
dependencies:
ofetch:
specifier: 1.4.1
version: 1.4.1
packages/scripts: packages/scripts:
devDependencies: devDependencies:
'@soybeanjs/changelog': '@soybeanjs/changelog':
@ -293,6 +287,9 @@ importers:
npm-check-updates: npm-check-updates:
specifier: 18.0.3 specifier: 18.0.3
version: 18.0.3 version: 18.0.3
picomatch:
specifier: 4.0.3
version: 4.0.3
rimraf: rimraf:
specifier: 6.0.1 specifier: 6.0.1
version: 6.0.1 version: 6.0.1

View File

@ -4,7 +4,6 @@ import { NConfigProvider, darkTheme } from 'naive-ui';
import type { WatermarkProps } from 'naive-ui'; import type { WatermarkProps } from 'naive-ui';
import { useAppStore } from './store/modules/app'; import { useAppStore } from './store/modules/app';
import { useThemeStore } from './store/modules/theme'; import { useThemeStore } from './store/modules/theme';
import { useAuthStore } from './store/modules/auth';
import { naiveDateLocales, naiveLocales } from './locales/naive'; import { naiveDateLocales, naiveLocales } from './locales/naive';
defineOptions({ defineOptions({
@ -13,7 +12,6 @@ defineOptions({
const appStore = useAppStore(); const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const { userInfo } = useAuthStore();
const naiveDarkTheme = computed(() => (themeStore.darkMode ? darkTheme : undefined)); const naiveDarkTheme = computed(() => (themeStore.darkMode ? darkTheme : undefined));
@ -26,24 +24,19 @@ const naiveDateLocale = computed(() => {
}); });
const watermarkProps = computed<WatermarkProps>(() => { const watermarkProps = computed<WatermarkProps>(() => {
const appTitle = import.meta.env.VITE_APP_TITLE || 'RuoYi-Vue-Plus';
const content =
themeStore.watermark.enableUserName && userInfo.user?.userName
? `${userInfo.user?.nickName}@${appTitle} ${userInfo.user?.userName}`
: appTitle;
return { return {
content, content: themeStore.watermarkContent,
cross: true, cross: true,
fullscreen: true, fullscreen: true,
fontSize: 14, fontSize: 14,
fontColor: themeStore.darkMode ? 'rgba(200, 200, 200, 0.03)' : 'rgba(200, 200, 200, 0.2)',
lineHeight: 14, lineHeight: 14,
width: 200, width: 384,
height: 300, height: 384,
xOffset: 12, xOffset: 12,
yOffset: 60, yOffset: 60,
rotate: -18, rotate: -13,
zIndex: 9999 zIndex: 9999,
fontColor: themeStore.darkMode ? 'rgba(200, 200, 200, 0.03)' : 'rgba(200, 200, 200, 0.2)'
}; };
}); });
</script> </script>

View File

@ -22,7 +22,12 @@ const columns = defineModel<NaiveUI.TableColumnCheck[]>('columns', {
</NButton> </NButton>
</template> </template>
<VueDraggable v-model="columns" :animation="150" filter=".none_draggable"> <VueDraggable v-model="columns" :animation="150" filter=".none_draggable">
<div v-for="item in columns" :key="item.key" class="h-36px flex-y-center rd-4px hover:(bg-primary bg-opacity-20)"> <div
v-for="item in columns"
:key="item.key"
class="h-36px flex-y-center rd-4px hover:(bg-primary bg-opacity-20)"
:class="{ hidden: !item.visible }"
>
<icon-mdi-drag class="mr-8px h-full cursor-move text-icon" /> <icon-mdi-drag class="mr-8px h-full cursor-move text-icon" />
<NCheckbox v-model:checked="item.checked" class="none_draggable flex-1"> <NCheckbox v-model:checked="item.checked" class="none_draggable flex-1">
<template v-if="typeof item.title === 'function'"> <template v-if="typeof item.title === 'function'">

View File

@ -0,0 +1,42 @@
<script lang="ts" setup>
import { computed, useSlots } from 'vue';
import type { PopoverPlacement } from 'naive-ui';
defineOptions({ name: 'IconTooltip' });
interface Props {
icon?: string;
localIcon?: string;
desc?: string;
placement?: PopoverPlacement;
}
const props = withDefaults(defineProps<Props>(), {
icon: 'mdi-help-circle',
localIcon: '',
desc: '',
placement: 'top'
});
const slots = useSlots();
const hasCustomTrigger = computed(() => Boolean(slots.trigger));
if (!hasCustomTrigger.value && !props.icon && !props.localIcon) {
throw new Error('icon or localIcon is required when no custom trigger slot is provided');
}
</script>
<template>
<NTooltip :placement="placement">
<template #trigger>
<slot name="trigger">
<div class="cursor-pointer">
<SvgIcon :icon="icon" :local-icon="localIcon" />
</div>
</slot>
</template>
<slot>
<span>{{ desc }}</span>
</slot>
</NTooltip>
</template>

View File

@ -1,61 +1,524 @@
<!-- Copyright By https://github.com/Daymychen/art-design-pro/blob/main/src/components/core/views/login/LoginLeftView.vue -->
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { useThemeStore } from '@/store/modules/theme';
import { getPaletteColorByNumber } from '@sa/color';
defineOptions({ name: 'WaveBg' }); defineOptions({ name: 'WaveBg' });
interface Props { const themeStore = useThemeStore();
/** Theme color */
themeColor: string; function toggleThemeScheme() {
if (themeStore.darkMode) {
themeStore.setThemeScheme('light');
return;
}
themeStore.setThemeScheme('dark');
} }
const props = defineProps<Props>();
const lightColor = computed(() => getPaletteColorByNumber(props.themeColor, 200));
const darkColor = computed(() => getPaletteColorByNumber(props.themeColor, 500));
</script> </script>
<template> <template>
<div class="absolute-lt z-1 size-full overflow-hidden"> <div class="wave-bg">
<div class="absolute -right-300px -top-900px lt-sm:(-right-100px -top-1170px)"> <!-- 几何装饰元素 -->
<svg height="1337" width="1337"> <div class="geometric-decorations">
<defs> <!-- 基础几何形状 -->
<path <div class="geo-element circle-outline animate-fade-in-up animate-delay-0s"></div>
id="path-1" <div class="geo-element square-rotated animate-fade-in-left animate-delay-0s"></div>
opacity="1" <div class="geo-element circle-small animate-fade-in-up animate-delay-0.3s"></div>
fill-rule="evenodd"
d="M1337,668.5 C1337,1037.455193874239 1037.455193874239,1337 668.5,1337 C523.6725684305388,1337 337,1236 370.50000000000006,1094 C434.03835568300906,824.6732385973953 6.906089672974592e-14,892.6277623047779 0,668.5000000000001 C0,299.5448061257611 299.5448061257609,1.1368683772161603e-13 668.4999999999999,0 C1037.455193874239,0 1337,299.544806125761 1337,668.5Z" <div class="geo-element square-bottom-right animate-fade-in-right animate-delay-0s"></div>
/>
<linearGradient id="linearGradient-2" x1="0.79" y1="0.62" x2="0.21" y2="0.86"> <!-- 背景泡泡 -->
<stop offset="0" :stop-color="lightColor" stop-opacity="1" /> <div class="geo-element bg-bubble animate-scale-in animate-delay-0.5s"></div>
<stop offset="1" :stop-color="darkColor" stop-opacity="1" />
</linearGradient> <!-- 太阳/月亮 -->
</defs> <div
<g opacity="1"> class="geo-element circle-top-right animate-fade-in-down animate-delay-0.5s"
<use xlink:href="#path-1" fill="url(#linearGradient-2)" fill-opacity="1" /> @click="toggleThemeScheme"
</g> ></div>
</svg>
</div> <!-- 装饰点 -->
<div class="absolute -bottom-400px -left-200px lt-sm:(-bottom-760px -left-100px)"> <div class="geo-element dot dot-top-left animate-bounce-in animate-delay-0s"></div>
<svg height="896" width="967.8852157128662"> <div class="geo-element dot dot-top-right animate-bounce-in animate-delay-0s"></div>
<defs> <div class="geo-element dot dot-center-right animate-bounce-in animate-delay-0s"></div>
<path
id="path-2" <!-- 叠加方块组 -->
opacity="1" <div class="squares-group">
fill-rule="evenodd" <i class="geo-element square square-blue animate-fade-in-left-rotated-blue animate-delay-0.2s"></i>
d="M896,448 C1142.6325445712241,465.5747656464056 695.2579309733121,896 448,896 C200.74206902668806,896 5.684341886080802e-14,695.2579309733121 0,448.0000000000001 C0,200.74206902668806 200.74206902668791,5.684341886080802e-14 447.99999999999994,0 C695.2579309733121,0 475,418 896,448Z" <i class="geo-element square square-pink animate-fade-in-left-rotated-pink animate-delay-0.4s"></i>
/> <i class="geo-element square square-purple animate-fade-in-left-no-rotation animate-delay-0.6s"></i>
<linearGradient id="linearGradient-3" x1="0.5" y1="0" x2="0.5" y2="1"> </div>
<stop offset="0" :stop-color="darkColor" stop-opacity="1" />
<stop offset="1" :stop-color="lightColor" stop-opacity="1" />
</linearGradient>
</defs>
<g opacity="1">
<use xlink:href="#path-2" fill="url(#linearGradient-3)" fill-opacity="1" />
</g>
</svg>
</div> </div>
</div> </div>
</template> </template>
<style scoped></style> <style lang="scss" scoped>
// 颜色变量定义
$primary-light-7: rgb(var(--primary-50-color));
$primary-light-8: rgb(var(--primary-100-color));
$primary-light-9: rgb(var(--primary-200-color));
$primary-base: rgb(var(--primary-color));
$main-bg: rgb(var(--primary-50-color));
// 混合颜色函数
$bg-mix-light-9: color-mix(in srgb, $primary-light-9 100%, $main-bg);
$bg-mix-light-8: color-mix(in srgb, $primary-light-8 80%, $main-bg);
$bg-mix-light-7: color-mix(in srgb, $primary-light-7 80%, $main-bg);
.wave-bg {
.geometric-decorations {
.geo-element {
position: absolute;
opacity: 0;
animation-fill-mode: forwards;
animation-duration: 0.8s;
animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
// 动画 mixin
@mixin fadeAnimation($direction: '', $rotation: 0deg) {
from {
opacity: 0;
@if $direction == 'up' {
transform: translateY(30px) rotate($rotation);
} @else if $direction == 'down' {
transform: translateY(-30px) rotate($rotation);
} @else if $direction == 'left' {
transform: translateX(-30px) rotate($rotation);
} @else if $direction == 'right' {
transform: translateX(30px) rotate($rotation);
}
}
to {
opacity: 1;
@if $direction == 'up' or $direction == 'down' {
transform: translateY(0) rotate($rotation);
} @else {
transform: translateX(0) rotate($rotation);
}
}
}
// 动画定义
@keyframes fadeInUp {
@include fadeAnimation('up');
}
@keyframes fadeInDown {
@include fadeAnimation('down');
}
@keyframes fadeInLeft {
@include fadeAnimation('left');
}
@keyframes fadeInLeftRotated {
@include fadeAnimation('left', -25deg);
}
@keyframes fadeInRight {
@include fadeAnimation('right');
}
@keyframes fadeInRightRotated {
@include fadeAnimation('right', 45deg);
}
@keyframes fadeInLeftRotatedBlue {
@include fadeAnimation('left', -10deg);
}
@keyframes fadeInLeftRotatedPink {
@include fadeAnimation('left', 10deg);
}
@keyframes fadeInLeftNoRotation {
@include fadeAnimation('left');
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes bounceIn {
0% {
opacity: 0;
transform: scale(0.3);
}
50% {
opacity: 1;
transform: scale(1.05);
}
70% {
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes lineGrow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
// 动画类
.animate-fade-in-up {
animation-name: fadeInUp;
}
.animate-fade-in-down {
animation-name: fadeInDown;
}
.animate-fade-in-left {
animation-name: fadeInLeft;
}
.animate-fade-in-right {
animation-name: fadeInRight;
}
.animate-scale-in {
animation-name: scaleIn;
animation-duration: 1.2s;
}
.animate-bounce-in {
animation-name: bounceIn;
animation-duration: 0.6s;
}
.animate-fade-in-left-rotated-blue {
animation-name: fadeInLeftRotatedBlue;
}
.animate-fade-in-left-rotated-pink {
animation-name: fadeInLeftRotatedPink;
}
.animate-fade-in-left-no-rotation {
animation-name: fadeInLeftNoRotation;
}
// 基础几何形状
.circle-outline {
top: 10%;
left: 25%;
width: 42px;
height: 42px;
border: 2px solid $primary-light-8;
border-radius: 50%;
}
.square-rotated {
top: 50%;
left: 16%;
width: 60px;
height: 60px;
background-color: $bg-mix-light-8;
&.animate-fade-in-left {
animation-name: fadeInLeftRotated;
}
}
.circle-small {
bottom: 26%;
left: 30%;
width: 18px;
height: 18px;
background-color: $primary-light-8;
border-radius: 50%;
}
// 太阳/月亮效果
.circle-top-right {
top: 3%;
right: 3%;
z-index: 100;
width: 50px;
height: 50px;
cursor: pointer;
background: $bg-mix-light-7;
border-radius: 50%;
transition: all 0.3s;
&::after {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
content: '';
background: linear-gradient(to right, #fcbb04, #fffc00);
border-radius: 50%;
opacity: 0;
transition: all 0.5s;
transform: translate(-50%, -50%);
}
&:hover {
box-shadow: 0 0 36px #fffc00;
&::after {
opacity: 1;
}
}
}
.square-bottom-right {
right: 10%;
bottom: 10%;
width: 50px;
height: 50px;
background-color: $primary-light-8;
&.animate-fade-in-right {
animation-name: fadeInRightRotated;
}
}
// 背景泡泡
.bg-bubble {
top: -120px;
right: -120px;
width: 360px;
height: 360px;
background-color: $bg-mix-light-8;
border-radius: 50%;
}
// 装饰点
.dot {
width: 14px;
height: 14px;
background-color: $primary-light-7;
border-radius: 50%;
&.dot-top-left {
top: 140px;
left: 100px;
}
&.dot-top-right {
top: 140px;
right: 120px;
}
&.dot-center-right {
top: 46%;
right: 22%;
background-color: $primary-light-8;
}
}
// 叠加方块组
.squares-group {
position: absolute;
bottom: 18px;
left: 20px;
width: 140px;
height: 140px;
pointer-events: none;
.square {
position: absolute;
display: block;
border-radius: 8px;
box-shadow: 0 8px 24px rgb(64 87 167 / 12%);
&.square-blue {
top: 12px;
left: 30px;
z-index: 2;
width: 50px;
height: 50px;
background-color: rgb(from $primary-base r g b / 30%);
}
&.square-pink {
top: 30px;
left: 48px;
z-index: 1;
width: 70px;
height: 70px;
background-color: rgb(from $primary-base r g b / 15%);
}
&.square-purple {
top: 66px;
left: 86px;
z-index: 3;
width: 32px;
height: 32px;
background-color: rgb(from $primary-base r g b / 45%);
}
}
// 装饰线条
&::after {
position: absolute;
top: 86px;
left: 72px;
width: 80px;
height: 1px;
content: '';
background: linear-gradient(90deg, var(--el-color-primary-light-6), transparent);
opacity: 0;
transform: rotate(50deg);
animation: lineGrow 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
animation-delay: 1.2s;
}
}
}
@media only screen and (max-width: 1600px) {
width: 60vw;
.text-wrap {
bottom: 40px;
}
}
@media only screen and (max-width: 1280px) {
width: auto;
height: auto;
padding: 0;
// 隐藏背景和其他内容,只保留 logo
background: transparent;
.left-img,
.text-wrap,
.geometric-decorations {
display: none;
}
.logo {
position: fixed;
top: 15px;
left: 25px;
z-index: 1000;
}
}
}
// 暗色主题
.dark .wave-bg {
background-color: color-mix(in srgb, $primary-light-9 60%, #070707);
@media only screen and (max-width: 1280px) {
background: transparent;
}
.geometric-decorations {
// 月亮效果
.circle-top-right {
background-color: $bg-mix-light-8;
box-shadow: 0 0 25px #333 inset;
transition: all 0.3s ease-in-out 0.1s;
rotate: -48deg;
&::before {
position: absolute;
top: 0;
left: 15px;
width: 50px;
height: 50px;
content: '';
background-color: $bg-mix-light-9;
border-radius: 50%;
transition: all 0.3s ease-in-out;
}
&:hover {
background-color: transparent;
box-shadow: 0 40px 25px #ddd inset;
&::before {
left: 18px;
}
&::after {
opacity: 0;
}
}
}
.bg-bubble {
background-color: $bg-mix-light-9;
}
// 其他元素颜色调整
.square-rotated {
background-color: $bg-mix-light-9;
}
.circle-small,
.dot {
background-color: $primary-light-8;
}
.square-bottom-right {
background-color: $primary-light-9;
}
.dot.dot-top-right {
background-color: $primary-light-8;
}
}
// 方块组暗色调整
.squares-group {
.square {
box-shadow: none;
&.square-blue {
background-color: rgb(from $primary-base r g b / 18%);
}
&.square-pink {
background-color: rgb(from $primary-base r g b / 10%);
}
&.square-purple {
background-color: rgb(from $primary-base r g b / 20%);
}
}
&::after {
background: linear-gradient(90deg, $primary-light-8, transparent);
}
}
}
</style>

View File

@ -5,9 +5,9 @@ export const GLOBAL_HEADER_MENU_ID = '__GLOBAL_HEADER_MENU__';
export const GLOBAL_SIDER_MENU_ID = '__GLOBAL_SIDER_MENU__'; export const GLOBAL_SIDER_MENU_ID = '__GLOBAL_SIDER_MENU__';
export const themeSchemaRecord: Record<UnionKey.ThemeScheme, App.I18n.I18nKey> = { export const themeSchemaRecord: Record<UnionKey.ThemeScheme, App.I18n.I18nKey> = {
light: 'theme.themeSchema.light', light: 'theme.appearance.themeSchema.light',
dark: 'theme.themeSchema.dark', dark: 'theme.appearance.themeSchema.dark',
auto: 'theme.themeSchema.auto' auto: 'theme.appearance.themeSchema.auto'
}; };
export const themeSchemaOptions = transformRecordToOption(themeSchemaRecord); export const themeSchemaOptions = transformRecordToOption(themeSchemaRecord);
@ -21,49 +21,61 @@ export const loginModuleRecord: Record<UnionKey.LoginModule, App.I18n.I18nKey> =
}; };
export const themeLayoutModeRecord: Record<UnionKey.ThemeLayoutMode, App.I18n.I18nKey> = { export const themeLayoutModeRecord: Record<UnionKey.ThemeLayoutMode, App.I18n.I18nKey> = {
vertical: 'theme.layoutMode.vertical', vertical: 'theme.layout.layoutMode.vertical',
'vertical-mix': 'theme.layoutMode.vertical-mix', 'vertical-mix': 'theme.layout.layoutMode.vertical-mix',
horizontal: 'theme.layoutMode.horizontal', 'vertical-hybrid-header-first': 'theme.layout.layoutMode.vertical-hybrid-header-first',
'horizontal-mix': 'theme.layoutMode.horizontal-mix' horizontal: 'theme.layout.layoutMode.horizontal',
'top-hybrid-sidebar-first': 'theme.layout.layoutMode.top-hybrid-sidebar-first',
'top-hybrid-header-first': 'theme.layout.layoutMode.top-hybrid-header-first'
}; };
export const themeLayoutModeOptions = transformRecordToOption(themeLayoutModeRecord); export const themeLayoutModeOptions = transformRecordToOption(themeLayoutModeRecord);
export const themeScrollModeRecord: Record<UnionKey.ThemeScrollMode, App.I18n.I18nKey> = { export const themeScrollModeRecord: Record<UnionKey.ThemeScrollMode, App.I18n.I18nKey> = {
wrapper: 'theme.scrollMode.wrapper', wrapper: 'theme.layout.content.scrollMode.wrapper',
content: 'theme.scrollMode.content' content: 'theme.layout.content.scrollMode.content'
}; };
export const themeScrollModeOptions = transformRecordToOption(themeScrollModeRecord); export const themeScrollModeOptions = transformRecordToOption(themeScrollModeRecord);
export const themeTabModeRecord: Record<UnionKey.ThemeTabMode, App.I18n.I18nKey> = { export const themeTabModeRecord: Record<UnionKey.ThemeTabMode, App.I18n.I18nKey> = {
chrome: 'theme.tab.mode.chrome', chrome: 'theme.layout.tab.mode.chrome',
button: 'theme.tab.mode.button' button: 'theme.layout.tab.mode.button'
}; };
export const themeTabModeOptions = transformRecordToOption(themeTabModeRecord); export const themeTabModeOptions = transformRecordToOption(themeTabModeRecord);
export const themePageAnimationModeRecord: Record<UnionKey.ThemePageAnimateMode, App.I18n.I18nKey> = { export const themePageAnimationModeRecord: Record<UnionKey.ThemePageAnimateMode, App.I18n.I18nKey> = {
'fade-slide': 'theme.page.mode.fade-slide', 'fade-slide': 'theme.layout.content.page.mode.fade-slide',
fade: 'theme.page.mode.fade', fade: 'theme.layout.content.page.mode.fade',
'fade-bottom': 'theme.page.mode.fade-bottom', 'fade-bottom': 'theme.layout.content.page.mode.fade-bottom',
'fade-scale': 'theme.page.mode.fade-scale', 'fade-scale': 'theme.layout.content.page.mode.fade-scale',
'zoom-fade': 'theme.page.mode.zoom-fade', 'zoom-fade': 'theme.layout.content.page.mode.zoom-fade',
'zoom-out': 'theme.page.mode.zoom-out', 'zoom-out': 'theme.layout.content.page.mode.zoom-out',
none: 'theme.page.mode.none' none: 'theme.layout.content.page.mode.none'
}; };
export const themePageAnimationModeOptions = transformRecordToOption(themePageAnimationModeRecord); export const themePageAnimationModeOptions = transformRecordToOption(themePageAnimationModeRecord);
export const resetCacheStrategyRecord: Record<UnionKey.ResetCacheStrategy, App.I18n.I18nKey> = { export const resetCacheStrategyRecord: Record<UnionKey.ResetCacheStrategy, App.I18n.I18nKey> = {
close: 'theme.resetCacheStrategy.close', refresh: 'theme.layout.resetCacheStrategy.refresh',
refresh: 'theme.resetCacheStrategy.refresh' close: 'theme.layout.resetCacheStrategy.close'
}; };
export const resetCacheStrategyOptions = transformRecordToOption(resetCacheStrategyRecord); export const resetCacheStrategyOptions = transformRecordToOption(resetCacheStrategyRecord);
export const DARK_CLASS = 'dark'; export const DARK_CLASS = 'dark';
export const watermarkTimeFormatOptions = [
{ label: 'YYYY-MM-DD HH:mm', value: 'YYYY-MM-DD HH:mm' },
{ label: 'YYYY-MM-DD HH:mm:ss', value: 'YYYY-MM-DD HH:mm:ss' },
{ label: 'YYYY/MM/DD HH:mm', value: 'YYYY/MM/DD HH:mm' },
{ label: 'YYYY/MM/DD HH:mm:ss', value: 'YYYY/MM/DD HH:mm:ss' },
{ label: 'HH:mm', value: 'HH:mm' },
{ label: 'HH:mm:ss', value: 'HH:mm:ss' },
{ label: 'MM-DD HH:mm', value: 'MM-DD HH:mm' }
];
export const themeTableSizeRecord: Record<UnionKey.ThemeTableSize, App.I18n.I18nKey> = { export const themeTableSizeRecord: Record<UnionKey.ThemeTableSize, App.I18n.I18nKey> = {
small: 'theme.table.size.small', small: 'theme.table.size.small',
medium: 'theme.table.size.medium', medium: 'theme.table.size.medium',

View File

@ -1,4 +1,4 @@
import { computed, effectScope, nextTick, onScopeDispose, ref, watch } from 'vue'; import { computed, effectScope, nextTick, onScopeDispose, shallowRef, watch } from 'vue';
import { useElementSize } from '@vueuse/core'; import { useElementSize } from '@vueuse/core';
import * as echarts from 'echarts/core'; import * as echarts from 'echarts/core';
import { BarChart, GaugeChart, LineChart, PictorialBarChart, PieChart, RadarChart, ScatterChart } from 'echarts/charts'; import { BarChart, GaugeChart, LineChart, PictorialBarChart, PieChart, RadarChart, ScatterChart } from 'echarts/charts';
@ -86,11 +86,11 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const darkMode = computed(() => themeStore.darkMode); const darkMode = computed(() => themeStore.darkMode);
const domRef = ref<HTMLElement | null>(null); const domRef = shallowRef<HTMLElement | null>(null);
const initialSize = { width: 0, height: 0 }; const initialSize = { width: 0, height: 0 };
const { width, height } = useElementSize(domRef, initialSize); const { width, height } = useElementSize(domRef, initialSize);
let chart: echarts.ECharts | null = null; const chart = shallowRef<echarts.ECharts | null>(null);
const chartOptions: T = optionsFactory(); const chartOptions: T = optionsFactory();
const { const {
@ -111,18 +111,9 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
onDestroy onDestroy
} = hooks; } = hooks;
/**
* whether can render chart
*
* when domRef is ready and initialSize is valid
*/
function canRender() {
return domRef.value && initialSize.width > 0 && initialSize.height > 0;
}
/** is chart rendered */ /** is chart rendered */
function isRendered() { function isRendered() {
return Boolean(domRef.value && chart); return Boolean(domRef.value && chart.value);
} }
/** /**
@ -131,59 +122,59 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
* @param callback callback function * @param callback callback function
*/ */
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) { async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
if (!isRendered()) return;
const updatedOpts = callback(chartOptions, optionsFactory); const updatedOpts = callback(chartOptions, optionsFactory);
Object.assign(chartOptions, updatedOpts); Object.assign(chartOptions, updatedOpts);
await nextTick();
if (!isRendered()) return;
if (isRendered()) { if (isRendered()) {
chart?.clear(); chart.value?.clear();
} }
chart?.setOption({ ...updatedOpts, backgroundColor: 'transparent' }); chart.value?.setOption({ ...updatedOpts, backgroundColor: 'transparent' });
await onUpdated?.(chart!); await onUpdated?.(chart.value!);
} }
function setOptions(options: T) { function setOptions(options: T) {
chart?.setOption(options); chart.value?.setOption(options);
} }
/** render chart */ /** render chart */
async function render() { async function render() {
if (!isRendered()) { if (isRendered()) return;
const chartTheme = darkMode.value ? 'dark' : 'light';
await nextTick(); const chartTheme = darkMode.value ? 'dark' : 'light';
chart = echarts.init(domRef.value, chartTheme); chart.value = echarts.init(domRef.value, chartTheme);
chart.setOption({ ...chartOptions, backgroundColor: 'transparent' }); chart.value?.setOption({ ...chartOptions, backgroundColor: 'transparent' });
await onRender?.(chart); await onRender?.(chart.value!);
}
} }
/** resize chart */ /** resize chart */
function resize() { function resize() {
chart?.resize(); chart.value?.resize();
} }
/** destroy chart */ /** destroy chart */
async function destroy() { async function destroy() {
if (!chart) return; if (!chart.value) return;
await onDestroy?.(chart); await onDestroy?.(chart.value);
chart?.dispose(); chart.value?.dispose();
chart = null; chart.value = null;
} }
/** change chart theme */ /** change chart theme */
async function changeTheme() { async function changeTheme() {
await destroy(); await destroy();
await render(); await render();
await onUpdated?.(chart!); await onUpdated?.(chart.value!);
} }
/** /**
@ -196,30 +187,29 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
initialSize.width = w; initialSize.width = w;
initialSize.height = h; initialSize.height = h;
// size is abnormal, destroy chart
if (!canRender()) {
await destroy();
return;
}
// resize chart // resize chart
if (isRendered()) { if (isRendered()) {
resize(); resize();
return;
} }
// render chart // render chart
await render(); await render();
if (chart) { if (chart.value) {
await onUpdated?.(chart); await onUpdated?.(chart.value);
} }
} }
scope.run(() => { scope.run(() => {
watch([width, height], ([newWidth, newHeight]) => { watch(
renderChartBySize(newWidth, newHeight); [width, height],
}); ([newWidth, newHeight]) => {
renderChartBySize(newWidth, newHeight);
},
{ flush: 'post' }
);
watch(darkMode, () => { watch(darkMode, () => {
changeTheme(); changeTheme();
@ -233,6 +223,7 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
return { return {
domRef, domRef,
chart,
updateOptions, updateOptions,
setOptions setOptions
}; };

View File

@ -1,195 +1,55 @@
import { computed, effectScope, onScopeDispose, reactive, ref, watch } from 'vue'; import { computed, effectScope, onScopeDispose, reactive, ref, shallowRef, watch } from 'vue';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import type { PaginationProps } from 'naive-ui'; import type { PaginationProps } from 'naive-ui';
import { useBoolean, useTable } from '@sa/hooks';
import type { PaginationData, TableColumnCheck, UseTableOptions } from '@sa/hooks';
import type { FlatResponseData } from '@sa/axios';
import { jsonClone } from '@sa/utils'; import { jsonClone } from '@sa/utils';
import { useBoolean, useHookTable } from '@sa/hooks';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales'; import { $t } from '@/locales';
type TableData = NaiveUI.TableData; export type UseNaiveTableOptions<ResponseData, ApiData, Pagination extends boolean> = Omit<
type GetTableData<A extends NaiveUI.TableApiFn> = NaiveUI.GetTableData<A>; UseTableOptions<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, Pagination>,
type TableColumn<T> = NaiveUI.TableColumn<T>; 'pagination' | 'getColumnChecks' | 'getColumns'
> & {
/**
* get column visible
*
* @param column
*
* @default true
*
* @returns true if the column is visible, false otherwise
*/
getColumnVisible?: (column: NaiveUI.TableColumn<ApiData>) => boolean;
};
export function useTable<A extends NaiveUI.TableApiFn>(config: NaiveUI.NaiveTableConfig<A>) { const SELECTION_KEY = '__selection__';
const EXPAND_KEY = '__expand__';
export function useNaiveTable<ResponseData, ApiData>(options: UseNaiveTableOptions<ResponseData, ApiData, false>) {
const scope = effectScope(); const scope = effectScope();
const appStore = useAppStore(); const appStore = useAppStore();
const isMobile = computed(() => appStore.isMobile); const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, false>({
...options,
const { apiFn, apiParams, immediate, showTotal = true } = config; getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
getColumns
const SELECTION_KEY = '__selection__';
const EXPAND_KEY = '__expand__';
const {
loading,
empty,
data,
columns,
columnChecks,
reloadColumns,
getData,
searchParams,
updateSearchParams,
resetSearchParams
} = useHookTable<A, GetTableData<A>, TableColumn<NaiveUI.TableDataWithIndex<GetTableData<A>>>>({
apiFn,
apiParams,
columns: config.columns,
transformer: res => {
const { rows: records = [], total = 0 } = res.data || {};
const current = searchParams.pageNum as number;
const size = (searchParams.pageSize || 0) as number;
// Ensure that the size is greater than 0, If it is less than 0, it will cause paging calculation errors.
const pageSize = size <= 0 ? 10 : size;
const recordsWithIndex = records.map((item, index) => {
return {
...item,
index: (current - 1) * pageSize + index + 1
};
});
return {
data: recordsWithIndex,
pageNum: current,
pageSize,
total
};
},
getColumnChecks: cols => {
const checks: NaiveUI.TableColumnCheck[] = [];
cols.forEach(column => {
if (isTableColumnHasKey(column)) {
checks.push({
key: column.key as string,
title: column.title!,
checked: true
});
} else if (column.type === 'selection') {
checks.push({
key: SELECTION_KEY,
title: $t('common.check'),
checked: true
});
} else if (column.type === 'expand') {
checks.push({
key: EXPAND_KEY,
title: $t('common.expandColumn'),
checked: true
});
}
});
return checks;
},
getColumns: (cols, checks) => {
const columnMap = new Map<string, TableColumn<GetTableData<A>>>();
cols.forEach(column => {
if (isTableColumnHasKey(column)) {
columnMap.set(column.key as string, column);
} else if (column.type === 'selection') {
columnMap.set(SELECTION_KEY, column);
} else if (column.type === 'expand') {
columnMap.set(EXPAND_KEY, column);
}
});
const filteredColumns = checks
.filter(item => item.checked)
.map(check => columnMap.get(check.key) as TableColumn<GetTableData<A>>);
return filteredColumns;
},
onFetched: async transformed => {
const { total } = transformed;
updatePagination({
page: searchParams.pageNum,
pageSize: searchParams.pageSize,
itemCount: total
});
},
immediate
}); });
const pagination: PaginationProps = reactive({ // calculate the total width of the table this is used for horizontal scrolling
page: 1, const scrollX = computed(() => {
pageSize: 10, return result.columns.value.reduce((acc, column) => {
showSizePicker: true, return acc + Number(column.width ?? column.minWidth ?? 120);
itemCount: 0, }, 0);
pageSizes: [10, 15, 20, 25, 30],
onUpdatePage: async (page: number) => {
pagination.page = page;
updateSearchParams({
pageNum: page,
pageSize: pagination.pageSize!
});
getData();
},
onUpdatePageSize: async (pageSize: number) => {
pagination.pageSize = pageSize;
pagination.page = 1;
updateSearchParams({
pageNum: pagination.page,
pageSize
});
getData();
},
...(showTotal
? {
prefix: page => $t('datatable.itemCount', { total: page.itemCount })
}
: {})
}); });
// this is for mobile, if the system does not support mobile, you can use `pagination` directly
const mobilePagination = computed(() => {
const p: PaginationProps = {
...pagination,
pageSlot: isMobile.value ? 3 : 9,
prefix: !isMobile.value && showTotal ? pagination.prefix : undefined
};
return p;
});
function updatePagination(update: Partial<PaginationProps>) {
Object.assign(pagination, update);
}
/**
* get data by page number
*
* @param pageNum the page number. default is 1
*/
async function getDataByPage(pageNum: number = 1) {
updatePagination({
page: pageNum
});
updateSearchParams({
pageNum,
pageSize: pagination.pageSize!
});
await getData();
}
scope.run(() => { scope.run(() => {
watch( watch(
() => appStore.locale, () => appStore.locale,
() => { () => {
reloadColumns(); result.reloadColumns();
} }
); );
}); });
@ -199,27 +59,126 @@ export function useTable<A extends NaiveUI.TableApiFn>(config: NaiveUI.NaiveTabl
}); });
return { return {
loading, ...result,
empty, scrollX
data,
columns,
columnChecks,
reloadColumns,
pagination,
mobilePagination,
updatePagination,
getData,
getDataByPage,
searchParams,
updateSearchParams,
resetSearchParams
}; };
} }
export function useTableOperate<T extends TableData = TableData>(data: Ref<T[]>, getData: () => Promise<void>) { type PaginationParams = Pick<PaginationProps, 'page' | 'pageSize'>;
type UseNaivePaginatedTableOptions<ResponseData, ApiData> = UseNaiveTableOptions<ResponseData, ApiData, true> & {
paginationProps?: Omit<PaginationProps, 'page' | 'pageSize' | 'itemCount'>;
/**
* whether to show the total count of the table
*
* @default true
*/
showTotal?: boolean;
onPaginationParamsChange?: (params: PaginationParams) => void | Promise<void>;
};
export function useNaivePaginatedTable<ResponseData, ApiData>(
options: UseNaivePaginatedTableOptions<ResponseData, ApiData>
) {
const scope = effectScope();
const appStore = useAppStore();
const isMobile = computed(() => appStore.isMobile);
const showTotal = computed(() => options.showTotal ?? true);
const pagination = reactive({
page: 1,
pageSize: 10,
itemCount: 0,
showSizePicker: true,
pageSizes: [10, 15, 20, 25, 30],
prefix: showTotal.value ? page => $t('datatable.itemCount', { total: page.itemCount }) : undefined,
onUpdatePage(page) {
pagination.page = page;
},
onUpdatePageSize(pageSize) {
pagination.pageSize = pageSize;
pagination.page = 1;
},
...options.paginationProps
}) as PaginationProps;
// this is for mobile, if the system does not support mobile, you can use `pagination` directly
const mobilePagination = computed(() => {
const p: PaginationProps = {
...pagination,
pageSlot: isMobile.value ? 3 : 9,
prefix: !isMobile.value && showTotal.value ? pagination.prefix : undefined
};
return p;
});
const paginationParams = computed(() => {
const { page, pageSize } = pagination;
return {
page,
pageSize
};
});
const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, true>({
...options,
pagination: true,
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
getColumns,
onFetched: data => {
pagination.itemCount = data.total;
}
});
async function getDataByPage(page: number = 1) {
if (page !== pagination.page) {
pagination.page = page;
return;
}
await result.getData();
}
scope.run(() => {
watch(
() => appStore.locale,
() => {
result.reloadColumns();
}
);
watch(paginationParams, async newVal => {
await options.onPaginationParamsChange?.(newVal);
await result.getData();
});
});
onScopeDispose(() => {
scope.stop();
});
return {
...result,
getDataByPage,
pagination,
mobilePagination
};
}
export function useTableOperate<TableData>(
data: Ref<TableData[]>,
idKey: keyof TableData,
getData: () => Promise<void>
) {
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean(); const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean();
const operateType = ref<NaiveUI.TableOperateType>('add'); const operateType = shallowRef<NaiveUI.TableOperateType>('add');
function handleAdd() { function handleAdd() {
operateType.value = 'add'; operateType.value = 'add';
@ -227,18 +186,18 @@ export function useTableOperate<T extends TableData = TableData>(data: Ref<T[]>,
} }
/** the editing row data */ /** the editing row data */
const editingData: Ref<T | null> = ref(null); const editingData = shallowRef<TableData | null>(null);
function handleEdit(field: keyof T, id: CommonType.IdType) { function handleEdit(id: TableData[keyof TableData]) {
operateType.value = 'edit'; operateType.value = 'edit';
const findItem = data.value.find(item => item[field] === id) || null; const findItem = data.value.find(item => item[idKey] === id) || null;
editingData.value = jsonClone(findItem); editingData.value = jsonClone(findItem);
openDrawer(); openDrawer();
} }
/** the checked row keys of table */ /** the checked row keys of table */
const checkedRowKeys = ref<CommonType.IdType[]>([]); const checkedRowKeys = shallowRef<string[]>([]);
/** the hook after the batch delete operation is completed */ /** the hook after the batch delete operation is completed */
async function onBatchDeleted() { async function onBatchDeleted() {
@ -270,6 +229,285 @@ export function useTableOperate<T extends TableData = TableData>(data: Ref<T[]>,
}; };
} }
function isTableColumnHasKey<T>(column: TableColumn<T>): column is NaiveUI.TableColumnWithKey<T> { export function defaultTransform<ApiData>(
response: FlatResponseData<any, Api.Common.PaginatingQueryRecord<ApiData>>
): PaginationData<ApiData> {
const { data, error } = response;
if (error) {
return {
data: [],
pageNum: 1,
pageSize: 10,
total: 0
};
}
const { rows: records, pageSize: current, pageNum: size, total } = data;
return {
data: records,
pageNum: current,
pageSize: size,
total
};
}
type UseNaiveTreeTableOptions<ResponseData, ApiData> = UseNaiveTableOptions<ResponseData, ApiData, false> & {
keyField: keyof ApiData;
defaultExpandAll?: boolean;
};
export function useNaiveTreeTable<ResponseData, ApiData>(options: UseNaiveTreeTableOptions<ResponseData, ApiData>) {
const scope = effectScope();
const appStore = useAppStore();
const rows: Ref<ApiData[]> = ref([]);
const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, false>({
...options,
pagination: false,
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
getColumns,
onFetched: transformData => {
const data: ApiData[] = [];
const collect = (nodes: any[]) => {
nodes.forEach(node => {
data.push(node);
if (node?.children?.length) {
collect(node.children);
}
});
};
collect(transformData);
rows.value = data;
}
});
const { keyField = 'id', defaultExpandAll = false } = options;
const expandedRowKeys = ref<ApiData[keyof ApiData][]>([]);
const { bool: isCollapse, toggle: toggleCollapse } = useBoolean(defaultExpandAll);
/** expand all nodes */
function expandAll() {
toggleCollapse();
expandedRowKeys.value = rows.value.map(item => item[keyField as keyof ApiData]);
}
/** collapse all nodes */
function collapseAll() {
toggleCollapse();
expandedRowKeys.value = [];
}
scope.run(() => {
watch(
() => appStore.locale,
() => {
result.reloadColumns();
}
);
});
onScopeDispose(() => {
scope.stop();
});
return {
...result,
rows,
isCollapse,
expandedRowKeys,
expandAll,
collapseAll
};
}
export function useTreeTableOperate<ApiData>(data: Ref<ApiData[]>, idKey: keyof ApiData, getData: () => Promise<void>) {
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean();
const operateType = shallowRef<NaiveUI.TableOperateType>('add');
function handleAdd() {
operateType.value = 'add';
openDrawer();
}
/** the editing row data */
const editingData = shallowRef<ApiData | null>(null);
function handleEdit(id: ApiData[keyof ApiData]) {
operateType.value = 'edit';
const findItem = data.value.find(item => item[idKey] === id) || null;
editingData.value = jsonClone(findItem);
openDrawer();
}
/** the checked row keys of table */
const checkedRowKeys = shallowRef<string[]>([]);
/** the hook after the batch delete operation is completed */
async function onBatchDeleted() {
window.$message?.success($t('common.deleteSuccess'));
checkedRowKeys.value = [];
await getData();
}
/** the hook after the delete operation is completed */
async function onDeleted() {
window.$message?.success($t('common.deleteSuccess'));
await getData();
}
return {
drawerVisible,
openDrawer,
closeDrawer,
operateType,
handleAdd,
editingData,
handleEdit,
checkedRowKeys,
onBatchDeleted,
onDeleted
};
}
type TreeTableOptions<ApiData> = {
/** id field name */
idField?: keyof ApiData;
/** parent id field name */
parentIdField?: keyof ApiData;
/** children field name */
childrenField?: keyof ApiData;
/** filter function */
filterFn?: (node: ApiData) => boolean;
};
export function treeTransform<ApiData>(
response: FlatResponseData<any, ApiData[]>,
options: TreeTableOptions<ApiData>
): ApiData[] {
const { data, error } = response;
if (error || !data.length) {
return [];
}
const { idField = 'id', parentIdField = 'parentId', childrenField = 'children', filterFn = () => true } = options;
// 使用 Map 替代普通对象,提高性能
const childrenMap = new Map<ApiData[keyof ApiData], ApiData[]>();
const nodeMap = new Map<ApiData[keyof ApiData], ApiData>();
const tree: ApiData[] = [];
// 第一遍遍历:构建节点映射
for (const item of data) {
const id = item[idField as keyof ApiData];
const parentId = item[parentIdField as keyof ApiData];
nodeMap.set(id, item);
if (!childrenMap.has(parentId)) {
childrenMap.set(parentId, []);
}
// 应用过滤函数
if (filterFn(item)) {
childrenMap.get(parentId)!.push(item);
}
}
// 第二遍遍历:找出根节点
for (const item of data) {
const parentId = item[parentIdField as keyof ApiData];
if (!nodeMap.has(parentId) && filterFn(item)) {
tree.push(item);
}
}
// 递归构建树形结构
const buildTree = (node: ApiData) => {
const id = node[idField as keyof ApiData];
const children = childrenMap.get(id);
if (children?.length) {
// 使用类型断言确保类型安全
(node as any)[childrenField] = children;
for (const child of children) {
buildTree(child);
}
} else {
// 如果没有子节点,设置为 undefined
(node as any)[childrenField] = undefined;
}
};
// 从根节点开始构建树
for (const root of tree) {
buildTree(root);
}
return tree;
}
function getColumnChecks<Column extends NaiveUI.TableColumn<any>>(
cols: Column[],
getColumnVisible?: (column: Column) => boolean
) {
const checks: TableColumnCheck[] = [];
cols.forEach(column => {
if (isTableColumnHasKey(column)) {
checks.push({
key: column.key as string,
title: column.title!,
checked: true,
visible: getColumnVisible?.(column) ?? true
});
} else if (column.type === 'selection') {
checks.push({
key: SELECTION_KEY,
title: $t('common.check'),
checked: true,
visible: getColumnVisible?.(column) ?? false
});
} else if (column.type === 'expand') {
checks.push({
key: EXPAND_KEY,
title: $t('common.expandColumn'),
checked: true,
visible: getColumnVisible?.(column) ?? false
});
}
});
return checks;
}
function getColumns<Column extends NaiveUI.TableColumn<any>>(cols: Column[], checks: TableColumnCheck[]) {
const columnMap = new Map<string, Column>();
cols.forEach(column => {
if (isTableColumnHasKey(column)) {
columnMap.set(column.key as string, column);
} else if (column.type === 'selection') {
columnMap.set(SELECTION_KEY, column);
} else if (column.type === 'expand') {
columnMap.set(EXPAND_KEY, column);
}
});
const filteredColumns = checks.filter(item => item.checked).map(check => columnMap.get(check.key) as Column);
return filteredColumns;
}
export function isTableColumnHasKey<T>(column: NaiveUI.TableColumn<T>): column is NaiveUI.TableColumnWithKey<T> {
return Boolean((column as NaiveUI.TableColumnWithKey<T>).key); return Boolean((column as NaiveUI.TableColumnWithKey<T>).key);
} }

View File

@ -1,237 +0,0 @@
import { effectScope, onScopeDispose, ref, watch } from 'vue';
import type { Ref } from 'vue';
import { jsonClone } from '@sa/utils';
import { useBoolean, useHookTable } from '@sa/hooks';
import { useAppStore } from '@/store/modules/app';
import { handleTree } from '@/utils/common';
import { $t } from '@/locales';
type TableData = NaiveUI.TableData;
type GetTableData<A extends NaiveUI.TreeTableApiFn> = NaiveUI.GetTreeTableData<A>;
type TableColumn<T> = NaiveUI.TableColumn<T>;
export function useTreeTable<A extends NaiveUI.TreeTableApiFn>(
config: NaiveUI.NaiveTreeTableConfig<A> & CommonType.TreeConfig & { defaultExpandAll?: boolean }
) {
const scope = effectScope();
const appStore = useAppStore();
const {
apiFn,
apiParams,
immediate,
idField,
parentIdField = 'parentId',
childrenField = 'children',
defaultExpandAll = false
} = config;
const SELECTION_KEY = '__selection__';
const EXPAND_KEY = '__expand__';
const expandedRowKeys = ref<CommonType.IdType[]>([]);
const {
loading,
empty,
data,
columns,
columnChecks,
reloadColumns,
getData,
searchParams,
updateSearchParams,
resetSearchParams
} = useHookTable<A, GetTableData<A>, TableColumn<NaiveUI.TableDataWithIndex<GetTableData<A>>>>({
apiFn,
apiParams,
columns: config.columns,
transformer: res => {
const records = res.data || [];
if (!records.length) return { data: [] };
const treeData = handleTree(records, {
idField,
parentIdField,
childrenField
});
// if defaultExpandAll is true, expand all nodes
expandedRowKeys.value = defaultExpandAll
? records.map(item => item[idField])
: records.filter(item => item[parentIdField] === 0).map(item => item[idField]) || [];
return { data: treeData };
},
getColumnChecks: cols => {
const checks: NaiveUI.TableColumnCheck[] = [];
cols.forEach(column => {
if (isTableColumnHasKey(column)) {
checks.push({
key: column.key as string,
title: column.title as string,
checked: true
});
} else if (column.type === 'selection') {
checks.push({
key: SELECTION_KEY,
title: $t('common.check'),
checked: true
});
} else if (column.type === 'expand') {
checks.push({
key: EXPAND_KEY,
title: $t('common.expandColumn'),
checked: true
});
}
});
return checks;
},
getColumns: (cols, checks) => {
const columnMap = new Map<string, TableColumn<GetTableData<A>>>();
cols.forEach(column => {
if (isTableColumnHasKey(column)) {
columnMap.set(column.key as string, column);
} else if (column.type === 'selection') {
columnMap.set(SELECTION_KEY, column);
} else if (column.type === 'expand') {
columnMap.set(EXPAND_KEY, column);
}
});
const filteredColumns = checks
.filter(item => item.checked)
.map(check => columnMap.get(check.key) as TableColumn<GetTableData<A>>);
return filteredColumns;
},
immediate
});
/** 收集所有节点的key */
function collectAllNodeKeys(treeNodes: any[]): CommonType.IdType[] {
const keys: CommonType.IdType[] = [];
const collect = (nodes: any[]) => {
nodes.forEach(node => {
keys.push(node[idField]);
if (node[childrenField]?.length) {
collect(node[childrenField]);
}
});
};
collect(treeNodes);
return keys;
}
const { bool: isCollapse, toggle: toggleCollapse } = useBoolean(defaultExpandAll);
/** expand all nodes */
function expandAll() {
toggleCollapse();
expandedRowKeys.value = collectAllNodeKeys(data.value);
}
/** collapse all nodes */
function collapseAll() {
toggleCollapse();
expandedRowKeys.value = [];
}
scope.run(() => {
watch(
() => appStore.locale,
() => {
reloadColumns();
}
);
});
onScopeDispose(() => {
scope.stop();
});
return {
loading,
empty,
data,
columns,
columnChecks,
reloadColumns,
getData,
searchParams,
updateSearchParams,
resetSearchParams,
expandedRowKeys,
isCollapse,
expandAll,
collapseAll
};
}
export function useTreeTableOperate<T extends TableData = TableData>(_: Ref<T[]>, getData: () => Promise<void>) {
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean();
const operateType = ref<NaiveUI.TableOperateType>('add');
function handleAdd() {
operateType.value = 'add';
openDrawer();
}
/** the editing row data */
const editingData: Ref<T | null> = ref(null);
function handleEdit(row: T) {
operateType.value = 'edit';
editingData.value = jsonClone(row);
openDrawer();
}
/** the checked row keys of table */
const checkedRowKeys = ref<CommonType.IdType[]>([]);
function clearCheckedRowKeys() {
checkedRowKeys.value = [];
}
/** the hook after the batch delete operation is completed */
async function onBatchDeleted() {
window.$message?.success($t('common.deleteSuccess'));
checkedRowKeys.value = [];
await getData();
}
/** the hook after the delete operation is completed */
async function onDeleted() {
window.$message?.success($t('common.deleteSuccess'));
await getData();
}
return {
drawerVisible,
openDrawer,
closeDrawer,
operateType,
handleAdd,
editingData,
handleEdit,
checkedRowKeys,
onBatchDeleted,
onDeleted,
clearCheckedRowKeys
};
}
function isTableColumnHasKey<T>(column: TableColumn<T>): column is NaiveUI.TableColumnWithKey<T> {
return Boolean((column as NaiveUI.TableColumnWithKey<T>).key);
}

View File

@ -12,7 +12,7 @@ import GlobalTab from '../modules/global-tab/index.vue';
import GlobalContent from '../modules/global-content/index.vue'; import GlobalContent from '../modules/global-content/index.vue';
import GlobalFooter from '../modules/global-footer/index.vue'; import GlobalFooter from '../modules/global-footer/index.vue';
import ThemeDrawer from '../modules/theme-drawer/index.vue'; import ThemeDrawer from '../modules/theme-drawer/index.vue';
import { setupMixMenuContext } from '../context'; import { provideMixMenuContext } from '../modules/global-menu/context';
defineOptions({ defineOptions({
name: 'BaseLayout' name: 'BaseLayout'
@ -20,7 +20,7 @@ defineOptions({
const appStore = useAppStore(); const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const { childLevelMenus, isActiveFirstLevelMenuHasChildren } = setupMixMenuContext(); const { childLevelMenus, isActiveFirstLevelMenuHasChildren } = provideMixMenuContext();
const GlobalMenu = defineAsyncComponent(() => import('../modules/global-menu/index.vue')); const GlobalMenu = defineAsyncComponent(() => import('../modules/global-menu/index.vue'));
@ -31,7 +31,7 @@ const layoutMode = computed(() => {
}); });
const headerProps = computed(() => { const headerProps = computed(() => {
const { mode, reverseHorizontalMix } = themeStore.layout; const { mode } = themeStore.layout;
const headerPropsConfig: Record<UnionKey.ThemeLayoutMode, App.Global.HeaderProps> = { const headerPropsConfig: Record<UnionKey.ThemeLayoutMode, App.Global.HeaderProps> = {
vertical: { vertical: {
@ -44,15 +44,25 @@ const headerProps = computed(() => {
showMenu: false, showMenu: false,
showMenuToggler: false showMenuToggler: false
}, },
'vertical-hybrid-header-first': {
showLogo: !isActiveFirstLevelMenuHasChildren.value,
showMenu: true,
showMenuToggler: false
},
horizontal: { horizontal: {
showLogo: true, showLogo: true,
showMenu: true, showMenu: true,
showMenuToggler: false showMenuToggler: false
}, },
'horizontal-mix': { 'top-hybrid-sidebar-first': {
showLogo: true, showLogo: true,
showMenu: true, showMenu: true,
showMenuToggler: reverseHorizontalMix && isActiveFirstLevelMenuHasChildren.value showMenuToggler: false
},
'top-hybrid-header-first': {
showLogo: true,
showMenu: true,
showMenuToggler: isActiveFirstLevelMenuHasChildren.value
} }
}; };
@ -63,44 +73,56 @@ const siderVisible = computed(() => themeStore.layout.mode !== 'horizontal');
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix'); const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix'); const isVerticalHybridHeaderFirst = computed(() => themeStore.layout.mode === 'vertical-hybrid-header-first');
const isTopHybridSidebarFirst = computed(() => themeStore.layout.mode === 'top-hybrid-sidebar-first');
const isTopHybridHeaderFirst = computed(() => themeStore.layout.mode === 'top-hybrid-header-first');
const siderWidth = computed(() => getSiderWidth()); const siderWidth = computed(() => getSiderWidth());
const siderCollapsedWidth = computed(() => getSiderCollapsedWidth()); const siderCollapsedWidth = computed(() => getSiderCollapsedWidth());
function getSiderWidth() { function getSiderAndCollapsedWidth(isCollapsed: boolean) {
const { reverseHorizontalMix } = themeStore.layout; const {
const { width, mixWidth, mixChildMenuWidth } = themeStore.sider; mixChildMenuWidth,
collapsedWidth,
width: themeWidth,
mixCollapsedWidth,
mixWidth: themeMixWidth
} = themeStore.sider;
if (isHorizontalMix.value && reverseHorizontalMix) { const width = isCollapsed ? collapsedWidth : themeWidth;
const mixWidth = isCollapsed ? mixCollapsedWidth : themeMixWidth;
if (isTopHybridHeaderFirst.value) {
return isActiveFirstLevelMenuHasChildren.value ? width : 0; return isActiveFirstLevelMenuHasChildren.value ? width : 0;
} }
let w = isVerticalMix.value || isHorizontalMix.value ? mixWidth : width; if (isVerticalHybridHeaderFirst.value && !isActiveFirstLevelMenuHasChildren.value) {
return 0;
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
w += mixChildMenuWidth;
} }
return w; const isMixMode = isVerticalMix.value || isTopHybridSidebarFirst.value || isVerticalHybridHeaderFirst.value;
let finalWidth = isMixMode ? mixWidth : width;
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
finalWidth += mixChildMenuWidth;
}
if (isVerticalHybridHeaderFirst.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
finalWidth += mixChildMenuWidth;
}
return finalWidth;
}
function getSiderWidth() {
return getSiderAndCollapsedWidth(false);
} }
function getSiderCollapsedWidth() { function getSiderCollapsedWidth() {
const { reverseHorizontalMix } = themeStore.layout; return getSiderAndCollapsedWidth(true);
const { collapsedWidth, mixCollapsedWidth, mixChildMenuWidth } = themeStore.sider;
if (isHorizontalMix.value && reverseHorizontalMix) {
return isActiveFirstLevelMenuHasChildren.value ? collapsedWidth : 0;
}
let w = isVerticalMix.value || isHorizontalMix.value ? mixCollapsedWidth : collapsedWidth;
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
w += mixChildMenuWidth;
}
return w;
} }
onMounted(() => { onMounted(() => {

View File

@ -1,83 +0,0 @@
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useContext } from '@sa/hooks';
import { useRouteStore } from '@/store/modules/route';
export const { setupStore: setupMixMenuContext, useStore: useMixMenuContext } = useContext('mix-menu', useMixMenu);
function useMixMenu() {
const route = useRoute();
const routeStore = useRouteStore();
const { selectedKey } = useMenu();
const activeFirstLevelMenuKey = ref('');
function setActiveFirstLevelMenuKey(key: string) {
activeFirstLevelMenuKey.value = key;
}
function getActiveFirstLevelMenuKey() {
const [firstLevelRouteName] = selectedKey.value.split('_');
setActiveFirstLevelMenuKey(firstLevelRouteName);
}
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
routeStore.menus.map(menu => {
const { children: _, ...rest } = menu;
return rest;
})
);
const childLevelMenus = computed<App.Global.Menu[]>(
() => routeStore.menus.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
);
const isActiveFirstLevelMenuHasChildren = computed(() => {
if (!activeFirstLevelMenuKey.value) {
return false;
}
const findItem = allMenus.value.find(item => item.key === activeFirstLevelMenuKey.value);
return Boolean(findItem?.children?.length);
});
watch(
() => route.name,
() => {
getActiveFirstLevelMenuKey();
},
{ immediate: true }
);
return {
allMenus,
firstLevelMenus,
childLevelMenus,
isActiveFirstLevelMenuHasChildren,
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
getActiveFirstLevelMenuKey
};
}
export function useMenu() {
const route = useRoute();
const selectedKey = computed(() => {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
return routeName;
});
return {
selectedKey
};
}

View File

@ -3,6 +3,7 @@ import { computed } from 'vue';
import { createReusableTemplate } from '@vueuse/core'; import { createReusableTemplate } from '@vueuse/core';
import { SimpleScrollbar } from '@sa/materials'; import { SimpleScrollbar } from '@sa/materials';
import { transformColorWithOpacity } from '@sa/color'; import { transformColorWithOpacity } from '@sa/color';
import type { RouteKey } from '@elegant-router/types';
defineOptions({ defineOptions({
name: 'FirstLevelMenu' name: 'FirstLevelMenu'
@ -20,7 +21,7 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
interface Emits { interface Emits {
(e: 'select', menu: App.Global.Menu): boolean; (e: 'select', menuKey: RouteKey): boolean;
(e: 'toggleSiderCollapse'): void; (e: 'toggleSiderCollapse'): void;
} }
@ -47,8 +48,8 @@ const selectedBgColor = computed(() => {
return darkMode ? dark : light; return darkMode ? dark : light;
}); });
function handleClickMixMenu(menu: App.Global.Menu) { function handleClickMixMenu(menuKey: RouteKey) {
emit('select', menu); emit('select', menuKey);
} }
function toggleSiderCollapse() { function toggleSiderCollapse() {
@ -88,7 +89,7 @@ function toggleSiderCollapse() {
:icon="menu.icon" :icon="menu.icon"
:active="menu.key === activeMenuKey" :active="menu.key === activeMenuKey"
:is-mini="siderCollapse" :is-mini="siderCollapse"
@click="handleClickMixMenu(menu)" @click="handleClickMixMenu(menu.routeKey)"
/> />
</SimpleScrollbar> </SimpleScrollbar>
<MenuToggler <MenuToggler

View File

@ -0,0 +1,143 @@
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useContext } from '@sa/hooks';
import type { RouteKey } from '@elegant-router/types';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
export const [provideMixMenuContext, useMixMenuContext] = useContext('MixMenu', useMixMenu);
function useMixMenu() {
const route = useRoute();
const routeStore = useRouteStore();
const { selectedKey } = useMenu();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
routeStore.menus.map(menu => {
const { children: _, ...rest } = menu;
return rest;
})
);
const activeFirstLevelMenuKey = ref('');
function setActiveFirstLevelMenuKey(key: string) {
activeFirstLevelMenuKey.value = key;
}
function getActiveFirstLevelMenuKey() {
const [firstLevelRouteName] = selectedKey.value.split('_');
setActiveFirstLevelMenuKey(firstLevelRouteName);
}
const isActiveFirstLevelMenuHasChildren = computed(() => {
if (!activeFirstLevelMenuKey.value) {
return false;
}
const findItem = allMenus.value.find(item => item.key === activeFirstLevelMenuKey.value);
return Boolean(findItem?.children?.length);
});
function handleSelectFirstLevelMenu(key: RouteKey) {
setActiveFirstLevelMenuKey(key);
if (!isActiveFirstLevelMenuHasChildren.value) {
routerPushByKeyWithMetaQuery(key);
}
}
const secondLevelMenus = computed<App.Global.Menu[]>(
() => allMenus.value.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
);
const activeSecondLevelMenuKey = ref('');
function setActiveSecondLevelMenuKey(key: string) {
activeSecondLevelMenuKey.value = key;
}
function getActiveSecondLevelMenuKey() {
const keys = selectedKey.value.split('_');
if (keys.length < 2) {
setActiveSecondLevelMenuKey('');
return;
}
const [firstLevelRouteName, level2SuffixName] = keys;
const secondLevelRouteName = `${firstLevelRouteName}_${level2SuffixName}`;
setActiveSecondLevelMenuKey(secondLevelRouteName);
}
const isActiveSecondLevelMenuHasChildren = computed(() => {
if (!activeSecondLevelMenuKey.value) {
return false;
}
const findItem = secondLevelMenus.value.find(item => item.key === activeSecondLevelMenuKey.value);
return Boolean(findItem?.children?.length);
});
function handleSelectSecondLevelMenu(key: RouteKey) {
setActiveSecondLevelMenuKey(key);
if (!isActiveSecondLevelMenuHasChildren.value) {
routerPushByKeyWithMetaQuery(key);
}
}
const childLevelMenus = computed<App.Global.Menu[]>(
() => secondLevelMenus.value.find(menu => menu.key === activeSecondLevelMenuKey.value)?.children || []
);
watch(
() => route.name,
() => {
getActiveFirstLevelMenuKey();
},
{ immediate: true }
);
return {
firstLevelMenus,
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
isActiveFirstLevelMenuHasChildren,
handleSelectFirstLevelMenu,
getActiveFirstLevelMenuKey,
secondLevelMenus,
activeSecondLevelMenuKey,
setActiveSecondLevelMenuKey,
isActiveSecondLevelMenuHasChildren,
handleSelectSecondLevelMenu,
getActiveSecondLevelMenuKey,
childLevelMenus
};
}
export function useMenu() {
const route = useRoute();
const selectedKey = computed(() => {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
return routeName;
});
return {
selectedKey
};
}

View File

@ -5,9 +5,10 @@ import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import VerticalMenu from './modules/vertical-menu.vue'; import VerticalMenu from './modules/vertical-menu.vue';
import VerticalMixMenu from './modules/vertical-mix-menu.vue'; import VerticalMixMenu from './modules/vertical-mix-menu.vue';
import VerticalHybridHeaderFirst from './modules/vertical-hybrid-header-first.vue';
import HorizontalMenu from './modules/horizontal-menu.vue'; import HorizontalMenu from './modules/horizontal-menu.vue';
import HorizontalMixMenu from './modules/horizontal-mix-menu.vue'; import TopHybridSidebarFirst from './modules/top-hybrid-sidebar-first.vue';
import ReversedHorizontalMixMenu from './modules/reversed-horizontal-mix-menu.vue'; import TopHybridHeaderFirst from './modules/top-hybrid-header-first.vue';
defineOptions({ defineOptions({
name: 'GlobalMenu' name: 'GlobalMenu'
@ -20,8 +21,10 @@ const activeMenu = computed(() => {
const menuMap: Record<UnionKey.ThemeLayoutMode, Component> = { const menuMap: Record<UnionKey.ThemeLayoutMode, Component> = {
vertical: VerticalMenu, vertical: VerticalMenu,
'vertical-mix': VerticalMixMenu, 'vertical-mix': VerticalMixMenu,
'vertical-hybrid-header-first': VerticalHybridHeaderFirst,
horizontal: HorizontalMenu, horizontal: HorizontalMenu,
'horizontal-mix': themeStore.layout.reverseHorizontalMix ? ReversedHorizontalMixMenu : HorizontalMixMenu 'top-hybrid-sidebar-first': TopHybridSidebarFirst,
'top-hybrid-header-first': TopHybridHeaderFirst
}; };
return menuMap[themeStore.layout.mode]; return menuMap[themeStore.layout.mode];

View File

@ -2,7 +2,7 @@
import { GLOBAL_HEADER_MENU_ID } from '@/constants/app'; import { GLOBAL_HEADER_MENU_ID } from '@/constants/app';
import { useRouteStore } from '@/store/modules/route'; import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import { useMenu } from '../../../context'; import { useMenu } from '../context';
defineOptions({ defineOptions({
name: 'HorizontalMenu' name: 'HorizontalMenu'

View File

@ -1,17 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import type { RouteKey } from '@elegant-router/types';
import { SimpleScrollbar } from '@sa/materials'; import { SimpleScrollbar } from '@sa/materials';
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app'; import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route'; import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import { useMenu, useMixMenuContext } from '../../../context'; import { useMenu, useMixMenuContext } from '../context';
defineOptions({ defineOptions({
name: 'ReversedHorizontalMixMenu' name: 'TopHybridHeaderFirst'
}); });
const route = useRoute(); const route = useRoute();
@ -19,23 +18,10 @@ const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const routeStore = useRouteStore(); const routeStore = useRouteStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush(); const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { const { firstLevelMenus, secondLevelMenus, activeFirstLevelMenuKey, handleSelectFirstLevelMenu } =
firstLevelMenus, useMixMenuContext('TopHybridHeaderFirst');
childLevelMenus,
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
isActiveFirstLevelMenuHasChildren
} = useMixMenuContext();
const { selectedKey } = useMenu(); const { selectedKey } = useMenu();
function handleSelectMixMenu(key: RouteKey) {
setActiveFirstLevelMenuKey(key);
if (!isActiveFirstLevelMenuHasChildren.value) {
routerPushByKeyWithMetaQuery(key);
}
}
const expandedKeys = ref<string[]>([]); const expandedKeys = ref<string[]>([]);
function updateExpandedKeys() { function updateExpandedKeys() {
@ -63,7 +49,7 @@ watch(
:options="firstLevelMenus" :options="firstLevelMenus"
:indent="18" :indent="18"
responsive responsive
@update:value="handleSelectMixMenu" @update:value="handleSelectFirstLevelMenu"
/> />
</Teleport> </Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`"> <Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
@ -75,7 +61,7 @@ watch(
:collapsed="appStore.siderCollapse" :collapsed="appStore.siderCollapse"
:collapsed-width="themeStore.sider.collapsedWidth" :collapsed-width="themeStore.sider.collapsedWidth"
:collapsed-icon-size="22" :collapsed-icon-size="22"
:options="childLevelMenus" :options="secondLevelMenus"
:indent="18" :indent="18"
@update:value="routerPushByKeyWithMetaQuery" @update:value="routerPushByKeyWithMetaQuery"
/> />

View File

@ -4,25 +4,18 @@ import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import FirstLevelMenu from '../components/first-level-menu.vue'; import FirstLevelMenu from '../components/first-level-menu.vue';
import { useMenu, useMixMenuContext } from '../../../context'; import { useMenu, useMixMenuContext } from '../context';
defineOptions({ defineOptions({
name: 'HorizontalMixMenu' name: 'TopHybridSidebarFirst'
}); });
const appStore = useAppStore(); const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush(); const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { allMenus, childLevelMenus, activeFirstLevelMenuKey, setActiveFirstLevelMenuKey } = useMixMenuContext(); const { firstLevelMenus, secondLevelMenus, activeFirstLevelMenuKey, handleSelectFirstLevelMenu } =
useMixMenuContext('TopHybridSidebarFirst');
const { selectedKey } = useMenu(); const { selectedKey } = useMenu();
function handleSelectMixMenu(menu: App.Global.Menu) {
setActiveFirstLevelMenuKey(menu.key);
if (!menu.children?.length) {
routerPushByKeyWithMetaQuery(menu.routeKey);
}
}
</script> </script>
<template> <template>
@ -30,22 +23,24 @@ function handleSelectMixMenu(menu: App.Global.Menu) {
<NMenu <NMenu
mode="horizontal" mode="horizontal"
:value="selectedKey" :value="selectedKey"
:options="childLevelMenus" :options="secondLevelMenus"
:indent="18" :indent="18"
responsive responsive
@update:value="routerPushByKeyWithMetaQuery" @update:value="routerPushByKeyWithMetaQuery"
/> />
</Teleport> </Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`"> <Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<FirstLevelMenu <div class="h-full pt-2">
:menus="allMenus" <FirstLevelMenu
:active-menu-key="activeFirstLevelMenuKey" :menus="firstLevelMenus"
:sider-collapse="appStore.siderCollapse" :active-menu-key="activeFirstLevelMenuKey"
:dark-mode="themeStore.darkMode" :sider-collapse="appStore.siderCollapse"
:theme-color="themeStore.themeColor" :dark-mode="themeStore.darkMode"
@select="handleSelectMixMenu" :theme-color="themeStore.themeColor"
@toggle-sider-collapse="appStore.toggleSiderCollapse" @select="handleSelectFirstLevelMenu"
/> @toggle-sider-collapse="appStore.toggleSiderCollapse"
/>
</div>
</Teleport> </Teleport>
</template> </template>

View File

@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import type { RouteKey } from '@elegant-router/types';
import { SimpleScrollbar } from '@sa/materials';
import { useBoolean } from '@sa/hooks';
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
import { useMenu, useMixMenuContext } from '../context';
import FirstLevelMenu from '../components/first-level-menu.vue';
import GlobalLogo from '../../global-logo/index.vue';
defineOptions({
name: 'VerticalHybridHeaderFirst'
});
const route = useRoute();
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
const {
firstLevelMenus,
activeFirstLevelMenuKey,
handleSelectFirstLevelMenu,
getActiveFirstLevelMenuKey,
secondLevelMenus,
activeSecondLevelMenuKey,
isActiveSecondLevelMenuHasChildren,
handleSelectSecondLevelMenu,
getActiveSecondLevelMenuKey,
childLevelMenus
} = useMixMenuContext('VerticalHybridHeaderFirst');
const { selectedKey } = useMenu();
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
const hasChildMenus = computed(() => childLevelMenus.value.length > 0);
const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value || appStore.mixSiderFixed));
function handleSelectMixMenu(key: RouteKey) {
handleSelectSecondLevelMenu(key);
if (isActiveSecondLevelMenuHasChildren.value) {
setDrawerVisible(true);
}
}
function handleSelectMenu(key: RouteKey) {
handleSelectFirstLevelMenu(key);
if (secondLevelMenus.value.length > 0) {
handleSelectMixMenu(secondLevelMenus.value[0].routeKey);
}
}
function handleResetActiveMenu() {
setDrawerVisible(false);
if (!appStore.mixSiderFixed) {
getActiveFirstLevelMenuKey();
getActiveSecondLevelMenuKey();
}
}
const expandedKeys = ref<string[]>([]);
function updateExpandedKeys() {
if (appStore.siderCollapse || !selectedKey.value) {
expandedKeys.value = [];
return;
}
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
}
watch(
() => route.name,
() => {
updateExpandedKeys();
},
{ immediate: true }
);
</script>
<template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
<NMenu
mode="horizontal"
:value="activeFirstLevelMenuKey"
:options="firstLevelMenus"
:indent="18"
responsive
@update:value="handleSelectMenu"
/>
</Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
<FirstLevelMenu
:menus="secondLevelMenus"
:active-menu-key="activeSecondLevelMenuKey"
:inverted="inverted"
:sider-collapse="appStore.siderCollapse"
:dark-mode="themeStore.darkMode"
:theme-color="themeStore.themeColor"
@select="handleSelectMixMenu"
@toggle-sider-collapse="appStore.toggleSiderCollapse"
>
<GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" />
</FirstLevelMenu>
<div
class="relative h-full transition-width-300"
:style="{ width: appStore.mixSiderFixed && hasChildMenus ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
>
<DarkModeContainer
class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300"
:inverted="inverted"
:style="{ width: showDrawer ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
>
<header class="flex-y-center justify-between px-12px" :style="{ height: themeStore.header.height + 'px' }">
<h2 class="text-16px text-primary font-bold">{{ $t('system.title') }}</h2>
<PinToggler
:pin="appStore.mixSiderFixed"
:class="{ 'text-white:88 !hover:text-white': inverted }"
@click="appStore.toggleMixSiderFixed"
/>
</header>
<SimpleScrollbar>
<NMenu
v-model:expanded-keys="expandedKeys"
mode="vertical"
:value="selectedKey"
:options="childLevelMenus"
:inverted="inverted"
:indent="18"
@update:value="routerPushByKeyWithMetaQuery"
/>
</SimpleScrollbar>
</DarkModeContainer>
</div>
</div>
</Teleport>
</template>
<style scoped></style>

View File

@ -7,7 +7,7 @@ import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route'; import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import { useMenu } from '../../../context'; import { useMenu } from '../context';
defineOptions({ defineOptions({
name: 'VerticalMenu' name: 'VerticalMenu'

View File

@ -3,13 +3,14 @@ import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { SimpleScrollbar } from '@sa/materials'; import { SimpleScrollbar } from '@sa/materials';
import { useBoolean } from '@sa/hooks'; import { useBoolean } from '@sa/hooks';
import type { RouteKey } from '@elegant-router/types';
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app'; import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route'; import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import { $t } from '@/locales'; import { $t } from '@/locales';
import { useMenu, useMixMenuContext } from '../../../context'; import { useMenu, useMixMenuContext } from '../context';
import FirstLevelMenu from '../components/first-level-menu.vue'; import FirstLevelMenu from '../components/first-level-menu.vue';
import GlobalLogo from '../../global-logo/index.vue'; import GlobalLogo from '../../global-logo/index.vue';
@ -24,28 +25,26 @@ const routeStore = useRouteStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush(); const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean(); const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
const { const {
allMenus, firstLevelMenus,
childLevelMenus, secondLevelMenus,
activeFirstLevelMenuKey, activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey, isActiveFirstLevelMenuHasChildren,
getActiveFirstLevelMenuKey getActiveFirstLevelMenuKey,
// handleSelectFirstLevelMenu
} = useMixMenuContext(); } = useMixMenuContext('VerticalMixMenu');
const { selectedKey } = useMenu(); const { selectedKey } = useMenu();
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted); const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
const hasChildMenus = computed(() => childLevelMenus.value.length > 0); const hasChildMenus = computed(() => secondLevelMenus.value.length > 0);
const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value || appStore.mixSiderFixed)); const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value || appStore.mixSiderFixed));
function handleSelectMixMenu(menu: App.Global.Menu) { function handleSelectMenu(key: RouteKey) {
setActiveFirstLevelMenuKey(menu.key); handleSelectFirstLevelMenu(key);
if (menu.children?.length) { if (isActiveFirstLevelMenuHasChildren.value) {
setDrawerVisible(true); setDrawerVisible(true);
} else {
routerPushByKeyWithMetaQuery(menu.routeKey);
} }
} }
@ -80,13 +79,13 @@ watch(
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`"> <Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<div class="h-full flex" @mouseleave="handleResetActiveMenu"> <div class="h-full flex" @mouseleave="handleResetActiveMenu">
<FirstLevelMenu <FirstLevelMenu
:menus="allMenus" :menus="firstLevelMenus"
:active-menu-key="activeFirstLevelMenuKey" :active-menu-key="activeFirstLevelMenuKey"
:inverted="inverted" :inverted="inverted"
:sider-collapse="appStore.siderCollapse" :sider-collapse="appStore.siderCollapse"
:dark-mode="themeStore.darkMode" :dark-mode="themeStore.darkMode"
:theme-color="themeStore.themeColor" :theme-color="themeStore.themeColor"
@select="handleSelectMixMenu" @select="handleSelectMenu"
@toggle-sider-collapse="appStore.toggleSiderCollapse" @toggle-sider-collapse="appStore.toggleSiderCollapse"
> >
<GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" /> <GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" />
@ -113,7 +112,7 @@ watch(
v-model:expanded-keys="expandedKeys" v-model:expanded-keys="expandedKeys"
mode="vertical" mode="vertical"
:value="selectedKey" :value="selectedKey"
:options="childLevelMenus" :options="secondLevelMenus"
:inverted="inverted" :inverted="inverted"
:indent="18" :indent="18"
@update:value="routerPushByKeyWithMetaQuery" @update:value="routerPushByKeyWithMetaQuery"

View File

@ -12,10 +12,13 @@ defineOptions({
const appStore = useAppStore(); const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix'); const isTopHybridSidebarFirst = computed(() => themeStore.layout.mode === 'top-hybrid-sidebar-first');
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix'); const isTopHybridHeaderFirst = computed(() => themeStore.layout.mode === 'top-hybrid-header-first');
const darkMenu = computed(() => !themeStore.darkMode && !isHorizontalMix.value && themeStore.sider.inverted); const darkMenu = computed(
const showLogo = computed(() => !isVerticalMix.value && !isHorizontalMix.value); () =>
!themeStore.darkMode && !isTopHybridSidebarFirst.value && !isTopHybridHeaderFirst.value && themeStore.sider.inverted
);
const showLogo = computed(() => themeStore.layout.mode === 'vertical');
const menuWrapperClass = computed(() => (showLogo.value ? 'flex-1-hidden' : 'h-full')); const menuWrapperClass = computed(() => (showLogo.value ? 'flex-1-hidden' : 'h-full'));
</script> </script>

View File

@ -27,7 +27,6 @@ type LayoutConfig = Record<
UnionKey.ThemeLayoutMode, UnionKey.ThemeLayoutMode,
{ {
placement: PopoverPlacement; placement: PopoverPlacement;
headerClass: string;
menuClass: string; menuClass: string;
mainClass: string; mainClass: string;
} }
@ -36,25 +35,31 @@ type LayoutConfig = Record<
const layoutConfig: LayoutConfig = { const layoutConfig: LayoutConfig = {
vertical: { vertical: {
placement: 'bottom', placement: 'bottom',
headerClass: '',
menuClass: 'w-1/3 h-full', menuClass: 'w-1/3 h-full',
mainClass: 'w-2/3 h-3/4' mainClass: 'w-2/3 h-3/4'
}, },
'vertical-mix': { 'vertical-mix': {
placement: 'bottom', placement: 'bottom',
headerClass: '', menuClass: 'w-1/4 h-full',
mainClass: 'w-2/3 h-3/4'
},
'vertical-hybrid-header-first': {
placement: 'bottom',
menuClass: 'w-1/4 h-full', menuClass: 'w-1/4 h-full',
mainClass: 'w-2/3 h-3/4' mainClass: 'w-2/3 h-3/4'
}, },
horizontal: { horizontal: {
placement: 'bottom', placement: 'bottom',
headerClass: '',
menuClass: 'w-full h-1/4', menuClass: 'w-full h-1/4',
mainClass: 'w-full h-3/4' mainClass: 'w-full h-3/4'
}, },
'horizontal-mix': { 'top-hybrid-sidebar-first': {
placement: 'bottom',
menuClass: 'w-full h-1/4',
mainClass: 'w-2/3 h-3/4'
},
'top-hybrid-header-first': {
placement: 'bottom', placement: 'bottom',
headerClass: '',
menuClass: 'w-full h-1/4', menuClass: 'w-full h-1/4',
mainClass: 'w-2/3 h-3/4' mainClass: 'w-2/3 h-3/4'
} }
@ -68,25 +73,27 @@ function handleChangeMode(mode: UnionKey.ThemeLayoutMode) {
</script> </script>
<template> <template>
<div class="flex-center flex-wrap gap-x-32px gap-y-16px"> <div class="grid grid-cols-2 gap-x-16px gap-y-12px md:grid-cols-3">
<div <div
v-for="(item, key) in layoutConfig" v-for="(item, key) in layoutConfig"
:key="key" :key="key"
class="flex cursor-pointer border-2px rounded-6px hover:border-primary" class="flex-col-center cursor-pointer"
:class="[mode === key ? 'border-primary' : 'border-transparent']"
@click="handleChangeMode(key)" @click="handleChangeMode(key)"
> >
<NTooltip :placement="item.placement"> <IconTooltip :placement="item.placement">
<template #trigger> <template #trigger>
<div <div
class="h-64px w-96px gap-6px rd-4px p-6px shadow dark:shadow-coolGray-5" class="h-64px w-96px gap-6px rd-4px p-6px shadow ring-2 ring-transparent transition-all hover:ring-primary"
:class="[key.includes('vertical') ? 'flex' : 'flex-col']" :class="{ '!ring-primary': mode === key }"
> >
<slot :name="key"></slot> <div class="h-full w-full gap-1" :class="[key.includes('vertical') ? 'flex' : 'flex-col']">
<slot :name="key"></slot>
</div>
</div> </div>
</template> </template>
{{ $t(themeLayoutModeRecord[key]) }} {{ $t(`theme.layout.layoutMode.${key}_detail`) }}
</NTooltip> </IconTooltip>
<p class="mt-8px text-12px">{{ $t(themeLayoutModeRecord[key]) }}</p>
</div> </div>
</div> </div>
</template> </template>

View File

@ -13,7 +13,7 @@ defineProps<Props>();
<template> <template>
<div class="w-full flex-y-center justify-between"> <div class="w-full flex-y-center justify-between">
<div> <div class="flex-y-center">
<span class="pr-8px text-base-text">{{ label }}</span> <span class="pr-8px text-base-text">{{ label }}</span>
<slot name="suffix"></slot> <slot name="suffix"></slot>
</div> </div>

View File

@ -1,28 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales'; import { $t } from '@/locales';
import DarkMode from './modules/dark-mode.vue'; import AppearanceSettings from './modules/appearance/index.vue';
import LayoutMode from './modules/layout-mode.vue'; import LayoutSettings from './modules/layout/index.vue';
import ThemeColor from './modules/theme-color.vue'; import GeneralSettings from './modules/general/index.vue';
import PageFun from './modules/page-fun.vue';
import ConfigOperation from './modules/config-operation.vue'; import ConfigOperation from './modules/config-operation.vue';
import TableProps from './modules/table-props.vue'; import PresetSettings from './modules/preset/index.vue';
defineOptions({ defineOptions({
name: 'ThemeDrawer' name: 'ThemeDrawer'
}); });
const appStore = useAppStore(); const appStore = useAppStore();
const activeTab = ref('appearance');
const drawerWidth = computed(() => {
const width = 400;
// On mobile devices, use 90% of viewport width with a maximum of 400px
if (appStore.isMobile) {
return `min(90vw, ${width}px)`;
}
return width;
});
</script> </script>
<template> <template>
<NDrawer v-model:show="appStore.themeDrawerVisible" display-directive="show" :width="360"> <NDrawer v-model:show="appStore.themeDrawerVisible" display-directive="show" :width="drawerWidth">
<NDrawerContent :title="$t('theme.themeDrawerTitle')" :native-scrollbar="false" closable> <NDrawerContent :title="$t('theme.themeDrawerTitle')" :native-scrollbar="false" closable>
<DarkMode /> <NTabs v-model:value="activeTab" type="segment" size="medium" class="mb-16px">
<LayoutMode /> <NTab name="appearance" :tab="$t('theme.tabs.appearance')"></NTab>
<ThemeColor /> <NTab name="layout" :tab="$t('theme.tabs.layout')"></NTab>
<PageFun /> <NTab name="general" :tab="$t('theme.tabs.general')"></NTab>
<TableProps /> <NTab name="preset" :tab="$t('theme.tabs.preset')"></NTab>
</NTabs>
<div class="min-h-400px">
<KeepAlive>
<AppearanceSettings v-if="activeTab === 'appearance'" />
<LayoutSettings v-else-if="activeTab === 'layout'" />
<GeneralSettings v-else-if="activeTab === 'general'" />
<PresetSettings v-else-if="activeTab === 'preset'" />
</KeepAlive>
</div>
<template #footer> <template #footer>
<ConfigOperation /> <ConfigOperation />
</template> </template>
@ -30,4 +53,14 @@ const appStore = useAppStore();
</NDrawer> </NDrawer>
</template> </template>
<style scoped></style> <style scoped>
:deep(.n-tab) {
display: flex;
align-items: center;
gap: 8px;
}
:deep(.n-tab-pane) {
padding: 0;
}
</style>

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
import ThemeSchema from './modules/theme-schema.vue';
import ThemeColor from './modules/theme-color.vue';
defineOptions({
name: 'AppearanceSettings'
});
</script>
<template>
<div class="flex-col-stretch gap-16px">
<ThemeSchema />
<ThemeColor />
</div>
</template>
<style scoped></style>

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales'; import { $t } from '@/locales';
import SettingItem from '../components/setting-item.vue'; import SettingItem from '../../../components/setting-item.vue';
defineOptions({ defineOptions({
name: 'ThemeColor' name: 'ThemeColor'
@ -34,33 +34,38 @@ const swatches: string[] = [
</script> </script>
<template> <template>
<NDivider>{{ $t('theme.themeColor.title') }}</NDivider> <NDivider>{{ $t('theme.appearance.themeColor.title') }}</NDivider>
<div class="flex-col-stretch gap-12px"> <div class="flex-col-stretch gap-12px">
<NTooltip placement="top-start"> <SettingItem key="recommend-color" :label="$t('theme.appearance.recommendColor')">
<template #trigger> <template #suffix>
<SettingItem key="recommend-color" :label="$t('theme.recommendColor')"> <IconTooltip>
<NSwitch v-model:value="themeStore.recommendColor" /> <p>
</SettingItem> <span class="pr-12px">{{ $t('theme.appearance.recommendColorDesc') }}</span>
<br />
<NButton
text
tag="a"
href="https://uicolors.app/create"
target="_blank"
rel="noopener noreferrer"
class="text-gray"
>
https://uicolors.app/create
</NButton>
</p>
</IconTooltip>
</template> </template>
<p> <NSwitch v-model:value="themeStore.recommendColor" />
<span class="pr-12px">{{ $t('theme.recommendColorDesc') }}</span> </SettingItem>
<br />
<NButton <SettingItem
text v-for="(_, key) in themeStore.themeColors"
tag="a" :key="key"
href="https://uicolors.app/create" :label="$t(`theme.appearance.themeColor.${key}`)"
target="_blank" >
rel="noopener noreferrer"
class="text-gray"
>
https://uicolors.app/create
</NButton>
</p>
</NTooltip>
<SettingItem v-for="(_, key) in themeStore.themeColors" :key="key" :label="$t(`theme.themeColor.${key}`)">
<template v-if="key === 'info'" #suffix> <template v-if="key === 'info'" #suffix>
<NCheckbox v-model:checked="themeStore.isInfoFollowPrimary"> <NCheckbox v-model:checked="themeStore.isInfoFollowPrimary">
{{ $t('theme.themeColor.followPrimary') }} {{ $t('theme.appearance.themeColor.followPrimary') }}
</NCheckbox> </NCheckbox>
</template> </template>
<NColorPicker <NColorPicker

View File

@ -3,10 +3,10 @@ import { computed } from 'vue';
import { themeSchemaRecord } from '@/constants/app'; import { themeSchemaRecord } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales'; import { $t } from '@/locales';
import SettingItem from '../components/setting-item.vue'; import SettingItem from '../../../components/setting-item.vue';
defineOptions({ defineOptions({
name: 'DarkMode' name: 'ThemeSchema'
}); });
const themeStore = useThemeStore(); const themeStore = useThemeStore();
@ -33,7 +33,7 @@ const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layo
</script> </script>
<template> <template>
<NDivider>{{ $t('theme.themeSchema.title') }}</NDivider> <NDivider>{{ $t('theme.appearance.themeSchema.title') }}</NDivider>
<div class="flex-col-stretch gap-16px"> <div class="flex-col-stretch gap-16px">
<div class="i-flex-center"> <div class="i-flex-center">
<NTabs <NTabs
@ -50,14 +50,14 @@ const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layo
</NTabs> </NTabs>
</div> </div>
<Transition name="sider-inverted"> <Transition name="sider-inverted">
<SettingItem v-if="showSiderInverted" :label="$t('theme.sider.inverted')"> <SettingItem v-if="showSiderInverted" :label="$t('theme.layout.sider.inverted')">
<NSwitch v-model:value="themeStore.sider.inverted" /> <NSwitch v-model:value="themeStore.sider.inverted" />
</SettingItem> </SettingItem>
</Transition> </Transition>
<SettingItem :label="$t('theme.grayscale')"> <SettingItem :label="$t('theme.appearance.grayscale')">
<NSwitch :value="themeStore.grayscale" @update:value="handleGrayscaleChange" /> <NSwitch :value="themeStore.grayscale" @update:value="handleGrayscaleChange" />
</SettingItem> </SettingItem>
<SettingItem :label="$t('theme.colourWeakness')"> <SettingItem :label="$t('theme.appearance.colourWeakness')">
<NSwitch :value="themeStore.colourWeakness" @update:value="handleColourWeaknessChange" /> <NSwitch :value="themeStore.colourWeakness" @update:value="handleColourWeaknessChange" />
</SettingItem> </SettingItem>
</div> </div>

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
import GlobalSettings from './modules/global-settings.vue';
import WatermarkSettings from './modules/watermark-settings.vue';
defineOptions({
name: 'GeneralSettings'
});
</script>
<template>
<div class="flex-col-stretch gap-16px">
<GlobalSettings />
<WatermarkSettings />
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,39 @@
<script setup lang="ts">
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'GlobalSettings'
});
const themeStore = useThemeStore();
</script>
<template>
<NDivider>{{ $t('theme.general.title') }}</NDivider>
<SettingItem :label="$t('theme.general.multilingual.visible')">
<NSwitch v-model:value="themeStore.header.multilingual.visible" />
</SettingItem>
<SettingItem :label="$t('theme.general.globalSearch.visible')">
<NSwitch v-model:value="themeStore.header.globalSearch.visible" />
</SettingItem>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import { watermarkTimeFormatOptions } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'WatermarkSettings'
});
const themeStore = useThemeStore();
</script>
<template>
<NDivider>{{ $t('theme.general.watermark.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.general.watermark.visible')">
<NSwitch v-model:value="themeStore.watermark.visible" />
</SettingItem>
<SettingItem v-if="themeStore.watermark.visible" key="2" :label="$t('theme.general.watermark.enableUserName')">
<NSwitch :value="themeStore.watermark.enableUserName" @update:value="themeStore.setWatermarkEnableUserName" />
</SettingItem>
<SettingItem v-if="themeStore.watermark.visible" key="3" :label="$t('theme.general.watermark.enableTime')">
<NSwitch :value="themeStore.watermark.enableTime" @update:value="themeStore.setWatermarkEnableTime" />
</SettingItem>
<SettingItem
v-if="themeStore.watermark.visible && themeStore.watermark.enableTime"
key="4"
:label="$t('theme.general.watermark.timeFormat')"
>
<NSelect
v-model:value="themeStore.watermark.timeFormat"
:options="watermarkTimeFormatOptions"
size="small"
class="w-210px"
/>
</SettingItem>
<SettingItem key="5" :label="$t('theme.general.watermark.text')">
<NInput
v-model:value="themeStore.watermark.text"
autosize
type="text"
size="small"
class="w-120px"
placeholder="SoybeanAdmin"
/>
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import { useThemeStore } from '@/store/modules/theme';
import LayoutMode from './modules/layout-mode.vue';
import TabSettings from './modules/tab-settings.vue';
import HeaderSettings from './modules/header-settings.vue';
import SiderSettings from './modules/sider-settings.vue';
import FooterSettings from './modules/footer-settings.vue';
import ContentSettings from './modules/content-settings.vue';
import TableSettings from './modules/table-settings.vue';
defineOptions({
name: 'LayoutSettings'
});
const themeStore = useThemeStore();
</script>
<template>
<div class="flex-col-stretch gap-16px">
<LayoutMode />
<TabSettings />
<HeaderSettings />
<!-- The top menu mode does not have a sidebar -->
<SiderSettings v-if="themeStore.layout.mode !== 'horizontal'" />
<FooterSettings />
<ContentSettings />
<TableSettings />
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed } from 'vue';
import { themePageAnimationModeOptions, themeScrollModeOptions } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { translateOptions } from '@/utils/common';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'ContentSettings'
});
const themeStore = useThemeStore();
const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wrapper');
</script>
<template>
<NDivider>{{ $t('theme.layout.content.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.layout.content.scrollMode.title')">
<template #suffix>
<IconTooltip :desc="$t('theme.layout.content.scrollMode.tip')" />
</template>
<NSelect
v-model:value="themeStore.layout.scrollMode"
:options="translateOptions(themeScrollModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem key="2" :label="$t('theme.layout.content.page.animate')">
<NSwitch v-model:value="themeStore.page.animate" />
</SettingItem>
<SettingItem v-if="themeStore.page.animate" key="3" :label="$t('theme.layout.content.page.mode.title')">
<NSelect
v-model:value="themeStore.page.animateMode"
:options="translateOptions(themePageAnimationModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem v-if="isWrapperScrollMode" key="4" :label="$t('theme.layout.content.fixedHeaderAndTab')">
<NSwitch v-model:value="themeStore.fixedHeaderAndTab" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'FooterSettings'
});
const themeStore = useThemeStore();
const layoutMode = computed(() => themeStore.layout.mode);
const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wrapper');
const isMixHorizontalMode = computed(() =>
['top-hybrid-sidebar-first', 'top-hybrid-header-first'].includes(layoutMode.value)
);
</script>
<template>
<NDivider>{{ $t('theme.layout.footer.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.layout.footer.visible')">
<NSwitch v-model:value="themeStore.footer.visible" />
</SettingItem>
<SettingItem
v-if="themeStore.footer.visible && isWrapperScrollMode"
key="2"
:label="$t('theme.layout.footer.fixed')"
>
<NSwitch v-model:value="themeStore.footer.fixed" />
</SettingItem>
<SettingItem v-if="themeStore.footer.visible" key="3" :label="$t('theme.layout.footer.height')">
<NInputNumber v-model:value="themeStore.footer.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem
v-if="themeStore.footer.visible && isMixHorizontalMode"
key="4"
:label="$t('theme.layout.footer.right')"
>
<NSwitch v-model:value="themeStore.footer.right" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -0,0 +1,47 @@
<script setup lang="ts">
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'HeaderSettings'
});
const themeStore = useThemeStore();
</script>
<template>
<NDivider>{{ $t('theme.layout.header.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.layout.header.height')">
<NInputNumber v-model:value="themeStore.header.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem key="2" :label="$t('theme.layout.header.breadcrumb.visible')">
<NSwitch v-model:value="themeStore.header.breadcrumb.visible" />
</SettingItem>
<SettingItem
v-if="themeStore.header.breadcrumb.visible"
key="3"
:label="$t('theme.layout.header.breadcrumb.showIcon')"
>
<NSwitch v-model:value="themeStore.header.breadcrumb.showIcon" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -2,8 +2,7 @@
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales'; import { $t } from '@/locales';
import LayoutModeCard from '../components/layout-mode-card.vue'; import LayoutModeCard from '../../../components/layout-mode-card.vue';
import SettingItem from '../components/setting-item.vue';
defineOptions({ defineOptions({
name: 'LayoutMode' name: 'LayoutMode'
@ -11,56 +10,60 @@ defineOptions({
const appStore = useAppStore(); const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
function handleReverseHorizontalMixChange(value: boolean) {
themeStore.setLayoutReverseHorizontalMix(value);
}
</script> </script>
<template> <template>
<NDivider>{{ $t('theme.layoutMode.title') }}</NDivider> <NDivider>{{ $t('theme.layout.layoutMode.title') }}</NDivider>
<LayoutModeCard v-model:mode="themeStore.layout.mode" :disabled="appStore.isMobile"> <LayoutModeCard v-model:mode="themeStore.layout.mode" :disabled="appStore.isMobile">
<template #vertical> <template #vertical>
<div class="layout-sider h-full w-18px"></div> <div class="layout-sider h-full w-18px !bg-primary"></div>
<div class="vertical-wrapper"> <div class="vertical-wrapper">
<div class="layout-header"></div> <div class="layout-header bg-primary-200"></div>
<div class="layout-main"></div> <div class="layout-main"></div>
</div> </div>
</template> </template>
<template #vertical-mix> <template #vertical-mix>
<div class="layout-sider h-full w-8px"></div> <div class="layout-sider h-full w-8px !bg-primary"></div>
<div class="layout-sider h-full w-16px"></div> <div class="layout-sider h-full w-16px !bg-primary-300"></div>
<div class="vertical-wrapper"> <div class="vertical-wrapper">
<div class="layout-header"></div> <div class="layout-header bg-primary-200"></div>
<div class="layout-main"></div>
</div>
</template>
<template #vertical-hybrid-header-first>
<div class="layout-sider h-full w-8px !bg-primary"></div>
<div class="layout-sider h-full w-16px !bg-primary-300"></div>
<div class="vertical-wrapper">
<div class="layout-header bg-primary"></div>
<div class="layout-main"></div> <div class="layout-main"></div>
</div> </div>
</template> </template>
<template #horizontal> <template #horizontal>
<div class="layout-header"></div> <div class="layout-header !bg-primary"></div>
<div class="horizontal-wrapper"> <div class="horizontal-wrapper">
<div class="layout-main"></div> <div class="layout-main"></div>
</div> </div>
</template> </template>
<template #horizontal-mix> <template #top-hybrid-sidebar-first>
<div class="layout-header"></div> <div class="layout-header !bg-primary-300"></div>
<div class="horizontal-wrapper">
<div class="layout-sider w-18px !bg-primary"></div>
<div class="layout-main"></div>
</div>
</template>
<template #top-hybrid-header-first>
<div class="layout-header bg-primary"></div>
<div class="horizontal-wrapper"> <div class="horizontal-wrapper">
<div class="layout-sider w-18px"></div> <div class="layout-sider w-18px"></div>
<div class="layout-main"></div> <div class="layout-main"></div>
</div> </div>
</template> </template>
</LayoutModeCard> </LayoutModeCard>
<SettingItem
v-if="themeStore.layout.mode === 'horizontal-mix'"
:label="$t('theme.layoutMode.reverseHorizontalMix')"
class="mt-16px"
>
<NSwitch :value="themeStore.layout.reverseHorizontalMix" @update:value="handleReverseHorizontalMixChange" />
</SettingItem>
</template> </template>
<style scoped> <style scoped>
.layout-header { .layout-header {
--uno: h-16px bg-primary rd-4px; --uno: h-16px rd-4px;
} }
.layout-sider { .layout-sider {

View File

@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'SiderSettings'
});
const themeStore = useThemeStore();
const layoutMode = computed(() => themeStore.layout.mode);
const isMixLayoutMode = computed(() => layoutMode.value.includes('mix') || layoutMode.value.includes('hybrid'));
</script>
<template>
<NDivider>{{ $t('theme.layout.sider.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem v-if="layoutMode === 'vertical'" key="1" :label="$t('theme.layout.sider.width')">
<NInputNumber v-model:value="themeStore.sider.width" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="2" :label="$t('theme.layout.sider.collapsedWidth')">
<NInputNumber v-model:value="themeStore.sider.collapsedWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="3" :label="$t('theme.layout.sider.mixWidth')">
<NInputNumber v-model:value="themeStore.sider.mixWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="4" :label="$t('theme.layout.sider.mixCollapsedWidth')">
<NInputNumber v-model:value="themeStore.sider.mixCollapsedWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical-mix'" key="5" :label="$t('theme.layout.sider.mixChildMenuWidth')">
<NInputNumber v-model:value="themeStore.sider.mixChildMenuWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -0,0 +1,64 @@
<script setup lang="ts">
import { resetCacheStrategyOptions, themeTabModeOptions } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { translateOptions } from '@/utils/common';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'TabSettings'
});
const themeStore = useThemeStore();
</script>
<template>
<NDivider>{{ $t('theme.layout.tab.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="0" :label="$t('theme.layout.resetCacheStrategy.title')">
<NSelect
v-model:value="themeStore.resetCacheStrategy"
:options="translateOptions(resetCacheStrategyOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem key="1" :label="$t('theme.layout.tab.visible')">
<NSwitch v-model:value="themeStore.tab.visible" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="2" :label="$t('theme.layout.tab.cache')">
<template #suffix>
<IconTooltip :desc="$t('theme.layout.tab.cacheTip')" />
</template>
<NSwitch v-model:value="themeStore.tab.cache" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="3" :label="$t('theme.layout.tab.height')">
<NInputNumber v-model:value="themeStore.tab.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="4" :label="$t('theme.layout.tab.mode.title')">
<NSelect
v-model:value="themeStore.tab.mode"
:options="translateOptions(themeTabModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -0,0 +1,44 @@
<script setup lang="ts">
import { themeTableSizeOptions } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { translateOptions } from '@/utils/common';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'TableProps'
});
const themeStore = useThemeStore();
</script>
<template>
<NDivider>{{ $t('theme.tablePropsTitle') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="0" :label="$t('theme.table.size.title')">
<NSelect
v-model:value="themeStore.table.size"
:options="translateOptions(themeTableSizeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem key="1" :label="$t('theme.table.bordered')">
<NSwitch v-model:value="themeStore.table.bordered" />
</SettingItem>
<SettingItem key="2" :label="$t('theme.table.bottomBordered')">
<NSwitch v-model:value="themeStore.table.bottomBordered" />
</SettingItem>
<SettingItem key="3" :label="$t('theme.table.singleColumn')">
<NSwitch v-model:value="themeStore.table.singleColumn" :checked-value="false" :unchecked-value="true" />
</SettingItem>
<SettingItem key="4" :label="$t('theme.table.singleLine')">
<NSwitch v-model:value="themeStore.table.singleLine" :checked-value="false" :unchecked-value="true" />
</SettingItem>
<SettingItem key="5" :label="$t('theme.table.striped')">
<NSwitch v-model:value="themeStore.table.striped" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped></style>

View File

@ -1,157 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
resetCacheStrategyOptions,
themePageAnimationModeOptions,
themeScrollModeOptions,
themeTabModeOptions
} from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { translateOptions } from '@/utils/common';
import { $t } from '@/locales';
import SettingItem from '../components/setting-item.vue';
defineOptions({
name: 'PageFun'
});
const themeStore = useThemeStore();
const layoutMode = computed(() => themeStore.layout.mode);
const isMixLayoutMode = computed(() => layoutMode.value.includes('mix'));
const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wrapper');
</script>
<template>
<NDivider>{{ $t('theme.pageFunTitle') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="0" :label="$t('theme.resetCacheStrategy.title')">
<NSelect
v-model:value="themeStore.resetCacheStrategy"
:options="translateOptions(resetCacheStrategyOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem key="1" :label="$t('theme.scrollMode.title')">
<NSelect
v-model:value="themeStore.layout.scrollMode"
:options="translateOptions(themeScrollModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem key="1-1" :label="$t('theme.page.animate')">
<NSwitch v-model:value="themeStore.page.animate" />
</SettingItem>
<SettingItem v-if="themeStore.page.animate" key="1-2" :label="$t('theme.page.mode.title')">
<NSelect
v-model:value="themeStore.page.animateMode"
:options="translateOptions(themePageAnimationModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem v-if="isWrapperScrollMode" key="2" :label="$t('theme.fixedHeaderAndTab')">
<NSwitch v-model:value="themeStore.fixedHeaderAndTab" />
</SettingItem>
<SettingItem key="3" :label="$t('theme.header.height')">
<NInputNumber v-model:value="themeStore.header.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem key="4" :label="$t('theme.header.breadcrumb.visible')">
<NSwitch v-model:value="themeStore.header.breadcrumb.visible" />
</SettingItem>
<SettingItem v-if="themeStore.header.breadcrumb.visible" key="4-1" :label="$t('theme.header.breadcrumb.showIcon')">
<NSwitch v-model:value="themeStore.header.breadcrumb.showIcon" />
</SettingItem>
<SettingItem key="5" :label="$t('theme.tab.visible')">
<NSwitch v-model:value="themeStore.tab.visible" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-1" :label="$t('theme.tab.cache')">
<NSwitch v-model:value="themeStore.tab.cache" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-2" :label="$t('theme.tab.height')">
<NInputNumber v-model:value="themeStore.tab.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-3" :label="$t('theme.tab.mode.title')">
<NSelect
v-model:value="themeStore.tab.mode"
:options="translateOptions(themeTabModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="6-1" :label="$t('theme.sider.width')">
<NInputNumber v-model:value="themeStore.sider.width" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="6-2" :label="$t('theme.sider.collapsedWidth')">
<NInputNumber v-model:value="themeStore.sider.collapsedWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="6-3" :label="$t('theme.sider.mixWidth')">
<NInputNumber v-model:value="themeStore.sider.mixWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="6-4" :label="$t('theme.sider.mixCollapsedWidth')">
<NInputNumber v-model:value="themeStore.sider.mixCollapsedWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical-mix'" key="6-5" :label="$t('theme.sider.mixChildMenuWidth')">
<NInputNumber v-model:value="themeStore.sider.mixChildMenuWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem key="7" :label="$t('theme.footer.visible')">
<NSwitch v-model:value="themeStore.footer.visible" />
</SettingItem>
<SettingItem v-if="themeStore.footer.visible && isWrapperScrollMode" key="7-1" :label="$t('theme.footer.fixed')">
<NSwitch v-model:value="themeStore.footer.fixed" />
</SettingItem>
<SettingItem v-if="themeStore.footer.visible" key="7-2" :label="$t('theme.footer.height')">
<NInputNumber v-model:value="themeStore.footer.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem
v-if="themeStore.footer.visible && layoutMode === 'horizontal-mix'"
key="7-3"
:label="$t('theme.footer.right')"
>
<NSwitch v-model:value="themeStore.footer.right" />
</SettingItem>
<SettingItem key="8" :label="$t('theme.watermark.visible')">
<NSwitch v-model:value="themeStore.watermark.visible" />
</SettingItem>
<SettingItem v-if="themeStore.watermark.visible" key="8-1" :label="$t('theme.watermark.enableUserName')">
<NSwitch v-model:value="themeStore.watermark.enableUserName" />
</SettingItem>
<SettingItem v-if="themeStore.watermark.visible" key="8-2" :label="$t('theme.watermark.text')">
<NInput
v-model:value="themeStore.watermark.text"
autosize
type="text"
size="small"
class="w-120px"
placeholder="SoybeanAdmin"
/>
</SettingItem>
<SettingItem key="9" :label="$t('theme.header.multilingual.visible')">
<NSwitch v-model:value="themeStore.header.multilingual.visible" />
</SettingItem>
<SettingItem key="10" :label="$t('theme.header.globalSearch.visible')">
<NSwitch v-model:value="themeStore.header.globalSearch.visible" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import ThemePreset from './modules/theme-preset.vue';
defineOptions({
name: 'PresetSettings'
});
</script>
<template>
<div class="flex-col-stretch gap-16px">
<ThemePreset />
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,148 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
defineOptions({
name: 'ThemePreset'
});
type ThemePreset = Pick<
App.Theme.ThemeSetting,
| 'themeScheme'
| 'grayscale'
| 'colourWeakness'
| 'recommendColor'
| 'themeColor'
| 'otherColor'
| 'isInfoFollowPrimary'
| 'resetCacheStrategy'
| 'layout'
| 'page'
| 'header'
| 'tab'
| 'fixedHeaderAndTab'
| 'sider'
| 'footer'
| 'watermark'
| 'tokens'
> & {
name: string;
desc: string;
i18nkey?: string;
version: string;
};
const presetModules = import.meta.glob('@/theme/preset/*.json', { eager: true, import: 'default' });
const themeStore = useThemeStore();
// Extract preset data
const presets = computed(() =>
Object.entries(presetModules)
.map(([path, presetData]) => {
const fileName = path.split('/').pop()?.replace('.json', '') || '';
return {
id: fileName,
...(presetData as ThemePreset)
};
})
.sort((a, b) => {
if (a.name === 'default') return -1;
if (b.name === 'default') return 1;
return a.name.localeCompare(b.name);
})
);
const getPresetName = (preset: ThemePreset): string => {
if (!preset.i18nkey) return preset.name;
try {
const key = `${preset.i18nkey}.name` as App.I18n.I18nKey;
const translated = $t(key);
return translated !== key ? translated : preset.name;
} catch {
return preset.name;
}
};
const getPresetDesc = (preset: ThemePreset): string => {
if (!preset.i18nkey) return preset.desc;
try {
const key = `${preset.i18nkey}.desc` as App.I18n.I18nKey;
const translated = $t(key);
return translated !== key ? translated : preset.desc;
} catch {
return preset.desc;
}
};
const applyPreset = ({ themeScheme, grayscale, colourWeakness, layout, watermark, ...rest }: ThemePreset): void => {
themeStore.setThemeScheme(themeScheme);
themeStore.setGrayscale(grayscale);
themeStore.setColourWeakness(colourWeakness);
themeStore.setThemeLayout(layout.mode);
themeStore.setWatermarkEnableUserName(watermark.enableUserName);
themeStore.setWatermarkEnableTime(watermark.enableTime);
Object.assign(themeStore, {
...rest,
layout: { ...themeStore.layout, scrollMode: layout.scrollMode },
page: { ...rest.page },
header: { ...rest.header },
tab: { ...rest.tab },
sider: { ...rest.sider },
footer: { ...rest.footer },
watermark: { ...watermark },
tokens: { ...rest.tokens }
});
window.$message?.success($t('theme.appearance.preset.applySuccess'));
};
</script>
<template>
<NDivider>{{ $t('theme.appearance.preset.title') }}</NDivider>
<div class="flex flex-col gap-3">
<div
v-for="preset in presets"
:key="preset.id"
class="border border-primary/10 rounded-lg border-solid bg-white/5 p-3 backdrop-blur-10 transition-all duration-300 hover:(shadow-md -translate-y-0.5)"
>
<div class="mb-2 flex items-center justify-between">
<div class="min-w-0 w-full flex flex-1 items-center justify-between gap-2">
<h5 class="m-0 truncate text-sm text-primary font-600">
{{ getPresetName(preset) }}
</h5>
<NBadge :value="`v${preset.version}`" type="info" size="small" class="flex-shrink-0 opacity-80" />
</div>
<NButton type="primary" size="tiny" ghost round class="ml-2 flex-shrink-0" @click="applyPreset(preset)">
{{ $t('theme.appearance.preset.apply') }}
</NButton>
</div>
<p class="line-clamp-2 mb-3 text-xs text-gray-500 leading-4">{{ getPresetDesc(preset) }}</p>
<div class="flex items-center justify-between">
<div class="flex gap-1">
<div
v-for="(color, key) in { primary: preset.themeColor, ...preset.otherColor }"
:key="key"
class="h-3 w-3 cursor-pointer border border-white/30 rounded-full transition-transform hover:scale-110"
:style="{ backgroundColor: color }"
:class="{ 'ring-1 ring-primary/50': key === 'primary' }"
:title="key"
/>
</div>
<div class="flex items-center gap-1">
<div class="text-lg">
{{ preset.themeScheme === 'dark' ? '🌙' : '☀️' }}
</div>
<div class="text-lg">
{{ preset.grayscale ? '🎨' : '' }}
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -82,93 +82,164 @@ const local: App.I18n.Schema = {
tokenExpired: 'The requested token has expired' tokenExpired: 'The requested token has expired'
}, },
theme: { theme: {
themeSchema: { themeDrawerTitle: 'Theme Configuration',
title: 'Theme Schema', tabs: {
light: 'Light', appearance: 'Appearance',
dark: 'Dark', layout: 'Layout',
auto: 'Follow System' general: 'General',
preset: 'Preset'
}, },
grayscale: 'Grayscale', appearance: {
colourWeakness: 'Colour Weakness', themeSchema: {
layoutMode: { title: 'Theme Schema',
title: 'Layout Mode', light: 'Light',
vertical: 'Vertical Menu Mode', dark: 'Dark',
horizontal: 'Horizontal Menu Mode', auto: 'Follow System'
'vertical-mix': 'Vertical Mix Menu Mode', },
'horizontal-mix': 'Horizontal Mix menu Mode', grayscale: 'Grayscale',
reverseHorizontalMix: 'Reverse first level menus and child level menus position' colourWeakness: 'Colour Weakness',
}, themeColor: {
recommendColor: 'Apply Recommended Color Algorithm', title: 'Theme Color',
recommendColorDesc: 'The recommended color algorithm refers to', primary: 'Primary',
themeColor: { info: 'Info',
title: 'Theme Color', success: 'Success',
primary: 'Primary', warning: 'Warning',
info: 'Info', error: 'Error',
success: 'Success', followPrimary: 'Follow Primary'
warning: 'Warning', },
error: 'Error', recommendColor: 'Apply Recommended Color Algorithm',
followPrimary: 'Follow Primary' recommendColorDesc: 'The recommended color algorithm refers to',
}, preset: {
scrollMode: { title: 'Theme Presets',
title: 'Scroll Mode', apply: 'Apply',
wrapper: 'Wrapper', applySuccess: 'Preset applied successfully',
content: 'Content' default: {
}, name: 'Default Preset',
page: { desc: 'Default theme preset with balanced settings'
animate: 'Page Animate', },
mode: { dark: {
title: 'Page Animate Mode', name: 'Dark Preset',
fade: 'Fade', desc: 'Dark theme preset for night time usage'
'fade-slide': 'Slide', },
'fade-bottom': 'Fade Zoom', compact: {
'fade-scale': 'Fade Scale', name: 'Compact Preset',
'zoom-fade': 'Zoom Fade', desc: 'Compact layout preset for small screens'
'zoom-out': 'Zoom Out', },
none: 'None' azir: {
name: "Azir's Preset",
desc: 'It is a cold and elegant preset that Azir likes'
}
} }
}, },
fixedHeaderAndTab: 'Fixed Header And Tab', layout: {
header: { layoutMode: {
height: 'Header Height', title: 'Layout Mode',
breadcrumb: { vertical: 'Vertical Mode',
visible: 'Breadcrumb Visible', horizontal: 'Horizontal Mode',
showIcon: 'Breadcrumb Icon Visible' 'vertical-mix': 'Vertical Mix Mode',
'vertical-hybrid-header-first': 'Left Hybrid Header-First',
'top-hybrid-sidebar-first': 'Top-Hybrid Sidebar-First',
'top-hybrid-header-first': 'Top-Hybrid Header-First',
vertical_detail: 'Vertical menu layout, with the menu on the left and content on the right.',
'vertical-mix_detail':
'Vertical mix-menu layout, with the primary menu on the dark left side and the secondary menu on the lighter left side.',
'vertical-hybrid-header-first_detail':
'Left hybrid layout, with the primary menu at the top, the secondary menu on the dark left side, and the tertiary menu on the lighter left side.',
horizontal_detail: 'Horizontal menu layout, with the menu at the top and content below.',
'top-hybrid-sidebar-first_detail':
'Top hybrid layout, with the primary menu on the left and the secondary menu at the top.',
'top-hybrid-header-first_detail':
'Top hybrid layout, with the primary menu at the top and the secondary menu on the left.'
},
tab: {
title: 'Tab Settings',
visible: 'Tab Visible',
cache: 'Tag Bar Info Cache',
cacheTip: 'One-click to open/close global keepalive',
height: 'Tab Height',
mode: {
title: 'Tab Mode',
chrome: 'Chrome',
button: 'Button'
}
},
header: {
title: 'Header Settings',
height: 'Header Height',
breadcrumb: {
visible: 'Breadcrumb Visible',
showIcon: 'Breadcrumb Icon Visible'
}
},
sider: {
title: 'Sider Settings',
inverted: 'Dark Sider',
width: 'Sider Width',
collapsedWidth: 'Sider Collapsed Width',
mixWidth: 'Mix Sider Width',
mixCollapsedWidth: 'Mix Sider Collapse Width',
mixChildMenuWidth: 'Mix Child Menu Width'
},
footer: {
title: 'Footer Settings',
visible: 'Footer Visible',
fixed: 'Fixed Footer',
height: 'Footer Height',
right: 'Right Footer'
},
content: {
title: 'Content Area Settings',
scrollMode: {
title: 'Scroll Mode',
tip: 'The theme scroll only scrolls the main part, the outer scroll can carry the header and footer together',
wrapper: 'Wrapper',
content: 'Content'
},
page: {
animate: 'Page Animate',
mode: {
title: 'Page Animate Mode',
fade: 'Fade',
'fade-slide': 'Slide',
'fade-bottom': 'Fade Zoom',
'fade-scale': 'Fade Scale',
'zoom-fade': 'Zoom Fade',
'zoom-out': 'Zoom Out',
none: 'None'
}
},
fixedHeaderAndTab: 'Fixed Header And Tab'
},
resetCacheStrategy: {
title: 'Reset Cache Strategy',
close: 'Close Page',
refresh: 'Refresh Page'
}
},
general: {
title: 'General Settings',
watermark: {
title: 'Watermark Settings',
visible: 'Watermark Full Screen Visible',
text: 'Custom Watermark Text',
enableUserName: 'Enable User Name Watermark',
enableTime: 'Show Current Time',
timeFormat: 'Time Format'
}, },
multilingual: { multilingual: {
title: 'Multilingual Settings',
visible: 'Display multilingual button' visible: 'Display multilingual button'
}, },
globalSearch: { globalSearch: {
title: 'Global Search Settings',
visible: 'Display GlobalSearch button' visible: 'Display GlobalSearch button'
} }
}, },
tab: { configOperation: {
visible: 'Tab Visible', copyConfig: 'Copy Config',
cache: 'Tag Bar Info Cache', copySuccessMsg: 'Copy Success, Please replace the variable "themeSettings" in "src/theme/settings.ts"',
height: 'Tab Height', resetConfig: 'Reset Config',
mode: { resetSuccessMsg: 'Reset Success'
title: 'Tab Mode',
chrome: 'Chrome',
button: 'Button'
}
},
sider: {
inverted: 'Dark Sider',
width: 'Sider Width',
collapsedWidth: 'Sider Collapsed Width',
mixWidth: 'Mix Sider Width',
mixCollapsedWidth: 'Mix Sider Collapse Width',
mixChildMenuWidth: 'Mix Child Menu Width'
},
footer: {
visible: 'Footer Visible',
fixed: 'Fixed Footer',
height: 'Footer Height',
right: 'Right Footer'
},
watermark: {
visible: 'Watermark Full Screen Visible',
text: 'Watermark Text',
enableUserName: 'Enable User Name Watermark'
}, },
tablePropsTitle: 'Table Props', tablePropsTitle: 'Table Props',
table: { table: {
@ -183,19 +254,6 @@ const local: App.I18n.Schema = {
singleColumn: 'Single Column', singleColumn: 'Single Column',
singleLine: 'Single Line', singleLine: 'Single Line',
striped: 'Striped' striped: 'Striped'
},
themeDrawerTitle: 'Theme Configuration',
pageFunTitle: 'Page Function',
resetCacheStrategy: {
title: 'Reset Cache Strategy',
close: 'Close Page',
refresh: 'Refresh Page'
},
configOperation: {
copyConfig: 'Copy Config',
copySuccessMsg: 'Copy Success, Please replace the variable "themeSettings" in "src/theme/settings.ts"',
resetConfig: 'Reset Config',
resetSuccessMsg: 'Reset Success'
} }
}, },
route: { route: {
@ -205,29 +263,10 @@ const local: App.I18n.Schema = {
500: 'Server Error', 500: 'Server Error',
'iframe-page': 'Iframe', 'iframe-page': 'Iframe',
home: 'Home', home: 'Home',
system: 'System Management', monitor: 'Monitor',
system_menu: 'Menu Management',
tool: 'System Tools',
tool_gen: 'Code Generation',
system_user: 'User Management',
system_dict: 'Dict Management',
system_tenant: 'Tenant Management',
'system_tenant-package': 'Tenant Package Management',
system_config: 'Config Management',
system_dept: 'Dept Management',
system_post: 'Post Management',
monitor: 'Monitor Management',
monitor_logininfor: 'Login Log',
monitor_operlog: 'Operate Log',
system_client: 'Client Management',
system_notice: 'Notice Management',
'social-callback': 'Social Callback', 'social-callback': 'Social Callback',
system_oss: 'File Management',
'system_oss-config': 'OSS Config',
monitor_cache: 'Cache Monitor', monitor_cache: 'Cache Monitor',
monitor_online: 'Online User',
'user-center': 'User Center', 'user-center': 'User Center',
system_role: 'Role Management',
demo: 'Demo', demo: 'Demo',
demo_demo: 'Demo Table', demo_demo: 'Demo Table',
demo_tree: 'Demo Tree', demo_tree: 'Demo Tree',
@ -328,6 +367,8 @@ const local: App.I18n.Schema = {
page: { page: {
login: { login: {
common: { common: {
title: 'Modern enterprise-level multi-tenant management system',
subTitle: 'Provides developers with a complete enterprise management solution',
loginOrRegister: 'Login / Register', loginOrRegister: 'Login / Register',
register: 'Register', register: 'Register',
userNamePlaceholder: 'Please enter user name', userNamePlaceholder: 'Please enter user name',

View File

@ -82,93 +82,161 @@ const local: App.I18n.Schema = {
tokenExpired: 'token已过期' tokenExpired: 'token已过期'
}, },
theme: { theme: {
themeSchema: { themeDrawerTitle: '主题配置',
title: '主题模式', tabs: {
light: '亮色模式', appearance: '外观',
dark: '暗黑模式', layout: '布局',
auto: '跟随系统' general: '通用',
preset: '预设'
}, },
grayscale: '灰色模式', appearance: {
colourWeakness: '色弱模式', themeSchema: {
layoutMode: { title: '主题模式',
title: '布局模式', light: '亮色模式',
vertical: '左侧菜单模式', dark: '暗黑模式',
'vertical-mix': '左侧菜单混合模式', auto: '跟随系统'
horizontal: '顶部菜单模式', },
'horizontal-mix': '顶部菜单混合模式', grayscale: '灰色模式',
reverseHorizontalMix: '一级菜单与子级菜单位置反转' colourWeakness: '色弱模式',
}, themeColor: {
recommendColor: '应用推荐算法的颜色', title: '主题颜色',
recommendColorDesc: '推荐颜色的算法参照', primary: '主色',
themeColor: { info: '信息色',
title: '主题颜色', success: '成功色',
primary: '色', warning: '警告色',
info: '信息色', error: '错误色',
success: '成功色', followPrimary: '跟随主色'
warning: '警告色', },
error: '错误色', recommendColor: '应用推荐算法的颜色',
followPrimary: '跟随主色' recommendColorDesc: '推荐颜色的算法参照',
}, preset: {
scrollMode: { title: '主题预设',
title: '滚动模式', apply: '应用',
wrapper: '外层滚动', applySuccess: '预设应用成功',
content: '主体滚动' default: {
}, name: '默认预设',
page: { desc: 'Soybean 默认主题预设'
animate: '页面切换动画', },
mode: { dark: {
title: '页面切换动画类型', name: '暗色预设',
'fade-slide': '滑动', desc: '适用于夜间使用的暗色主题预设'
fade: '淡入淡出', },
'fade-bottom': '底部消退', compact: {
'fade-scale': '缩放消退', name: '紧凑型',
'zoom-fade': '渐变', desc: '适用于小屏幕的紧凑布局预设'
'zoom-out': '闪现', },
none: '无' azir: {
name: 'Azir的预设',
desc: '是 Azir 比较喜欢的莫兰迪色系冷淡风'
}
} }
}, },
fixedHeaderAndTab: '固定头部和标签栏', layout: {
header: { layoutMode: {
height: '头部高度', title: '布局模式',
breadcrumb: { vertical: '左侧菜单模式',
visible: '显示面包屑', 'vertical-mix': '左侧菜单混合模式',
showIcon: '显示面包屑图标' 'vertical-hybrid-header-first': '左侧混合-顶部优先',
horizontal: '顶部菜单模式',
'top-hybrid-sidebar-first': '顶部混合-侧边优先',
'top-hybrid-header-first': '顶部混合-顶部优先',
vertical_detail: '左侧菜单布局,菜单在左,内容在右。',
'vertical-mix_detail': '左侧双菜单布局,一级菜单在左侧深色区域,二级菜单在左侧浅色区域。',
'vertical-hybrid-header-first_detail':
'左侧混合布局,一级菜单在顶部,二级菜单在左侧深色区域,三级菜单在左侧浅色区域。',
horizontal_detail: '顶部菜单布局,菜单在顶部,内容在下方。',
'top-hybrid-sidebar-first_detail': '顶部混合布局,一级菜单在左侧,二级菜单在顶部。',
'top-hybrid-header-first_detail': '顶部混合布局,一级菜单在顶部,二级菜单在左侧。'
},
tab: {
title: '标签栏设置',
visible: '显示标签栏',
cache: '标签栏信息缓存',
cacheTip: '一键开启/关闭全局 keepalive',
height: '标签栏高度',
mode: {
title: '标签栏风格',
chrome: '谷歌风格',
button: '按钮风格'
}
},
header: {
title: '头部设置',
height: '头部高度',
breadcrumb: {
visible: '显示面包屑',
showIcon: '显示面包屑图标'
}
},
sider: {
title: '侧边栏设置',
inverted: '深色侧边栏',
width: '侧边栏宽度',
collapsedWidth: '侧边栏折叠宽度',
mixWidth: '混合布局侧边栏宽度',
mixCollapsedWidth: '混合布局侧边栏折叠宽度',
mixChildMenuWidth: '混合布局子菜单宽度'
},
footer: {
title: '底部设置',
visible: '显示底部',
fixed: '固定底部',
height: '底部高度',
right: '底部局右'
},
content: {
title: '内容区域设置',
scrollMode: {
title: '滚动模式',
tip: '主题滚动仅 main 部分滚动,外层滚动可携带头部底部一起滚动',
wrapper: '外层滚动',
content: '主体滚动'
},
page: {
animate: '页面切换动画',
mode: {
title: '页面切换动画类型',
'fade-slide': '滑动',
fade: '淡入淡出',
'fade-bottom': '底部消退',
'fade-scale': '缩放消退',
'zoom-fade': '渐变',
'zoom-out': '闪现',
none: '无'
}
},
fixedHeaderAndTab: '固定头部和标签栏'
},
resetCacheStrategy: {
title: '重置缓存策略',
close: '关闭页面',
refresh: '刷新页面'
}
},
general: {
title: '通用设置',
watermark: {
title: '水印设置',
visible: '显示全屏水印',
text: '自定义水印文本',
enableUserName: '启用用户名水印',
enableTime: '显示当前时间',
timeFormat: '时间格式'
}, },
multilingual: { multilingual: {
title: '多语言设置',
visible: '显示多语言按钮' visible: '显示多语言按钮'
}, },
globalSearch: { globalSearch: {
title: '全局搜索设置',
visible: '显示全局搜索按钮' visible: '显示全局搜索按钮'
} }
}, },
tab: { configOperation: {
visible: '显示标签栏', copyConfig: '复制配置',
cache: '标签栏信息缓存', copySuccessMsg: '复制成功,请替换 src/theme/settings.ts 中的变量 themeSettings',
height: '标签栏高度', resetConfig: '重置配置',
mode: { resetSuccessMsg: '重置成功'
title: '标签栏风格',
chrome: '谷歌风格',
button: '按钮风格'
}
},
sider: {
inverted: '深色侧边栏',
width: '侧边栏宽度',
collapsedWidth: '侧边栏折叠宽度',
mixWidth: '混合布局侧边栏宽度',
mixCollapsedWidth: '混合布局侧边栏折叠宽度',
mixChildMenuWidth: '混合布局子菜单宽度'
},
footer: {
visible: '显示底部',
fixed: '固定底部',
height: '底部高度',
right: '底部局右'
},
watermark: {
visible: '显示全屏水印',
text: '水印文本',
enableUserName: '启用用户名水印'
}, },
tablePropsTitle: '表格配置', tablePropsTitle: '表格配置',
table: { table: {
@ -183,19 +251,6 @@ const local: App.I18n.Schema = {
singleColumn: '设定行的分割线', singleColumn: '设定行的分割线',
singleLine: '设定列的分割线', singleLine: '设定列的分割线',
striped: '斑马线条纹' striped: '斑马线条纹'
},
themeDrawerTitle: '主题配置',
pageFunTitle: '页面功能',
resetCacheStrategy: {
title: '重置缓存策略',
close: '关闭页面',
refresh: '刷新页面'
},
configOperation: {
copyConfig: '复制配置',
copySuccessMsg: '复制成功,请替换 src/theme/settings.ts 中的变量 themeSettings',
resetConfig: '重置配置',
resetSuccessMsg: '重置成功'
} }
}, },
route: { route: {
@ -205,29 +260,10 @@ const local: App.I18n.Schema = {
500: '服务器错误', 500: '服务器错误',
'iframe-page': '外链页面', 'iframe-page': '外链页面',
home: '首页', home: '首页',
system: '系统管理',
system_menu: '菜单管理',
tool: '系统工具',
tool_gen: '代码生成',
system_user: '用户管理',
system_dict: '字典管理',
system_tenant: '租户管理',
'system_tenant-package': '租户套餐',
system_config: '参数设置',
system_dept: '部门管理',
system_post: '岗位管理',
monitor: '系统监控', monitor: '系统监控',
monitor_logininfor: '登录日志',
monitor_operlog: '操作日志',
system_client: '客户端管理',
system_notice: '通知公告',
'social-callback': '单点登录回调', 'social-callback': '单点登录回调',
system_oss: '文件管理',
'system_oss-config': 'OSS 配置',
monitor_cache: '缓存监控', monitor_cache: '缓存监控',
monitor_online: '在线用户',
'user-center': '个人中心', 'user-center': '个人中心',
system_role: '角色管理',
demo: '测试', demo: '测试',
demo_demo: '测试单表', demo_demo: '测试单表',
demo_tree: '测试树表', demo_tree: '测试树表',
@ -328,6 +364,8 @@ const local: App.I18n.Schema = {
page: { page: {
login: { login: {
common: { common: {
title: '现代化的企业级多租户管理系统',
subTitle: '为开发者提供了完整的企业管理解决方案',
loginOrRegister: '登录 / 注册', loginOrRegister: '登录 / 注册',
register: '注册', register: '注册',
userNamePlaceholder: '请输入用户名', userNamePlaceholder: '请输入用户名',

View File

@ -26,21 +26,4 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
demo_tree: () => import("@/views/demo/tree/index.vue"), demo_tree: () => import("@/views/demo/tree/index.vue"),
home: () => import("@/views/home/index.vue"), home: () => import("@/views/home/index.vue"),
monitor_cache: () => import("@/views/monitor/cache/index.vue"), monitor_cache: () => import("@/views/monitor/cache/index.vue"),
monitor_logininfor: () => import("@/views/monitor/logininfor/index.vue"),
monitor_online: () => import("@/views/monitor/online/index.vue"),
monitor_operlog: () => import("@/views/monitor/operlog/index.vue"),
system_client: () => import("@/views/system/client/index.vue"),
system_config: () => import("@/views/system/config/index.vue"),
system_dept: () => import("@/views/system/dept/index.vue"),
system_dict: () => import("@/views/system/dict/index.vue"),
system_menu: () => import("@/views/system/menu/index.vue"),
system_notice: () => import("@/views/system/notice/index.vue"),
"system_oss-config": () => import("@/views/system/oss-config/index.vue"),
system_oss: () => import("@/views/system/oss/index.vue"),
system_post: () => import("@/views/system/post/index.vue"),
system_role: () => import("@/views/system/role/index.vue"),
"system_tenant-package": () => import("@/views/system/tenant-package/index.vue"),
system_tenant: () => import("@/views/system/tenant/index.vue"),
system_user: () => import("@/views/system/user/index.vue"),
tool_gen: () => import("@/views/tool/gen/index.vue"),
}; };

View File

@ -121,33 +121,6 @@ export const generatedRoutes: GeneratedRoute[] = [
title: 'monitor_cache', title: 'monitor_cache',
i18nKey: 'route.monitor_cache' i18nKey: 'route.monitor_cache'
} }
},
{
name: 'monitor_logininfor',
path: '/monitor/logininfor',
component: 'view.monitor_logininfor',
meta: {
title: 'monitor_logininfor',
i18nKey: 'route.monitor_logininfor'
}
},
{
name: 'monitor_online',
path: '/monitor/online',
component: 'view.monitor_online',
meta: {
title: 'monitor_online',
i18nKey: 'route.monitor_online'
}
},
{
name: 'monitor_operlog',
path: '/monitor/operlog',
component: 'view.monitor_operlog',
meta: {
title: 'monitor_operlog',
i18nKey: 'route.monitor_operlog'
}
} }
] ]
}, },
@ -162,165 +135,6 @@ export const generatedRoutes: GeneratedRoute[] = [
hideInMenu: true hideInMenu: true
} }
}, },
{
name: 'system',
path: '/system',
component: 'layout.base',
meta: {
title: 'system',
i18nKey: 'route.system',
localIcon: 'menu-system',
order: 1
},
children: [
{
name: 'system_client',
path: '/system/client',
component: 'view.system_client',
meta: {
title: 'system_client',
i18nKey: 'route.system_client'
}
},
{
name: 'system_config',
path: '/system/config',
component: 'view.system_config',
meta: {
title: 'system_config',
i18nKey: 'route.system_config'
}
},
{
name: 'system_dept',
path: '/system/dept',
component: 'view.system_dept',
meta: {
title: 'system_dept',
i18nKey: 'route.system_dept'
}
},
{
name: 'system_dict',
path: '/system/dict',
component: 'view.system_dict',
meta: {
title: 'system_dict',
i18nKey: 'route.system_dict'
}
},
{
name: 'system_menu',
path: '/system/menu',
component: 'view.system_menu',
meta: {
title: 'system_menu',
i18nKey: 'route.system_menu',
localIcon: 'menu-tree-table',
order: 3
}
},
{
name: 'system_notice',
path: '/system/notice',
component: 'view.system_notice',
meta: {
title: 'system_notice',
i18nKey: 'route.system_notice'
}
},
{
name: 'system_oss',
path: '/system/oss',
component: 'view.system_oss',
meta: {
title: 'system_oss',
i18nKey: 'route.system_oss'
}
},
{
name: 'system_oss-config',
path: '/system/oss-config',
component: 'view.system_oss-config',
meta: {
title: 'system_oss-config',
i18nKey: 'route.system_oss-config',
constant: true,
hideInMenu: true,
icon: 'hugeicons:configuration-01'
}
},
{
name: 'system_post',
path: '/system/post',
component: 'view.system_post',
meta: {
title: 'system_post',
i18nKey: 'route.system_post'
}
},
{
name: 'system_role',
path: '/system/role',
component: 'view.system_role',
meta: {
title: 'system_role',
i18nKey: 'route.system_role'
}
},
{
name: 'system_tenant',
path: '/system/tenant',
component: 'view.system_tenant',
meta: {
title: 'system_tenant',
i18nKey: 'route.system_tenant'
}
},
{
name: 'system_tenant-package',
path: '/system/tenant-package',
component: 'view.system_tenant-package',
meta: {
title: 'system_tenant-package',
i18nKey: 'route.system_tenant-package'
}
},
{
name: 'system_user',
path: '/system/user',
component: 'view.system_user',
meta: {
title: 'system_user',
i18nKey: 'route.system_user'
}
}
]
},
{
name: 'tool',
path: '/tool',
component: 'layout.base',
meta: {
title: 'tool',
i18nKey: 'route.tool',
localIcon: 'menu-tool',
order: 4
},
children: [
{
name: 'tool_gen',
path: '/tool/gen',
component: 'view.tool_gen',
meta: {
title: 'tool_gen',
i18nKey: 'route.tool_gen',
localIcon: 'menu-code',
order: 2
}
}
]
},
{ {
name: 'user-center', name: 'user-center',
path: '/user-center', path: '/user-center',

View File

@ -178,26 +178,7 @@ const routeMap: RouteMap = {
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?", "login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?",
"monitor": "/monitor", "monitor": "/monitor",
"monitor_cache": "/monitor/cache", "monitor_cache": "/monitor/cache",
"monitor_logininfor": "/monitor/logininfor",
"monitor_online": "/monitor/online",
"monitor_operlog": "/monitor/operlog",
"social-callback": "/social-callback", "social-callback": "/social-callback",
"system": "/system",
"system_client": "/system/client",
"system_config": "/system/config",
"system_dept": "/system/dept",
"system_dict": "/system/dict",
"system_menu": "/system/menu",
"system_notice": "/system/notice",
"system_oss": "/system/oss",
"system_oss-config": "/system/oss-config",
"system_post": "/system/post",
"system_role": "/system/role",
"system_tenant": "/system/tenant",
"system_tenant-package": "/system/tenant-package",
"system_user": "/system/user",
"tool": "/tool",
"tool_gen": "/tool/gen",
"user-center": "/user-center" "user-center": "/user-center"
}; };

View File

@ -12,7 +12,7 @@ const encryptHeader = 'encrypt-key';
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y'; const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
const { baseURL } = getServiceBaseURL(import.meta.env, isHttpProxy); const { baseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
export const request = createFlatRequest<App.Service.Response, RequestInstanceState>( export const request = createFlatRequest(
{ {
baseURL, baseURL,
'axios-retry': { 'axios-retry': {
@ -20,6 +20,39 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
} }
}, },
{ {
defaultState: {
errMsgStack: [],
refreshTokenPromise: null
} as RequestInstanceState,
transform(response: AxiosResponse<App.Service.Response<any>>) {
if (import.meta.env.VITE_APP_ENCRYPT === 'Y') {
// 加密后的 AES 秘钥
const keyStr = response.headers[encryptHeader];
// 加密
if (keyStr && keyStr !== '') {
const data = String(response.data);
// 请求体 AES 解密
const base64Str = decrypt(keyStr);
// base64 解码 得到请求头的 AES 秘钥
const aesKey = decryptBase64(base64Str.toString());
// aesKey 解码 data
const decryptData = decryptWithAes(data, aesKey);
// 将结果 (得到的是 JSON 字符串) 转为 JSON
response.data = JSON.parse(decryptData);
}
}
// 二进制数据则直接返回
if (response.request.responseType === 'blob' || response.request.responseType === 'arraybuffer') {
return response.data;
}
if (response.data.rows) {
return response.data;
}
return response.data.data;
},
async onRequest(config) { async onRequest(config) {
const isToken = config.headers?.isToken === false; const isToken = config.headers?.isToken === false;
// set token // set token
@ -124,35 +157,6 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
return null; return null;
}, },
transformBackendResponse(response) {
if (import.meta.env.VITE_APP_ENCRYPT === 'Y') {
// 加密后的 AES 秘钥
const keyStr = response.headers[encryptHeader];
// 加密
if (keyStr && keyStr !== '') {
const data = String(response.data);
// 请求体 AES 解密
const base64Str = decrypt(keyStr);
// base64 解码 得到请求头的 AES 秘钥
const aesKey = decryptBase64(base64Str.toString());
// aesKey 解码 data
const decryptData = decryptWithAes(data, aesKey);
// 将结果 (得到的是 JSON 字符串) 转为 JSON
response.data = JSON.parse(decryptData);
}
}
// 二进制数据则直接返回
if (response.request.responseType === 'blob' || response.request.responseType === 'arraybuffer') {
return response.data;
}
if (response.data.rows) {
return response.data;
}
return response.data.data;
},
onError(error) { onError(error) {
// when the request is fail, you can show error message // when the request is fail, you can show error message

View File

@ -27,14 +27,14 @@ async function handleRefreshToken() {
} }
export async function handleExpiredRequest(state: RequestInstanceState) { export async function handleExpiredRequest(state: RequestInstanceState) {
if (!state.refreshTokenFn) { if (!state.refreshTokenPromise) {
state.refreshTokenFn = handleRefreshToken(); state.refreshTokenPromise = handleRefreshToken();
} }
const success = await state.refreshTokenFn; const success = await state.refreshTokenPromise;
setTimeout(() => { setTimeout(() => {
state.refreshTokenFn = null; state.refreshTokenPromise = null;
}, 1000); }, 1000);
return success; return success;

View File

@ -1,6 +1,7 @@
export interface RequestInstanceState { export interface RequestInstanceState {
/** whether the request is refreshing token */ /** the promise of refreshing token */
refreshTokenFn: Promise<boolean> | null; refreshTokenPromise: Promise<boolean> | null;
/** the request error message stack */ /** the request error message stack */
errMsgStack: string[]; errMsgStack: string[];
[key: string]: unknown;
} }

View File

@ -389,7 +389,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
} }
async function onRouteSwitchWhenLoggedIn() { async function onRouteSwitchWhenLoggedIn() {
// await authStore.initUserInfo(); // some global init logic when logged in and switch route
} }
async function onRouteSwitchWhenNotLoggedIn() { async function onRouteSwitchWhenNotLoggedIn() {

View File

@ -1,10 +1,11 @@
import { computed, effectScope, onScopeDispose, ref, toRefs, watch } from 'vue'; import { computed, effectScope, onScopeDispose, ref, toRefs, watch } from 'vue';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import { useEventListener, usePreferredColorScheme } from '@vueuse/core'; import { useDateFormat, useEventListener, useNow, usePreferredColorScheme } from '@vueuse/core';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { getPaletteColorByNumber } from '@sa/color'; import { getPaletteColorByNumber } from '@sa/color';
import { localStg } from '@/utils/storage'; import { localStg } from '@/utils/storage';
import { SetupStoreId } from '@/enum'; import { SetupStoreId } from '@/enum';
import { useAuthStore } from '../auth';
import { import {
addThemeVarsToGlobal, addThemeVarsToGlobal,
createThemeToken, createThemeToken,
@ -18,10 +19,14 @@ import {
export const useThemeStore = defineStore(SetupStoreId.Theme, () => { export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
const scope = effectScope(); const scope = effectScope();
const osTheme = usePreferredColorScheme(); const osTheme = usePreferredColorScheme();
const authStore = useAuthStore();
/** Theme settings */ /** Theme settings */
const settings: Ref<App.Theme.ThemeSetting> = ref(initThemeSettings()); const settings: Ref<App.Theme.ThemeSetting> = ref(initThemeSettings());
/** Watermark time instance with controls */
const { now: watermarkTime, pause: pauseWatermarkTime, resume: resumeWatermarkTime } = useNow({ controls: true });
/** Dark mode */ /** Dark mode */
const darkMode = computed(() => { const darkMode = computed(() => {
if (settings.value.themeScheme === 'auto') { if (settings.value.themeScheme === 'auto') {
@ -57,6 +62,29 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
*/ */
const settingsJson = computed(() => JSON.stringify(settings.value)); const settingsJson = computed(() => JSON.stringify(settings.value));
/** Watermark time date formatter */
const formattedWatermarkTime = computed(() => {
const { watermark } = settings.value;
const date = useDateFormat(watermarkTime, watermark.timeFormat);
return date.value;
});
/** Watermark content */
const watermarkContent = computed(() => {
const { watermark } = settings.value;
let content = watermark.text;
if (watermark.enableUserName && authStore.userInfo.user?.userName) {
content = `${authStore.userInfo.user.userName}@${content}`;
}
if (watermark.enableTime) {
content = `${content} ${formattedWatermarkTime.value}`;
}
return content;
});
/** Reset store */ /** Reset store */
function resetStore() { function resetStore() {
const themeStore = useThemeStore(); const themeStore = useThemeStore();
@ -144,13 +172,43 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
); );
addThemeVarsToGlobal(themeTokens, darkThemeTokens); addThemeVarsToGlobal(themeTokens, darkThemeTokens);
} }
/** /**
* Set layout reverse horizontal mix * Set watermark enable user name
* *
* @param reverse Reverse horizontal mix * @param enable Whether to enable user name watermark
*/ */
function setLayoutReverseHorizontalMix(reverse: boolean) { function setWatermarkEnableUserName(enable: boolean) {
settings.value.layout.reverseHorizontalMix = reverse; settings.value.watermark.enableUserName = enable;
if (enable) {
// settings.value.watermark.enableTime = false;
}
}
/**
* Set watermark enable time
*
* @param enable Whether to enable time watermark
*/
function setWatermarkEnableTime(enable: boolean) {
settings.value.watermark.enableTime = enable;
if (enable) {
// settings.value.watermark.enableUserName = false;
}
}
/** Only run timer when watermark is visible and time display is enabled */
function updateWatermarkTimer() {
const { watermark } = settings.value;
const shouldRunTimer = watermark.visible && watermark.enableTime;
if (shouldRunTimer) {
resumeWatermarkTime();
} else {
pauseWatermarkTime();
}
} }
/** Cache theme settings */ /** Cache theme settings */
@ -196,6 +254,15 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
}, },
{ immediate: true } { immediate: true }
); );
// watch watermark settings to control timer
watch(
() => [settings.value.watermark.visible, settings.value.watermark.enableTime],
() => {
updateWatermarkTimer();
},
{ immediate: true }
);
}); });
/** On scope dispose */ /** On scope dispose */
@ -209,6 +276,7 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
themeColors, themeColors,
naiveTheme, naiveTheme,
settingsJson, settingsJson,
watermarkContent,
setGrayscale, setGrayscale,
setColourWeakness, setColourWeakness,
resetStore, resetStore,
@ -216,6 +284,7 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
toggleThemeScheme, toggleThemeScheme,
updateThemeColors, updateThemeColors,
setThemeLayout, setThemeLayout,
setLayoutReverseHorizontalMix setWatermarkEnableUserName,
setWatermarkEnableTime
}; };
}); });

View File

@ -0,0 +1,90 @@
{
"name": "Azir's Preset",
"desc": "It is a cold and elegant preset that Azir likes",
"i18nkey": "theme.appearance.preset.azir",
"version": "1.0.0",
"themeScheme": "light",
"grayscale": false,
"colourWeakness": false,
"recommendColor": true,
"themeColor": "#78a878",
"otherColor": {
"info": "#89b989",
"success": "#99c299",
"warning": "#d4bb9d",
"error": "#c49a9a"
},
"isInfoFollowPrimary": true,
"resetCacheStrategy": "refresh",
"layout": {
"mode": "vertical-mix",
"scrollMode": "wrapper"
},
"page": {
"animate": true,
"animateMode": "zoom-fade"
},
"header": {
"height": 64,
"breadcrumb": {
"visible": true,
"showIcon": true
},
"multilingual": {
"visible": true
},
"globalSearch": {
"visible": true
}
},
"tab": {
"visible": true,
"cache": true,
"height": 48,
"mode": "chrome"
},
"fixedHeaderAndTab": true,
"sider": {
"inverted": false,
"width": 220,
"collapsedWidth": 64,
"mixWidth": 90,
"mixCollapsedWidth": 64,
"mixChildMenuWidth": 200
},
"footer": {
"visible": true,
"fixed": true,
"height": 56,
"right": true
},
"watermark": {
"visible": false,
"text": "SoybeanAdmin",
"enableUserName": false,
"enableTime": true,
"timeFormat": "YYYY-MM-DD HH:mm:ss"
},
"tokens": {
"light": {
"colors": {
"container": "rgb(255, 255, 255)",
"layout": "rgb(247, 250, 252)",
"inverted": "rgb(0, 20, 40)",
"base-text": "rgb(31, 31, 31)"
},
"boxShadow": {
"header": "0 1px 2px rgb(0, 21, 41, 0.08)",
"sider": "2px 0 8px 0 rgb(29, 35, 41, 0.05)",
"tab": "0 1px 2px rgb(0, 21, 41, 0.08)"
}
},
"dark": {
"colors": {
"container": "rgb(28, 28, 28)",
"layout": "rgb(18, 18, 18)",
"base-text": "rgb(224, 224, 224)"
}
}
}
}

View File

@ -0,0 +1,90 @@
{
"name": "Compact Preset",
"desc": "Compact layout preset for small screens",
"i18nkey": "theme.appearance.preset.compact",
"version": "1.0.0",
"themeScheme": "light",
"grayscale": false,
"colourWeakness": false,
"recommendColor": false,
"themeColor": "#646cff",
"otherColor": {
"info": "#2080f0",
"success": "#52c41a",
"warning": "#faad14",
"error": "#f5222d"
},
"isInfoFollowPrimary": true,
"resetCacheStrategy": "close",
"layout": {
"mode": "vertical",
"scrollMode": "content"
},
"page": {
"animate": true,
"animateMode": "fade-slide"
},
"header": {
"height": 48,
"breadcrumb": {
"visible": true,
"showIcon": true
},
"multilingual": {
"visible": false
},
"globalSearch": {
"visible": false
}
},
"tab": {
"visible": true,
"cache": true,
"height": 36,
"mode": "button"
},
"fixedHeaderAndTab": true,
"sider": {
"inverted": false,
"width": 180,
"collapsedWidth": 48,
"mixWidth": 80,
"mixCollapsedWidth": 48,
"mixChildMenuWidth": 180
},
"footer": {
"visible": false,
"fixed": false,
"height": 40,
"right": true
},
"watermark": {
"visible": false,
"text": "SoybeanAdmin",
"enableUserName": false,
"enableTime": false,
"timeFormat": "YYYY-MM-DD HH:mm"
},
"tokens": {
"light": {
"colors": {
"container": "rgb(255, 255, 255)",
"layout": "rgb(247, 250, 252)",
"inverted": "rgb(0, 20, 40)",
"base-text": "rgb(31, 31, 31)"
},
"boxShadow": {
"header": "0 1px 2px rgb(0, 21, 41, 0.08)",
"sider": "2px 0 8px 0 rgb(29, 35, 41, 0.05)",
"tab": "0 1px 2px rgb(0, 21, 41, 0.08)"
}
},
"dark": {
"colors": {
"container": "rgb(28, 28, 28)",
"layout": "rgb(18, 18, 18)",
"base-text": "rgb(224, 224, 224)"
}
}
}
}

View File

@ -0,0 +1,90 @@
{
"name": "Dark Preset",
"desc": "Dark theme preset for night time usage",
"i18nkey": "theme.appearance.preset.dark",
"version": "1.0.0",
"themeScheme": "dark",
"grayscale": false,
"colourWeakness": false,
"recommendColor": false,
"themeColor": "#409eff",
"otherColor": {
"info": "#2080f0",
"success": "#52c41a",
"warning": "#faad14",
"error": "#f5222d"
},
"isInfoFollowPrimary": true,
"resetCacheStrategy": "close",
"layout": {
"mode": "vertical",
"scrollMode": "content"
},
"page": {
"animate": true,
"animateMode": "fade-slide"
},
"header": {
"height": 56,
"breadcrumb": {
"visible": true,
"showIcon": true
},
"multilingual": {
"visible": true
},
"globalSearch": {
"visible": true
}
},
"tab": {
"visible": true,
"cache": true,
"height": 44,
"mode": "chrome"
},
"fixedHeaderAndTab": true,
"sider": {
"inverted": true,
"width": 220,
"collapsedWidth": 64,
"mixWidth": 90,
"mixCollapsedWidth": 64,
"mixChildMenuWidth": 200
},
"footer": {
"visible": true,
"fixed": false,
"height": 48,
"right": true
},
"watermark": {
"visible": false,
"text": "SoybeanAdmin",
"enableUserName": false,
"enableTime": false,
"timeFormat": "YYYY-MM-DD HH:mm"
},
"tokens": {
"light": {
"colors": {
"container": "rgb(255, 255, 255)",
"layout": "rgb(247, 250, 252)",
"inverted": "rgb(0, 20, 40)",
"base-text": "rgb(31, 31, 31)"
},
"boxShadow": {
"header": "0 1px 2px rgb(0, 21, 41, 0.08)",
"sider": "2px 0 8px 0 rgb(29, 35, 41, 0.05)",
"tab": "0 1px 2px rgb(0, 21, 41, 0.08)"
}
},
"dark": {
"colors": {
"container": "rgb(28, 28, 28)",
"layout": "rgb(18, 18, 18)",
"base-text": "rgb(224, 224, 224)"
}
}
}
}

View File

@ -0,0 +1,90 @@
{
"name": "default",
"desc": "Default theme preset with balanced settings",
"i18nkey": "theme.appearance.preset.default",
"version": "1.0.0",
"themeScheme": "light",
"grayscale": false,
"colourWeakness": false,
"recommendColor": false,
"themeColor": "#646cff",
"otherColor": {
"info": "#2080f0",
"success": "#52c41a",
"warning": "#faad14",
"error": "#f5222d"
},
"isInfoFollowPrimary": true,
"resetCacheStrategy": "close",
"layout": {
"mode": "vertical",
"scrollMode": "content"
},
"page": {
"animate": true,
"animateMode": "fade-slide"
},
"header": {
"height": 56,
"breadcrumb": {
"visible": true,
"showIcon": true
},
"multilingual": {
"visible": true
},
"globalSearch": {
"visible": true
}
},
"tab": {
"visible": true,
"cache": true,
"height": 44,
"mode": "chrome"
},
"fixedHeaderAndTab": true,
"sider": {
"inverted": false,
"width": 220,
"collapsedWidth": 64,
"mixWidth": 90,
"mixCollapsedWidth": 64,
"mixChildMenuWidth": 200
},
"footer": {
"visible": true,
"fixed": false,
"height": 48,
"right": true
},
"watermark": {
"visible": false,
"text": "SoybeanAdmin",
"enableUserName": false,
"enableTime": false,
"timeFormat": "YYYY-MM-DD HH:mm"
},
"tokens": {
"light": {
"colors": {
"container": "rgb(255, 255, 255)",
"layout": "rgb(247, 250, 252)",
"inverted": "rgb(0, 20, 40)",
"base-text": "rgb(31, 31, 31)"
},
"boxShadow": {
"header": "0 1px 2px rgb(0, 21, 41, 0.08)",
"sider": "2px 0 8px 0 rgb(29, 35, 41, 0.05)",
"tab": "0 1px 2px rgb(0, 21, 41, 0.08)"
}
},
"dark": {
"colors": {
"container": "rgb(28, 28, 28)",
"layout": "rgb(18, 18, 18)",
"base-text": "rgb(224, 224, 224)"
}
}
}
}

View File

@ -12,11 +12,10 @@ export const themeSettings: App.Theme.ThemeSetting = {
error: '#CB2634' error: '#CB2634'
}, },
isInfoFollowPrimary: true, isInfoFollowPrimary: true,
resetCacheStrategy: 'close', resetCacheStrategy: 'refresh',
layout: { layout: {
mode: 'vertical', mode: 'vertical',
scrollMode: 'content', scrollMode: 'content'
reverseHorizontalMix: false
}, },
page: { page: {
animate: true, animate: true,
@ -58,8 +57,10 @@ export const themeSettings: App.Theme.ThemeSetting = {
}, },
watermark: { watermark: {
visible: import.meta.env.VITE_WATERMARK === 'Y', visible: import.meta.env.VITE_WATERMARK === 'Y',
text: 'RuoYi-Vue-Plus', text: 'RuoYi-Plus-Soybean',
enableUserName: false enableUserName: true,
enableTime: false,
timeFormat: 'YYYY-MM-DD HH:mm'
}, },
table: { table: {
bordered: true, bordered: true,

View File

@ -85,131 +85,4 @@ declare namespace Api {
children: CommonTreeRecord[]; children: CommonTreeRecord[];
}[]; }[];
} }
/**
* namespace Auth
*
* backend api module: "auth"
*/
namespace Auth {
/** base login form */
interface LoginForm {
/** 客户端 ID */
clientId?: string;
/** 授权类型 */
grantType?: string;
/** 租户ID */
tenantId?: string;
/** 验证码 */
code?: string;
/** 唯一标识 */
uuid?: string;
}
/** password login form */
interface PwdLoginForm extends LoginForm {
/** 用户名 */
username?: string;
/** 密码 */
password?: string;
}
/** social login form */
interface SocialLoginForm extends LoginForm {
/** 授权码 */
socialCode?: string;
/** 授权状态 */
socialState?: string;
/** 来源 */
source?: string;
}
/** register form */
interface RegisterForm extends LoginForm {
/** 用户名 */
username?: string;
/** 密码 */
password?: string;
/** 确认密码 */
confirmPassword?: string;
/** 用户类型 */
userType?: string;
}
/** login token data */
interface LoginToken {
/** 授权令牌 */
access_token?: string;
/** 应用id */
client_id?: string;
/** 授权令牌 access_token 的有效期 */
expire_in?: number;
/** 用户 openid */
openid?: string;
/** 刷新令牌 refresh_token 的有效期 */
refresh_expire_in?: number;
/** 刷新令牌 */
refresh_token?: string;
/** 令牌权限 */
scope?: string;
}
/** userinfo */
interface UserInfo {
/** 用户信息 */
user?: Api.System.User & {
/** 所属角色 */
roles: Api.System.Role[];
};
/** 角色列表 */
roles: string[];
/** 菜单权限 */
permissions: string[];
}
/** tenant */
interface Tenant {
/** 企业名称 */
companyName: string;
/** 域名 */
domain: string;
/** 租户编号 */
tenantId: string;
}
/** login tenant */
interface LoginTenant {
/** 租户开关 */
tenantEnabled: boolean;
/** 租户列表 */
voList: Tenant[];
}
interface CaptchaCode {
/** 是否开启验证码 */
captchaEnabled: boolean;
/** 唯一标识 */
uuid?: string;
/** 验证码图片 */
img?: string;
}
}
/**
* namespace Route
*
* backend api module: "route"
*/
namespace Route {
type ElegantConstRoute = import('@elegant-router/types').ElegantConstRoute;
interface MenuRoute extends ElegantConstRoute {
id: string;
}
interface UserRoute {
routes: MenuRoute[];
home: import('@elegant-router/types').LastLevelRouteKey;
}
}
} }

110
src/typings/api/auth.d.ts vendored Normal file
View File

@ -0,0 +1,110 @@
declare namespace Api {
/**
* namespace Auth
*
* backend api module: "auth"
*/
namespace Auth {
/** base login form */
interface LoginForm {
/** 客户端 ID */
clientId?: string;
/** 授权类型 */
grantType?: string;
/** 租户ID */
tenantId?: string;
/** 验证码 */
code?: string;
/** 唯一标识 */
uuid?: string;
}
/** password login form */
interface PwdLoginForm extends LoginForm {
/** 用户名 */
username?: string;
/** 密码 */
password?: string;
}
/** social login form */
interface SocialLoginForm extends LoginForm {
/** 授权码 */
socialCode?: string;
/** 授权状态 */
socialState?: string;
/** 来源 */
source?: string;
}
/** register form */
interface RegisterForm extends LoginForm {
/** 用户名 */
username?: string;
/** 密码 */
password?: string;
/** 确认密码 */
confirmPassword?: string;
/** 用户类型 */
userType?: string;
}
/** login token data */
interface LoginToken {
/** 授权令牌 */
access_token?: string;
/** 应用id */
client_id?: string;
/** 授权令牌 access_token 的有效期 */
expire_in?: number;
/** 用户 openid */
openid?: string;
/** 刷新令牌 refresh_token 的有效期 */
refresh_expire_in?: number;
/** 刷新令牌 */
refresh_token?: string;
/** 令牌权限 */
scope?: string;
}
/** userinfo */
interface UserInfo {
/** 用户信息 */
user?: Api.System.User & {
/** 所属角色 */
roles: Api.System.Role[];
};
/** 角色列表 */
roles: string[];
/** 菜单权限 */
permissions: string[];
}
/** tenant */
interface Tenant {
/** 企业名称 */
companyName: string;
/** 域名 */
domain: string;
/** 租户编号 */
tenantId: string;
}
/** login tenant */
interface LoginTenant {
/** 租户开关 */
tenantEnabled: boolean;
/** 租户列表 */
voList: Tenant[];
}
interface CaptchaCode {
/** 是否开启验证码 */
captchaEnabled: boolean;
/** 唯一标识 */
uuid?: string;
/** 验证码图片 */
img?: string;
}
}
}

19
src/typings/api/route.d.ts vendored Normal file
View File

@ -0,0 +1,19 @@
declare namespace Api {
/**
* namespace Route
*
* backend api module: "route"
*/
namespace Route {
type ElegantConstRoute = import('@elegant-router/types').ElegantConstRoute;
interface MenuRoute extends ElegantConstRoute {
id: string;
}
interface UserRoute {
routes: MenuRoute[];
home: import('@elegant-router/types').LastLevelRouteKey;
}
}
}

168
src/typings/app.d.ts vendored
View File

@ -28,12 +28,6 @@ declare namespace App {
mode: UnionKey.ThemeLayoutMode; mode: UnionKey.ThemeLayoutMode;
/** Scroll mode */ /** Scroll mode */
scrollMode: UnionKey.ThemeScrollMode; scrollMode: UnionKey.ThemeScrollMode;
/**
* Whether to reverse the horizontal mix
*
* if true, the vertical child level menus in left and horizontal first level menus in top
*/
reverseHorizontalMix: boolean;
}; };
/** Page */ /** Page */
page: { page: {
@ -88,11 +82,14 @@ declare namespace App {
width: number; width: number;
/** Collapsed sider width */ /** Collapsed sider width */
collapsedWidth: number; collapsedWidth: number;
/** Sider width when the layout is 'vertical-mix' or 'horizontal-mix' */ /** Sider width when the layout is 'vertical-mix', 'top-hybrid-sidebar-first', or 'top-hybrid-header-first' */
mixWidth: number; mixWidth: number;
/** Collapsed sider width when the layout is 'vertical-mix' or 'horizontal-mix' */ /**
* Collapsed sider width when the layout is 'vertical-mix', 'top-hybrid-sidebar-first', or
* 'top-hybrid-header-first'
*/
mixCollapsedWidth: number; mixCollapsedWidth: number;
/** Child menu width when the layout is 'vertical-mix' or 'horizontal-mix' */ /** Child menu width when the layout is 'vertical-mix', 'top-hybrid-sidebar-first', or 'top-hybrid-header-first' */
mixChildMenuWidth: number; mixChildMenuWidth: number;
}; };
/** Footer */ /** Footer */
@ -103,7 +100,10 @@ declare namespace App {
fixed: boolean; fixed: boolean;
/** Footer height */ /** Footer height */
height: number; height: number;
/** Whether float the footer to the right when the layout is 'horizontal-mix' */ /**
* Whether float the footer to the right when the layout is 'top-hybrid-sidebar-first' or
* 'top-hybrid-header-first'
*/
right: boolean; right: boolean;
}; };
/** Watermark */ /** Watermark */
@ -114,6 +114,10 @@ declare namespace App {
text: string; text: string;
/** Whether to use user name as watermark text */ /** Whether to use user name as watermark text */
enableUserName: boolean; enableUserName: boolean;
/** Whether to use current time as watermark text */
enableTime: boolean;
/** Time format for watermark text */
timeFormat: string;
}; };
table: { table: {
/** Whether to show the table border */ /** Whether to show the table border */
@ -397,59 +401,106 @@ declare namespace App {
tokenExpired: string; tokenExpired: string;
}; };
theme: { theme: {
themeSchema: { title: string } & Record<UnionKey.ThemeScheme, string>; themeDrawerTitle: string;
grayscale: string; tabs: {
colourWeakness: string; appearance: string;
layoutMode: { title: string; reverseHorizontalMix: string } & Record<UnionKey.ThemeLayoutMode, string>; layout: string;
recommendColor: string; general: string;
recommendColorDesc: string; preset: string;
themeColor: {
title: string;
followPrimary: string;
} & Theme.ThemeColor;
scrollMode: { title: string } & Record<UnionKey.ThemeScrollMode, string>;
page: {
animate: string;
mode: { title: string } & Record<UnionKey.ThemePageAnimateMode, string>;
}; };
fixedHeaderAndTab: string; appearance: {
header: { themeSchema: { title: string } & Record<UnionKey.ThemeScheme, string>;
height: string; grayscale: string;
breadcrumb: { colourWeakness: string;
themeColor: {
title: string;
followPrimary: string;
} & Theme.ThemeColor;
recommendColor: string;
recommendColorDesc: string;
preset: {
title: string;
apply: string;
applySuccess: string;
[key: string]:
| {
name: string;
desc: string;
}
| string;
};
};
layout: {
layoutMode: { title: string } & Record<UnionKey.ThemeLayoutMode, string> & {
[K in `${UnionKey.ThemeLayoutMode}_detail`]: string;
};
tab: {
title: string;
visible: string; visible: string;
showIcon: string; cache: string;
cacheTip: string;
height: string;
mode: { title: string } & Record<UnionKey.ThemeTabMode, string>;
};
header: {
title: string;
height: string;
breadcrumb: {
visible: string;
showIcon: string;
};
};
sider: {
title: string;
inverted: string;
width: string;
collapsedWidth: string;
mixWidth: string;
mixCollapsedWidth: string;
mixChildMenuWidth: string;
};
footer: {
title: string;
visible: string;
fixed: string;
height: string;
right: string;
};
content: {
title: string;
scrollMode: { title: string; tip: string } & Record<UnionKey.ThemeScrollMode, string>;
page: {
animate: string;
mode: { title: string } & Record<UnionKey.ThemePageAnimateMode, string>;
};
fixedHeaderAndTab: string;
};
resetCacheStrategy: { title: string } & Record<UnionKey.ResetCacheStrategy, string>;
};
general: {
title: string;
watermark: {
title: string;
visible: string;
text: string;
enableUserName: string;
enableTime: string;
timeFormat: string;
}; };
multilingual: { multilingual: {
title: string;
visible: string; visible: string;
}; };
globalSearch: { globalSearch: {
title: string;
visible: string; visible: string;
}; };
}; };
tab: { configOperation: {
visible: string; copyConfig: string;
cache: string; copySuccessMsg: string;
height: string; resetConfig: string;
mode: { title: string } & Record<UnionKey.ThemeTabMode, string>; resetSuccessMsg: string;
};
sider: {
inverted: string;
width: string;
collapsedWidth: string;
mixWidth: string;
mixCollapsedWidth: string;
mixChildMenuWidth: string;
};
footer: {
visible: string;
fixed: string;
height: string;
right: string;
};
watermark: {
visible: string;
text: string;
enableUserName: string;
}; };
tablePropsTitle: string; tablePropsTitle: string;
table: { table: {
@ -460,15 +511,6 @@ declare namespace App {
singleLine: string; singleLine: string;
striped: string; striped: string;
}; };
themeDrawerTitle: string;
pageFunTitle: string;
resetCacheStrategy: { title: string } & Record<UnionKey.ResetCacheStrategy, string>;
configOperation: {
copyConfig: string;
copySuccessMsg: string;
resetConfig: string;
resetSuccessMsg: string;
};
}; };
route: Record<I18nRouteKey, string>; route: Record<I18nRouteKey, string>;
menu: Record<string, string>; menu: Record<string, string>;
@ -487,6 +529,8 @@ declare namespace App {
}; };
login: { login: {
common: { common: {
title: string;
subTitle: string;
loginOrRegister: string; loginOrRegister: string;
register: string; register: string;
userNamePlaceholder: string; userNamePlaceholder: string;

View File

@ -27,10 +27,8 @@ declare module 'vue' {
IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default'] IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default'] IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default'] IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
IconEpCopyDocument: typeof import('~icons/ep/copy-document')['default']
IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default'] IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default'] IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
'IconHugeicons:configuration01': typeof import('~icons/hugeicons/configuration01')['default']
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default'] IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default'] IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
IconLocalBanner: typeof import('~icons/local/banner')['default'] IconLocalBanner: typeof import('~icons/local/banner')['default']
@ -38,14 +36,7 @@ declare module 'vue' {
'IconMaterialSymbols:add': typeof import('~icons/material-symbols/add')['default'] 'IconMaterialSymbols:add': typeof import('~icons/material-symbols/add')['default']
'IconMaterialSymbols:deleteOutline': typeof import('~icons/material-symbols/delete-outline')['default'] 'IconMaterialSymbols:deleteOutline': typeof import('~icons/material-symbols/delete-outline')['default']
'IconMaterialSymbols:downloadRounded': typeof import('~icons/material-symbols/download-rounded')['default'] 'IconMaterialSymbols:downloadRounded': typeof import('~icons/material-symbols/download-rounded')['default']
'IconMaterialSymbols:imageOutline': typeof import('~icons/material-symbols/image-outline')['default']
'IconMaterialSymbols:refreshRounded': typeof import('~icons/material-symbols/refresh-rounded')['default'] 'IconMaterialSymbols:refreshRounded': typeof import('~icons/material-symbols/refresh-rounded')['default']
'IconMaterialSymbols:syncOutline': typeof import('~icons/material-symbols/sync-outline')['default']
'IconMaterialSymbols:uploadRounded': typeof import('~icons/material-symbols/upload-rounded')['default']
'IconMaterialSymbols:warningOutlineRounded': typeof import('~icons/material-symbols/warning-outline-rounded')['default']
IconMaterialSymbolsAddRounded: typeof import('~icons/material-symbols/add-rounded')['default']
IconMaterialSymbolsDeleteOutline: typeof import('~icons/material-symbols/delete-outline')['default']
IconMaterialSymbolsDriveFileRenameOutlineOutline: typeof import('~icons/material-symbols/drive-file-rename-outline-outline')['default']
'IconMdi:github': typeof import('~icons/mdi/github')['default'] 'IconMdi:github': typeof import('~icons/mdi/github')['default']
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default'] IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default'] IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
@ -55,6 +46,7 @@ declare module 'vue' {
'IconQuill:collapse': typeof import('~icons/quill/collapse')['default'] 'IconQuill:collapse': typeof import('~icons/quill/collapse')['default']
'IconQuill:expand': typeof import('~icons/quill/expand')['default'] 'IconQuill:expand': typeof import('~icons/quill/expand')['default']
'IconSimpleIcons:gitee': typeof import('~icons/simple-icons/gitee')['default'] 'IconSimpleIcons:gitee': typeof import('~icons/simple-icons/gitee')['default']
IconTooltip: typeof import('./../components/common/icon-tooltip.vue')['default']
IconUilSearch: typeof import('~icons/uil/search')['default'] IconUilSearch: typeof import('~icons/uil/search')['default']
JsonPreview: typeof import('./../components/custom/json-preview.vue')['default'] JsonPreview: typeof import('./../components/custom/json-preview.vue')['default']
LangSwitch: typeof import('./../components/common/lang-switch.vue')['default'] LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
@ -72,12 +64,10 @@ declare module 'vue' {
NButton: typeof import('naive-ui')['NButton'] NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard'] NCard: typeof import('naive-ui')['NCard']
NCheckbox: typeof import('naive-ui')['NCheckbox'] NCheckbox: typeof import('naive-ui')['NCheckbox']
NCode: typeof import('naive-ui')['NCode']
NCollapse: typeof import('naive-ui')['NCollapse'] NCollapse: typeof import('naive-ui')['NCollapse']
NCollapseItem: typeof import('naive-ui')['NCollapseItem'] NCollapseItem: typeof import('naive-ui')['NCollapseItem']
NColorPicker: typeof import('naive-ui')['NColorPicker'] NColorPicker: typeof import('naive-ui')['NColorPicker']
NDataTable: typeof import('naive-ui')['NDataTable'] NDataTable: typeof import('naive-ui')['NDataTable']
NDatePicker: typeof import('naive-ui')['NDatePicker']
NDescriptions: typeof import('naive-ui')['NDescriptions'] NDescriptions: typeof import('naive-ui')['NDescriptions']
NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem'] NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem']
NDialogProvider: typeof import('naive-ui')['NDialogProvider'] NDialogProvider: typeof import('naive-ui')['NDialogProvider']
@ -85,7 +75,6 @@ declare module 'vue' {
NDrawer: typeof import('naive-ui')['NDrawer'] NDrawer: typeof import('naive-ui')['NDrawer']
NDrawerContent: typeof import('naive-ui')['NDrawerContent'] NDrawerContent: typeof import('naive-ui')['NDrawerContent']
NDropdown: typeof import('naive-ui')['NDropdown'] NDropdown: typeof import('naive-ui')['NDropdown']
NDynamicInput: typeof import('naive-ui')['NDynamicInput']
NEllipsis: typeof import('naive-ui')['NEllipsis'] NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty'] NEmpty: typeof import('naive-ui')['NEmpty']
NForm: typeof import('naive-ui')['NForm'] NForm: typeof import('naive-ui')['NForm']
@ -93,14 +82,9 @@ declare module 'vue' {
NFormItemGi: typeof import('naive-ui')['NFormItemGi'] NFormItemGi: typeof import('naive-ui')['NFormItemGi']
NGi: typeof import('naive-ui')['NGi'] NGi: typeof import('naive-ui')['NGi']
NGrid: typeof import('naive-ui')['NGrid'] NGrid: typeof import('naive-ui')['NGrid']
NGridItem: typeof import('naive-ui')['NGridItem']
NInput: typeof import('naive-ui')['NInput'] NInput: typeof import('naive-ui')['NInput']
NInputGroup: typeof import('naive-ui')['NInputGroup'] NInputGroup: typeof import('naive-ui')['NInputGroup']
NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
NInputNumber: typeof import('naive-ui')['NInputNumber'] NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout']
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NList: typeof import('naive-ui')['NList'] NList: typeof import('naive-ui')['NList']
NListItem: typeof import('naive-ui')['NListItem'] NListItem: typeof import('naive-ui')['NListItem']
NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider'] NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
@ -111,9 +95,6 @@ declare module 'vue' {
NP: typeof import('naive-ui')['NP'] NP: typeof import('naive-ui')['NP']
NPopconfirm: typeof import('naive-ui')['NPopconfirm'] NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NPopover: typeof import('naive-ui')['NPopover'] NPopover: typeof import('naive-ui')['NPopover']
NRadio: typeof import('naive-ui')['NRadio']
NRadioButton: typeof import('naive-ui')['NRadioButton']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NScrollbar: typeof import('naive-ui')['NScrollbar'] NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect'] NSelect: typeof import('naive-ui')['NSelect']
NSpace: typeof import('naive-ui')['NSpace'] NSpace: typeof import('naive-ui')['NSpace']
@ -121,13 +102,11 @@ declare module 'vue' {
NStatistic: typeof import('naive-ui')['NStatistic'] NStatistic: typeof import('naive-ui')['NStatistic']
NSwitch: typeof import('naive-ui')['NSwitch'] NSwitch: typeof import('naive-ui')['NSwitch']
NTab: typeof import('naive-ui')['NTab'] NTab: typeof import('naive-ui')['NTab']
NTabPane: typeof import('naive-ui')['NTabPane']
NTabs: typeof import('naive-ui')['NTabs'] NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag'] NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText'] NText: typeof import('naive-ui')['NText']
NThing: typeof import('naive-ui')['NThing'] NThing: typeof import('naive-ui')['NThing']
NTooltip: typeof import('naive-ui')['NTooltip'] NTooltip: typeof import('naive-ui')['NTooltip']
NTree: typeof import('naive-ui')['NTree']
NTreeSelect: typeof import('naive-ui')['NTreeSelect'] NTreeSelect: typeof import('naive-ui')['NTreeSelect']
NUpload: typeof import('naive-ui')['NUpload'] NUpload: typeof import('naive-ui')['NUpload']
NUploadDragger: typeof import('naive-ui')['NUploadDragger'] NUploadDragger: typeof import('naive-ui')['NUploadDragger']

View File

@ -32,26 +32,7 @@ declare module "@elegant-router/types" {
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?"; "login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?";
"monitor": "/monitor"; "monitor": "/monitor";
"monitor_cache": "/monitor/cache"; "monitor_cache": "/monitor/cache";
"monitor_logininfor": "/monitor/logininfor";
"monitor_online": "/monitor/online";
"monitor_operlog": "/monitor/operlog";
"social-callback": "/social-callback"; "social-callback": "/social-callback";
"system": "/system";
"system_client": "/system/client";
"system_config": "/system/config";
"system_dept": "/system/dept";
"system_dict": "/system/dict";
"system_menu": "/system/menu";
"system_notice": "/system/notice";
"system_oss": "/system/oss";
"system_oss-config": "/system/oss-config";
"system_post": "/system/post";
"system_role": "/system/role";
"system_tenant": "/system/tenant";
"system_tenant-package": "/system/tenant-package";
"system_user": "/system/user";
"tool": "/tool";
"tool_gen": "/tool/gen";
"user-center": "/user-center"; "user-center": "/user-center";
}; };
@ -97,8 +78,6 @@ declare module "@elegant-router/types" {
| "login" | "login"
| "monitor" | "monitor"
| "social-callback" | "social-callback"
| "system"
| "tool"
| "user-center" | "user-center"
>; >;
@ -128,23 +107,6 @@ declare module "@elegant-router/types" {
| "demo_tree" | "demo_tree"
| "home" | "home"
| "monitor_cache" | "monitor_cache"
| "monitor_logininfor"
| "monitor_online"
| "monitor_operlog"
| "system_client"
| "system_config"
| "system_dept"
| "system_dict"
| "system_menu"
| "system_notice"
| "system_oss-config"
| "system_oss"
| "system_post"
| "system_role"
| "system_tenant-package"
| "system_tenant"
| "system_user"
| "tool_gen"
>; >;
/** /**

View File

@ -6,32 +6,14 @@ declare namespace NaiveUI {
type DataTableExpandColumn<T> = import('naive-ui').DataTableExpandColumn<T>; type DataTableExpandColumn<T> = import('naive-ui').DataTableExpandColumn<T>;
type DataTableSelectionColumn<T> = import('naive-ui').DataTableSelectionColumn<T>; type DataTableSelectionColumn<T> = import('naive-ui').DataTableSelectionColumn<T>;
type TableColumnGroup<T> = import('naive-ui/es/data-table/src/interface').TableColumnGroup<T>; type TableColumnGroup<T> = import('naive-ui/es/data-table/src/interface').TableColumnGroup<T>;
type PaginationProps = import('naive-ui').PaginationProps;
type TableColumnCheck = import('@sa/hooks').TableColumnCheck; type TableColumnCheck = import('@sa/hooks').TableColumnCheck;
type TableDataWithIndex<T> = import('@sa/hooks').TableDataWithIndex<T>;
type FlatResponseData<T> = import('@sa/axios').FlatResponseData<T>;
/** type SetTableColumnKey<C, T> = Omit<C, 'key'> & { key: keyof T | (string & {}) };
* the custom column key
*
* if you want to add a custom column, you should add a key to this type
*/
type CustomColumnKey = 'operate';
type SetTableColumnKey<C, T> = Omit<C, 'key'> & { key: keyof T | CustomColumnKey };
type TableData = Api.Common.CommonRecord<object>;
type TableColumnWithKey<T> = SetTableColumnKey<DataTableBaseColumn<T>, T> | SetTableColumnKey<TableColumnGroup<T>, T>; type TableColumnWithKey<T> = SetTableColumnKey<DataTableBaseColumn<T>, T> | SetTableColumnKey<TableColumnGroup<T>, T>;
type TableColumn<T> = TableColumnWithKey<T> | DataTableSelectionColumn<T> | DataTableExpandColumn<T>; type TableColumn<T> = TableColumnWithKey<T> | DataTableSelectionColumn<T> | DataTableExpandColumn<T>;
type TableApiFn<T = any, R = Api.Common.CommonSearchParams> = (
params: R
) => Promise<FlatResponseData<Api.Common.PaginatingQueryRecord<T>>>;
type TreeTableApiFn<T = any, R = Record<string, any>> = (params: R) => Promise<FlatResponseData<T[]>>;
/** /**
* the type of table operation * the type of table operation
* *
@ -39,27 +21,4 @@ declare namespace NaiveUI {
* - edit: edit table item * - edit: edit table item
*/ */
type TableOperateType = 'add' | 'edit'; type TableOperateType = 'add' | 'edit';
type GetTableData<A extends TableApiFn> = A extends TableApiFn<infer T> ? T : never;
type GetTreeTableData<A extends TreeTableApiFn> = A extends TreeTableApiFn<infer T> ? T : never;
type NaiveTableConfig<A extends TableApiFn> = Pick<
import('@sa/hooks').TableConfig<A, GetTableData<A>, TableColumn<TableDataWithIndex<GetTableData<A>>>>,
'apiFn' | 'apiParams' | 'columns' | 'immediate'
> & {
/**
* whether to display the total items count
*
* @default false
*/
showTotal?: boolean;
};
type NaiveTreeTableConfig<A extends TreeTableApiFn> = Pick<
import('@sa/hooks').TableConfig<A, GetTreeTableData<A>, TableColumn<TableDataWithIndex<GetTreeTableData<A>>>>,
'apiFn' | 'apiParams' | 'columns' | 'immediate'
>;
type CodeMirrorLang = 'js' | 'json';
} }

View File

@ -28,9 +28,16 @@ declare namespace UnionKey {
* - vertical: the vertical menu in left * - vertical: the vertical menu in left
* - horizontal: the horizontal menu in top * - horizontal: the horizontal menu in top
* - vertical-mix: two vertical mixed menus in left * - vertical-mix: two vertical mixed menus in left
* - horizontal-mix: the vertical first level menus in left and horizontal child level menus in top * - top-hybrid-sidebar-first: the vertical first level menus in left and horizontal child level menus in top
* - top-hybrid-header-first: the horizontal first level menus in top and vertical child level menus in left
*/ */
type ThemeLayoutMode = 'vertical' | 'horizontal' | 'vertical-mix' | 'horizontal-mix'; type ThemeLayoutMode =
| 'vertical'
| 'horizontal'
| 'vertical-mix'
| 'vertical-hybrid-header-first'
| 'top-hybrid-sidebar-first'
| 'top-hybrid-header-first';
/** /**
* The scroll mode when content overflow * The scroll mode when content overflow

View File

@ -3,11 +3,11 @@ import { isNotNull } from '@/utils/common';
import { $t } from '@/locales'; import { $t } from '@/locales';
export interface ExportExcelProps<T> { export interface ExportExcelProps<T> {
columns: NaiveUI.TableColumn<NaiveUI.TableDataWithIndex<T>>[]; columns: NaiveUI.TableColumn<T>[];
data: NaiveUI.TableDataWithIndex<T>[]; data: T[];
filename: string; filename: string;
ignoreKeys?: (keyof NaiveUI.TableDataWithIndex<T> | NaiveUI.CustomColumnKey)[]; ignoreKeys?: (keyof T | (string & {}))[];
dicts?: Record<keyof NaiveUI.TableDataWithIndex<T>, string>; dicts?: Record<keyof T, string>;
} }
export function exportExcel<T>({ export function exportExcel<T>({
@ -38,11 +38,7 @@ export function exportExcel<T>({
writeFile(workBook, `${filename}.xlsx`); writeFile(workBook, `${filename}.xlsx`);
} }
function getTableValue<T>( function getTableValue<T>(col: NaiveUI.TableColumn<T>, item: T, dicts?: Record<keyof T, string>) {
col: NaiveUI.TableColumn<NaiveUI.TableDataWithIndex<T>>,
item: NaiveUI.TableDataWithIndex<T>,
dicts?: Record<keyof NaiveUI.TableDataWithIndex<T>, string>
) {
if (!isTableColumnHasKey(col)) { if (!isTableColumnHasKey(col)) {
return null; return null;
} }
@ -53,11 +49,11 @@ function getTableValue<T>(
return null; return null;
} }
if (isNotNull(dicts?.[key]) && isNotNull(item[key])) { if (isNotNull(dicts?.[key as keyof T]) && isNotNull(item[key as keyof T])) {
return $t(item[key] as App.I18n.I18nKey); return $t(item[key as keyof T] as App.I18n.I18nKey);
} }
return item[key]; return item[key as keyof T];
} }
function isTableColumnHasKey<T>(column: NaiveUI.TableColumn<T>): column is NaiveUI.TableColumnWithKey<T> { function isTableColumnHasKey<T>(column: NaiveUI.TableColumn<T>): column is NaiveUI.TableColumnWithKey<T> {

View File

@ -39,45 +39,51 @@ const activeModule = computed(() => moduleMap[props.module || 'pwd-login']);
</script> </script>
<template> <template>
<div class="relative min-h-screen w-full flex flex-wrap"> <!-- Copyright By https://github.com/Daymychen/art-design-pro/blob/main/src/components/core/views/login/LoginLeftView.vue -->
<div class="hidden min-h-screen w-50% bg-primary-100 lg:block dark:bg-primary-800"> <div class="box-border size-full flex">
<div class="size-full flex-center"> <div class="relative box-border hidden h-full w-65vw overflow-hidden bg-primary-50 xl:block dark:bg-primary-900">
<img class="w-60% sm:w-80%" :src="loginBackground" /> <div class="relative z-100 flex items-center pl-30px pt-30px">
<SystemLogo class="text-32px text-primary" />
<h3 class="ml-10px text-20px font-400">{{ $t('system.title') }}</h3>
</div> </div>
</div> <div class="absolute inset-x-0 inset-b-10.5% inset-t-0 z-10 m-auto w-40%">
<div class="w-full flex-col-center px-24px py-32px lg:w-50%"> <img class="size-full" :src="loginBackground" />
<div class="mx-auto max-w-464px w-full">
<header class="flex-y-center justify-between">
<div class="flex-y-center gap-16px">
<SystemLogo class="text-30px text-primary sm:text-42px" />
<h3 class="text-24px text-primary font-500 sm:text-32px">{{ $t('system.title') }}</h3>
</div>
<div class="flex-y-center">
<ThemeSchemaSwitch
:theme-schema="themeStore.themeScheme"
:show-tooltip="false"
class="text-20px lt-sm:text-18px"
@switch="themeStore.toggleThemeScheme"
/>
<LangSwitch
v-if="themeStore.header.multilingual.visible"
:lang="appStore.locale"
:lang-options="appStore.localeOptions"
:show-tooltip="false"
class="text-20px lt-sm:text-18px"
@change-lang="appStore.changeLocale"
/>
</div>
</header>
<main class="pt-24px">
<div>
<Transition :name="themeStore.page.animateMode" mode="out-in" appear>
<component :is="activeModule.component" />
</Transition>
</div>
</main>
</div> </div>
<div class="absolute bottom-80px w-full text-center">
<h1 class="text-24px font-400">{{ $t('page.login.common.title') }}</h1>
<p class="mt-8px text-14px color-gray-500">{{ $t('page.login.common.subTitle') }}</p>
</div>
<WaveBg />
</div> </div>
<header class="relative h-full flex-1 xl:m-auto sm:!w-full">
<div class="relative z-100 block flex items-center pl-30px pt-30px xl:hidden">
<SystemLogo class="text-32px text-primary" />
<h3 class="ml-10px text-20px font-400">{{ $t('system.title') }}</h3>
</div>
<div class="position-fixed right-30px top-24px z-100 flex items-center justify-end">
<div class="ml-15px inline-block flex cursor-pointer select-none p-5px">
<ThemeSchemaSwitch
:theme-schema="themeStore.themeScheme"
:show-tooltip="false"
class="text-20px lt-sm:text-18px"
@switch="themeStore.toggleThemeScheme"
/>
<LangSwitch
v-if="themeStore.header.multilingual.visible"
:lang="appStore.locale"
:lang-options="appStore.localeOptions"
:show-tooltip="false"
class="text-20px lt-sm:text-18px"
@change-lang="appStore.changeLocale"
/>
</div>
</div>
<main class="absolute inset-0 m-auto h-630px max-w-450px w-full overflow-hidden rounded-5px bg-cover px-24px">
<Transition :name="themeStore.page.animateMode" mode="out-in" appear>
<component :is="activeModule.component" />
</Transition>
</main>
</header>
</div> </div>
</template> </template>

View File

@ -40,6 +40,10 @@ async function handleSubmit() {
</script> </script>
<template> <template>
<div class="mb-5px text-32px text-black font-600 sm:text-30px dark:text-white">
{{ $t('page.login.codeLogin.title') }}
</div>
<div class="pb-18px text-16px text-#858585">请输入您的手机号我们将发送验证码到您的手机</div>
<NForm ref="formRef" :model="model" :rules="rules" size="large" :show-label="false" @keyup.enter="handleSubmit"> <NForm ref="formRef" :model="model" :rules="rules" size="large" :show-label="false" @keyup.enter="handleSubmit">
<NFormItem path="phone"> <NFormItem path="phone">
<NInput v-model:value="model.phone" :placeholder="$t('page.login.common.phonePlaceholder')" /> <NInput v-model:value="model.phone" :placeholder="$t('page.login.common.phonePlaceholder')" />
@ -52,15 +56,32 @@ async function handleSubmit() {
</NButton> </NButton>
</div> </div>
</NFormItem> </NFormItem>
<NSpace vertical :size="18" class="w-full"> <NSpace vertical :size="20" class="w-full">
<NButton type="primary" size="large" round block @click="handleSubmit"> <NButton type="primary" size="large" block @click="handleSubmit">
{{ $t('common.confirm') }} {{ $t('page.login.codeLogin.title') }}
</NButton> </NButton>
<NButton size="large" round block @click="toggleLoginModule('pwd-login')"> <NButton size="large" block @click="toggleLoginModule('pwd-login')">
{{ $t('page.login.common.back') }} {{ $t('page.login.common.back') }}
</NButton> </NButton>
</NSpace> </NSpace>
</NForm> </NForm>
</template> </template>
<style scoped></style> <style scoped>
:deep(.n-base-selection),
:deep(.n-input) {
--n-height: 42px !important;
--n-font-size: 16px !important;
--n-border-radius: 8px !important;
}
:deep(.n-base-selection-label) {
padding: 0 6px !important;
}
:deep(.n-button) {
--n-height: 42px !important;
--n-font-size: 18px !important;
--n-border-radius: 8px !important;
}
</style>

View File

@ -124,8 +124,8 @@ async function handleSocialLogin(type: Api.System.SocialSource) {
<template> <template>
<div> <div>
<div class="mb-12px text-24px text-black font-500 sm:text-30px dark:text-white">登录到您的账户</div> <div class="mb-5px text-32px text-black font-600 dark:text-white">登录到您的账户</div>
<div class="pb-24px text-18px text-#858585">欢迎回来请输入您的账户信息</div> <div class="pb-18px text-16px text-#858585">欢迎回来请输入您的账户信息</div>
<NForm <NForm
ref="formRef" ref="formRef"
:model="model" :model="model"
@ -156,16 +156,16 @@ async function handleSocialLogin(type: Api.System.SocialSource) {
<NFormItem v-if="captchaEnabled" path="code"> <NFormItem v-if="captchaEnabled" path="code">
<div class="w-full flex-y-center gap-16px"> <div class="w-full flex-y-center gap-16px">
<NInput v-model:value="model.code" :placeholder="$t('page.login.common.codePlaceholder')" /> <NInput v-model:value="model.code" :placeholder="$t('page.login.common.codePlaceholder')" />
<NSpin :show="codeLoading" :size="28" class="h-52px"> <NSpin :show="codeLoading" :size="28" class="h-42px">
<NButton :focusable="false" class="login-code h-52px w-136px" @click="handleFetchCaptchaCode"> <NButton :focusable="false" class="login-code h-42px w-136px" @click="handleFetchCaptchaCode">
<img v-if="codeUrl" :src="codeUrl" /> <img v-if="codeUrl" :src="codeUrl" />
<NEmpty v-else :show-icon="false" description="暂无验证码" /> <NEmpty v-else :show-icon="false" description="暂无验证码" />
</NButton> </NButton>
</NSpin> </NSpin>
</div> </div>
</NFormItem> </NFormItem>
<NSpace vertical :size="16" class="mb-8px"> <NSpace vertical :size="12" class="mb-8px">
<div class="mx-6px mb-10px flex-y-center justify-between"> <div class="mx-6px mb-8px flex-y-center justify-between">
<NCheckbox v-model:checked="remberMe" size="large">{{ $t('page.login.pwdLogin.rememberMe') }}</NCheckbox> <NCheckbox v-model:checked="remberMe" size="large">{{ $t('page.login.pwdLogin.rememberMe') }}</NCheckbox>
<NA type="primary" class="text-18px" @click="toggleLoginModule('reset-pwd')"> <NA type="primary" class="text-18px" @click="toggleLoginModule('reset-pwd')">
{{ $t('page.login.pwdLogin.forgetPassword') }} {{ $t('page.login.pwdLogin.forgetPassword') }}
@ -199,7 +199,7 @@ async function handleSocialLogin(type: Api.System.SocialSource) {
</NButton> </NButton>
</div> </div>
<div class="mt-32px w-full text-center text-18px text-#858585"> <div class="mt-24px w-full text-center text-18px text-#858585">
您还没有账户 您还没有账户
<NA type="primary" class="text-18px" @click="toggleLoginModule('register')"> <NA type="primary" class="text-18px" @click="toggleLoginModule('register')">
{{ $t('page.login.common.register') }} {{ $t('page.login.common.register') }}
@ -216,13 +216,13 @@ async function handleSocialLogin(type: Api.System.SocialSource) {
} }
img { img {
height: 52px; height: 42px;
} }
} }
:deep(.n-base-selection), :deep(.n-base-selection),
:deep(.n-input) { :deep(.n-input) {
--n-height: 52px !important; --n-height: 42px !important;
--n-font-size: 16px !important; --n-font-size: 16px !important;
--n-border-radius: 8px !important; --n-border-radius: 8px !important;
} }
@ -237,7 +237,7 @@ async function handleSocialLogin(type: Api.System.SocialSource) {
} }
:deep(.n-button) { :deep(.n-button) {
--n-height: 52px !important; --n-height: 42px !important;
--n-font-size: 18px !important; --n-font-size: 18px !important;
--n-border-radius: 8px !important; --n-border-radius: 8px !important;
} }

View File

@ -104,8 +104,8 @@ handleFetchCaptchaCode();
<template> <template>
<div> <div>
<div class="mb-12px text-24px text-black font-500 sm:text-30px dark:text-white">注册新账户</div> <div class="mb-5px text-32px text-black font-600 sm:text-30px dark:text-white">注册新账户</div>
<div class="pb-24px text-18px text-#858585">欢迎注册请输入您的账户信息</div> <div class="pb-18px text-16px text-#858585">欢迎注册请输入您的账户信息</div>
<NForm <NForm
ref="formRef" ref="formRef"
:model="model" :model="model"
@ -147,14 +147,14 @@ handleFetchCaptchaCode();
</NSpin> </NSpin>
</div> </div>
</NFormItem> </NFormItem>
<NSpace vertical :size="18" class="w-full pt-6px"> <NSpace vertical :size="18" class="w-full">
<NButton type="primary" size="large" block :loading="registerLoading" @click="handleSubmit"> <NButton type="primary" size="large" block :loading="registerLoading" @click="handleSubmit">
{{ $t('page.login.common.register') }} {{ $t('page.login.common.register') }}
</NButton> </NButton>
</NSpace> </NSpace>
</NForm> </NForm>
<div class="mt-32px w-full text-center text-18px text-#858585"> <div class="mt-24px w-full text-center text-18px text-#858585">
您已有账户 您已有账户
<NA type="primary" class="text-18px" @click="toggleLoginModule('pwd-login')"> <NA type="primary" class="text-18px" @click="toggleLoginModule('pwd-login')">
{{ $t('common.login') }} {{ $t('common.login') }}
@ -177,7 +177,7 @@ handleFetchCaptchaCode();
:deep(.n-base-selection), :deep(.n-base-selection),
:deep(.n-input) { :deep(.n-input) {
--n-height: 52px !important; --n-height: 42px !important;
--n-font-size: 16px !important; --n-font-size: 16px !important;
--n-border-radius: 8px !important; --n-border-radius: 8px !important;
} }
@ -187,7 +187,7 @@ handleFetchCaptchaCode();
} }
:deep(.n-button) { :deep(.n-button) {
--n-height: 52px !important; --n-height: 42px !important;
--n-font-size: 18px !important; --n-font-size: 18px !important;
--n-border-radius: 8px !important; --n-border-radius: 8px !important;
} }

View File

@ -46,10 +46,10 @@ async function handleSubmit() {
<template> <template>
<div> <div>
<div class="mb-12px text-24px text-black font-500 sm:text-30px dark:text-white"> <div class="mb-5px text-32px text-black font-600 sm:text-30px dark:text-white">
{{ $t('page.login.resetPwd.title') }} {{ $t('page.login.resetPwd.title') }}
</div> </div>
<div class="pb-24px text-18px text-#858585">请输入您的手机号我们将发送验证码到您的手机</div> <div class="pb-18px text-16px text-#858585">请输入您的手机号我们将发送验证码到您的手机</div>
<NForm ref="formRef" :model="model" :rules="rules" size="large" :show-label="false" @keyup.enter="handleSubmit"> <NForm ref="formRef" :model="model" :rules="rules" size="large" :show-label="false" @keyup.enter="handleSubmit">
<NFormItem path="phone"> <NFormItem path="phone">
<NInput v-model:value="model.phone" :placeholder="$t('page.login.common.phonePlaceholder')" /> <NInput v-model:value="model.phone" :placeholder="$t('page.login.common.phonePlaceholder')" />
@ -73,7 +73,7 @@ async function handleSubmit() {
:placeholder="$t('page.login.common.confirmPasswordPlaceholder')" :placeholder="$t('page.login.common.confirmPasswordPlaceholder')"
/> />
</NFormItem> </NFormItem>
<NSpace vertical :size="18" class="w-full"> <NSpace vertical :size="20" class="w-full">
<NButton type="primary" size="large" block @click="handleSubmit"> <NButton type="primary" size="large" block @click="handleSubmit">
{{ $t('page.login.resetPwd.title') }} {{ $t('page.login.resetPwd.title') }}
</NButton> </NButton>
@ -88,7 +88,7 @@ async function handleSubmit() {
<style scoped> <style scoped>
:deep(.n-base-selection), :deep(.n-base-selection),
:deep(.n-input) { :deep(.n-input) {
--n-height: 52px !important; --n-height: 42px !important;
--n-font-size: 16px !important; --n-font-size: 16px !important;
--n-border-radius: 8px !important; --n-border-radius: 8px !important;
} }
@ -98,7 +98,7 @@ async function handleSubmit() {
} }
:deep(.n-button) { :deep(.n-button) {
--n-height: 52px !important; --n-height: 42px !important;
--n-font-size: 18px !important; --n-font-size: 18px !important;
--n-border-radius: 8px !important; --n-border-radius: 8px !important;
} }

View File

@ -1,243 +1,7 @@
<script setup lang="ts"> <script setup lang="ts"></script>
import { reactive } from 'vue';
import { NButton } from 'naive-ui';
import { useLoading } from '@sa/hooks';
import { fetchUpdateUserPassword, fetchUpdateUserProfile } from '@/service/api/system';
import { useAuthStore } from '@/store/modules/auth';
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
import OnlineTable from './modules/online-table.vue';
import SocialCard from './modules/social-card.vue';
import UserAvatar from './modules/user-avatar.vue';
defineOptions({
name: 'UserCenter'
});
const authStore = useAuthStore();
const { userInfo } = authStore;
const { loading: btnLoading, startLoading: startBtnLoading, endLoading: endBtnLoading } = useLoading();
const {
formRef: profileFormRef,
validate: profileValidate,
restoreValidation: profileRestoreValidation
} = useNaiveForm();
const {
formRef: passwordFormRef,
validate: passwordValidate,
restoreValidation: passwordRestoreValidation
} = useNaiveForm();
const { createRequiredRule, patternRules } = useFormRules();
type ProfileModel = Api.System.UserProfileOperateParams;
type PasswordModel = Api.System.UserPasswordOperateParams & { confirmPassword: string };
const profileModel: ProfileModel = reactive(createDefaultProfileModel());
const passwordModel: PasswordModel = reactive(createDefaultPasswordModel());
function createDefaultProfileModel(): ProfileModel {
return {
nickName: userInfo.user?.nickName || '',
email: userInfo.user?.email || '',
phonenumber: userInfo.user?.phonenumber || '',
sex: userInfo.user?.sex || '0'
};
}
function createDefaultPasswordModel(): PasswordModel {
return {
oldPassword: '',
confirmPassword: '',
newPassword: ''
};
}
type ProfileRuleKey = Extract<keyof ProfileModel, 'nickName' | 'email' | 'phonenumber' | 'sex'>;
type PasswordRuleKey = Extract<keyof PasswordModel, 'oldPassword' | 'newPassword' | 'confirmPassword'>;
const profileRules: Record<ProfileRuleKey, App.Global.FormRule> = {
nickName: createRequiredRule('昵称不能为空'),
email: { ...patternRules.email, required: true },
phonenumber: { ...patternRules.phone, required: true },
sex: createRequiredRule('性别不能为空')
};
const passwordRules: Record<PasswordRuleKey, App.Global.FormRule> = {
oldPassword: createRequiredRule('旧密码不能为空'),
confirmPassword: createRequiredRule('确认密码不能为空'),
newPassword: createRequiredRule('新密码不能为空')
};
async function updateProfile() {
await profileValidate();
startBtnLoading();
const { error } = await fetchUpdateUserProfile(profileModel);
if (!error) {
window.$message?.success('更新成功');
// 更新本地用户信息
if (userInfo.user) {
Object.assign(userInfo.user, profileModel);
profileRestoreValidation();
}
}
endBtnLoading();
}
async function updatePassword() {
await passwordValidate();
if (passwordModel.newPassword !== passwordModel.confirmPassword) {
window.$message?.error('两次输入的密码不一致');
return;
}
startBtnLoading();
const { oldPassword, newPassword } = passwordModel;
const { error } = await fetchUpdateUserPassword({ oldPassword, newPassword });
if (!error) {
window.$message?.success('密码修改成功');
// 清空表单
Object.assign(passwordModel, createDefaultPasswordModel());
passwordRestoreValidation();
}
endBtnLoading();
}
</script>
<template> <template>
<div class="flex gap-16px"> <LookForward />
<!-- 个人信息卡片 -->
<NCard title="个人信息" class="w-360px shadow-sm">
<div class="flex-x-center flex-wrap gap-24px">
<div class="flex-center flex-col gap-16px">
<div class="relative">
<UserAvatar />
</div>
<div class="text-18px font-medium">{{ userInfo.user?.nickName }}</div>
<div class="text-14px text-gray-500">{{ userInfo.user?.userName }}</div>
</div>
<NDescriptions :column="1" label-placement="left" label-width="120px">
<NDescriptionsItem label="手机号码">
<div class="text-14px">{{ userInfo.user?.phonenumber }}</div>
</NDescriptionsItem>
<NDescriptionsItem label="用户邮箱">
<div class="text-14px">{{ userInfo.user?.email }}</div>
</NDescriptionsItem>
<NDescriptionsItem label="所属部门">
<div class="text-14px">{{ userInfo.user?.deptName }}</div>
</NDescriptionsItem>
<NDescriptionsItem label="所属角色">
<NSpace>
<NTag v-for="role in userInfo.user?.roles" :key="role.roleId" type="primary" size="small">
{{ role.roleName }}
</NTag>
</NSpace>
</NDescriptionsItem>
<NDescriptionsItem label="创建日期">
<div class="text-14px">{{ userInfo.user?.createTime }}</div>
</NDescriptionsItem>
</NDescriptions>
</div>
</NCard>
<!-- 基本资料卡片 -->
<NCard title="基本资料" class="shadow-sm">
<NTabs type="line" animated class="h-full" s>
<NTabPane name="userInfo" tab="基本资料">
<NForm
ref="profileFormRef"
:model="profileModel"
:rules="profileRules"
label-placement="left"
label-width="100px"
class="mt-16px max-w-520px"
>
<NFormItem label="昵称" path="nickName">
<NInput v-model:value="profileModel.nickName" placeholder="请输入昵称" />
</NFormItem>
<NFormItem label="邮箱" path="email">
<NInput v-model:value="profileModel.email" placeholder="请输入邮箱" />
</NFormItem>
<NFormItem label="手机号" path="phonenumber">
<NInput v-model:value="profileModel.phonenumber" placeholder="请输入手机号" />
</NFormItem>
<NFormItem label="性别" path="sex">
<NRadioGroup v-model:value="profileModel.sex">
<NRadio value="0"></NRadio>
<NRadio value="1"></NRadio>
</NRadioGroup>
</NFormItem>
<NFormItem class="flex items-center justify-end">
<NButton class="ml-20px w-80px" type="primary" :loading="btnLoading" @click="updateProfile">
<template #icon>
<SvgIcon icon="ic:outline-save" class="size-24px" />
</template>
保存
</NButton>
</NFormItem>
</NForm>
</NTabPane>
<NTabPane name="updatePwd" tab="修改密码">
<NForm
ref="passwordFormRef"
:model="passwordModel"
:rules="passwordRules"
label-placement="left"
label-width="100px"
class="mt-16px max-w-520px"
>
<NFormItem label="旧密码" path="oldPassword">
<NInput
v-model:value="passwordModel.oldPassword"
type="password"
placeholder="请输入旧密码"
show-password-on="click"
/>
</NFormItem>
<NFormItem label="新密码" path="newPassword">
<NInput
v-model:value="passwordModel.newPassword"
type="password"
placeholder="请输入新密码"
show-password-on="click"
/>
</NFormItem>
<NFormItem label="确认密码" path="confirmPassword">
<NInput
v-model:value="passwordModel.confirmPassword"
type="password"
placeholder="请再次输入新密码"
show-password-on="click"
/>
</NFormItem>
<NFormItem class="flex items-center justify-end">
<NButton class="ml-20px w-120px" type="primary" :loading="btnLoading" @click="updatePassword">
<template #icon>
<SvgIcon icon="ic:outline-key" class="size-24px" />
</template>
修改密码
</NButton>
</NFormItem>
</NForm>
</NTabPane>
<NTabPane name="social" tab="第三方应用">
<SocialCard />
</NTabPane>
<NTabPane name="online" tab="在线设备">
<div class="h-full">
<OnlineTable />
</div>
</NTabPane>
</NTabs>
</NCard>
</div>
</template> </template>
<style scoped> <style scoped></style>
.shadow-sm {
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
:deep(.n-tabs-pane-wrapper),
:deep(.n-tab-pane) {
height: 100% !important;
}
</style>

View File

@ -1,124 +0,0 @@
<script setup lang="tsx">
import { NTime } from 'naive-ui';
import { useLoading } from '@sa/hooks';
import { fetchGetOnlineDeviceList, fetchKickOutCurrentDevice } from '@/service/api/monitor';
import { useAppStore } from '@/store/modules/app';
import { useTable } from '@/hooks/common/table';
import { useDict } from '@/hooks/business/dict';
import { getBrowserIcon, getOsIcon } from '@/utils/icon-tag-format';
import { $t } from '@/locales';
import DictTag from '@/components/custom/dict-tag.vue';
import ButtonIcon from '@/components/custom/button-icon.vue';
import SvgIcon from '@/components/custom/svg-icon.vue';
defineOptions({
name: 'OnlineTable'
});
useDict('sys_device_type');
const appStore = useAppStore();
const { loading: btnLoading, startLoading: startBtnLoading, endLoading: endBtnLoading } = useLoading(false);
const { columns, data, loading, getData } = useTable({
apiFn: fetchGetOnlineDeviceList,
columns: () => [
{
title: '设备类型',
key: 'deviceType',
align: 'center',
minWidth: 120,
render: row => {
return <DictTag size="small" value={row.deviceType} dict-code="sys_device_type" />;
}
},
{ title: 'IP地址', key: 'ipaddr', align: 'center', minWidth: 120 },
{ title: '登录地点', key: 'loginLocation', align: 'center', minWidth: 120 },
{
title: '浏览器',
key: 'browser',
align: 'center',
minWidth: 120,
render: row => {
return (
<div class="flex items-center justify-center gap-2">
<SvgIcon icon={getBrowserIcon(row.browser)} />
{row.browser}
</div>
);
}
},
{
title: '操作系统',
key: 'os',
align: 'center',
minWidth: 120,
render: row => {
const osName = row.os?.split(' or ')[0] ?? '';
return (
<div class="flex items-center justify-center gap-2">
<SvgIcon icon={getOsIcon(osName)} />
{osName}
</div>
);
}
},
{
title: '登录时间',
key: 'loginTime',
align: 'center',
minWidth: 180,
render: row => <NTime time={row.loginTime} format="yyyy-MM-dd HH:mm:ss" />
},
{
key: 'operate',
title: $t('common.operate'),
align: 'center',
minWidth: 80,
render: row => {
return (
<div class="flex-center gap-8px">
<ButtonIcon
text
type="error"
icon="material-symbols:delete-outline"
loading={btnLoading.value}
class="text-18px"
tooltipContent="强制下线"
popconfirmContent="确定强制下线吗?"
onPositiveClick={() => forceLogout(row.tokenId)}
/>
</div>
);
}
}
]
});
/** 强制下线 */
async function forceLogout(tokenId: string) {
startBtnLoading();
const { error } = await fetchKickOutCurrentDevice(tokenId);
if (!error) {
window.$message?.success('强制下线成功');
await getData();
}
endBtnLoading();
}
</script>
<template>
<NDataTable
:columns="columns"
:data="data"
size="small"
:flex-height="!appStore.isMobile"
:scroll-x="962"
:loading="loading"
remote
:row-key="row => row.noticeId"
class="h-full"
/>
</template>
<style scoped></style>

View File

@ -1,114 +0,0 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useLoading } from '@sa/hooks';
import { fetchSocialAuthBinding, fetchSocialAuthUnbinding, fetchSocialList } from '@/service/api/system';
import { useAuthStore } from '@/store/modules/auth';
defineOptions({
name: 'SocialCard'
});
const authStore = useAuthStore();
const { userInfo } = authStore;
const socialList = ref<Api.System.Social[]>([]);
const { loading, startLoading, endLoading } = useLoading();
const { loading: btnLoading, startLoading: startBtnLoading, endLoading: endBtnLoading } = useLoading();
/** 获取SSO账户列表 */
async function getSsoUserList() {
startLoading();
const { data, error } = await fetchSocialList();
if (!error) {
socialList.value = data || [];
}
endLoading();
}
/** 绑定SSO账户 */
async function bindSsoAccount(type: Api.System.SocialSource) {
const { data, error } = await fetchSocialAuthBinding(type, userInfo.user?.tenantId);
if (!error) {
window.location.href = data;
}
}
/** 解绑SSO账户 */
async function unbindSsoAccount(socialId: CommonType.IdType) {
startBtnLoading();
const { error } = await fetchSocialAuthUnbinding(socialId);
if (!error) {
window.$message?.success('账户解绑成功');
await getSsoUserList();
}
endBtnLoading();
}
const socialSources: {
key: Api.System.SocialSource;
icon?: string;
localIcon?: string;
color: string;
name: string;
}[] = [
{ key: 'wechat_open', icon: 'ic:outline-wechat', color: '#44b549', name: '微信' },
{ key: 'topiam', localIcon: 'topiam', color: '', name: 'TopIAM' },
{ key: 'maxkey', localIcon: 'maxkey', color: '', name: 'MaxKey' },
{ key: 'gitee', icon: 'simple-icons:gitee', color: '#c71d23', name: 'Gitee' },
{ key: 'github', icon: 'mdi:github', color: '#010409', name: 'GitHub' }
];
getSsoUserList();
function getSocial(key: string) {
return socialList.value.find(s => s.source.toLowerCase() === key);
}
</script>
<template>
<NSpin :show="loading" class="mt-16px">
<div class="grid grid-cols-1 gap-16px 2xl:grid-cols-3 xl:grid-cols-2">
<div v-for="source in socialSources" :key="source.key" class="relative">
<NCard class="h-full transition-all duration-300 hover:shadow-md" :bordered="true">
<template v-if="getSocial(source.key)">
<div class="flex flex-col items-center gap-16px">
<NAvatar round size="large" :src="getSocial(source.key)?.avatar" class="size-80px" />
<div class="text-center">
<div class="text-16px font-medium">
{{ getSocial(source.key)?.nickName }}
</div>
<div class="mt-4px text-12px text-gray-500">绑定时间{{ getSocial(source.key)?.createTime }}</div>
</div>
<NButton
type="error"
size="small"
:loading="btnLoading"
@click="unbindSsoAccount(getSocial(source.key)?.id || '')"
>
解绑
</NButton>
</div>
</template>
<template v-else>
<div class="h-full flex flex-col items-center justify-center gap-16px">
<SvgIcon
:local-icon="source.localIcon"
:icon="source.icon"
class="size-48px"
:style="{ color: source.color }"
/>
<div class="text-16px font-medium">{{ source.name }}</div>
<NButton type="primary" size="small" @click="bindSsoAccount(source.key)">绑定</NButton>
</div>
</template>
</NCard>
</div>
</div>
</NSpin>
</template>
<style scoped>
.border-primary {
border-color: var(--primary-color);
}
</style>

View File

@ -1,211 +0,0 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import type { UploadFileInfo } from 'naive-ui';
import { NButton, NModal, NUpload } from 'naive-ui';
import { Cropper } from 'vue-advanced-cropper';
import { useBoolean, useLoading } from '@sa/hooks';
import { fetchUpdateUserAvatar } from '@/service/api/system';
import { useAuthStore } from '@/store/modules/auth';
import defaultAvatar from '@/assets/imgs/soybean.jpg';
import 'vue-advanced-cropper/dist/style.css';
interface CropperOptions {
img: string;
fileName: string;
stencilProps: {
aspectRatio: number;
};
}
interface CropperRef {
getResult: () => {
canvas: HTMLCanvasElement;
};
}
const authStore = useAuthStore();
// 使用 useBoolean 管理模态框显示状态
const { bool: showModal, setTrue: showDrawer, setFalse: hideDrawer } = useBoolean();
// 使用 useLoading 管理加载状态
const { loading, startLoading, endLoading } = useLoading();
const imageUrl = ref(authStore.userInfo.user?.avatar || defaultAvatar);
const cropperRef = ref<CropperRef | null>(null);
// 图片裁剪数据
const options = reactive<CropperOptions>({
img: imageUrl.value,
fileName: '',
stencilProps: {
aspectRatio: 1
}
});
/** 编辑头像 */
function handleEdit() {
options.img = imageUrl.value;
showDrawer();
}
/** 处理文件选择 */
async function handleFileSelect(data: { file: UploadFileInfo }) {
const file = data.file.file;
if (!file) return false;
if (!file.type.includes('image/')) {
window.$message?.error('请上传图片类型文件JPG、PNG等');
return false;
}
const reader = new FileReader();
reader.onload = () => {
options.img = reader.result as string;
options.fileName = file.name;
};
reader.readAsDataURL(file);
return false;
}
/** 处理裁剪 */
async function handleCrop() {
if (!cropperRef.value) return;
startLoading();
try {
const { canvas } = cropperRef.value.getResult();
// 将 canvas 转换为 blob
canvas.toBlob(async (blob: Blob | null) => {
if (!blob) return;
const formData = new FormData();
formData.append('avatarfile', blob, options.fileName || 'avatar.png');
const { error } = await fetchUpdateUserAvatar(formData);
if (!error) {
window.$message?.success('头像更新成功!');
imageUrl.value = URL.createObjectURL(blob);
authStore.userInfo.user!.avatar = imageUrl.value;
hideDrawer();
}
}, 'image/png');
} finally {
endLoading();
}
}
/** 关闭对话框 */
function handleClose() {
hideDrawer();
options.img = imageUrl.value;
}
</script>
<template>
<div class="cursor-pointer" @click="handleEdit">
<div class="relative h-120px w-120px overflow-hidden rounded-full">
<img :src="imageUrl" alt="user-avatar" class="h-full w-full object-cover" />
<div
class="absolute inset-0 flex-center bg-black/50 text-white opacity-0 transition-opacity duration-300 hover:opacity-100"
>
<SvgIcon icon="ep:plus" class="text-24px" />
</div>
</div>
<NModal v-model:show="showModal" preset="card" title="修改头像" class="w-400px" @close="handleClose">
<div class="flex-col-center gap-20px py-20px">
<div class="h-300px w-full">
<Cropper
ref="cropperRef"
class="h-full bg-gray-100"
:src="options.img"
:stencil-props="options.stencilProps"
/>
</div>
<div class="flex gap-12px">
<NUpload accept=".jpg,.jpeg,.png,.gif" :max="1" :show-file-list="false" @before-upload="handleFileSelect">
<NButton class="min-w-100px">选择图片</NButton>
</NUpload>
<NButton type="primary" class="min-w-100px" :loading="loading" @click="handleCrop">确认裁剪</NButton>
</div>
</div>
</NModal>
</div>
</template>
<style lang="scss" scoped>
.avatar-wrapper {
display: inline-block;
cursor: pointer;
}
.avatar-container {
position: relative;
width: 120px;
height: 120px;
border-radius: 50%;
overflow: hidden;
&:hover .avatar-overlay {
opacity: 1;
}
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.3s ease;
color: #fff;
}
.upload-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 20px 0;
}
.cropper-container {
width: 100%;
height: 300px;
}
.cropper {
height: 100%;
background: #f8f8f8;
}
.preview-image {
width: 200px;
height: 200px;
border-radius: 50%;
object-fit: cover;
border: 1px solid #eee;
}
.button-group {
display: flex;
gap: 12px;
}
.upload-button {
min-width: 100px;
}
</style>

View File

@ -1,10 +1,11 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { reactive } from 'vue';
import { NDivider } from 'naive-ui'; import { NDivider } from 'naive-ui';
import { fetchBatchDeleteDemo, fetchGetDemoList } from '@/service/api/demo'; import { fetchBatchDeleteDemo, fetchGetDemoList } from '@/service/api/demo';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { useAuth } from '@/hooks/business/auth'; import { useAuth } from '@/hooks/business/auth';
import { useDownload } from '@/hooks/business/download'; import { useDownload } from '@/hooks/business/download';
import { useTable, useTableOperate } from '@/hooks/common/table'; import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table';
import { $t } from '@/locales'; import { $t } from '@/locales';
import ButtonIcon from '@/components/custom/button-icon.vue'; import ButtonIcon from '@/components/custom/button-icon.vue';
import DemoOperateDrawer from './modules/demo-operate-drawer.vue'; import DemoOperateDrawer from './modules/demo-operate-drawer.vue';
@ -18,29 +19,23 @@ const appStore = useAppStore();
const { download } = useDownload(); const { download } = useDownload();
const { hasAuth } = useAuth(); const { hasAuth } = useAuth();
const { const searchParams: Api.Demo.DemoSearchParams = reactive({
columns, pageNum: 1,
columnChecks, pageSize: 10,
data, deptId: null,
getData, userId: null,
getDataByPage, orderNum: null,
loading, testKey: null,
mobilePagination, value: null,
searchParams, params: {}
resetSearchParams });
} = useTable({
apiFn: fetchGetDemoList, const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
apiParams: { api: () => fetchGetDemoList(searchParams),
pageNum: 1, transform: response => defaultTransform(response),
pageSize: 10, onPaginationParamsChange: params => {
// if you want to use the searchParams in Form, you need to define the following properties, and the value is null searchParams.pageSize = params.page;
// the value can not be undefined, otherwise the property in Form will not be reactive searchParams.pageNum = params.pageSize;
deptId: null,
userId: null,
orderNum: null,
testKey: null,
value: null,
params: {}
}, },
columns: () => [ columns: () => [
{ {
@ -141,7 +136,7 @@ const {
}); });
const { drawerVisible, operateType, editingData, handleAdd, handleEdit, checkedRowKeys, onBatchDeleted, onDeleted } = const { drawerVisible, operateType, editingData, handleAdd, handleEdit, checkedRowKeys, onBatchDeleted, onDeleted } =
useTableOperate(data, getData); useTableOperate(data, 'id', getData);
async function handleBatchDelete() { async function handleBatchDelete() {
// request // request
@ -158,7 +153,7 @@ async function handleDelete(id: CommonType.IdType) {
} }
async function edit(id: CommonType.IdType) { async function edit(id: CommonType.IdType) {
handleEdit('id', id); handleEdit(id);
} }
async function handleExport() { async function handleExport() {
@ -168,7 +163,7 @@ async function handleExport() {
<template> <template>
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto"> <div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<DemoSearch v-model:model="searchParams" @reset="resetSearchParams" @search="getDataByPage" /> <DemoSearch v-model:model="searchParams" @search="getDataByPage" />
<NCard title="测试单表列表" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden"> <NCard title="测试单表列表" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
<template #header-extra> <template #header-extra>
<TableHeaderOperation <TableHeaderOperation

View File

@ -7,7 +7,6 @@ defineOptions({
}); });
interface Emits { interface Emits {
(e: 'reset'): void;
(e: 'search'): void; (e: 'search'): void;
} }
@ -17,9 +16,22 @@ const { formRef, validate, restoreValidation } = useNaiveForm();
const model = defineModel<Api.Demo.DemoSearchParams>('model', { required: true }); const model = defineModel<Api.Demo.DemoSearchParams>('model', { required: true });
function resetModel() {
model.value = {
pageNum: 1,
pageSize: 10,
deptId: null,
userId: null,
orderNum: null,
testKey: null,
value: null,
params: {}
};
}
async function reset() { async function reset() {
await restoreValidation(); await restoreValidation();
emit('reset'); resetModel();
} }
async function search() { async function search() {

View File

@ -1,11 +1,11 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { reactive } from 'vue';
import { NDivider } from 'naive-ui'; import { NDivider } from 'naive-ui';
import { jsonClone } from '@sa/utils'; import { jsonClone } from '@sa/utils';
import { type TableDataWithIndex } from '@sa/hooks';
import { fetchBatchDeleteTree, fetchGetTreeList } from '@/service/api/demo/tree'; import { fetchBatchDeleteTree, fetchGetTreeList } from '@/service/api/demo/tree';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { useAuth } from '@/hooks/business/auth'; import { useAuth } from '@/hooks/business/auth';
import { useTreeTable, useTreeTableOperate } from '@/hooks/common/tree-table'; import { treeTransform, useNaiveTreeTable, useTableOperate } from '@/hooks/common/table';
import { useDownload } from '@/hooks/business/download'; import { useDownload } from '@/hooks/business/download';
import { $t } from '@/locales'; import { $t } from '@/locales';
import ButtonIcon from '@/components/custom/button-icon.vue'; import ButtonIcon from '@/components/custom/button-icon.vue';
@ -20,133 +20,122 @@ const appStore = useAppStore();
const { download } = useDownload(); const { download } = useDownload();
const { hasAuth } = useAuth(); const { hasAuth } = useAuth();
const { const searchParams: Api.Demo.TreeSearchParams = reactive({
columns, pageNum: 1,
columnChecks, pageSize: 10,
data, parentId: null,
getData, deptId: null,
loading, userId: null,
searchParams, treeName: null,
resetSearchParams, params: {}
expandedRowKeys,
isCollapse,
expandAll,
collapseAll
} = useTreeTable({
apiFn: fetchGetTreeList,
apiParams: {
pageNum: 1,
pageSize: 10,
// if you want to use the searchParams in Form, you need to define the following properties, and the value is null
// the value can not be undefined, otherwise the property in Form will not be reactive
parentId: null,
deptId: null,
userId: null,
treeName: null,
params: {}
},
idField: 'id',
columns: () => [
{
type: 'selection',
align: 'center',
width: 48
},
{
key: 'id',
title: '主键',
align: 'center',
minWidth: 120
},
{
key: 'parentId',
title: '父 ID',
align: 'center',
minWidth: 120
},
{
key: 'deptId',
title: '部门 ID',
align: 'center',
minWidth: 120
},
{
key: 'userId',
title: '用户 ID',
align: 'center',
minWidth: 120
},
{
key: 'treeName',
title: '值',
align: 'center',
minWidth: 120
},
{
key: 'operate',
title: $t('common.operate'),
align: 'center',
width: 130,
render: row => {
const addBtn = () => {
return (
<ButtonIcon
text
type="primary"
icon="material-symbols:add-2-rounded"
tooltipContent={$t('common.add')}
onClick={() => addInRow(row)}
/>
);
};
const editBtn = () => {
return (
<ButtonIcon
text
type="primary"
icon="material-symbols:drive-file-rename-outline-outline"
tooltipContent={$t('common.edit')}
onClick={() => edit(row)}
/>
);
};
const deleteBtn = () => {
return (
<ButtonIcon
text
type="error"
icon="material-symbols:delete-outline"
tooltipContent={$t('common.delete')}
popconfirmContent={$t('common.confirmDelete')}
onPositiveClick={() => handleDelete(row.id!)}
/>
);
};
const buttons = [];
if (hasAuth('demo:tree:add')) buttons.push(addBtn());
if (hasAuth('demo:tree:edit')) buttons.push(editBtn());
if (hasAuth('demo:tree:remove')) buttons.push(deleteBtn());
return (
<div class="flex-center gap-8px">
{buttons.map((btn, index) => (
<>
{index !== 0 && <NDivider vertical />}
{btn}
</>
))}
</div>
);
}
}
]
}); });
const { columns, columnChecks, data, rows, getData, loading, expandedRowKeys, isCollapse, expandAll, collapseAll } =
useNaiveTreeTable({
keyField: 'id',
api: fetchGetTreeList,
transform: response => treeTransform(response, { idField: 'id' }),
columns: () => [
{
type: 'selection',
align: 'center',
width: 48
},
{
key: 'id',
title: '主键',
align: 'center',
minWidth: 120
},
{
key: 'parentId',
title: '父 ID',
align: 'center',
minWidth: 120
},
{
key: 'deptId',
title: '部门 ID',
align: 'center',
minWidth: 120
},
{
key: 'userId',
title: '用户 ID',
align: 'center',
minWidth: 120
},
{
key: 'treeName',
title: '值',
align: 'center',
minWidth: 120
},
{
key: 'operate',
title: $t('common.operate'),
align: 'center',
width: 130,
render: row => {
const addBtn = () => {
return (
<ButtonIcon
text
type="primary"
icon="material-symbols:add-2-rounded"
tooltipContent={$t('common.add')}
onClick={() => addInRow(row)}
/>
);
};
const editBtn = () => {
return (
<ButtonIcon
text
type="primary"
icon="material-symbols:drive-file-rename-outline-outline"
tooltipContent={$t('common.edit')}
onClick={() => edit(row.id)}
/>
);
};
const deleteBtn = () => {
return (
<ButtonIcon
text
type="error"
icon="material-symbols:delete-outline"
tooltipContent={$t('common.delete')}
popconfirmContent={$t('common.confirmDelete')}
onPositiveClick={() => handleDelete(row.id)}
/>
);
};
const buttons = [];
if (hasAuth('demo:tree:add')) buttons.push(addBtn());
if (hasAuth('demo:tree:edit')) buttons.push(editBtn());
if (hasAuth('demo:tree:remove')) buttons.push(deleteBtn());
return (
<div class="flex-center gap-8px">
{buttons.map((btn, index) => (
<>
{index !== 0 && <NDivider vertical />}
{btn}
</>
))}
</div>
);
}
}
]
});
const { drawerVisible, operateType, editingData, handleAdd, handleEdit, checkedRowKeys, onBatchDeleted, onDeleted } = const { drawerVisible, operateType, editingData, handleAdd, handleEdit, checkedRowKeys, onBatchDeleted, onDeleted } =
useTreeTableOperate(data, getData); useTableOperate(rows, 'id', getData);
async function handleBatchDelete() { async function handleBatchDelete() {
// request // request
@ -162,11 +151,11 @@ async function handleDelete(id: CommonType.IdType) {
onDeleted(); onDeleted();
} }
async function edit(row: TableDataWithIndex<Api.Demo.Tree>) { async function edit(id: CommonType.IdType) {
handleEdit(row); handleEdit(id);
} }
function addInRow(row: TableDataWithIndex<Api.Demo.Tree>) { function addInRow(row: Api.Demo.Tree) {
editingData.value = jsonClone(row); editingData.value = jsonClone(row);
handleAdd(); handleAdd();
} }
@ -178,7 +167,7 @@ function handleExport() {
<template> <template>
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto"> <div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<TreeSearch v-model:model="searchParams" :tree-list="data" @reset="resetSearchParams" @search="getData" /> <TreeSearch v-model:model="searchParams" :tree-list="data" @search="getData" />
<NCard title="测试树列表" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden"> <NCard title="测试树列表" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
<template #header-extra> <template #header-extra>
<TableHeaderOperation <TableHeaderOperation

View File

@ -14,7 +14,6 @@ interface Props {
defineProps<Props>(); defineProps<Props>();
interface Emits { interface Emits {
(e: 'reset'): void;
(e: 'search'): void; (e: 'search'): void;
} }
@ -24,9 +23,21 @@ const { formRef, validate, restoreValidation } = useNaiveForm();
const model = defineModel<Api.Demo.TreeSearchParams>('model', { required: true }); const model = defineModel<Api.Demo.TreeSearchParams>('model', { required: true });
function resetModel() {
model.value = {
pageNum: 1,
pageSize: 10,
parentId: null,
deptId: null,
userId: null,
treeName: null,
params: {}
};
}
async function reset() { async function reset() {
await restoreValidation(); await restoreValidation();
emit('reset'); resetModel();
} }
async function search() { async function search() {

View File

@ -1,278 +0,0 @@
<script setup lang="tsx">
import { NDivider } from 'naive-ui';
import {
fetchBatchDeleteLoginInfor,
fetchCleanLoginInfor,
fetchGetLoginInforList,
fetchUnlockLoginInfor
} from '@/service/api/monitor/login-infor';
import { useAppStore } from '@/store/modules/app';
import { useAuth } from '@/hooks/business/auth';
import { useDownload } from '@/hooks/business/download';
import { useTable, useTableOperate } from '@/hooks/common/table';
import { useDict } from '@/hooks/business/dict';
import { getBrowserIcon, getOsIcon } from '@/utils/icon-tag-format';
import DictTag from '@/components/custom/dict-tag.vue';
import SvgIcon from '@/components/custom/svg-icon.vue';
import { $t } from '@/locales';
import ButtonIcon from '@/components/custom/button-icon.vue';
import LoginInforSearch from './modules/login-infor-search.vue';
import LoginInforViewDrawer from './modules/login-infor-view-drawer.vue';
defineOptions({
name: 'LoginInforList'
});
const appStore = useAppStore();
const { download } = useDownload();
const { hasAuth } = useAuth();
useDict('sys_common_status');
useDict('sys_device_type');
const {
columns,
columnChecks,
data,
getData,
getDataByPage,
loading,
mobilePagination,
searchParams,
resetSearchParams
} = useTable({
apiFn: fetchGetLoginInforList,
apiParams: {
pageNum: 1,
pageSize: 10,
// if you want to use the searchParams in Form, you need to define the following properties, and the value is null
// the value can not be undefined, otherwise the property in Form will not be reactive
userName: null,
ipaddr: null,
status: null,
params: {}
},
columns: () => [
{
type: 'selection',
align: 'center',
width: 48
},
{
key: 'index',
title: $t('common.index'),
align: 'center',
width: 64
},
{
key: 'userName',
title: '用户账号',
align: 'center',
minWidth: 120
},
{
key: 'deviceType',
title: '设备类型',
align: 'center',
minWidth: 120,
render: row => {
return <DictTag size="small" value={row.deviceType} dict-code="sys_device_type" />;
}
},
{
key: 'ipaddr',
title: '登录IP地址',
align: 'center',
minWidth: 120
},
{
key: 'loginLocation',
title: '登录地点',
align: 'center',
minWidth: 120
},
{
key: 'browser',
title: '浏览器类型',
align: 'center',
minWidth: 120,
render: row => {
return (
<div class="flex items-center justify-center gap-2">
<SvgIcon icon={getBrowserIcon(row.browser)} />
{row.browser}
</div>
);
}
},
{
key: 'os',
title: '操作系统',
align: 'center',
ellipsis: {
tooltip: true
},
minWidth: 120,
render: row => {
const osName = row.os?.split(' or ')[0] ?? '';
return (
<div class="flex items-center justify-center gap-2">
<SvgIcon icon={getOsIcon(osName)} />
{osName}
</div>
);
}
},
{
key: 'status',
title: '登录状态',
align: 'center',
minWidth: 120,
render: row => {
return <DictTag size="small" value={row.status} dict-code="sys_common_status" />;
}
},
{
key: 'loginTime',
title: '访问时间',
align: 'center',
ellipsis: {
tooltip: true
},
minWidth: 120
},
{
key: 'operate',
title: $t('common.operate'),
align: 'center',
width: 130,
render: row => {
const viewBtn = () => {
return (
<ButtonIcon
type="primary"
text
icon="material-symbols:visibility-outline"
tooltipContent="详情"
onClick={() => view(row.infoId!)}
/>
);
};
const unlockBtn = () => {
return (
<>
<NDivider vertical />
<ButtonIcon
type="primary"
text
icon="material-symbols:lock-open-outline"
tooltipContent="解锁"
popconfirmContent={`确认解锁用户 ${row.userName} 吗?`}
onPositiveClick={() => handleUnlockLoginInfor(row.userName!)}
/>
</>
);
};
return (
<div class="flex-center gap-8px">
{viewBtn()}
{unlockBtn()}
</div>
);
}
}
]
});
const { drawerVisible, editingData, handleEdit, checkedRowKeys, onBatchDeleted } = useTableOperate(data, getData);
async function handleBatchDelete() {
// request
const { error } = await fetchBatchDeleteLoginInfor(checkedRowKeys.value);
if (error) return;
onBatchDeleted();
}
async function view(infoId: CommonType.IdType) {
handleEdit('infoId', infoId);
}
async function handleExport() {
download('/monitor/logininfor/export', searchParams, `登录日志记录_${new Date().getTime()}.xlsx`);
}
async function handleCleanLoginInfor() {
window.$dialog?.error({
title: '提示',
content: '是否确认清空所有登录日志数据项?',
positiveText: '确认清空',
negativeText: '取消',
onPositiveClick: async () => {
const { error } = await fetchCleanLoginInfor();
if (error) return;
window.$message?.success('清空成功');
await getData();
}
});
}
async function handleUnlockLoginInfor(username: string) {
const { error } = await fetchUnlockLoginInfor(username);
if (error) return;
window.$message?.success('解锁成功');
await getDataByPage();
}
</script>
<template>
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<LoginInforSearch v-model:model="searchParams" @reset="resetSearchParams" @search="getDataByPage" />
<NCard title="登录日志列表" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
<template #header-extra>
<TableHeaderOperation
v-model:columns="columnChecks"
:disabled-delete="checkedRowKeys.length === 0"
:loading="loading"
:show-add="false"
:show-delete="hasAuth('monitor:logininfor:remove')"
:show-export="hasAuth('monitor:logininfor:export')"
@delete="handleBatchDelete"
@export="handleExport"
@refresh="getData"
>
<template #prefix>
<NButton
v-if="hasAuth('monitor:logininfor:remove')"
type="error"
ghost
size="small"
@click="handleCleanLoginInfor"
>
<template #icon>
<icon-material-symbols:warning-outline-rounded />
</template>
清空
</NButton>
</template>
</TableHeaderOperation>
</template>
<NDataTable
v-model:checked-row-keys="checkedRowKeys"
:columns="columns"
:data="data"
size="small"
:flex-height="!appStore.isMobile"
:scroll-x="962"
:loading="loading"
remote
:row-key="row => row.infoId"
:pagination="mobilePagination"
class="sm:h-full"
/>
<LoginInforViewDrawer v-model:visible="drawerVisible" :row-data="editingData" />
</NCard>
</div>
</template>
<style scoped></style>

View File

@ -1,94 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useNaiveForm } from '@/hooks/common/form';
import { $t } from '@/locales';
defineOptions({
name: 'LoginInforSearch'
});
interface Emits {
(e: 'reset'): void;
(e: 'search'): void;
}
const emit = defineEmits<Emits>();
const { formRef, validate, restoreValidation } = useNaiveForm();
const dateRangeLoginTime = ref<[string, string] | null>(null);
const model = defineModel<Api.Monitor.LoginInforSearchParams>('model', { required: true });
function onDateRangeLoginTimeUpdate(value: [string, string] | null) {
if (value?.length) {
model.value.params!.beginTime = value[0];
model.value.params!.endTime = value[1];
}
}
async function reset() {
dateRangeLoginTime.value = null;
await restoreValidation();
emit('reset');
}
async function search() {
await validate();
emit('search');
}
</script>
<template>
<NCard :bordered="false" size="small" class="card-wrapper">
<NCollapse>
<NCollapseItem :title="$t('common.search')" name="user-search">
<NForm ref="formRef" :model="model" label-placement="left" :label-width="80">
<NGrid responsive="screen" item-responsive>
<NFormItemGi span="24 s:12 m:6" label="IP地址" path="ipaddr" class="pr-24px">
<NInput v-model:value="model.ipaddr" placeholder="请输入登录IP地址" />
</NFormItemGi>
<NFormItemGi span="24 s:12 m:6" label="用户账号" path="userName" class="pr-24px">
<NInput v-model:value="model.userName" placeholder="请输入用户账号" />
</NFormItemGi>
<NFormItemGi span="24 s:12 m:6" label="登录状态" path="status" class="pr-24px">
<DictSelect
v-model:value="model.status"
placeholder="请选择登录状态"
dict-code="sys_common_status"
clearable
/>
</NFormItemGi>
<NFormItemGi span="24 s:12 m:6" label="登录时间" path="loginTime" class="pr-24px">
<NDatePicker
v-model:formatted-value="dateRangeLoginTime"
type="datetimerange"
value-format="yyyy-MM-dd HH:mm:ss"
clearable
@update:formatted-value="onDateRangeLoginTimeUpdate"
/>
</NFormItemGi>
<NFormItemGi span="24" class="pr-24px">
<NSpace class="w-full" justify="end">
<NButton @click="reset">
<template #icon>
<icon-ic-round-refresh class="text-icon" />
</template>
{{ $t('common.reset') }}
</NButton>
<NButton type="primary" ghost @click="search">
<template #icon>
<icon-ic-round-search class="text-icon" />
</template>
{{ $t('common.search') }}
</NButton>
</NSpace>
</NFormItemGi>
</NGrid>
</NForm>
</NCollapseItem>
</NCollapse>
</NCard>
</template>
<style scoped></style>

View File

@ -1,71 +0,0 @@
<script setup lang="ts">
import { getBrowserIcon, getOsIcon } from '@/utils/icon-tag-format';
import { $t } from '@/locales';
defineOptions({
name: 'LoginInforViewDrawer'
});
interface Props {
/** the edit row data */
rowData: Api.Monitor.LoginInfor | null;
}
const props = defineProps<Props>();
const visible = defineModel<boolean>('visible', {
default: false
});
const title = '登录信息详情';
function closeDrawer() {
visible.value = false;
}
</script>
<template>
<NDrawer v-model:show="visible" :title="title" display-directive="show" :width="800" class="max-w-90%">
<NDrawerContent :title="title" :native-scrollbar="false" closable>
<NDescriptions label-placement="left" :column="1" size="small" bordered>
<NDescriptionsItem label="账号信息">
{{ props.rowData?.userName }} | {{ props.rowData?.ipaddr }} | {{ props.rowData?.loginLocation }}
</NDescriptionsItem>
<NDescriptionsItem label="客户端">
{{ props.rowData?.clientKey }}
</NDescriptionsItem>
<NDescriptionsItem label="设备类型">
<DictTag size="small" :value="props.rowData?.deviceType" dict-code="sys_device_type" />
</NDescriptionsItem>
<NDescriptionsItem label="浏览器类型">
<div class="flex items-center gap-2">
<SvgIcon :icon="getBrowserIcon(props.rowData?.browser ?? '')" />
{{ props.rowData?.browser }}
</div>
</NDescriptionsItem>
<NDescriptionsItem label="操作系统">
<div class="flex items-center gap-2">
<SvgIcon :icon="getOsIcon(props.rowData?.os ?? '')" />
{{ props.rowData?.os }}
</div>
</NDescriptionsItem>
<NDescriptionsItem label="登录状态">
<DictTag size="small" :value="props.rowData?.status" dict-code="sys_common_status" />
</NDescriptionsItem>
<NDescriptionsItem label="提示消息">
{{ props.rowData?.msg }}
</NDescriptionsItem>
<NDescriptionsItem label="访问时间">
{{ props.rowData?.loginTime }}
</NDescriptionsItem>
</NDescriptions>
<template #footer>
<NSpace :size="16">
<NButton @click="closeDrawer">{{ $t('common.close') }}</NButton>
</NSpace>
</template>
</NDrawerContent>
</NDrawer>
</template>
<style scoped></style>

View File

@ -1,170 +0,0 @@
<script setup lang="tsx">
import dayjs from 'dayjs';
import { fetchForceLogout, fetchGetOnlineUserList } from '@/service/api/monitor/online';
import { useAppStore } from '@/store/modules/app';
import { useAuth } from '@/hooks/business/auth';
import { useTable } from '@/hooks/common/table';
import { useDict } from '@/hooks/business/dict';
import { getBrowserIcon, getOsIcon } from '@/utils/icon-tag-format';
import ButtonIcon from '@/components/custom/button-icon.vue';
import DictTag from '@/components/custom/dict-tag.vue';
import SvgIcon from '@/components/custom/svg-icon.vue';
import { $t } from '@/locales';
import OnlineSearch from './modules/online-search.vue';
defineOptions({
name: 'OnlineList'
});
const appStore = useAppStore();
const { hasAuth } = useAuth();
useDict('sys_common_status');
useDict('sys_device_type');
const { columns, columnChecks, data, getData, loading, searchParams, resetSearchParams } = useTable({
apiFn: fetchGetOnlineUserList,
apiParams: {
// if you want to use the searchParams in Form, you need to define the following properties, and the value is null
// the value can not be undefined, otherwise the property in Form will not be reactive
userName: null,
ipaddr: null
},
columns: () => [
{
key: 'userName',
title: '用户账号',
align: 'center',
minWidth: 120
},
{
key: 'deviceType',
title: '设备类型',
align: 'center',
minWidth: 120,
render: row => {
return <DictTag size="small" value={row.deviceType} dict-code="sys_device_type" />;
}
},
{
key: 'ipaddr',
title: '登录IP地址',
align: 'center',
minWidth: 120
},
{
key: 'loginLocation',
title: '登录地点',
align: 'center',
minWidth: 120
},
{
key: 'browser',
title: '浏览器类型',
align: 'center',
minWidth: 120,
render: row => {
return (
<div class="flex items-center justify-center gap-2">
<SvgIcon icon={getBrowserIcon(row.browser)} />
{row.browser}
</div>
);
}
},
{
key: 'os',
title: '操作系统',
align: 'center',
ellipsis: {
tooltip: true
},
minWidth: 120,
render: row => {
const osName = row.os?.split(' or ')[0] ?? '';
return (
<div class="flex items-center justify-center gap-2">
<SvgIcon icon={getOsIcon(osName)} />
{osName}
</div>
);
}
},
{
key: 'loginTime',
title: '登录时间',
align: 'center',
ellipsis: {
tooltip: true
},
minWidth: 120,
render: row => {
return dayjs(row.loginTime).format('YYYY-MM-DD HH:mm:ss');
}
},
{
key: 'operate',
title: $t('common.operate'),
align: 'center',
width: 130,
render: row => {
const forceLogoutBtn = () => {
if (!hasAuth('monitor:online:forceLogout')) {
return null;
}
return (
<ButtonIcon
text
type="error"
icon="material-symbols:delete-outline"
class="text-20px"
tooltipContent="强制下线"
popconfirmContent="确认强制下线吗?"
onPositiveClick={() => handleForceLogout(row.tokenId)}
/>
);
};
return <div>{forceLogoutBtn()}</div>;
}
}
]
});
async function handleForceLogout(tokenId: string) {
// request
const { error } = await fetchForceLogout(tokenId);
if (error) return;
getData();
}
</script>
<template>
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<OnlineSearch v-model:model="searchParams" @reset="resetSearchParams" @search="getData" />
<NCard title="在线用户列表" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
<template #header-extra>
<TableHeaderOperation
v-model:columns="columnChecks"
:loading="loading"
:show-add="false"
:show-delete="false"
:show-export="false"
@refresh="getData"
/>
</template>
<NDataTable
:columns="columns"
:data="data"
size="small"
:flex-height="!appStore.isMobile"
:scroll-x="962"
:loading="loading"
remote
:row-key="row => row.tokenId"
class="sm:h-full"
/>
</NCard>
</div>
</template>
<style scoped></style>

View File

@ -1,66 +0,0 @@
<script setup lang="ts">
import { useNaiveForm } from '@/hooks/common/form';
import { $t } from '@/locales';
defineOptions({
name: 'LoginInforSearch'
});
interface Emits {
(e: 'reset'): void;
(e: 'search'): void;
}
const emit = defineEmits<Emits>();
const { formRef, validate, restoreValidation } = useNaiveForm();
const model = defineModel<Api.Monitor.OnlineUserSearchParams>('model', { required: true });
async function reset() {
await restoreValidation();
emit('reset');
}
async function search() {
await validate();
emit('search');
}
</script>
<template>
<NCard :bordered="false" size="small" class="card-wrapper">
<NCollapse>
<NCollapseItem :title="$t('common.search')" name="user-search">
<NForm ref="formRef" :model="model" label-placement="left" :label-width="80">
<NGrid responsive="screen" item-responsive>
<NFormItemGi span="24 s:12 m:8" label="IP地址" path="ipaddr" class="pr-24px">
<NInput v-model:value="model.ipaddr" placeholder="请输入IP地址" />
</NFormItemGi>
<NFormItemGi span="24 s:12 m:8" label="用户账号" path="userName" class="pr-24px">
<NInput v-model:value="model.userName" placeholder="请输入用户账号" />
</NFormItemGi>
<NFormItemGi span="24 s:24 m:8" class="pr-24px">
<NSpace class="w-full" justify="end">
<NButton @click="reset">
<template #icon>
<icon-ic-round-refresh class="text-icon" />
</template>
{{ $t('common.reset') }}
</NButton>
<NButton type="primary" ghost @click="search">
<template #icon>
<icon-ic-round-search class="text-icon" />
</template>
{{ $t('common.search') }}
</NButton>
</NSpace>
</NFormItemGi>
</NGrid>
</NForm>
</NCollapseItem>
</NCollapse>
</NCard>
</template>
<style scoped></style>

View File

@ -1,224 +0,0 @@
<script setup lang="tsx">
import { NButton } from 'naive-ui';
import { fetchBatchDeleteOperLog, fetchCleanOperLog, fetchGetOperLogList } from '@/service/api/monitor/oper-log';
import { useAppStore } from '@/store/modules/app';
import { useAuth } from '@/hooks/business/auth';
import { useDownload } from '@/hooks/business/download';
import { useTable, useTableOperate } from '@/hooks/common/table';
import { useDict } from '@/hooks/business/dict';
import DictTag from '@/components/custom/dict-tag.vue';
import { $t } from '@/locales';
import ButtonIcon from '@/components/custom/button-icon.vue';
import OperLogViewDrawer from './modules/oper-log-view-drawer.vue';
import OperLogSearch from './modules/oper-log-search.vue';
defineOptions({
name: 'OperLogList'
});
useDict('sys_common_status');
useDict('sys_oper_type');
const appStore = useAppStore();
const { download } = useDownload();
const { hasAuth } = useAuth();
const {
columns,
columnChecks,
data,
getData,
getDataByPage,
loading,
mobilePagination,
searchParams,
resetSearchParams
} = useTable({
apiFn: fetchGetOperLogList,
apiParams: {
pageNum: 1,
pageSize: 10,
// if you want to use the searchParams in Form, you need to define the following properties, and the value is null
// the value can not be undefined, otherwise the property in Form will not be reactive
title: null,
businessType: null,
operName: null,
operIp: null,
status: null,
params: {}
},
columns: () => [
{
type: 'selection',
align: 'center',
width: 48
},
{
key: 'index',
title: $t('common.index'),
align: 'center',
width: 64
},
{
key: 'title',
title: '系统模块',
align: 'center',
minWidth: 120
},
{
key: 'businessType',
title: '操作类型',
align: 'center',
minWidth: 120,
render(row) {
return <DictTag size="small" value={row.businessType} dictCode="sys_oper_type" />;
}
},
{
key: 'operName',
title: '操作人员',
align: 'center',
minWidth: 120
},
{
key: 'operIp',
title: '操作IP',
align: 'center',
minWidth: 120
},
{
key: 'operLocation',
title: '操作地点',
align: 'center',
minWidth: 120
},
{
key: 'status',
title: '操作状态',
align: 'center',
minWidth: 120,
render(row) {
return <DictTag size="small" value={row.status} dictCode="sys_common_status" />;
}
},
{
key: 'operTime',
title: '操作时间',
align: 'center',
minWidth: 120
},
{
key: 'costTime',
title: '消耗时间',
align: 'center',
minWidth: 120,
render(row) {
return `${row.costTime} ms`;
}
},
{
key: 'operate',
title: $t('common.operate'),
align: 'center',
width: 130,
render: row => {
const viewBtn = () => {
return (
<ButtonIcon
type="primary"
text
icon="material-symbols:visibility-outline"
tooltipContent="详情"
onClick={() => view(row.operId!)}
/>
);
};
return <div class="flex-center gap-8px">{viewBtn()}</div>;
}
}
]
});
const { drawerVisible, editingData, handleEdit, checkedRowKeys, onBatchDeleted } = useTableOperate(data, getData);
async function handleBatchDelete() {
// request
const { error } = await fetchBatchDeleteOperLog(checkedRowKeys.value);
if (error) return;
onBatchDeleted();
}
async function view(operId: CommonType.IdType) {
handleEdit('operId', operId);
}
async function handleExport() {
download('/monitor/operlog/export', searchParams, `操作日志_${new Date().getTime()}.xlsx`);
}
async function handleCleanOperLog() {
window.$dialog?.error({
title: '提示',
content: '是否确认清空所有操作日志数据项?',
positiveText: '确认清空',
negativeText: '取消',
onPositiveClick: async () => {
const { error } = await fetchCleanOperLog();
if (error) return;
window.$message?.success('清空成功');
await getData();
}
});
}
</script>
<template>
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<OperLogSearch v-model:model="searchParams" @reset="resetSearchParams" @search="getDataByPage" />
<NCard title="操作日志列表" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
<template #header-extra>
<TableHeaderOperation
v-model:columns="columnChecks"
:disabled-delete="checkedRowKeys.length === 0"
:loading="loading"
:show-add="false"
:show-delete="hasAuth('monitor:operlog:remove')"
:show-export="hasAuth('monitor:operlog:export')"
@delete="handleBatchDelete"
@export="handleExport"
@refresh="getData"
>
<template #prefix>
<NButton
v-if="hasAuth('monitor:operlog:remove')"
type="error"
ghost
size="small"
@click="handleCleanOperLog"
>
<template #icon>
<icon-material-symbols:warning-outline-rounded />
</template>
清空
</NButton>
</template>
</TableHeaderOperation>
</template>
<NDataTable
v-model:checked-row-keys="checkedRowKeys"
:columns="columns"
:data="data"
size="small"
:flex-height="!appStore.isMobile"
:scroll-x="962"
:loading="loading"
remote
:row-key="row => row.operId"
:pagination="mobilePagination"
class="sm:h-full"
/>
<OperLogViewDrawer v-model:visible="drawerVisible" :row-data="editingData" />
</NCard>
</div>
</template>
<style scoped></style>

View File

@ -1,105 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useNaiveForm } from '@/hooks/common/form';
import { $t } from '@/locales';
defineOptions({
name: 'OperLogSearch'
});
interface Emits {
(e: 'reset'): void;
(e: 'search'): void;
}
const emit = defineEmits<Emits>();
const { formRef, validate, restoreValidation } = useNaiveForm();
const dateRangeOperTime = ref<[string, string] | null>(null);
const model = defineModel<Api.Monitor.OperLogSearchParams>('model', { required: true });
function onDateRangeOperTimeUpdate(value: [string, string] | null) {
if (value?.length) {
model.value.params!.beginTime = value[0];
model.value.params!.endTime = value[1];
}
}
async function reset() {
dateRangeOperTime.value = null;
await restoreValidation();
emit('reset');
}
async function search() {
await validate();
emit('search');
}
</script>
<template>
<NCard :bordered="false" size="small" class="card-wrapper">
<NCollapse>
<NCollapseItem :title="$t('common.search')" name="user-search">
<NForm ref="formRef" :model="model" label-placement="left" :label-width="80">
<NGrid responsive="screen" item-responsive>
<NFormItemGi span="24 s:12 m:6" label="系统模块" path="title" class="pr-24px">
<NInput v-model:value="model.title" placeholder="请输入系统模块" />
</NFormItemGi>
<NFormItemGi span="24 s:12 m:6" label="操作类型" path="businessType" class="pr-24px">
<DictSelect
v-model:value="model.businessType"
placeholder="请选择操作类型"
dict-code="sys_oper_type"
clearable
/>
</NFormItemGi>
<NFormItemGi span="24 s:12 m:6" label="操作人员" path="operName" class="pr-24px">
<NInput v-model:value="model.operName" placeholder="请输入操作人员" />
</NFormItemGi>
<NFormItemGi span="24 s:12 m:6" label="操作IP" path="operIp" class="pr-24px">
<NInput v-model:value="model.operIp" placeholder="请输入操作IP" />
</NFormItemGi>
<NFormItemGi span="24 s:12 m:8" label="操作状态" path="status" class="pr-24px">
<DictSelect
v-model:value="model.status"
placeholder="请选择操作状态"
dict-code="sys_common_status"
clearable
/>
</NFormItemGi>
<NFormItemGi span="24 s:12 m:8" label="操作时间" path="operTime" class="pr-24px">
<NDatePicker
v-model:formatted-value="dateRangeOperTime"
type="datetimerange"
value-format="yyyy-MM-dd HH:mm:ss"
clearable
@update:formatted-value="onDateRangeOperTimeUpdate"
/>
</NFormItemGi>
<NFormItemGi span="24 s:12 m:8" class="pr-24px">
<NSpace class="w-full" justify="end">
<NButton @click="reset">
<template #icon>
<icon-ic-round-refresh class="text-icon" />
</template>
{{ $t('common.reset') }}
</NButton>
<NButton type="primary" ghost @click="search">
<template #icon>
<icon-ic-round-search class="text-icon" />
</template>
{{ $t('common.search') }}
</NButton>
</NSpace>
</NFormItemGi>
</NGrid>
</NForm>
</NCollapseItem>
</NCollapse>
</NCard>
</template>
<style scoped></style>

Some files were not shown because too many files have changed in this diff Show More