diff --git a/README copy.md b/README copy.md deleted file mode 100644 index e69de29..0000000 diff --git a/index.html b/index.html index 396c997..7bd8c20 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Uroboros +
diff --git a/package.json b/package.json index 8d5ecb2..300200f 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "blog-admin", + "name": "react-tmpl", "private": true, "version": "0.0.0", "type": "module", @@ -11,9 +11,11 @@ "preview": "vite preview", "preinstall": "npx only-allow pnpm", "format": "prettier --write src/", - "type-check": "vue-tsc --build" + "type-check": "vue-tsc --build", + "deploy": "./scripts/deploy.sh" }, "dependencies": { + "@base-ui/react": "^1.6.0", "@hookform/resolvers": "^5.4.0", "@mantine/hooks": "^9.4.1", "@radix-ui/react-collapsible": "^1.1.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d795a9..0be2bf0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@base-ui/react': + specifier: ^1.6.0 + version: 1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@hookform/resolvers': specifier: ^5.4.0 version: 5.4.0(react-hook-form@7.81.0(react@19.2.7)) @@ -221,6 +224,33 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@base-ui/react@1.6.0': + resolution: {integrity: sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@date-fns/tz': ^1.2.0 + '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@date-fns/tz': + optional: true + '@types/react': + optional: true + date-fns: + optional: true + + '@base-ui/utils@0.3.1': + resolution: {integrity: sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@date-fns/tz@1.5.0': resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} @@ -2836,6 +2866,9 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3298,6 +3331,31 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@base-ui/react@1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@base-ui/utils': 0.3.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@floating-ui/utils': 0.2.11 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@date-fns/tz': 1.5.0 + '@types/react': 19.2.17 + date-fns: 4.4.0 + + '@base-ui/utils@0.3.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@floating-ui/utils': 0.2.11 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + reselect: 5.2.0 + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@date-fns/tz@1.5.0': {} '@esbuild/aix-ppc64@0.28.1': @@ -6022,6 +6080,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + reselect@5.2.0: {} + resolve-from@4.0.0: {} resolve@2.0.0-next.7: diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..5e506ce --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -euo pipefail + +PROJECT_ROOT=$(cd -- "$(dirname "${BASH_SOURCE[0]}")/.." &> /dev/null && pwd) +REMOTE_SERVER="01" +REMOTE_DIR="/srv/docker/nginx/www/admin" + +cd "${PROJECT_ROOT}" + +pnpm run build + +echo $PWD + +tar -czf dist.tar.gz ./dist >/dev/null 2>&1 + +scp dist.tar.gz "${REMOTE_SERVER}:${REMOTE_DIR}" + +ssh "$REMOTE_SERVER" bash -s -- "${REMOTE_DIR}" << 'EOF' + REMOTE_DIR="$1" + + set -euo pipefail + + cd "${REMOTE_DIR}" + rm -rf dist + tar -xzf dist.tar.gz + rm dist.tar.gz +EOF + +rm -rf dist +rm -f dist.tar.gz +echo "部署完成" diff --git a/src/api/blog/tag.ts b/src/api/blog/tag.ts new file mode 100644 index 0000000..02b0040 --- /dev/null +++ b/src/api/blog/tag.ts @@ -0,0 +1,22 @@ +import { http } from '@/lib' +import type { PageParams, Tag, TagForm } from '@/schemas' + +export const getTags = (params: PageParams) => { + return http.get>('/tag', { params }) +} + +export const createTag = (data: TagForm) => { + return http.post('/tag', data) +} + +export const updateTag = (id: number, data: TagForm) => { + return http.patch(`/tag/${id}`, data) +} + +export const deleteTag = (id: number) => { + return http.delete(`/tag/${id}`) +} + +export const listAllTags = () => { + return http.get('/tag/all') +} diff --git a/src/api/index.ts b/src/api/index.ts index c15c2a2..9c289a1 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -8,3 +8,4 @@ export * from './system/file' // blog export * from './blog/post' export * from './blog/category' +export * from './blog/tag' diff --git a/src/api/system/api.ts b/src/api/system/api.ts index c37a8e1..797ff47 100644 --- a/src/api/system/api.ts +++ b/src/api/system/api.ts @@ -1,7 +1,7 @@ import { http } from '@/lib' -import type { PageParams, SysApi, SysApiForm } from '@/schemas' +import type { PageParams, SearchSysApiParams, SysApi, SysApiForm } from '@/schemas' -export const getSysApis = (params: PageParams) => { +export const getSysApis = (params: PageParams & SearchSysApiParams) => { return http.get>('/api', { params }) } diff --git a/src/api/system/user.ts b/src/api/system/user.ts index 535d3cb..b0b9805 100644 --- a/src/api/system/user.ts +++ b/src/api/system/user.ts @@ -1,8 +1,14 @@ import { http } from '@/lib' -import type { SysRole, SysUser, SysUserFormOutput, SysUserInfo } from '@/schemas' +import type { + SearchSysUserParams, + SysRole, + SysUser, + SysUserFormOutput, + SysUserInfo, +} from '@/schemas' import { type PageParams } from '@/schemas' -export const getSysUsers = (params: PageParams) => { +export const getSysUsers = (params: PageParams & SearchSysUserParams) => { return http.get>('/user', { params }) } diff --git a/src/components/auth/index.tsx b/src/components/auth/index.tsx new file mode 100644 index 0000000..3b31d2f --- /dev/null +++ b/src/components/auth/index.tsx @@ -0,0 +1,29 @@ +import { type ReactNode } from 'react' + +import { useStore } from '@/store' + +interface AuthProps { + authority: string | string[] + fallback?: ReactNode | null + mode?: 'all' | 'any' + children: ReactNode +} + +export const Auth = ({ authority, fallback = null, mode = 'all', children }: AuthProps) => { + const userInfo = useStore((state) => state.userInfo) + const permissions = userInfo?.permissions || [] + + let hasAuth: boolean + + if (Array.isArray(authority)) { + if (mode === 'all') { + hasAuth = authority.every((role) => permissions.includes(role)) + } else { + hasAuth = authority.some((role) => permissions.includes(role)) + } + } else { + hasAuth = permissions.includes(authority) + } + + return hasAuth ? <>{children} : <>{fallback} +} diff --git a/src/components/checkbox-tree/use-checkbox-tree.ts b/src/components/checkbox-tree/use-checkbox-tree.ts index 10ac3b8..6913afa 100644 --- a/src/components/checkbox-tree/use-checkbox-tree.ts +++ b/src/components/checkbox-tree/use-checkbox-tree.ts @@ -89,5 +89,20 @@ export const useCheckboxTree = (treeData: CheckboxTreeNode[], defaultChecked: st [checkedIds, childIdsMap, directChildrenMap, getNodeState, parentMap], ) - return { checkedIds, toggleNode, getNodeState, setCheckedIds } + const getCheckedIdsWithParents = useCallback(() => { + const ids = new Set(checkedIds) + + checkedIds.forEach((id) => { + let parentId = parentMap.get(id) + + while (parentId) { + ids.add(parentId) + parentId = parentMap.get(parentId) + } + }) + + return Array.from(ids) + }, [checkedIds, parentMap]) + + return { checkedIds, toggleNode, getNodeState, setCheckedIds, getCheckedIdsWithParents } } diff --git a/src/components/data-table/base-table.tsx b/src/components/data-table/base-table.tsx index f005bb6..97d0837 100644 --- a/src/components/data-table/base-table.tsx +++ b/src/components/data-table/base-table.tsx @@ -24,7 +24,7 @@ export function BaseTable({ table }: Props) { const pageSize = table.getState().pagination.pageSize const rowCount = table.options.rowCount const currentPageRowsLength = table.getCoreRowModel().rows.length - const showLoading = table.options.meta?.showLoading + const isFetching = table.options.meta?.isFetching useEffect(() => { if (pageIndex > 0 && currentPageRowsLength === 0 && rowCount) { @@ -39,12 +39,12 @@ export function BaseTable({ table }: Props) { const isTree = !!table.options.getExpandedRowModel return ( - -
+ +
{table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => ( { table: Table + toolbarLeft?: ReactNode + toolbarRight?: ReactNode + search?: ReactNode } -export function DataTable({ table }: Props) { +function TableTitle({ title }: { title?: string }) { + if (!title) return null + return
{title}
+} + +export function DataTable({ table, search, toolbarLeft, toolbarRight }: Props) { const pageIndex = table.getState().pagination.pageIndex const pageSize = table.getState().pagination.pageSize const rowCount = table.options.rowCount const rowLength = table.getCoreRowModel().rows.length + const title = useRouterState().matches.at(-1)?.staticData?.title useEffect(() => { // 如果非第一页且数据为空 返回最近一页有数据的页面 @@ -29,6 +40,12 @@ export function DataTable({ table }: Props) { return ( <> + + + {search &&
{search}
} + + + diff --git a/src/components/data-table/pagination.tsx b/src/components/data-table/pagination.tsx index 2b7bec5..59681c7 100644 --- a/src/components/data-table/pagination.tsx +++ b/src/components/data-table/pagination.tsx @@ -51,66 +51,68 @@ export function DataTablePagination({ table }: DataTablePaginationProps +
当前在第 {pagination.active} 页,共 {total}
-
- - {pagination.range.map((range, index) => { - if (range === 'dots') { +
+
+ + {pagination.range.map((range, index) => { + if (range === 'dots') { + return ( + + ) + } + return ( - ) - } + })} - return ( - - ) - })} + +
- -
- -
- +
+ +
) diff --git a/src/components/data-table/toolbar.tsx b/src/components/data-table/toolbar.tsx new file mode 100644 index 0000000..8b2d6bf --- /dev/null +++ b/src/components/data-table/toolbar.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from 'react' + +import type { Table } from '@tanstack/react-table' +import { RefreshCcw } from 'lucide-react' + +import { DataTableViewOptions } from '@/components/data-table/view-options.tsx' +import { Button } from '@/components/ui/button.tsx' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip.tsx' + +type DataTableToolbarProps = { + table: Table + toolbarLeft?: ReactNode + toolbarRight?: ReactNode +} + +export function DataTableToolbar({ + table, + toolbarLeft, + toolbarRight, +}: DataTableToolbarProps) { + const refetch = table.options.meta?.refetch + const isFetching = table.options.meta?.isFetching + + return ( +
+
{toolbarLeft}
+
+ {toolbarRight} + +
+ {refetch && ( + + + + + + + 刷新表格 + + )} + + +
+
+
+ ) +} diff --git a/src/components/data-table/view-options.tsx b/src/components/data-table/view-options.tsx new file mode 100644 index 0000000..ba36e11 --- /dev/null +++ b/src/components/data-table/view-options.tsx @@ -0,0 +1,63 @@ +import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu' +import { type Column, type Table } from '@tanstack/react-table' +import { Settings2 } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, +} from '@/components/ui/dropdown-menu' + +type DataTableViewOptionsProps = { + table: Table +} + +const getColumnLabel = (column: Column) => { + const metaLabel = column.columnDef.meta?.label + const header = column.columnDef.header + + if (typeof metaLabel === 'string') { + return metaLabel + } + + if (typeof header === 'string') { + return header + } + + return column.id +} + +export function DataTableViewOptions({ table }: DataTableViewOptionsProps) { + return ( + + + + + + 显示列 + + {table + .getAllColumns() + .filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide()) + .map((column) => { + return ( + column.toggleVisibility(!!value)} + > + {getColumnLabel(column)} + + ) + })} + + + ) +} diff --git a/src/components/dialog-skeleton/index.tsx b/src/components/dialog-skeleton/index.tsx new file mode 100644 index 0000000..a1e520e --- /dev/null +++ b/src/components/dialog-skeleton/index.tsx @@ -0,0 +1,28 @@ +import { type ReactNode } from 'react' + +import { Skeleton } from '@/components/ui/skeleton' +import { cn } from '@/lib/utils' + +interface Props { + loading: boolean + children: ReactNode + className?: string + rows?: number +} + +export const DialogSkeleton = ({ loading, children, className, rows = 3 }: Props) => { + if (!loading) { + return children + } + + return ( +
+ {Array.from({ length: rows }).map((_, index) => ( + 1 && 'w-3/4')} + /> + ))} +
+ ) +} diff --git a/src/components/label/index.ts b/src/components/label/index.ts new file mode 100644 index 0000000..f92340b --- /dev/null +++ b/src/components/label/index.ts @@ -0,0 +1 @@ +export { StatusLabel } from './status.tsx' diff --git a/src/components/label/status.tsx b/src/components/label/status.tsx new file mode 100644 index 0000000..c4002bc --- /dev/null +++ b/src/components/label/status.tsx @@ -0,0 +1,18 @@ +import { Status, statusDict, type StatusValues } from '@/enum' +import { cn } from '@/lib' + +const colorMap = { + [Status.enabled]: 'bg-green-500', + [Status.disabled]: 'bg-red-500', +} + +export function StatusLabel({ value }: { value: StatusValues }) { + const label = statusDict[value] || value + + return ( +
+ + {label} +
+ ) +} diff --git a/src/components/menu-icon/index.tsx b/src/components/menu-icon/index.tsx index 9b0e2bb..1559eda 100644 --- a/src/components/menu-icon/index.tsx +++ b/src/components/menu-icon/index.tsx @@ -7,6 +7,7 @@ import { House, NotebookText, SquarePen, + Tags, TextAlignJustify, UserRoundKey, UsersRound, @@ -31,6 +32,7 @@ const iconMap: Partial> = { [MenuIconKey.POST]: NotebookText, [MenuIconKey.POST_EDIT]: SquarePen, [MenuIconKey.CATEGORY]: ChartBarStacked, + [MenuIconKey.TAG]: Tags, } export const MenuIcon = ({ className, menuIconKey }: Props) => { diff --git a/src/components/multiple-select/index.tsx b/src/components/multiple-select/index.tsx new file mode 100644 index 0000000..3e3566a --- /dev/null +++ b/src/components/multiple-select/index.tsx @@ -0,0 +1,78 @@ +'use client' + +import * as React from 'react' + +import { + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxValue, + useComboboxAnchor, +} from '@/components/ui/combobox' + +export interface MultipleSelectOption { + label: string + value: number +} + +interface MultipleSelectProps { + value?: number[] + onValueChange?: (value: number[]) => void + items?: MultipleSelectOption[] + placeholder?: string + className?: string +} + +export function MultipleSelect({ + value, + onValueChange, + items = [], + placeholder, + className, +}: MultipleSelectProps) { + const anchor = useComboboxAnchor() + + const selectedItems = items.filter((item) => value?.includes(item.value)) + + const handleValueChange = (selectedOptions: MultipleSelectOption[]) => { + onValueChange?.(selectedOptions.map((item) => item.value)) + } + + return ( + + + + {(values) => ( + + {values.map((item: MultipleSelectOption) => ( + {item.label} + ))} + + + )} + + + + No items found. + + {(item) => ( + + {item.label} + + )} + + + + ) +} diff --git a/src/components/loading/index.tsx b/src/components/page-loading/index.tsx similarity index 88% rename from src/components/loading/index.tsx rename to src/components/page-loading/index.tsx index eb4125c..29bdf34 100644 --- a/src/components/loading/index.tsx +++ b/src/components/page-loading/index.tsx @@ -2,7 +2,7 @@ import logo from '@/assets/logo.png' import styles from './loading.module.css' -export const Loading = () => { +export const PageLoading = () => { return (
Logo diff --git a/src/components/loading/loading.module.css b/src/components/page-loading/loading.module.css similarity index 100% rename from src/components/loading/loading.module.css rename to src/components/page-loading/loading.module.css diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx new file mode 100644 index 0000000..2415bd8 --- /dev/null +++ b/src/components/ui/combobox.tsx @@ -0,0 +1,310 @@ +"use client" + +import * as React from "react" +import { Combobox as ComboboxPrimitive } from "@base-ui/react" +import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group" + +const Combobox = ComboboxPrimitive.Root + +function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) { + return +} + +function ComboboxTrigger({ + className, + children, + ...props +}: ComboboxPrimitive.Trigger.Props) { + return ( + + {children} + + + ) +} + +function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { + return ( + } + className={cn(className)} + {...props} + > + + + ) +} + +function ComboboxInput({ + className, + children, + disabled = false, + showTrigger = true, + showClear = false, + ...props +}: ComboboxPrimitive.Input.Props & { + showTrigger?: boolean + showClear?: boolean +}) { + return ( + + } + {...props} + /> + + {showTrigger && ( + + + + )} + {showClear && } + + {children} + + ) +} + +function ComboboxContent({ + className, + side = "bottom", + sideOffset = 6, + align = "start", + alignOffset = 0, + anchor, + ...props +}: ComboboxPrimitive.Popup.Props & + Pick< + ComboboxPrimitive.Positioner.Props, + "side" | "align" | "sideOffset" | "alignOffset" | "anchor" + >) { + return ( + + + + + + ) +} + +function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { + return ( + + ) +} + +function ComboboxItem({ + className, + children, + ...props +}: ComboboxPrimitive.Item.Props) { + return ( + + {children} + + } + > + + + + ) +} + +function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) { + return ( + + ) +} + +function ComboboxLabel({ + className, + ...props +}: ComboboxPrimitive.GroupLabel.Props) { + return ( + + ) +} + +function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) { + return ( + + ) +} + +function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) { + return ( + + ) +} + +function ComboboxSeparator({ + className, + ...props +}: ComboboxPrimitive.Separator.Props) { + return ( + + ) +} + +function ComboboxChips({ + className, + ...props +}: React.ComponentPropsWithRef & + ComboboxPrimitive.Chips.Props) { + return ( + + ) +} + +function ComboboxChip({ + className, + children, + showRemove = true, + ...props +}: ComboboxPrimitive.Chip.Props & { + showRemove?: boolean +}) { + return ( + + {children} + {showRemove && ( + } + className="-ml-1 opacity-50 hover:opacity-100" + data-slot="combobox-chip-remove" + > + + + )} + + ) +} + +function ComboboxChipsInput({ + className, + children, + ...props +}: ComboboxPrimitive.Input.Props) { + return ( + + ) +} + +function useComboboxAnchor() { + return React.useRef(null) +} + +export { + Combobox, + ComboboxInput, + ComboboxContent, + ComboboxList, + ComboboxItem, + ComboboxGroup, + ComboboxLabel, + ComboboxCollection, + ComboboxEmpty, + ComboboxSeparator, + ComboboxChips, + ComboboxChip, + ComboboxChipsInput, + ComboboxTrigger, + ComboboxValue, + useComboboxAnchor, +} diff --git a/src/components/ui/input-group.tsx b/src/components/ui/input-group.tsx index 6db1784..7f78fec 100644 --- a/src/components/ui/input-group.tsx +++ b/src/components/ui/input-group.tsx @@ -12,7 +12,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) { data-slot="input-group" role="group" className={cn( - "group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none", + "group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30", "h-9 min-w-0 has-[>textarea]:h-auto", // Variants based on alignment. @@ -22,10 +22,10 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) { "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", // Focus state. - "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]", + "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50", // Error state. - "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", + "has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", className )} @@ -35,7 +35,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) { } const inputGroupAddonVariants = cva( - "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50", + "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", { variants: { align: { @@ -44,9 +44,9 @@ const inputGroupAddonVariants = cva( "inline-end": "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", "block-start": - "order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5", + "order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3", "block-end": - "order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5", + "order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3", }, }, defaultVariants: { @@ -78,12 +78,12 @@ function InputGroupAddon({ } const inputGroupButtonVariants = cva( - "text-sm shadow-none flex gap-2 items-center", + "flex items-center gap-2 text-sm shadow-none", { variants: { size: { - xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2", - sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5", + xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", + sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5", "icon-xs": "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", "icon-sm": "size-8 p-0 has-[>svg]:p-0", @@ -118,7 +118,7 @@ function InputGroupText({ className, ...props }: React.ComponentProps<"span">) { return ( ) { type={type} data-slot="input" className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", - "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", - "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30", + "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", + "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40", className )} {...props} diff --git a/src/enum/menu_icon.ts b/src/enum/menu_icon.ts index 78d8247..5e37527 100644 --- a/src/enum/menu_icon.ts +++ b/src/enum/menu_icon.ts @@ -8,6 +8,7 @@ export const MenuIconKey = { POST: 7, POST_EDIT: 8, CATEGORY: 9, + TAG: 10, } as const export type MenuIconKeyValues = (typeof MenuIconKey)[keyof typeof MenuIconKey] @@ -22,6 +23,6 @@ export const MenuIconOptions: MenuIconOption[] = Object.entries(MenuIconKey).map ([key, value]) => ({ label: key as keyof typeof MenuIconKey, value, - selectValue: value.toString() as `${MenuIconKeyValues}`, + selectValue: value?.toString() as `${MenuIconKeyValues}`, }), ) diff --git a/src/enum/post_status.tsx b/src/enum/post_status.ts similarity index 81% rename from src/enum/post_status.tsx rename to src/enum/post_status.ts index b74de22..ed6666e 100644 --- a/src/enum/post_status.tsx +++ b/src/enum/post_status.ts @@ -18,19 +18,19 @@ export const postStatusOptions = [ { label: postStatusDict[PostStatus.draft], value: String(PostStatus.draft), - icon: , + icon: ClipboardPenLine, className: 'bg-gray-200 text-gray-600', }, { label: postStatusDict[PostStatus.published], value: String(PostStatus.published), - icon: , - className: 'bg-lime-100 text-emerald-700', + icon: Globe, + className: 'bg-blue-400 text-white', }, { label: postStatusDict[PostStatus.offline], value: String(PostStatus.offline), - icon: , - className: 'bg-gray-100 text-gray-300', + icon: EyeOff, + className: 'bg-gray-100 text-gray-400', }, ] diff --git a/src/layout/components/app-header.tsx b/src/layout/components/app-header.tsx index de0d5f7..7aafe08 100644 --- a/src/layout/components/app-header.tsx +++ b/src/layout/components/app-header.tsx @@ -26,13 +26,13 @@ import { generateBreadcrumbs } from '@/lib' import { useStore } from '@/store' export const AppHeader = () => { - const location = useLocation() + const pathname = useLocation({ select: (location) => location.pathname }) const userInfo = useStore((state) => state.userInfo) const logout = useStore((state) => state.logout) const breadcrumbs = useMemo(() => { - return generateBreadcrumbs(userInfo?.menus || [], location.pathname) - }, [userInfo?.menus, location.pathname]) + return generateBreadcrumbs(userInfo?.menus || [], pathname) + }, [userInfo?.menus, pathname]) return (
diff --git a/src/layout/components/app-sidebar.tsx b/src/layout/components/app-sidebar.tsx index c939189..1974b2e 100644 --- a/src/layout/components/app-sidebar.tsx +++ b/src/layout/components/app-sidebar.tsx @@ -19,6 +19,7 @@ import { import { MenuType } from '@/enum' import { generateMenus } from '@/lib' import type { SysMenuTree } from '@/schemas' +import { siteConfig } from '@/siteConfig.ts' import { useStore } from '@/store' const isMenuActive = (menu: SysMenuTree, pathname: string): boolean => { @@ -86,7 +87,7 @@ export function AppSidebar({ ...props }: ComponentProps) {
- Uroboros + {siteConfig.title}
diff --git a/src/layout/index.tsx b/src/layout/index.tsx index d30fcd7..a2ff791 100644 --- a/src/layout/index.tsx +++ b/src/layout/index.tsx @@ -21,8 +21,8 @@ export default function Layout({ children }: { children?: React.ReactNode }) { -
-
+
+
{children ?? }
diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..1fad14c --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,19 @@ +import { useStore } from '@/store' + +export function hasPermission(authority: string | string[], mode: 'all' | 'any' = 'all'): boolean { + const permissions = useStore.getState().userInfo?.permissions ?? [] + + let hasAuth: boolean + + if (Array.isArray(authority)) { + if (mode === 'all') { + hasAuth = authority.every((role) => permissions.includes(role)) + } else { + hasAuth = authority.some((role) => permissions.includes(role)) + } + } else { + hasAuth = permissions.includes(authority) + } + + return hasAuth +} diff --git a/src/lib/http.ts b/src/lib/http.ts index 6bd00a2..78d93b1 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -5,7 +5,22 @@ import { toast } from 'sonner' import type { LoginResponse } from '@/schemas' import { useStore } from '@/store' +type RequestConfig = AxiosRequestConfig & { + skipAuth?: boolean +} + const TOKEN_REFRESH_BUFFER = 60 +const AUTH_WHITELIST = ['/auth/login', '/auth/logout'] + +const isAuthWhitelistUrl = (url?: string) => { + if (!url) { + return false + } + + const pathname = url.split('?')[0] + + return AUTH_WHITELIST.includes(pathname) +} const isTokenExpired = (tokenExp: string | null) => { if (!tokenExp) { @@ -41,7 +56,6 @@ const getValidToken = async () => { if (token && !isTokenExpired(tokenExp)) { return token } - if (!refreshTokenPromise) { refreshTokenPromise = refreshInstance .post>('/auth/refresh-token') @@ -51,7 +65,9 @@ const getValidToken = async () => { return access_token }) .catch((error) => { - if (!isLoginPage()) { + const status = error.response?.status + + if (!isLoginPage() && (status === 400 || status === 401 || status === 403)) { logout() } @@ -76,6 +92,12 @@ class HttpClient { // 请求拦截器 this.instance.interceptors.request.use( async (config) => { + const requestConfig = config as RequestConfig + + if (requestConfig.skipAuth || isAuthWhitelistUrl(config.url)) { + return config + } + const token = await getValidToken() if (token) { config.headers.Authorization = `Bearer ${token}` @@ -127,14 +149,14 @@ class HttpClient { } public request, D = unknown>( - config: AxiosRequestConfig, + config: RequestConfig, ): Promise { return this.instance(config) } public get, D = unknown>( url: string, - config?: AxiosRequestConfig, + config?: RequestConfig, ): Promise { return this.instance.get(url, config) } @@ -142,7 +164,7 @@ class HttpClient { public post, D = unknown>( url: string, data?: D, - config?: AxiosRequestConfig, + config?: RequestConfig, ): Promise { return this.instance.post(url, data, config) } @@ -150,7 +172,7 @@ class HttpClient { public patch, D = unknown>( url: string, data?: D, - config?: AxiosRequestConfig, + config?: RequestConfig, ): Promise { return this.instance.patch(url, data, config) } @@ -158,14 +180,14 @@ class HttpClient { public put, D = unknown>( url: string, data?: D, - config?: AxiosRequestConfig, + config?: RequestConfig, ): Promise { return this.instance.put(url, data, config) } public delete, D = unknown>( url: string, - config?: AxiosRequestConfig, + config?: RequestConfig, ): Promise { return this.instance.delete(url, config) } diff --git a/src/lib/index.tsx b/src/lib/index.tsx index 6f87951..2647000 100644 --- a/src/lib/index.tsx +++ b/src/lib/index.tsx @@ -3,3 +3,4 @@ export * from './menus' export * from './utils' export * from './format' export * from './sleep' +export * from './auth' diff --git a/src/lib/menus.ts b/src/lib/menus.ts index f2b7adb..6715d5a 100644 --- a/src/lib/menus.ts +++ b/src/lib/menus.ts @@ -23,7 +23,7 @@ export const generateMenus = ({ .sort((a, b) => a.sort - b.sort) .map((item) => ({ ...item, - children: generateMenus({ menus, parent_id: item.id, showHidden }), + children: generateMenus({ menus, parent_id: item.id, showHidden, showButton }), })) } diff --git a/src/main.tsx b/src/main.tsx index 27da0c1..847b209 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,7 +7,7 @@ import { Toaster } from 'sonner' import { TooltipProvider } from '@/components/ui/tooltip' -import { Loading } from './components/loading' +import { PageLoading } from './components/page-loading' import { setupRouter } from './routes' import { useStore } from './store' @@ -26,7 +26,7 @@ const queryClient = new QueryClient({ const bootstrap = async () => { const root = ReactDOM.createRoot(document.getElementById('root')!) - root.render() + root.render() try { await useStore.getState().init() diff --git a/src/pages/blog/category/components/action-dialog.tsx b/src/pages/blog/category/components/action-dialog.tsx index e736a75..4232605 100644 --- a/src/pages/blog/category/components/action-dialog.tsx +++ b/src/pages/blog/category/components/action-dialog.tsx @@ -41,6 +41,7 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) { : { name: '', code: '', + sort: 0, }, }) @@ -75,10 +76,10 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) { control={form.control} render={({ field, fieldState }) => ( - 分类名称 + 分类名称 ( - 分类编码 + 分类编码 )} + + ( + + 排序 + + {fieldState.invalid && } + + )} + /> diff --git a/src/pages/blog/category/components/category-table.tsx b/src/pages/blog/category/components/category-table.tsx index fb106aa..76ffd54 100644 --- a/src/pages/blog/category/components/category-table.tsx +++ b/src/pages/blog/category/components/category-table.tsx @@ -1,5 +1,7 @@ import { type Table } from '@tanstack/react-table' +import { Plus } from 'lucide-react' +import { Auth } from '@/components/auth' import { DataTable } from '@/components/data-table' import { Button } from '@/components/ui/button.tsx' @@ -13,12 +15,18 @@ export function CategoryTable({ table }: Props) { const { setAction } = useCrud() return ( - <> - - - - + + + + + } + /> + ) } diff --git a/src/pages/blog/category/components/columns.tsx b/src/pages/blog/category/components/columns.tsx index 6e6f2f7..34bfb60 100644 --- a/src/pages/blog/category/components/columns.tsx +++ b/src/pages/blog/category/components/columns.tsx @@ -1,12 +1,14 @@ import type { ColumnDef } from '@tanstack/react-table' -import { formatDate } from '@/lib' +import { formatDate, hasPermission } from '@/lib' import type { Category } from '@/schemas' import { RowActions } from './row-actions' export const createColumns = (): ColumnDef[] => { - return [ + const showAction = hasPermission(['category:update', 'category:delete'], 'any') + + const columns: ColumnDef[] = [ { accessorKey: 'name', header: '分类名称', @@ -19,6 +21,12 @@ export const createColumns = (): ColumnDef[] => { meta: { className: ' max-w-36' }, cell: ({ row }) =>
{row.getValue('code')}
, }, + { + accessorKey: 'sort', + header: '排序', + meta: { className: ' max-w-36' }, + cell: ({ row }) =>
{row.getValue('sort')}
, + }, { accessorKey: 'created_at', header: '创建时间', @@ -37,11 +45,15 @@ export const createColumns = (): ColumnDef[] => { return
{formatDate(rawDate)}
}, }, - { + ] + + if (showAction) { + columns.push({ id: 'actions', enableHiding: false, meta: { className: ' max-w-36 sticky right-0' }, cell: RowActions, - }, - ] + }) + } + return columns } diff --git a/src/pages/blog/category/components/row-actions.tsx b/src/pages/blog/category/components/row-actions.tsx index 12167a8..7601322 100644 --- a/src/pages/blog/category/components/row-actions.tsx +++ b/src/pages/blog/category/components/row-actions.tsx @@ -1,6 +1,7 @@ import { type Row } from '@tanstack/react-table' import { MoreHorizontal } from 'lucide-react' +import { Auth } from '@/components/auth' import { Button } from '@/components/ui/button.tsx' import { DropdownMenu, @@ -42,8 +43,12 @@ export function RowActions({ row }: Props) { 操作 - 编辑分类 - 删除分类 + + 编辑分类 + + + 删除分类 + ) diff --git a/src/pages/blog/category/index.tsx b/src/pages/blog/category/index.tsx index 5e26caf..d2adcb3 100644 --- a/src/pages/blog/category/index.tsx +++ b/src/pages/blog/category/index.tsx @@ -1,9 +1,10 @@ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { getCoreRowModel, useReactTable } from '@tanstack/react-table' import { getCategories } from '@/api' +import { hasPermission } from '@/lib' import { type Category } from '@/schemas' import { ActionDialogs } from './components/action-dialogs.tsx' @@ -17,9 +18,8 @@ export default function CrudPage() { pageIndex: 0, pageSize: 10, }) - const [rowCount, setRowCount] = useState() - const { data, isFetching } = useQuery({ + const { data, isFetching, refetch } = useQuery({ queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize], queryFn: () => getCategories({ @@ -27,28 +27,24 @@ export default function CrudPage() { page_size: pagination.pageSize, }), placeholderData: (prev) => prev, + enabled: hasPermission('category:list'), }) const tableData = useMemo(() => data?.list ?? [], [data]) const columns = useMemo(() => createColumns(), []) - useEffect(() => { - if (!isFetching && data?.total !== undefined) { - setRowCount(data.total) - } - }, [isFetching, data?.total]) - const table = useReactTable({ data: tableData, columns, getCoreRowModel: getCoreRowModel(), manualPagination: true, - rowCount: rowCount, + rowCount: data?.total, state: { pagination }, onPaginationChange: setPagination, meta: { - showLoading: isFetching, + isFetching, + refetch, }, }) diff --git a/src/pages/blog/post-edit/index.tsx b/src/pages/blog/post-edit/index.tsx index e04a0e4..e1d50ef 100644 --- a/src/pages/blog/post-edit/index.tsx +++ b/src/pages/blog/post-edit/index.tsx @@ -6,20 +6,20 @@ import dayjs from 'dayjs' import { Save } from 'lucide-react' import { Controller, useForm } from 'react-hook-form' import { toast } from 'sonner' -import { Button } from 'src/components/ui/button' -import { Card, CardContent, CardHeader, CardTitle } from 'src/components/ui/card' + +import { createPost, getPost, listAllCategories, listAllTags, updatePost } from '@/api' +import { WEditor } from '@/components/editor' +import { MultipleSelect, type MultipleSelectOption } from '@/components/multiple-select' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, -} from 'src/components/ui/field.tsx' -import { Input } from 'src/components/ui/input.tsx' -import { postFormSchema, type Category, type PostFormInput, type PostFormOutput } from 'src/schemas' - -import { createPost, getPost, listAllCategories, updatePost } from '@/api' -import { WEditor } from '@/components/editor' +} from '@/components/ui/field.tsx' +import { Input } from '@/components/ui/input.tsx' import { Select, SelectContent, @@ -32,6 +32,7 @@ import { Spinner } from '@/components/ui/spinner.tsx' import { Textarea } from '@/components/ui/textarea.tsx' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { postStatusOptions } from '@/enum' +import { postFormSchema, type Category, type PostFormInput, type PostFormOutput } from '@/schemas' import { CoverUpload } from './components/cover-upload.tsx' import { DatetimePicker } from './components/datetime-picker.tsx' @@ -42,6 +43,7 @@ export default function PostEdit() { const { id } = useSearch({ strict: false }) const [editorContent, setEditorContent] = useState('') const [categories, setCategories] = useState([]) + const [tags, setTags] = useState([]) const [loading, setLoading] = useState(false) const form = useForm({ @@ -56,12 +58,16 @@ export default function PostEdit() { cover: undefined, sort: 0, category_id: undefined, + tags: [], }, }) const fetchCategories = async () => { const { data } = await listAllCategories() setCategories(data) + + const { data: tags } = await listAllTags() + setTags(tags.map((tag) => ({ label: tag.name, value: tag.id }))) } const fetchPostDetail = useCallback( @@ -174,7 +180,7 @@ export default function PostEdit() { control={form.control} render={({ field, fieldState }) => ( - 可见性 + 可见性 - {item.icon} + {item.label} ))} @@ -216,12 +222,12 @@ export default function PostEdit() { control={form.control} render={({ field, fieldState }) => ( - 文章分类 + 文章分类 ( - URL 别名 + URL 别名 ( - SEO摘要 + SEO摘要