chore: init read-star monorepo

This commit is contained in:
2026-01-16 13:06:44 +08:00
commit 1e510efe8e
508 changed files with 44803 additions and 0 deletions

View File

@ -0,0 +1,9 @@
import useBoolean from './use-boolean'
import useContext from './use-context'
import useCountDown from './use-count-down'
import useLoading from './use-loading'
import useSvgIconRender from './use-svg-icon-render'
import useTable from './use-table'
export { useBoolean, useContext, useCountDown, useLoading, useSvgIconRender, useTable }
export type * from './use-table'

View File

@ -0,0 +1,31 @@
import { ref } from 'vue'
/**
* Boolean
*
* @param initValue Init value
*/
export default function useBoolean(initValue = false) {
const bool = ref(initValue)
function setBool(value: boolean) {
bool.value = value
}
function setTrue() {
setBool(true)
}
function setFalse() {
setBool(false)
}
function toggle() {
setBool(!bool.value)
}
return {
bool,
setBool,
setTrue,
setFalse,
toggle,
}
}

View File

@ -0,0 +1,96 @@
import { inject, provide } from 'vue'
/**
* Use context
*
* @example
* ```ts
* // there are three vue files: A.vue, B.vue, C.vue, and A.vue is the parent component of B.vue and C.vue
*
* // context.ts
* import { ref } from 'vue';
* import { useContext } from '@sa/hooks';
*
* export const [provideDemoContext, useDemoContext] = useContext('demo', () => {
* const count = ref(0);
*
* function increment() {
* count.value++;
* }
*
* function decrement() {
* count.value--;
* }
*
* return {
* count,
* increment,
* decrement
* };
* })
* ``` // A.vue
* ```vue
* <template>
* <div>A</div>
* </template>
* <script setup lang="ts">
* import { provideDemoContext } from './context';
*
* provideDemoContext();
* // const { increment } = provideDemoContext(); // also can control the store in the parent component
* </script>
* ``` // B.vue
* ```vue
* <template>
* <div>B</div>
* </template>
* <script setup lang="ts">
* import { useDemoContext } from './context';
*
* const { count, increment } = useDemoContext();
* </script>
* ```;
*
* // C.vue is same as B.vue
*
* @param contextName Context name
* @param fn Context function
*/
export default function useContext<Arguments extends Array<any>, T>(
contextName: string,
composable: (...args: Arguments) => T,
) {
const key = Symbol(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)
if (consumerName && !value) {
throw new Error(`\`${consumerName}\` must be used within \`${contextName}\``)
}
// @ts-expect-error - we want to return null if the value is undefined or null
return value || null
}
const useProvide = (...args: Arguments) => {
const value = composable(...args)
provide(key, value)
return value
}
return [useProvide, useInject] as const
}

View File

@ -0,0 +1,68 @@
import { useRafFn } from '@vueuse/core'
import { computed, onScopeDispose, ref } from 'vue'
/**
* A hook for implementing a countdown timer. It uses `requestAnimationFrame` for smooth and accurate timing,
* independent of the screen refresh rate.
*
* @param initialSeconds - The total number of seconds for the countdown.
*/
export default function useCountDown(initialSeconds: number) {
const remainingSeconds = ref(0)
const count = computed(() => Math.ceil(remainingSeconds.value))
const isCounting = computed(() => remainingSeconds.value > 0)
const { pause, resume } = useRafFn(
({ delta }) => {
// delta: milliseconds elapsed since the last frame.
// If countdown already reached zero or below, ensure it's 0 and stop.
if (remainingSeconds.value <= 0) {
remainingSeconds.value = 0
pause()
return
}
// Calculate seconds passed since the last frame.
const secondsPassed = delta / 1000
remainingSeconds.value -= secondsPassed
// If countdown has finished after decrementing.
if (remainingSeconds.value <= 0) {
remainingSeconds.value = 0
pause()
}
},
{ immediate: false }, // The timer does not start automatically.
)
/**
* Starts the countdown.
*
* @param [updatedSeconds] - Optionally, start with a new duration. Default is `initialSeconds`
*/
function start(updatedSeconds: number = initialSeconds) {
remainingSeconds.value = updatedSeconds
resume()
}
/** Stops the countdown and resets the remaining time to 0. */
function stop() {
remainingSeconds.value = 0
pause()
}
// Ensure the rAF loop is cleaned up when the component is unmounted.
onScopeDispose(() => {
pause()
})
return {
count,
isCounting,
start,
stop,
}
}

View File

@ -0,0 +1,16 @@
import useBoolean from './use-boolean'
/**
* Loading
*
* @param initValue Init value
*/
export default function useLoading(initValue = false) {
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(initValue)
return {
loading,
startLoading,
endLoading,
}
}

View File

