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,21 @@
import { useAuthStore } from '@/store/modules/auth'
export function useAuth() {
const authStore = useAuthStore()
function hasAuth(codes: string | string[]) {
if (!authStore.isLogin) {
return false
}
if (typeof codes === 'string') {
return authStore.userInfo.buttons.includes(codes)
}
return codes.some(code => authStore.userInfo.buttons.includes(code))
}
return {
hasAuth,
}
}

View File

@ -0,0 +1,71 @@
import { useCountDown, useLoading } from '@sa/hooks'
import { computed } from 'vue'
import { REG_PHONE } from '@/constants/reg'
import { $t } from '@/locales'
export function useCaptcha() {
const { loading, startLoading, endLoading } = useLoading()
const { count, start, stop, isCounting } = useCountDown(10)
const label = computed(() => {
let text = $t('page.login.codeLogin.getCode')
const countingLabel = $t('page.login.codeLogin.reGetCode', { time: count.value })
if (loading.value) {
text = ''
}
if (isCounting.value) {
text = countingLabel
}
return text
})
function isPhoneValid(phone: string) {
if (phone.trim() === '') {
window.$message?.error?.($t('form.phone.required'))
return false
}
if (!REG_PHONE.test(phone)) {
window.$message?.error?.($t('form.phone.invalid'))
return false
}
return true
}
async function getCaptcha(phone: string) {
const valid = isPhoneValid(phone)
if (!valid || loading.value) {
return
}
startLoading()
// request
await new Promise((resolve) => {
setTimeout(resolve, 500)
})
window.$message?.success?.($t('page.login.codeLogin.sendCodeSuccess'))
start()
endLoading()
}
return {
label,
start,
stop,
isCounting,
loading,
getCaptcha,
}
}

View File

@ -0,0 +1,233 @@
import type {
BarSeriesOption,
GaugeSeriesOption,
LineSeriesOption,
PictorialBarSeriesOption,
PieSeriesOption,
RadarSeriesOption,
ScatterSeriesOption,
} from 'echarts/charts'
import type {
DatasetComponentOption,
GridComponentOption,
LegendComponentOption,
TitleComponentOption,
ToolboxComponentOption,
TooltipComponentOption,
} from 'echarts/components'
import { useElementSize } from '@vueuse/core'
import { BarChart, GaugeChart, LineChart, PictorialBarChart, PieChart, RadarChart, ScatterChart } from 'echarts/charts'
import {
DatasetComponent,
GridComponent,
LegendComponent,
TitleComponent,
ToolboxComponent,
TooltipComponent,
TransformComponent,
} from 'echarts/components'
import * as echarts from 'echarts/core'
import { LabelLayout, UniversalTransition } from 'echarts/features'
import { CanvasRenderer } from 'echarts/renderers'
import { computed, effectScope, nextTick, onScopeDispose, shallowRef, watch } from 'vue'
import { useThemeStore } from '@/store/modules/theme'
export type ECOption = echarts.ComposeOption<
| BarSeriesOption
| LineSeriesOption
| PieSeriesOption
| ScatterSeriesOption
| PictorialBarSeriesOption
| RadarSeriesOption
| GaugeSeriesOption
| TitleComponentOption
| LegendComponentOption
| TooltipComponentOption
| GridComponentOption
| ToolboxComponentOption
| DatasetComponentOption
>
echarts.use([
TitleComponent,
LegendComponent,
TooltipComponent,
GridComponent,
DatasetComponent,
TransformComponent,
ToolboxComponent,
BarChart,
LineChart,
PieChart,
ScatterChart,
PictorialBarChart,
RadarChart,
GaugeChart,
LabelLayout,
UniversalTransition,
CanvasRenderer,
])
interface ChartHooks {
onRender?: (chart: echarts.ECharts) => void | Promise<void>
onUpdated?: (chart: echarts.ECharts) => void | Promise<void>
onDestroy?: (chart: echarts.ECharts) => void | Promise<void>
}
/**
* use echarts
*
* @param optionsFactory echarts options factory function
* @param darkMode dark mode
*/
export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: ChartHooks = {}) {
const scope = effectScope()
const themeStore = useThemeStore()
const darkMode = computed(() => themeStore.darkMode)
const domRef = shallowRef<HTMLElement | null>(null)
const initialSize = { width: 0, height: 0 }
const { width, height } = useElementSize(domRef, initialSize)
const chart = shallowRef<echarts.ECharts | null>(null)
const chartOptions: T = optionsFactory()
const {
onRender = (instance) => {
const textColor = darkMode.value ? 'rgb(224, 224, 224)' : 'rgb(31, 31, 31)'
const maskColor = darkMode.value ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.8)'
instance.showLoading({
color: themeStore.themeColor,
textColor,
fontSize: 14,
maskColor,
})
},
onUpdated = (instance) => {
instance.hideLoading()
},
onDestroy,
} = hooks
/** is chart rendered */
function isRendered() {
return Boolean(domRef.value && chart.value)
}
/**
* update chart options
*
* @param callback callback function
*/
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
const updatedOpts = callback(chartOptions, optionsFactory)
Object.assign(chartOptions, updatedOpts)
await nextTick()
if (!isRendered())
return
if (isRendered()) {
chart.value?.clear()
}
chart.value?.setOption({ ...updatedOpts, backgroundColor: 'transparent' })
await onUpdated?.(chart.value!)
}
function setOptions(options: T) {
chart.value?.setOption(options)
}
/** render chart */
async function render() {
if (isRendered())
return
const chartTheme = darkMode.value ? 'dark' : 'light'
chart.value = echarts.init(domRef.value, chartTheme)
chart.value?.setOption({ ...chartOptions, backgroundColor: 'transparent' })
await onRender?.(chart.value!)
}
/** resize chart */
function resize() {
chart.value?.resize()
}
/** destroy chart */
async function destroy() {
if (!chart.value)
return
await onDestroy?.(chart.value)
chart.value?.dispose()
chart.value = null
}
/** change chart theme */
async function changeTheme() {
await destroy()
await render()
await onUpdated?.(chart.value!)
}
/**
* render chart by size
*
* @param w width
* @param h height
*/
async function renderChartBySize(w: number, h: number) {
initialSize.width = w
initialSize.height = h
// resize chart
if (isRendered()) {
resize()
return
}
// render chart
await render()
if (chart.value) {
await onUpdated?.(chart.value)
}
}
scope.run(() => {
watch(
[width, height],
([newWidth, newHeight]) => {
renderChartBySize(newWidth, newHeight)
},
{ flush: 'post' },
)
watch(darkMode, () => {
changeTheme()
})
})
onScopeDispose(() => {
destroy()
scope.stop()
})
return {
domRef,
chart,
updateOptions,
setOptions,
}
}

