Compare commits

..

24 Commits

Author SHA1 Message Date
Cell
9a0e8eba5e
fix(packages): fix the parsing logic for stored data to ensure correct return of boolean values 2025-07-31 11:19:36 +08:00
Soybean
87adc35f2e refactor(hooks): remove useSignal hook and update exports 2025-07-20 00:31:03 +08:00
Soybean
ee4341457a refactor(hooks): streamline column visibility handling in useTable and table components 2025-07-20 00:30:16 +08:00
Soybean
8a7cd5934b fix(hooks): correct chart rendering logic in useEcharts 2025-07-19 20:09:20 +08:00
Soybean
c962f7b2c5 chore(deps): update deps 2025-07-19 19:45:49 +08:00
Soybean
8cc5177cda refactor(hooks)!: refactor useTable and enhance type definitions 2025-07-19 19:43:58 +08:00
Soybean
3a343eea33 optimize(projects): optimize api type file 2025-07-19 18:25:59 +08:00
Soybean
f83eefbc3e refactor(request): unify response transformation methods and deprecate transformBackendResponse 2025-07-19 12:04:00 +08:00
Soybean
936b834e62 optimize(hooks): optimize useEcharts 2025-07-19 02:53:46 +08:00
Soybean
c965140b87 refactor(hooks): optimize useContext and update useMixMenuContext 2025-07-19 02:40:25 +08:00
Soybean
32b8f99071 fix(table): add type annotations for records in useTable hook 2025-07-19 02:28:05 +08:00
Soybean
abaaa4a068 optimize(packages): remove ofetch package 2025-07-19 02:27:54 +08:00
Soybean
b4e125300e refactor(request)!: remove cancelRequest method and related logic from request instances 2025-07-19 02:24:14 +08:00
Soybean
50a5cba088 optimize(request): enhance request options and response handling with generic types 2025-07-19 02:17:50 +08:00
wenyuan
d6c8142bb4 refactor(projects): remove unnecessary logic in onRouteSwitchWhenLoggedIn 2025-07-15 22:04:18 +08:00
Soybean
8146858b96 optimize(projects): optimize theme drawer width 2025-07-14 00:48:17 +08:00
wenyuan
8b8a2083bb optimize(projects): improve robustness of second-level menu key logic 2025-07-14 00:48:16 +08:00
wenyuan
6207292d81 typo(projects): update description of vertical-hybrid-header-first layout mode 2025-07-14 00:48:16 +08:00
wenyuan
b4e5c6d990 feat(projects): add 'vertical-hybrid-header-first' layout mode 2025-07-14 00:48:16 +08:00
Azir
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
Azir
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
wenyuan
8439a60070 optimize(projects): improve theme drawer responsive width for mobile devices 2025-07-14 00:48:16 +08:00
wenyuan
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
Azir
8ba71a0857 feat(projects): refactor theme drawer with tabbed layout for better UX. 2025-07-14 00:48:16 +08:00
66 changed files with 2328 additions and 1812 deletions

View File