@ -0,0 +1,83 @@
import type {
AxiosError,
CreateAxiosDefaults,
CustomAxiosRequestConfig,
MappedType,
RequestInstanceCommon,
RequestOption,
ResponseType,
} from '@sa/axios'
import type { Ref } from 'vue'
import { createFlatRequest } from '@sa/axios'
import { ref } from 'vue'
import useLoading from './use-loading'
export interface HookRequestInstanceResponseSuccessData<ApiData> {
data: Ref<ApiData>
error: Ref<null>
}
export interface HookRequestInstanceResponseFailData<ResponseData> {
data: Ref<null>
error: Ref<AxiosError<ResponseData>>
}
export type HookRequestInstanceResponseData<ResponseData, ApiData> = {
loading: Ref<boolean>
} & (HookRequestInstanceResponseSuccessData<ApiData> | HookRequestInstanceResponseFailData<ResponseData>)
export interface HookRequestInstance<
ResponseData,
ApiData,
State extends Record<string, unknown>,
> extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig
): HookRequestInstanceResponseData<ResponseData, MappedType<R, T>>
}
/**
* create a hook request instance
*
* @param axiosConfig
* @param options
*/
export default function createHookRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData, ApiData, State>>,
) {
const request = createFlatRequest<ResponseData, ApiData, State>(axiosConfig, options)
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(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 as MappedType<R, T>
}
else {
error.value = res.error
}
endLoading()
})
return {
loading,
data,
error,
}
} as HookRequestInstance<ResponseData, ApiData, State>
hookRequest.cancelAllRequest = request.cancelAllRequest
return hookRequest
}

View File

@ -0,0 +1,50 @@
import type { Component } from 'vue'
import { h } from 'vue'
/**
* Svg icon render hook
*
* @param SvgIcon Svg icon component
*/
export default function useSvgIconRender(SvgIcon: Component) {
interface IconConfig {
/** Iconify icon name */
icon?: string
/** Local icon name */
localIcon?: string
/** Icon color */
color?: string
/** Icon size */
fontSize?: number
}
type IconStyle = Partial<Pick<CSSStyleDeclaration, 'color' | 'fontSize'>>
/**
* Svg icon VNode
*
* @param config
*/
const SvgIconVNode = (config: IconConfig) => {
const { color, fontSize, icon, localIcon } = config
const style: IconStyle = {}
if (color) {
style.color = color
}
if (fontSize) {
style.fontSize = `${fontSize}px`
}
if (!icon && !localIcon) {
return undefined
}
return () => h(SvgIcon, { icon, localIcon, style })
}
return {
SvgIconVNode,
}
}

View File

@ -0,0 +1,133 @@
import type { Ref, VNodeChild } from 'vue'
import { computed, ref } from 'vue'
import useBoolean from './use-boolean'
import useLoading from './use-loading'
export interface PaginationData<T> {
data: T[]
pageNum: number
pageSize: number
total: number
}
type GetApiData<ApiData, Pagination extends boolean> = Pagination extends true ? PaginationData<ApiData> : ApiData[]
type Transform<ResponseData, ApiData, Pagination extends boolean> = (
response: ResponseData
) => GetApiData<ApiData, Pagination>
export type TableColumnCheckTitle = string | ((...args: any) => VNodeChild)
export interface TableColumnCheck {
key: string
title: TableColumnCheckTitle
checked: boolean
visible: boolean
}
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
*/
getColumnChecks: (columns: Column[]) => TableColumnCheck[]
/**
* get columns
*/
getColumns: (columns: Column[], checks: TableColumnCheck[]) => Column[]
/**
* callback when response fetched
*/
onFetched?: (data: GetApiData<ApiData, Pagination>) => void | Promise<void>
/**
* whether to get data immediately
*
* @default true
*/
immediate?: boolean
}
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 { api, pagination, transform, columns, getColumnChecks, getColumns, onFetched, immediate = true } = options
const data = ref([]) as Ref<ApiData[]>
const columnChecks = ref(getColumnChecks(columns())) as Ref<TableColumnCheck[]>
const $columns = computed(() => getColumns(columns(), columnChecks.value))
function reloadColumns() {
const checkMap = new Map(columnChecks.value.map(col => [col.key, col.checked]))
const defaultChecks = getColumnChecks(columns())
columnChecks.value = defaultChecks.map(col => ({
...col,
checked: checkMap.get(col.key) ?? col.checked,
}))
}
async function getData() {
try {
startLoading()
const response = await api()
const transformed = transform(response)
data.value = getTableData(transformed, pagination)
setEmpty(data.value.length === 0)
await onFetched?.(transformed)
}
finally {
endLoading()
}
}
if (immediate) {
getData()
}
return {
loading,
empty,
data,
columns: $columns,
columnChecks,
reloadColumns,
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[]
}