View File

@ -0,0 +1,97 @@
import type { FormInst } from 'naive-ui'
import type { ComputedRef, Ref } from 'vue'
import { ref, toValue } from 'vue'
import { REG_CODE_SIX, REG_EMAIL, REG_PHONE, REG_PWD, REG_USER_NAME } from '@/constants/reg'
import { $t } from '@/locales'
export function useFormRules() {
const patternRules = {
userName: {
pattern: REG_USER_NAME,
message: $t('form.userName.invalid'),
trigger: 'change',
},
phone: {
pattern: REG_PHONE,
message: $t('form.phone.invalid'),
trigger: 'change',
},
pwd: {
pattern: REG_PWD,
message: $t('form.pwd.invalid'),
trigger: 'change',
},
code: {
pattern: REG_CODE_SIX,
message: $t('form.code.invalid'),
trigger: 'change',
},
email: {
pattern: REG_EMAIL,
message: $t('form.email.invalid'),
trigger: 'change',
},
} satisfies Record<string, App.Global.FormRule>
const formRules = {
userName: [createRequiredRule($t('form.userName.required')), patternRules.userName],
phone: [createRequiredRule($t('form.phone.required')), patternRules.phone],
pwd: [createRequiredRule($t('form.pwd.required')), patternRules.pwd],
code: [createRequiredRule($t('form.code.required')), patternRules.code],
email: [createRequiredRule($t('form.email.required')), patternRules.email],
} satisfies Record<string, App.Global.FormRule[]>
/** the default required rule */
const defaultRequiredRule = createRequiredRule($t('form.required'))
function createRequiredRule(message: string): App.Global.FormRule {
return {
required: true,
message,
}
}
/** create a rule for confirming the password */
function createConfirmPwdRule(pwd: string | Ref<string> | ComputedRef<string>) {
const confirmPwdRule: App.Global.FormRule[] = [
{ required: true, message: $t('form.confirmPwd.required') },
{
asyncValidator: (rule, value) => {
if (value.trim() !== '' && value !== toValue(pwd)) {
return Promise.reject(rule.message)
}
return Promise.resolve()
},
message: $t('form.confirmPwd.invalid'),
trigger: 'input',
},
]
return confirmPwdRule
}
return {
patternRules,
formRules,
defaultRequiredRule,
createRequiredRule,
createConfirmPwdRule,
}
}
export function useNaiveForm() {
const formRef = ref<FormInst | null>(null)
async function validate() {
await formRef.value?.validate()
}
async function restoreValidation() {
formRef.value?.restoreValidation()
}
return {
formRef,
validate,
restoreValidation,
}
}

View File

@ -0,0 +1,10 @@
import { useSvgIconRender } from '@sa/hooks'
import SvgIcon from '@/components/custom/svg-icon.vue'
export function useSvgIcon() {
const { SvgIconVNode } = useSvgIconRender(SvgIcon)
return {
SvgIconVNode,
}
}

View File