@ -7,8 +7,8 @@
---
[![license](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
[![github stars](https://img.shields.io/github/stars/honghuangdc/soybean-admin)](https://github.com/soybeanjs/soybean-admin)
[![github forks](https://img.shields.io/github/forks/honghuangdc/soybean-admin)](https://github.com/soybeanjs/soybean-admin)
[![github stars](https://img.shields.io/github/stars/soybeanjs/soybean-admin)](https://github.com/soybeanjs/soybean-admin)
[![github forks](https://img.shields.io/github/forks/soybeanjs/soybean-admin)](https://github.com/soybeanjs/soybean-admin)
[![gitee stars](https://gitee.com/honghuangdc/soybean-admin/badge/star.svg)](https://gitee.com/honghuangdc/soybean-admin)
[![gitcode star](https://gitcode.com/soybeanjs/soybean-admin/star/badge.svg)](https://gitcode.com/soybeanjs/soybean-admin)
[![Powered by DartNode](https://dartnode.com/branding/DN-Open-Source-sm.png)](https://dartnode.com "Powered by DartNode - Free VPS for Open Source")

View File

@ -7,8 +7,8 @@
---
[![license](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
[![github stars](https://img.shields.io/github/stars/honghuangdc/soybean-admin)](https://github.com/soybeanjs/soybean-admin)
[![github forks](https://img.shields.io/github/forks/honghuangdc/soybean-admin)](https://github.com/soybeanjs/soybean-admin)
[![github stars](https://img.shields.io/github/stars/soybeanjs/soybean-admin)](https://github.com/soybeanjs/soybean-admin)
[![github forks](https://img.shields.io/github/forks/soybeanjs/soybean-admin)](https://github.com/soybeanjs/soybean-admin)
[![gitee stars](https://gitee.com/honghuangdc/soybean-admin/badge/star.svg)](https://gitee.com/honghuangdc/soybean-admin)
[![gitcode star](https://gitcode.com/soybeanjs/soybean-admin/star/badge.svg)](https://gitcode.com/soybeanjs/soybean-admin)
[![Powered by DartNode](https://dartnode.com/branding/DN-Open-Source-sm.png)](https://dartnode.com "Powered by DartNode - Free VPS for Open Source")

View File

@ -66,16 +66,16 @@
"tailwind-merge": "3.3.1",
"vue": "3.5.17",
"vue-draggable-plus": "0.6.0",
"vue-i18n": "11.1.9",
"vue-i18n": "11.1.10",
"vue-router": "4.5.1"
},
"devDependencies": {
"@elegant-router/vue": "0.3.8",
"@iconify/json": "2.2.357",
"@iconify/json": "2.2.359",
"@sa/scripts": "workspace:*",
"@sa/uno-preset": "workspace:*",
"@soybeanjs/eslint-config": "1.7.1",
"@types/node": "24.0.13",
"@types/node": "24.0.15",
"@types/nprogress": "0.2.3",
"@unocss/eslint-config": "66.3.3",
"@unocss/preset-icons": "66.3.3",
@ -95,12 +95,12 @@
"typescript": "5.8.3",
"unplugin-icons": "22.1.0",
"unplugin-vue-components": "28.8.0",
"vite": "7.0.4",
"vite": "7.0.5",
"vite-plugin-progress": "0.0.7",
"vite-plugin-svg-icons": "2.0.1",
"vite-plugin-vue-devtools": "7.7.7",
"vue-eslint-parser": "10.2.0",
"vue-tsc": "3.0.1"
"vue-tsc": "3.0.3"
},
"simple-git-hooks": {
"commit-msg": "pnpm sa git-commit-verify",

View File

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

View File

@ -4,15 +4,27 @@ import { stringify } from 'qs';
import { isHttpSuccess } from './shared';
import type { RequestOption } from './type';
export function createDefaultOptions<ResponseData = any>(options?: Partial<RequestOption<ResponseData>>) {
const opts: RequestOption<ResponseData> = {
export function createDefaultOptions<
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,
isBackendSuccess: _response => true,
onBackendFail: async () => {},
transformBackendResponse: async response => response.data,
onError: async () => {}
};
if (options?.transform) {
opts.transform = options.transform;
} else {
opts.transform = options?.transformBackendResponse || opts.transform;
}
Object.assign(opts, options);
return opts;

View File

@ -8,7 +8,30 @@ export type ContentType =
| 'application/x-www-form-urlencoded'
| '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
*
@ -35,12 +58,6 @@ export interface RequestOption<ResponseData = any> {
response: AxiosResponse<ResponseData>,
instance: AxiosInstance
) => 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
*
@ -68,15 +85,7 @@ export type CustomAxiosRequestConfig<R extends ResponseType = 'json'> = Omit<Axi
responseType?: R;
};
export interface RequestInstanceCommon<T> {
/**
* 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;
export interface RequestInstanceCommon<State extends Record<string, unknown>> {
/**
* cancel all request
*
@ -84,32 +93,35 @@ export interface RequestInstanceCommon<T> {
*/
cancelAllRequest: () => void;
/** you can set custom state in the request instance */
state: T;
state: State;
}
/** The request instance */
export interface RequestInstance<S = Record<string, unknown>> extends RequestInstanceCommon<S> {
<T = any, R extends ResponseType = 'json'>(config: CustomAxiosRequestConfig<R>): Promise<MappedType<R, T>>;
export interface RequestInstance<ApiData, State extends Record<string, unknown>> extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig<R>
): Promise<MappedType<R, T>>;
}
export type FlatResponseSuccessData<T = any, ResponseData = any> = {
data: T;
export type FlatResponseSuccessData<ResponseData, ApiData> = {
data: ApiData;
error: null;
response: AxiosResponse<ResponseData>;
};
export type FlatResponseFailData<ResponseData = any> = {
export type FlatResponseFailData<ResponseData> = {
data: null;
error: AxiosError<ResponseData>;
response: AxiosResponse<ResponseData>;
};
export type FlatResponseData<T = any, ResponseData = any> =
| FlatResponseSuccessData<T, ResponseData>
export type FlatResponseData<ResponseData, ApiData> =
| FlatResponseSuccessData<ResponseData, ApiData>
| FlatResponseFailData<ResponseData>;
export interface FlatRequestInstance<S = Record<string, unknown>, ResponseData = any> extends RequestInstanceCommon<S> {
<T = any, R extends ResponseType = 'json'>(
export interface FlatRequestInstance<ResponseData, ApiData, State extends Record<string, unknown>>
extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
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 useContext from './use-context';
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 * from './use-signal';
export * from './use-table';
export { useBoolean, useLoading, useCountDown, useContext, useSvgIconRender, useTable };
export type * from './use-table';

View File

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

View File

@ -6,31 +6,31 @@ import type {
CreateAxiosDefaults,
CustomAxiosRequestConfig,
MappedType,
RequestInstanceCommon,
RequestOption,
ResponseType
} from '@sa/axios';
import useLoading from './use-loading';
export type HookRequestInstanceResponseSuccessData<T = any> = {
data: Ref<T>;
export type HookRequestInstanceResponseSuccessData<ApiData> = {
data: Ref<ApiData>;
error: Ref<null>;
};
export type HookRequestInstanceResponseFailData<ResponseData = any> = {
export type HookRequestInstanceResponseFailData<ResponseData> = {
data: Ref<null>;
error: Ref<AxiosError<ResponseData>>;
};
export type HookRequestInstanceResponseData<T = any, ResponseData = any> = {
export type HookRequestInstanceResponseData<ResponseData, ApiData> = {
loading: Ref<boolean>;
} & (HookRequestInstanceResponseSuccessData<T> | HookRequestInstanceResponseFailData<ResponseData>);
} & (HookRequestInstanceResponseSuccessData<ApiData> | HookRequestInstanceResponseFailData<ResponseData>);
export interface HookRequestInstance<ResponseData = any> {
<T = any, R extends ResponseType = 'json'>(
export interface HookRequestInstance<ResponseData, ApiData, State extends Record<string, unknown>>
extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig
): HookRequestInstanceResponseData<MappedType<R, T>, ResponseData>;
cancelRequest: (requestId: string) => void;
cancelAllRequest: () => void;
): HookRequestInstanceResponseData<ResponseData, MappedType<R, T>>;
}
/**
@ -39,25 +39,26 @@ export interface HookRequestInstance<ResponseData = any> {
* @param axiosConfig
* @param options
*/
export default function createHookRequest<ResponseData = any>(
export default function createHookRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
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'>(
config: CustomAxiosRequestConfig
) {
const hookRequest: HookRequestInstance<ResponseData, ApiData, State> = function hookRequest<
T extends ApiData = ApiData,
R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) {
const { loading, startLoading, endLoading } = useLoading();
const data = ref<MappedType<R, T> | null>(null) as Ref<MappedType<R, T>>;
const error = ref<AxiosError<ResponseData> | null>(null) as Ref<AxiosError<ResponseData> | null>;
const data = ref(null) as Ref<MappedType<R, T>>;
const error = ref(null) as Ref<AxiosError<ResponseData> | null>;
startLoading();
request(config).then(res => {
if (res.data) {
data.value = res.data;
data.value = res.data as MappedType<R, T>;
} else {
error.value = res.error;
}
@ -70,9 +71,8 @@ export default function createHookRequest<ResponseData = any>(
data,
error
};
} as HookRequestInstance<ResponseData>;
} as HookRequestInstance<ResponseData, ApiData, State>;
hookRequest.cancelRequest = request.cancelRequest;
hookRequest.cancelAllRequest = request.cancelAllRequest;
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 { jsonClone } from '@sa/utils';
import useBoolean from './use-boolean';
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);
@ -14,76 +22,64 @@ export type TableColumnCheck = {
key: string;
title: TableColumnCheckTitle;
checked: boolean;
visible: boolean;
};
export type TableDataWithIndex<T> = T & { index: number };
export type TransformedData<T> = {
data: TableDataWithIndex<T>[];
pageNum: number;
pageSize: number;
total: number;
};
export type Transformer<T, Response> = (response: Response) => TransformedData<T>;
export type TableConfig<A extends ApiFn, T, C> = {
/** api function to get table data */
apiFn: A;
/** api params */
apiParams?: Parameters<A>[0];
/** transform api response to table data */
transformer: Transformer<T, Awaited<ReturnType<A>>>;
/** columns factory */
columns: () => C[];
export interface UseTableOptions<ResponseData, ApiData, Column, Pagination extends boolean> {
/**
* api function to get table data
*/
api: () => Promise<ResponseData>;
/**
* whether to enable pagination
*/
pagination?: Pagination;
/**
* transform api response to table data
*/
transform: Transform<ResponseData, ApiData, Pagination>;
/**
* columns factory
*/
columns: () => Column[];
/**
* get column checks
*
* @param columns
*/
getColumnChecks: (columns: C[]) => TableColumnCheck[];
getColumnChecks: (columns: Column[]) => TableColumnCheck[];
/**
* get columns
*
* @param columns
*/
getColumns: (columns: C[], checks: TableColumnCheck[]) => C[];
getColumns: (columns: Column[], checks: TableColumnCheck[]) => Column[];
/**
* 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
*
* @default true
*/
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 { 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 columnChecks: Ref<TableColumnCheck[]> = ref(getColumnChecks(config.columns()));
const columns = computed(() => getColumns(allColumns.value, columnChecks.value));
const $columns = computed(() => getColumns(columns(), columnChecks.value));
function reloadColumns() {
allColumns.value = config.columns();
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 => ({
...col,
@ -92,47 +88,21 @@ export default function useHookTable<A extends ApiFn, T, C>(config: TableConfig<
}
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 config.onFetched?.(transformed);
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));
await onFetched?.(transformed);
} finally {
endLoading();
}
}
if (immediate) {
@ -143,12 +113,20 @@ export default function useHookTable<A extends ApiFn, T, C>(config: TableConfig<
loading,
empty,
data,
columns,
columns: $columns,
columnChecks,
reloadColumns,
getData,
searchParams,
updateSearchParams,
resetSearchParams
getData
};
}
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="[
style['layout-header'],
commonClass,
headerClass,
headerLeftGapClass,
{ 'absolute top-0 left-0 w-full': fixedHeaderAndTab }
]"

View File

@ -6,12 +6,6 @@ interface AdminLayoutHeaderConfig {
* @default true
*/
headerVisible?: boolean;
/**
* Header class
*
* @default ''
*/
headerClass?: string;
/**
* 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

@ -15,7 +15,7 @@
"devDependencies": {
"@soybeanjs/changelog": "0.3.24",
"bumpp": "10.2.0",
"c12": "3.0.4",
"c12": "3.1.0",
"cac": "6.7.14",
"consola": "3.4.2",
"enquirer": "2.4.1",

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,6 @@ import { NConfigProvider, darkTheme } from 'naive-ui';
import type { WatermarkProps } from 'naive-ui';
import { useAppStore } from './store/modules/app';
import { useThemeStore } from './store/modules/theme';
import { useAuthStore } from './store/modules/auth';
import { naiveDateLocales, naiveLocales } from './locales/naive';
defineOptions({
@ -13,7 +12,6 @@ defineOptions({
const appStore = useAppStore();
const themeStore = useThemeStore();
const authStore = useAuthStore();
const naiveDarkTheme = computed(() => (themeStore.darkMode ? darkTheme : undefined));
@ -26,13 +24,8 @@ const naiveDateLocale = computed(() => {
});
const watermarkProps = computed<WatermarkProps>(() => {
const content =
themeStore.watermark.enableUserName && authStore.userInfo.userName
? authStore.userInfo.userName
: themeStore.watermark.text;
return {
content,
content: themeStore.watermarkContent,
cross: true,
fullscreen: true,
fontSize: 16,

View File

@ -22,7 +22,12 @@ const columns = defineModel<NaiveUI.TableColumnCheck[]>('columns', {
</NButton>
</template>
<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" />
<NCheckbox v-model:checked="item.checked" class="none_draggable flex-1">
<template v-if="typeof item.title === 'function'">

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 themeSchemaRecord: Record<UnionKey.ThemeScheme, App.I18n.I18nKey> = {
light: 'theme.themeSchema.light',
dark: 'theme.themeSchema.dark',
auto: 'theme.themeSchema.auto'
light: 'theme.appearance.themeSchema.light',
dark: 'theme.appearance.themeSchema.dark',
auto: 'theme.appearance.themeSchema.auto'
};
export const themeSchemaOptions = transformRecordToOption(themeSchemaRecord);
@ -21,45 +21,57 @@ export const loginModuleRecord: Record<UnionKey.LoginModule, App.I18n.I18nKey> =
};
export const themeLayoutModeRecord: Record<UnionKey.ThemeLayoutMode, App.I18n.I18nKey> = {
vertical: 'theme.layoutMode.vertical',
'vertical-mix': 'theme.layoutMode.vertical-mix',
horizontal: 'theme.layoutMode.horizontal',
'horizontal-mix': 'theme.layoutMode.horizontal-mix'
vertical: 'theme.layout.layoutMode.vertical',
'vertical-mix': 'theme.layout.layoutMode.vertical-mix',
'vertical-hybrid-header-first': 'theme.layout.layoutMode.vertical-hybrid-header-first',
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 themeScrollModeRecord: Record<UnionKey.ThemeScrollMode, App.I18n.I18nKey> = {
wrapper: 'theme.scrollMode.wrapper',
content: 'theme.scrollMode.content'
wrapper: 'theme.layout.content.scrollMode.wrapper',
content: 'theme.layout.content.scrollMode.content'
};
export const themeScrollModeOptions = transformRecordToOption(themeScrollModeRecord);
export const themeTabModeRecord: Record<UnionKey.ThemeTabMode, App.I18n.I18nKey> = {
chrome: 'theme.tab.mode.chrome',
button: 'theme.tab.mode.button'
chrome: 'theme.layout.tab.mode.chrome',
button: 'theme.layout.tab.mode.button'
};
export const themeTabModeOptions = transformRecordToOption(themeTabModeRecord);
export const themePageAnimationModeRecord: Record<UnionKey.ThemePageAnimateMode, App.I18n.I18nKey> = {
'fade-slide': 'theme.page.mode.fade-slide',
fade: 'theme.page.mode.fade',
'fade-bottom': 'theme.page.mode.fade-bottom',
'fade-scale': 'theme.page.mode.fade-scale',
'zoom-fade': 'theme.page.mode.zoom-fade',
'zoom-out': 'theme.page.mode.zoom-out',
none: 'theme.page.mode.none'
'fade-slide': 'theme.layout.content.page.mode.fade-slide',
fade: 'theme.layout.content.page.mode.fade',
'fade-bottom': 'theme.layout.content.page.mode.fade-bottom',
'fade-scale': 'theme.layout.content.page.mode.fade-scale',
'zoom-fade': 'theme.layout.content.page.mode.zoom-fade',
'zoom-out': 'theme.layout.content.page.mode.zoom-out',
none: 'theme.layout.content.page.mode.none'
};
export const themePageAnimationModeOptions = transformRecordToOption(themePageAnimationModeRecord);
export const resetCacheStrategyRecord: Record<UnionKey.ResetCacheStrategy, App.I18n.I18nKey> = {
close: 'theme.resetCacheStrategy.close',
refresh: 'theme.resetCacheStrategy.refresh'
close: 'theme.layout.resetCacheStrategy.close',
refresh: 'theme.layout.resetCacheStrategy.refresh'
};
export const resetCacheStrategyOptions = transformRecordToOption(resetCacheStrategyRecord);
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' }
];

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 * as echarts from 'echarts/core';
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 darkMode = computed(() => themeStore.darkMode);
const domRef = ref<HTMLElement | null>(null);
const domRef = shallowRef<HTMLElement | null>(null);
const initialSize = { width: 0, height: 0 };
const { width, height } = useElementSize(domRef, initialSize);
let chart: echarts.ECharts | null = null;
const chart = shallowRef<echarts.ECharts | null>(null);
const chartOptions: T = optionsFactory();
const {
@ -111,18 +111,9 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
onDestroy
} = 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 */
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
*/
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
if (!isRendered()) return;
const updatedOpts = callback(chartOptions, optionsFactory);
Object.assign(chartOptions, updatedOpts);
await nextTick();
if (!isRendered()) return;
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) {
chart?.setOption(options);
chart.value?.setOption(options);
}
/** render chart */
async function render() {
if (!isRendered()) {
const chartTheme = darkMode.value ? 'dark' : 'light';
if (isRendered()) return;
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 */
function resize() {
chart?.resize();
chart.value?.resize();
}
/** destroy chart */
async function destroy() {
if (!chart) return;
if (!chart.value) return;
await onDestroy?.(chart);
chart?.dispose();
chart = null;
await onDestroy?.(chart.value);
chart.value?.dispose();
chart.value = null;
}
/** change chart theme */
async function changeTheme() {
await destroy();
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.height = h;
// size is abnormal, destroy chart
if (!canRender()) {
await destroy();
return;
}
// resize chart
if (isRendered()) {
resize();
return;
}
// render chart
await render();
if (chart) {
await onUpdated?.(chart);
if (chart.value) {
await onUpdated?.(chart.value);
}
}
scope.run(() => {
watch([width, height], ([newWidth, newHeight]) => {
renderChartBySize(newWidth, newHeight);
});
watch(
[width, height],
([newWidth, newHeight]) => {
renderChartBySize(newWidth, newHeight);
},
{ flush: 'post' }
);
watch(darkMode, () => {
changeTheme();
@ -233,6 +223,7 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
return {
domRef,
chart,
updateOptions,
setOptions
};

View File

@ -1,192 +1,48 @@
import { computed, effectScope, onScopeDispose, reactive, ref, watch } from 'vue';
import { computed, effectScope, onScopeDispose, reactive, shallowRef, watch } from 'vue';
import type { Ref } from 'vue';
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 { useBoolean, useHookTable } from '@sa/hooks';
import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales';
type TableData = NaiveUI.TableData;
type GetTableData<A extends NaiveUI.TableApiFn> = NaiveUI.GetTableData<A>;
type TableColumn<T> = NaiveUI.TableColumn<T>;
export type UseNaiveTableOptions<ResponseData, ApiData, Pagination extends boolean> = Omit<
UseTableOptions<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, Pagination>,
'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 appStore = useAppStore();
const isMobile = computed(() => appStore.isMobile);
const { apiFn, apiParams, immediate, showTotal } = config;
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 { records = [], current = 1, size = 10, total = 0 } = res.data || {};
// 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 { pageNum, pageSize, total } = transformed;
updatePagination({
page: pageNum,
pageSize,
itemCount: total
});
},
immediate
const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, false>({
...options,
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
getColumns
});
const pagination: PaginationProps = reactive({
page: 1,
pageSize: 10,
showSizePicker: true,
itemCount: 0,
pageSizes: [10, 15, 20, 25, 30],
onUpdatePage: async (page: number) => {
pagination.page = page;
updateSearchParams({
current: page,
size: pagination.pageSize!
});
getData();
},
onUpdatePageSize: async (pageSize: number) => {
pagination.pageSize = pageSize;
pagination.page = 1;
updateSearchParams({
current: pagination.page,
size: 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({
current: pageNum,
size: pagination.pageSize!
});
await getData();
}
scope.run(() => {
watch(
() => appStore.locale,
() => {
reloadColumns();
result.reloadColumns();
}
);
});
@ -195,28 +51,124 @@ export function useTable<A extends NaiveUI.TableApiFn>(config: NaiveUI.NaiveTabl
scope.stop();
});
return result;
}
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 {
loading,
empty,
data,
columns,
columnChecks,
reloadColumns,
pagination,
mobilePagination,
updatePagination,
getData,
...result,
getDataByPage,
searchParams,
updateSearchParams,
resetSearchParams
pagination,
mobilePagination
};
}
export function useTableOperate<T extends TableData = TableData>(data: Ref<T[]>, getData: () => Promise<void>) {
export function useTableOperate<TableData>(
data: Ref<TableData[]>,
idKey: keyof TableData,
getData: () => Promise<void>
) {
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean();
const operateType = ref<NaiveUI.TableOperateType>('add');
const operateType = shallowRef<NaiveUI.TableOperateType>('add');
function handleAdd() {
operateType.value = 'add';
@ -224,18 +176,18 @@ export function useTableOperate<T extends TableData = TableData>(data: Ref<T[]>,
}
/** the editing row data */
const editingData: Ref<T | null> = ref(null);
const editingData = shallowRef<TableData | null>(null);
function handleEdit(id: T['id']) {
function handleEdit(id: TableData[keyof TableData]) {
operateType.value = 'edit';
const findItem = data.value.find(item => item.id === id) || null;
const findItem = data.value.find(item => item[idKey] === id) || null;
editingData.value = jsonClone(findItem);
openDrawer();
}
/** the checked row keys of table */
const checkedRowKeys = ref<string[]>([]);
const checkedRowKeys = shallowRef<string[]>([]);
/** the hook after the batch delete operation is completed */
async function onBatchDeleted() {
@ -267,6 +219,82 @@ 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) {
const { records, current, size, total } = data;
return {
data: records,
pageNum: current,
pageSize: size,
total
};
}
return {
data: [],
pageNum: 1,
pageSize: 10,
total: 0
};
}
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);
}

View File

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

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 { SimpleScrollbar } from '@sa/materials';
import { transformColorWithOpacity } from '@sa/color';
import type { RouteKey } from '@elegant-router/types';
defineOptions({
name: 'FirstLevelMenu'
@ -20,7 +21,7 @@ interface Props {
const props = defineProps<Props>();
interface Emits {
(e: 'select', menu: App.Global.Menu): boolean;
(e: 'select', menuKey: RouteKey): boolean;
(e: 'toggleSiderCollapse'): void;
}
@ -47,8 +48,8 @@ const selectedBgColor = computed(() => {
return darkMode ? dark : light;
});
function handleClickMixMenu(menu: App.Global.Menu) {
emit('select', menu);
function handleClickMixMenu(menuKey: RouteKey) {
emit('select', menuKey);
}
function toggleSiderCollapse() {
@ -88,7 +89,7 @@ function toggleSiderCollapse() {
:icon="menu.icon"
:active="menu.key === activeMenuKey"
:is-mini="siderCollapse"
@click="handleClickMixMenu(menu)"
@click="handleClickMixMenu(menu.routeKey)"
/>
</SimpleScrollbar>
<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 VerticalMenu from './modules/vertical-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 HorizontalMixMenu from './modules/horizontal-mix-menu.vue';
import ReversedHorizontalMixMenu from './modules/reversed-horizontal-mix-menu.vue';
import TopHybridSidebarFirst from './modules/top-hybrid-sidebar-first.vue';
import TopHybridHeaderFirst from './modules/top-hybrid-header-first.vue';
defineOptions({
name: 'GlobalMenu'
@ -20,8 +21,10 @@ const activeMenu = computed(() => {
const menuMap: Record<UnionKey.ThemeLayoutMode, Component> = {
vertical: VerticalMenu,
'vertical-mix': VerticalMixMenu,
'vertical-hybrid-header-first': VerticalHybridHeaderFirst,
horizontal: HorizontalMenu,
'horizontal-mix': themeStore.layout.reverseHorizontalMix ? ReversedHorizontalMixMenu : HorizontalMixMenu
'top-hybrid-sidebar-first': TopHybridSidebarFirst,
'top-hybrid-header-first': TopHybridHeaderFirst
};
return menuMap[themeStore.layout.mode];

View File

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

View File

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

View File

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

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 { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
import { useMenu } from '../../../context';
import { useMenu } from '../context';
defineOptions({
name: 'VerticalMenu'

View File

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

View File

@ -12,10 +12,13 @@ defineOptions({
const appStore = useAppStore();
const themeStore = useThemeStore();
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix');
const darkMenu = computed(() => !themeStore.darkMode && !isHorizontalMix.value && themeStore.sider.inverted);
const showLogo = computed(() => !isVerticalMix.value && !isHorizontalMix.value);
const isTopHybridSidebarFirst = computed(() => themeStore.layout.mode === 'top-hybrid-sidebar-first');
const isTopHybridHeaderFirst = computed(() => themeStore.layout.mode === 'top-hybrid-header-first');
const darkMenu = computed(
() =>
!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'));
</script>

View File

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

View File

@ -1,10 +1,10 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales';
import DarkMode from './modules/dark-mode.vue';
import LayoutMode from './modules/layout-mode.vue';
import ThemeColor from './modules/theme-color.vue';
import PageFun from './modules/page-fun.vue';
import AppearanceSettings from './modules/appearance/index.vue';
import LayoutSettings from './modules/layout/index.vue';
import GeneralSettings from './modules/general/index.vue';
import ConfigOperation from './modules/config-operation.vue';
defineOptions({
@ -12,15 +12,37 @@ defineOptions({
});
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>
<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>
<DarkMode />
<LayoutMode />
<ThemeColor />
<PageFun />
<NTabs v-model:value="activeTab" type="segment" size="medium" class="mb-16px">
<NTab name="appearance" :tab="$t('theme.tabs.appearance')"></NTab>
<NTab name="layout" :tab="$t('theme.tabs.layout')"></NTab>
<NTab name="general" :tab="$t('theme.tabs.general')"></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'" />
</KeepAlive>
</div>
<template #footer>
<ConfigOperation />
</template>
@ -28,4 +50,14 @@ const appStore = useAppStore();
</NDrawer>
</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">
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../components/setting-item.vue';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'ThemeColor'
@ -34,16 +34,16 @@ const swatches: string[] = [
</script>
<template>
<NDivider>{{ $t('theme.themeColor.title') }}</NDivider>
<NDivider>{{ $t('theme.appearance.themeColor.title') }}</NDivider>
<div class="flex-col-stretch gap-12px">
<NTooltip placement="top-start">
<template #trigger>
<SettingItem key="recommend-color" :label="$t('theme.recommendColor')">
<SettingItem key="recommend-color" :label="$t('theme.appearance.recommendColor')">
<NSwitch v-model:value="themeStore.recommendColor" />
</SettingItem>
</template>
<p>
<span class="pr-12px">{{ $t('theme.recommendColorDesc') }}</span>
<span class="pr-12px">{{ $t('theme.appearance.recommendColorDesc') }}</span>
<br />
<NButton
text
@ -57,10 +57,14 @@ const swatches: string[] = [
</NButton>
</p>
</NTooltip>
<SettingItem v-for="(_, key) in themeStore.themeColors" :key="key" :label="$t(`theme.themeColor.${key}`)">
<SettingItem
v-for="(_, key) in themeStore.themeColors"
:key="key"
:label="$t(`theme.appearance.themeColor.${key}`)"
>
<template v-if="key === 'info'" #suffix>
<NCheckbox v-model:checked="themeStore.isInfoFollowPrimary">
{{ $t('theme.themeColor.followPrimary') }}
{{ $t('theme.appearance.themeColor.followPrimary') }}
</NCheckbox>
</template>
<NColorPicker

View File

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

View File

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

@ -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

@ -58,101 +58,135 @@ const local: App.I18n.Schema = {
tokenExpired: 'The requested token has expired'
},
theme: {
themeSchema: {
title: 'Theme Schema',
light: 'Light',
dark: 'Dark',
auto: 'Follow System'
themeDrawerTitle: 'Theme Configuration',
tabs: {
appearance: 'Appearance',
layout: 'Layout',
general: 'General'
},
grayscale: 'Grayscale',
colourWeakness: 'Colour Weakness',
layoutMode: {
title: 'Layout Mode',
vertical: 'Vertical Menu Mode',
horizontal: 'Horizontal Menu Mode',
'vertical-mix': 'Vertical Mix Menu Mode',
'horizontal-mix': 'Horizontal Mix menu Mode',
reverseHorizontalMix: 'Reverse first level menus and child level menus position'
appearance: {
themeSchema: {
title: 'Theme Schema',
light: 'Light',
dark: 'Dark',
auto: 'Follow System'
},
grayscale: 'Grayscale',
colourWeakness: 'Colour Weakness',
themeColor: {
title: 'Theme Color',
primary: 'Primary',
info: 'Info',
success: 'Success',
warning: 'Warning',
error: 'Error',
followPrimary: 'Follow Primary'
},
recommendColor: 'Apply Recommended Color Algorithm',
recommendColorDesc: 'The recommended color algorithm refers to'
},
recommendColor: 'Apply Recommended Color Algorithm',
recommendColorDesc: 'The recommended color algorithm refers to',
themeColor: {
title: 'Theme Color',
primary: 'Primary',
info: 'Info',
success: 'Success',
warning: 'Warning',
error: 'Error',
followPrimary: 'Follow Primary'
},
scrollMode: {
title: 'Scroll Mode',
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'
layout: {
layoutMode: {
title: 'Layout Mode',
vertical: 'Vertical Mode',
horizontal: 'Horizontal Mode',
'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',
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',
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'
}
},
fixedHeaderAndTab: 'Fixed Header And Tab',
header: {
height: 'Header Height',
breadcrumb: {
visible: 'Breadcrumb Visible',
showIcon: 'Breadcrumb Icon Visible'
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: {
title: 'Multilingual Settings',
visible: 'Display multilingual button'
},
globalSearch: {
title: 'Global Search Settings',
visible: 'Display GlobalSearch button'
}
},
tab: {
visible: 'Tab Visible',
cache: 'Tag Bar Info Cache',
height: 'Tab Height',
mode: {
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'
},
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"',

View File

@ -58,101 +58,132 @@ const local: App.I18n.Schema = {
tokenExpired: 'token已过期'
},
theme: {
themeSchema: {
title: '主题模式',
light: '亮色模式',
dark: '暗黑模式',
auto: '跟随系统'
themeDrawerTitle: '主题配置',
tabs: {
appearance: '外观',
layout: '布局',
general: '通用'
},
grayscale: '灰色模式',
colourWeakness: '色弱模式',
layoutMode: {
title: '布局模式',
vertical: '左侧菜单模式',
'vertical-mix': '左侧菜单混合模式',
horizontal: '顶部菜单模式',
'horizontal-mix': '顶部菜单混合模式',
reverseHorizontalMix: '一级菜单与子级菜单位置反转'
appearance: {
themeSchema: {
title: '主题模式',
light: '亮色模式',
dark: '暗黑模式',
auto: '跟随系统'
},
grayscale: '灰色模式',
colourWeakness: '色弱模式',
themeColor: {
title: '主题颜色',
primary: '主色',
info: '信息色',
success: '成功色',
warning: '警告色',
error: '错误色',
followPrimary: '跟随主色'
},
recommendColor: '应用推荐算法的颜色',
recommendColorDesc: '推荐颜色的算法参照'
},
recommendColor: '应用推荐算法的颜色',
recommendColorDesc: '推荐颜色的算法参照',
themeColor: {
title: '主题颜色',
primary: '主色',
info: '信息色',
success: '成功色',
warning: '警告色',
error: '错误色',
followPrimary: '跟随主色'
},
scrollMode: {
title: '滚动模式',
wrapper: '外层滚动',
content: '主体滚动'
},
page: {
animate: '页面切换动画',
mode: {
title: '页面切换动画类型',
'fade-slide': '滑动',
fade: '淡入淡出',
'fade-bottom': '底部消退',
'fade-scale': '缩放消退',
'zoom-fade': '渐变',
'zoom-out': '闪现',
none: '无'
layout: {
layoutMode: {
title: '布局模式',
vertical: '左侧菜单模式',
'vertical-mix': '左侧菜单混合模式',
'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: '标签栏信息缓存',
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: '滚动模式',
wrapper: '外层滚动',
content: '主体滚动'
},
page: {
animate: '页面切换动画',
mode: {
title: '页面切换动画类型',
'fade-slide': '滑动',
fade: '淡入淡出',
'fade-bottom': '底部消退',
'fade-scale': '缩放消退',
'zoom-fade': '渐变',
'zoom-out': '闪现',
none: '无'
}
},
fixedHeaderAndTab: '固定头部和标签栏'
},
resetCacheStrategy: {
title: '重置缓存策略',
close: '关闭页面',
refresh: '刷新页面'
}
},
fixedHeaderAndTab: '固定头部和标签栏',
header: {
height: '头部高度',
breadcrumb: {
visible: '显示面包屑',
showIcon: '显示面包屑图标'
general: {
title: '通用设置',
watermark: {
title: '水印设置',
visible: '显示全屏水印',
text: '自定义水印文本',
enableUserName: '启用用户名水印',
enableTime: '显示当前时间',
timeFormat: '时间格式'
},
multilingual: {
title: '多语言设置',
visible: '显示多语言按钮'
},
globalSearch: {
title: '全局搜索设置',
visible: '显示全局搜索按钮'
}
},
tab: {
visible: '显示标签栏',
cache: '标签栏信息缓存',
height: '标签栏高度',
mode: {
title: '标签栏风格',
chrome: '谷歌风格',
button: '按钮风格'
}
},
sider: {
inverted: '深色侧边栏',
width: '侧边栏宽度',
collapsedWidth: '侧边栏折叠宽度',
mixWidth: '混合布局侧边栏宽度',
mixCollapsedWidth: '混合布局侧边栏折叠宽度',
mixChildMenuWidth: '混合布局子菜单宽度'
},
footer: {
visible: '显示底部',
fixed: '固定底部',
height: '底部高度',
right: '底部局右'
},
watermark: {
visible: '显示全屏水印',
text: '水印文本',
enableUserName: '启用用户名水印'
},
themeDrawerTitle: '主题配置',
pageFunTitle: '页面功能',
resetCacheStrategy: {
title: '重置缓存策略',
close: '关闭页面',
refresh: '刷新页面'
},
configOperation: {
copyConfig: '复制配置',
copySuccessMsg: '复制成功,请替换 src/theme/settings.ts 中的变量 themeSettings',

View File

@ -10,7 +10,7 @@ import type { RequestInstanceState } from './type';
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
export const request = createFlatRequest<App.Service.Response, RequestInstanceState>(
export const request = createFlatRequest(
{
baseURL,
headers: {
@ -18,6 +18,13 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
}
},
{
defaultState: {
errMsgStack: [],
refreshTokenPromise: null
} as RequestInstanceState,
transform(response: AxiosResponse<App.Service.Response<any>>) {
return response.data.data;
},
async onRequest(config) {
const Authorization = getAuthorization();
Object.assign(config.headers, { Authorization });
@ -91,9 +98,6 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
return null;
},
transformBackendResponse(response) {
return response.data.data;
},
onError(error) {
// when the request is fail, you can show error message
@ -123,11 +127,14 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
}
);
export const demoRequest = createRequest<App.Service.DemoResponse>(
export const demoRequest = createRequest(
{
baseURL: otherBaseURL.demo
},
{
transform(response: AxiosResponse<App.Service.DemoResponse>) {
return response.data.result;
},
async onRequest(config) {
const { headers } = config;
@ -147,9 +154,6 @@ export const demoRequest = createRequest<App.Service.DemoResponse>(
// when the backend response code is not "200", it means the request is fail
// for example: the token is expired, refresh token and retry request
},
transformBackendResponse(response) {
return response.data.result;
},
onError(error) {
// when the request is fail, you can show error message

View File

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

View File

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

View File

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

View File

@ -1,10 +1,11 @@
import { computed, effectScope, onScopeDispose, ref, toRefs, watch } 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 { getPaletteColorByNumber } from '@sa/color';
import { localStg } from '@/utils/storage';
import { SetupStoreId } from '@/enum';
import { useAuthStore } from '../auth';
import {
addThemeVarsToGlobal,
createThemeToken,
@ -18,10 +19,14 @@ import {
export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
const scope = effectScope();
const osTheme = usePreferredColorScheme();
const authStore = useAuthStore();
/** Theme settings */
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 */
const darkMode = computed(() => {
if (settings.value.themeScheme === 'auto') {
@ -57,6 +62,28 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
*/
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;
if (watermark.enableUserName && authStore.userInfo.userName) {
return authStore.userInfo.userName;
}
if (watermark.enableTime) {
return formattedWatermarkTime.value;
}
return watermark.text;
});
/** Reset store */
function resetStore() {
const themeStore = useThemeStore();
@ -144,13 +171,43 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
);
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) {
settings.value.layout.reverseHorizontalMix = reverse;
function setWatermarkEnableUserName(enable: boolean) {
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 */
@ -196,6 +253,15 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
},
{ immediate: true }
);
// watch watermark settings to control timer
watch(
() => [settings.value.watermark.visible, settings.value.watermark.enableTime],
() => {
updateWatermarkTimer();
},
{ immediate: true }
);
});
/** On scope dispose */
@ -209,6 +275,7 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
themeColors,
naiveTheme,
settingsJson,
watermarkContent,
setGrayscale,
setColourWeakness,
resetStore,
@ -216,6 +283,7 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
toggleThemeScheme,
updateThemeColors,
setThemeLayout,
setLayoutReverseHorizontalMix
setWatermarkEnableUserName,
setWatermarkEnableTime
};
});

View File

@ -15,8 +15,7 @@ export const themeSettings: App.Theme.ThemeSetting = {
resetCacheStrategy: 'close',
layout: {
mode: 'vertical',
scrollMode: 'content',
reverseHorizontalMix: false
scrollMode: 'content'
},
page: {
animate: true,
@ -59,7 +58,9 @@ export const themeSettings: App.Theme.ThemeSetting = {
watermark: {
visible: false,
text: 'SoybeanAdmin',
enableUserName: false
enableUserName: false,
enableTime: false,
timeFormat: 'YYYY-MM-DD HH:mm'
},
tokens: {
light: {

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

@ -0,0 +1,20 @@
declare namespace Api {
/**
* namespace Auth
*
* backend api module: "auth"
*/
namespace Auth {
interface LoginToken {
token: string;
refreshToken: string;
}
interface UserInfo {
userId: string;
userName: string;
roles: string[];
buttons: string[];
}
}
}

View File

@ -47,41 +47,4 @@ declare namespace Api {
status: EnableStatus | null;
} & T;
}
/**
* namespace Auth
*
* backend api module: "auth"
*/
namespace Auth {
interface LoginToken {
token: string;
refreshToken: string;
}
interface UserInfo {
userId: string;
userName: string;
roles: string[];
buttons: 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;
}
}
}

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;
}
}
}

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

@ -28,12 +28,6 @@ declare namespace App {
mode: UnionKey.ThemeLayoutMode;
/** Scroll mode */
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: {
@ -88,11 +82,14 @@ declare namespace App {
width: number;
/** Collapsed sider width */
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;
/** 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;
/** 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;
};
/** Footer */
@ -103,7 +100,10 @@ declare namespace App {
fixed: boolean;
/** Footer height */
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;
};
/** Watermark */
@ -114,6 +114,10 @@ declare namespace App {
text: string;
/** Whether to use user name as watermark text */
enableUserName: boolean;
/** Whether to use current time as watermark text */
enableTime: boolean;
/** Time format for watermark text */
timeFormat: string;
};
/** define some theme settings tokens, will transform to css variables */
tokens: {
@ -358,63 +362,88 @@ declare namespace App {
tokenExpired: string;
};
theme: {
themeSchema: { title: string } & Record<UnionKey.ThemeScheme, string>;
grayscale: string;
colourWeakness: string;
layoutMode: { title: string; reverseHorizontalMix: string } & Record<UnionKey.ThemeLayoutMode, string>;
recommendColor: string;
recommendColorDesc: 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>;
themeDrawerTitle: string;
tabs: {
appearance: string;
layout: string;
general: string;
};
fixedHeaderAndTab: string;
header: {
height: string;
breadcrumb: {
appearance: {
themeSchema: { title: string } & Record<UnionKey.ThemeScheme, string>;
grayscale: string;
colourWeakness: string;
themeColor: {
title: string;
followPrimary: string;
} & Theme.ThemeColor;
recommendColor: string;
recommendColorDesc: string;
};
layout: {
layoutMode: { title: string } & Record<UnionKey.ThemeLayoutMode, string> & {
[K in `${UnionKey.ThemeLayoutMode}_detail`]: string;
};
tab: {
title: string;
visible: string;
showIcon: string;
cache: 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 } & 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: {
title: string;
visible: string;
};
globalSearch: {
title: string;
visible: string;
};
};
tab: {
visible: string;
cache: string;
height: string;
mode: { title: string } & Record<UnionKey.ThemeTabMode, 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;
};
themeDrawerTitle: string;
pageFunTitle: string;
resetCacheStrategy: { title: string } & Record<UnionKey.ResetCacheStrategy, string>;
configOperation: {
copyConfig: string;
copySuccessMsg: string;

View File

@ -17,23 +17,14 @@ declare module 'vue' {
FullScreen: typeof import('./../components/common/full-screen.vue')['default']
IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
'IconIc:roundPlus': typeof import('~icons/ic/round-plus')['default']
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default']
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
IconLocalBanner: typeof import('~icons/local/banner')['default']
IconLocalLogo: typeof import('~icons/local/logo')['default']
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
IconMdiDrag: typeof import('~icons/mdi/drag')['default']
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
IconUilSearch: typeof import('~icons/uil/search')['default']
LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
LookForward: typeof import('./../components/custom/look-forward.vue')['default']
@ -45,19 +36,14 @@ declare module 'vue' {
NCard: typeof import('naive-ui')['NCard']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NColorPicker: typeof import('naive-ui')['NColorPicker']
NDataTable: typeof import('naive-ui')['NDataTable']
NDescriptions: typeof import('naive-ui')['NDescriptions']
NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDivider: typeof import('naive-ui')['NDivider']
NDrawer: typeof import('naive-ui')['NDrawer']
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
NDropdown: typeof import('naive-ui')['NDropdown']
NDynamicInput: typeof import('naive-ui')['NDynamicInput']
NEmpty: typeof import('naive-ui')['NEmpty']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
NGi: typeof import('naive-ui')['NGi']
NGrid: typeof import('naive-ui')['NGrid']
NInput: typeof import('naive-ui')['NInput']
@ -70,10 +56,6 @@ declare module 'vue' {
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NPopover: typeof import('naive-ui')['NPopover']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSpace: typeof import('naive-ui')['NSpace']
@ -81,10 +63,8 @@ declare module 'vue' {
NSwitch: typeof import('naive-ui')['NSwitch']
NTab: typeof import('naive-ui')['NTab']
NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag']
NThing: typeof import('naive-ui')['NThing']
NTooltip: typeof import('naive-ui')['NTooltip']
NTree: typeof import('naive-ui')['NTree']
NWatermark: typeof import('naive-ui')['NWatermark']
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
ReloadButton: typeof import('./../components/common/reload-button.vue')['default']

View File

@ -6,30 +6,14 @@ declare namespace NaiveUI {
type DataTableExpandColumn<T> = import('naive-ui').DataTableExpandColumn<T>;
type DataTableSelectionColumn<T> = import('naive-ui').DataTableSelectionColumn<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 TableDataWithIndex<T> = import('@sa/hooks').TableDataWithIndex<T>;
type FlatResponseData<T> = import('@sa/axios').FlatResponseData<T>;
/**
* 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 SetTableColumnKey<C, T> = Omit<C, 'key'> & { key: keyof T | (string & {}) };
type TableColumnWithKey<T> = SetTableColumnKey<DataTableBaseColumn<T>, T> | SetTableColumnKey<TableColumnGroup<T>, 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>>>;
/**
* the type of table operation
*
@ -37,18 +21,4 @@ declare namespace NaiveUI {
* - edit: edit table item
*/
type TableOperateType = 'add' | 'edit';
type GetTableData<A extends TableApiFn> = A extends TableApiFn<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;
};
}

View File

@ -28,9 +28,16 @@ declare namespace UnionKey {
* - vertical: the vertical menu in left
* - horizontal: the horizontal menu in top
* - 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