@ -0,0 +1,116 @@
import type { RouteKey } from '@elegant-router/types'
import type { RouteLocationRaw } from 'vue-router'
import { useRouter } from 'vue-router'
import { router as globalRouter } from '@/router'
/**
* Router push
*
* Jump to the specified route, it can replace function router.push
*
* @param inSetup Whether is in vue script setup
*/
export function useRouterPush(inSetup = true) {
const router = inSetup ? useRouter() : globalRouter
const route = globalRouter.currentRoute
const routerPush = router.push
const routerBack = router.back
async function routerPushByKey(key: RouteKey, options?: App.Global.RouterPushOptions) {
const { query, params } = options || {}
const routeLocation: RouteLocationRaw = {
name: key,
}
if (Object.keys(query || {}).length) {
routeLocation.query = query
}
if (Object.keys(params || {}).length) {
routeLocation.params = params
}
return routerPush(routeLocation)
}
function routerPushByKeyWithMetaQuery(key: RouteKey) {
const allRoutes = router.getRoutes()
const meta = allRoutes.find(item => item.name === key)?.meta || null
const query: Record<string, string> = {}
meta?.query?.forEach((item) => {
query[item.key] = item.value
})
return routerPushByKey(key, { query })
}
async function toHome() {
return routerPushByKey('root')
}
/**
* Navigate to login page
*
* @param loginModule The login module
* @param redirectUrl The redirect url, if not specified, it will be the current route fullPath
*/
async function toLogin(loginModule?: UnionKey.LoginModule, redirectUrl?: string) {
const module = loginModule || 'pwd-login'
const options: App.Global.RouterPushOptions = {
params: {
module,
},
}
const redirect = redirectUrl || route.value.fullPath
options.query = {
redirect,
}
return routerPushByKey('login', options)
}
/**
* Toggle login module
*
* @param module
*/
async function toggleLoginModule(module: UnionKey.LoginModule) {
const query = route.value.query as Record<string, string>
return routerPushByKey('login', { query, params: { module } })
}
/**
* Redirect from login
*
* @param [needRedirect] Whether to redirect after login. Default is `true`
*/
async function redirectFromLogin(needRedirect = true) {
const redirect = route.value.query?.redirect as string
if (needRedirect && redirect) {
await routerPush(redirect)
}
else {
await toHome()
}
}
return {
routerPush,
routerBack,
routerPushByKey,
routerPushByKeyWithMetaQuery,
toLogin,
toggleLoginModule,
redirectFromLogin,
}
}

View File

@ -0,0 +1,315 @@
import type { FlatResponseData } from '@sa/axios'
import type { PaginationData, TableColumnCheck, UseTableOptions } from '@sa/hooks'
import type { PaginationProps } from 'naive-ui'
import type { Ref } from 'vue'
import { useBoolean, useTable } from '@sa/hooks'
import { jsonClone } from '@sa/utils'
import { computed, effectScope, onScopeDispose, reactive, shallowRef, watch } from 'vue'
import { $t } from '@/locales'
import { useAppStore } from '@/store/modules/app'
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
}
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 result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, false>({
...options,
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
getColumns,
})
// calculate the total width of the table this is used for horizontal scrolling
const scrollX = computed(() => {
return result.columns.value.reduce((acc, column) => {
return acc + Number(column.width ?? column.minWidth ?? 120)
}, 0)
})
scope.run(() => {
watch(
() => appStore.locale,
() => {
result.reloadColumns()
},
)
})
onScopeDispose(() => {
scope.stop()
})
return {
...result,
scrollX,
}
}
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
pagination.pageSize = data.pageSize
},
})
async function getDataByPage(page: number = 1) {
if (page !== pagination.page) {
pagination.page = page
return
}
await result.getData()
}
scope.run(() => {
watch(
() => appStore.locale,
() => {
result.reloadColumns()
},
)
watch(paginationParams, async (newVal) => {
await options.onPaginationParamsChange?.(newVal)
await result.getData()
})
})
onScopeDispose(() => {
scope.stop()
})
return {
...result,
getDataByPage,
pagination,
mobilePagination,
}
}
export function useTableOperate<TableData>(
data: Ref<TableData[]>,
idKey: keyof TableData,
getData: () => Promise<void>,
) {
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean()
const operateType = shallowRef<NaiveUI.TableOperateType>('add')
function handleAdd() {
operateType.value = 'add'
openDrawer()
}
/** the editing row data */
const editingData = shallowRef<TableData | null>(null)
function handleEdit(id: TableData[keyof TableData]) {
operateType.value = 'edit'
const findItem = data.value.find(item => item[idKey] === id) || null
editingData.value = jsonClone(findItem)
openDrawer()
}
/** the checked row keys of table */
const checkedRowKeys = shallowRef<string[]>([])
/** the hook after the batch delete operation is completed */
async function onBatchDeleted() {
window.$message?.success($t('common.deleteSuccess'))
checkedRowKeys.value = []
await getData()
}
/** the hook after the delete operation is completed */
async function onDeleted() {
window.$message?.success($t('common.deleteSuccess'))
await getData()
}
return {
drawerVisible,
openDrawer,
closeDrawer,
operateType,
handleAdd,
editingData,
handleEdit,
checkedRowKeys,
onBatchDeleted,
onDeleted,
}
}
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)
}