feat: api client, auth store, login, authed shell, data-table + CRUD kit
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Backend API origin (no trailing slash)
|
||||||
|
VITE_API_BASE_URL=https://gig-platform.ddev.site
|
||||||
131
src/api/client.ts
Normal file
131
src/api/client.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { useAuthStore } from '@/auth/store'
|
||||||
|
import type { ApiErrorPayload, ApiResponse, ListParams, TokenGrant } from './types'
|
||||||
|
|
||||||
|
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? ''
|
||||||
|
|
||||||
|
export const ADMIN_CLIENT_MACHINE_NAME = 'admin-api-client'
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public code: string,
|
||||||
|
message: string,
|
||||||
|
public errors?: Record<string, string[]>,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions {
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||||
|
body?: unknown
|
||||||
|
params?: ListParams
|
||||||
|
/** skip Bearer + refresh handling (login/refresh calls) */
|
||||||
|
anonymous?: boolean
|
||||||
|
signal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(path: string, params?: ListParams): string {
|
||||||
|
const url = new URL(path, BASE_URL)
|
||||||
|
if (params) {
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
url.searchParams.set(key, String(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-flight refresh: concurrent 401s share one refresh attempt.
|
||||||
|
let refreshInFlight: Promise<boolean> | null = null
|
||||||
|
|
||||||
|
async function tryRefresh(): Promise<boolean> {
|
||||||
|
refreshInFlight ??= (async () => {
|
||||||
|
const { refreshToken } = useAuthStore.getState()
|
||||||
|
if (!refreshToken) return false
|
||||||
|
try {
|
||||||
|
const res = await fetch(buildUrl('/api/v1/auth/refresh-token'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
client_machine_name: ADMIN_CLIENT_MACHINE_NAME,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) return false
|
||||||
|
const json = (await res.json()) as ApiResponse<TokenGrant>
|
||||||
|
if (!json.success || !json.data.access_token) return false
|
||||||
|
useAuthStore.getState().setTokens(json.data)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
// allow the next expiry to trigger a fresh attempt
|
||||||
|
setTimeout(() => {
|
||||||
|
refreshInFlight = null
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return refreshInFlight
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseError(res: Response): Promise<ApiError> {
|
||||||
|
let payload: ApiErrorPayload | null = null
|
||||||
|
try {
|
||||||
|
payload = (await res.json()) as ApiErrorPayload
|
||||||
|
} catch {
|
||||||
|
// non-JSON error body
|
||||||
|
}
|
||||||
|
return new ApiError(
|
||||||
|
res.status,
|
||||||
|
payload?.code ?? `HTTP_${res.status}`,
|
||||||
|
payload?.message ?? res.statusText,
|
||||||
|
payload?.data?.errors,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rawRequest(path: string, options: RequestOptions, retried = false): Promise<Response> {
|
||||||
|
const { accessToken } = useAuthStore.getState()
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-Language-Locale': 'zh-CN',
|
||||||
|
}
|
||||||
|
if (options.body !== undefined) headers['Content-Type'] = 'application/json'
|
||||||
|
if (!options.anonymous && accessToken) headers.Authorization = `Bearer ${accessToken}`
|
||||||
|
|
||||||
|
const res = await fetch(buildUrl(path, options.params), {
|
||||||
|
method: options.method ?? 'GET',
|
||||||
|
headers,
|
||||||
|
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||||
|
signal: options.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 401 && !options.anonymous && !retried) {
|
||||||
|
if (await tryRefresh()) {
|
||||||
|
return rawRequest(path, options, true)
|
||||||
|
}
|
||||||
|
useAuthStore.getState().clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const res = await rawRequest(path, options)
|
||||||
|
if (!res.ok) throw await parseError(res)
|
||||||
|
return (await res.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(path: string, params?: ListParams, signal?: AbortSignal) =>
|
||||||
|
request<T>(path, { params, signal }),
|
||||||
|
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body }),
|
||||||
|
put: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PUT', body }),
|
||||||
|
patch: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body }),
|
||||||
|
delete: <T>(path: string, body?: unknown) => request<T>(path, { method: 'DELETE', body }),
|
||||||
|
anonymousPost: <T>(path: string, body?: unknown) =>
|
||||||
|
request<T>(path, { method: 'POST', body, anonymous: true }),
|
||||||
|
}
|
||||||
17
src/api/modules/auth.ts
Normal file
17
src/api/modules/auth.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { api } from '@/api/client'
|
||||||
|
import type { ApiResponse, MeResponse, TokenGrant } from '@/api/types'
|
||||||
|
|
||||||
|
export function loginWithPassword(identity: string, password: string) {
|
||||||
|
return api.anonymousPost<ApiResponse<TokenGrant>>('/api/admin/v1/auth/login/password', {
|
||||||
|
identity,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchMe() {
|
||||||
|
return api.get<MeResponse>('/api/admin/v1/auth/me')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function issueRobotSessionToken(uid: string) {
|
||||||
|
return api.post<ApiResponse<TokenGrant>>(`/api/admin/v1/auth/robots/${uid}/token`)
|
||||||
|
}
|
||||||
55
src/auth/store.ts
Normal file
55
src/auth/store.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
import type { AdminUser, TokenGrant } from '@/api/types'
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
accessToken: string | null
|
||||||
|
refreshToken: string | null
|
||||||
|
user: AdminUser | null
|
||||||
|
roles: string[]
|
||||||
|
permissions: string[]
|
||||||
|
setTokens: (grant: TokenGrant) => void
|
||||||
|
setSession: (user: AdminUser, roles: string[], permissions: string[]) => void
|
||||||
|
clear: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
user: null,
|
||||||
|
roles: [],
|
||||||
|
permissions: [],
|
||||||
|
setTokens: (grant) =>
|
||||||
|
set({
|
||||||
|
accessToken: grant.access_token,
|
||||||
|
refreshToken: grant.refresh_token ?? null,
|
||||||
|
}),
|
||||||
|
setSession: (user, roles, permissions) => set({ user, roles, permissions }),
|
||||||
|
clear: () =>
|
||||||
|
set({ accessToken: null, refreshToken: null, user: null, roles: [], permissions: [] }),
|
||||||
|
}),
|
||||||
|
{ name: 'gig-admin-auth' },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export function hasRole(role: string): boolean {
|
||||||
|
return useAuthStore.getState().roles.includes(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** super-admin implicitly passes every permission check (mirrors backend seeding). */
|
||||||
|
export function can(permission: string): boolean {
|
||||||
|
const { roles, permissions } = useAuthStore.getState()
|
||||||
|
return roles.includes('super-admin') || permissions.includes(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCan(permission: string): boolean {
|
||||||
|
const roles = useAuthStore((s) => s.roles)
|
||||||
|
const permissions = useAuthStore((s) => s.permissions)
|
||||||
|
return roles.includes('super-admin') || permissions.includes(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthenticated(): boolean {
|
||||||
|
return Boolean(useAuthStore.getState().accessToken)
|
||||||
|
}
|
||||||
@ -1,44 +0,0 @@
|
|||||||
export default function Footer() {
|
|
||||||
const year = new Date().getFullYear()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="mt-20 border-t border-[var(--line)] px-4 pb-14 pt-10 text-[var(--sea-ink-soft)]">
|
|
||||||
<div className="page-wrap flex flex-col items-center justify-between gap-4 text-center sm:flex-row sm:text-left">
|
|
||||||
<p className="m-0 text-sm">
|
|
||||||
© {year} Your name here. All rights reserved.
|
|
||||||
</p>
|
|
||||||
<p className="island-kicker m-0">Built with TanStack Start</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex justify-center gap-4">
|
|
||||||
<a
|
|
||||||
href="https://x.com/tan_stack"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="rounded-xl p-2 text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)]"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Follow TanStack on X</span>
|
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12.6 1h2.2L10 6.48 15.64 15h-4.41L7.78 9.82 3.23 15H1l5.14-5.84L.72 1h4.52l3.12 4.73L12.6 1zm-.77 12.67h1.22L4.57 2.26H3.26l8.57 11.41z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://github.com/TanStack"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="rounded-xl p-2 text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)]"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Go to TanStack GitHub</span>
|
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import ThemeToggle from './ThemeToggle'
|
|
||||||
|
|
||||||
export default function Header() {
|
|
||||||
return (
|
|
||||||
<header className="sticky top-0 z-50 border-b border-[var(--line)] bg-[var(--header-bg)] px-4 backdrop-blur-lg">
|
|
||||||
<nav className="page-wrap flex flex-wrap items-center gap-x-3 gap-y-2 py-3 sm:py-4">
|
|
||||||
<h2 className="m-0 flex-shrink-0 text-base font-semibold tracking-tight">
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm text-[var(--sea-ink)] no-underline shadow-[0_8px_24px_rgba(30,90,72,0.08)] sm:px-4 sm:py-2"
|
|
||||||
>
|
|
||||||
<span className="h-2 w-2 rounded-full bg-[linear-gradient(90deg,#56c6be,#7ed3bf)]" />
|
|
||||||
TanStack Start
|
|
||||||
</Link>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="order-3 flex w-full flex-wrap items-center gap-x-4 gap-y-1 pb-1 text-sm font-semibold sm:order-none sm:w-auto sm:flex-nowrap sm:pb-0">
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="nav-link"
|
|
||||||
activeProps={{ className: 'nav-link is-active' }}
|
|
||||||
>
|
|
||||||
Home
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/about"
|
|
||||||
className="nav-link"
|
|
||||||
activeProps={{ className: 'nav-link is-active' }}
|
|
||||||
>
|
|
||||||
About
|
|
||||||
</Link>
|
|
||||||
<a
|
|
||||||
href="https://tanstack.com/start/latest/docs/framework/react/overview"
|
|
||||||
className="nav-link"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Docs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-1.5 sm:gap-2">
|
|
||||||
<a
|
|
||||||
href="https://x.com/tan_stack"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="hidden rounded-xl p-2 text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)] sm:block"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Follow TanStack on X</span>
|
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true" width="24" height="24">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12.6 1h2.2L10 6.48 15.64 15h-4.41L7.78 9.82 3.23 15H1l5.14-5.84L.72 1h4.52l3.12 4.73L12.6 1zm-.77 12.67h1.22L4.57 2.26H3.26l8.57 11.41z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://github.com/TanStack"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="hidden rounded-xl p-2 text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)] sm:block"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Go to TanStack GitHub</span>
|
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true" width="24" height="24">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
type ThemeMode = 'light' | 'dark' | 'auto'
|
|
||||||
|
|
||||||
function getInitialMode(): ThemeMode {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return 'auto'
|
|
||||||
}
|
|
||||||
|
|
||||||
const stored = window.localStorage.getItem('theme')
|
|
||||||
if (stored === 'light' || stored === 'dark' || stored === 'auto') {
|
|
||||||
return stored
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'auto'
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyThemeMode(mode: ThemeMode) {
|
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
const resolved = mode === 'auto' ? (prefersDark ? 'dark' : 'light') : mode
|
|
||||||
|
|
||||||
document.documentElement.classList.remove('light', 'dark')
|
|
||||||
document.documentElement.classList.add(resolved)
|
|
||||||
|
|
||||||
if (mode === 'auto') {
|
|
||||||
document.documentElement.removeAttribute('data-theme')
|
|
||||||
} else {
|
|
||||||
document.documentElement.setAttribute('data-theme', mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
document.documentElement.style.colorScheme = resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ThemeToggle() {
|
|
||||||
const [mode, setMode] = useState<ThemeMode>('auto')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const initialMode = getInitialMode()
|
|
||||||
setMode(initialMode)
|
|
||||||
applyThemeMode(initialMode)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== 'auto') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
|
||||||
const onChange = () => applyThemeMode('auto')
|
|
||||||
|
|
||||||
media.addEventListener('change', onChange)
|
|
||||||
return () => {
|
|
||||||
media.removeEventListener('change', onChange)
|
|
||||||
}
|
|
||||||
}, [mode])
|
|
||||||
|
|
||||||
function toggleMode() {
|
|
||||||
const nextMode: ThemeMode =
|
|
||||||
mode === 'light' ? 'dark' : mode === 'dark' ? 'auto' : 'light'
|
|
||||||
setMode(nextMode)
|
|
||||||
applyThemeMode(nextMode)
|
|
||||||
window.localStorage.setItem('theme', nextMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
const label =
|
|
||||||
mode === 'auto'
|
|
||||||
? 'Theme mode: auto (system). Click to switch to light mode.'
|
|
||||||
: `Theme mode: ${mode}. Click to switch mode.`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleMode}
|
|
||||||
aria-label={label}
|
|
||||||
title={label}
|
|
||||||
className="rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm font-semibold text-[var(--sea-ink)] shadow-[0_8px_22px_rgba(30,90,72,0.08)] transition hover:-translate-y-0.5"
|
|
||||||
>
|
|
||||||
{mode === 'auto' ? 'Auto' : mode === 'dark' ? 'Dark' : 'Light'}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
68
src/components/confirm-dialog.tsx
Normal file
68
src/components/confirm-dialog.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
trigger: React.ReactNode
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
confirmLabel?: string
|
||||||
|
destructive?: boolean
|
||||||
|
onConfirm: () => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
trigger,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel = '确认',
|
||||||
|
destructive = false,
|
||||||
|
onConfirm,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
{description && <AlertDialogDescription>{description}</AlertDialogDescription>}
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={busy}
|
||||||
|
className={destructive ? 'bg-destructive text-white hover:bg-destructive/90' : undefined}
|
||||||
|
onClick={async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await onConfirm()
|
||||||
|
// close via Escape-equivalent: AlertDialogAction default closes,
|
||||||
|
// but we prevented it — dispatch close by clicking cancel sibling.
|
||||||
|
;(event.target as HTMLElement)
|
||||||
|
.closest('[role="alertdialog"]')
|
||||||
|
?.querySelector<HTMLButtonElement>('[data-slot="alert-dialog-cancel"]')
|
||||||
|
?.click()
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? '处理中…' : confirmLabel}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
155
src/components/data-table/data-table.tsx
Normal file
155
src/components/data-table/data-table.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type ColumnDef,
|
||||||
|
} from '@tanstack/react-table'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
import type { Pagination } from '@/api/types'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
|
||||||
|
interface DataTableProps<T> {
|
||||||
|
columns: ColumnDef<T, any>[]
|
||||||
|
data: T[]
|
||||||
|
pagination?: Pagination
|
||||||
|
loading?: boolean
|
||||||
|
emptyText?: string
|
||||||
|
onPageChange?: (page: number) => void
|
||||||
|
onPageSizeChange?: (pageSize: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-paginated table bound to the backend's Pagination envelope
|
||||||
|
* (page / page_size request params).
|
||||||
|
*/
|
||||||
|
export function DataTable<T>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
pagination,
|
||||||
|
loading = false,
|
||||||
|
emptyText = '暂无数据',
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: DataTableProps<T>) {
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
manualPagination: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
{columns.map((_, j) => (
|
||||||
|
<TableCell key={j}>
|
||||||
|
<Skeleton className="h-4 w-full max-w-32" />
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : table.getRowModel().rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
|
||||||
|
{emptyText}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pagination && (
|
||||||
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
|
<div>共 {pagination.total_count} 条</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{onPageSizeChange && (
|
||||||
|
<Select
|
||||||
|
value={String(pagination.page_size)}
|
||||||
|
onValueChange={(value) => onPageSizeChange(Number(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger size="sm" className="w-28">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[10, 20, 50, 100].map((size) => (
|
||||||
|
<SelectItem key={size} value={String(size)}>
|
||||||
|
{size} 条 / 页
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
disabled={!pagination.prev_page || loading}
|
||||||
|
onClick={() => onPageChange?.(pagination.prev_page ?? 1)}
|
||||||
|
aria-label="上一页"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-16 text-center tabular-nums">
|
||||||
|
{pagination.current_page} / {Math.max(pagination.total_pages, 1)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
disabled={!pagination.next_page || loading}
|
||||||
|
onClick={() => onPageChange?.(pagination.next_page ?? 1)}
|
||||||
|
aria-label="下一页"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
src/components/layout/nav.ts
Normal file
65
src/components/layout/nav.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
FolderTree,
|
||||||
|
Hash,
|
||||||
|
Inbox,
|
||||||
|
KeyRound,
|
||||||
|
Languages,
|
||||||
|
LayoutList,
|
||||||
|
ListChecks,
|
||||||
|
Newspaper,
|
||||||
|
Palette,
|
||||||
|
ScrollText,
|
||||||
|
Smartphone,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
label: string
|
||||||
|
to: string
|
||||||
|
icon: LucideIcon
|
||||||
|
/** hide when the user lacks this permission (super-admin always passes) */
|
||||||
|
permission?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavGroup {
|
||||||
|
label: string
|
||||||
|
items: NavItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NAV_GROUPS: NavGroup[] = [
|
||||||
|
{
|
||||||
|
label: '分类管理',
|
||||||
|
items: [
|
||||||
|
{ label: '分类树', to: '/categories', icon: FolderTree },
|
||||||
|
{ label: '待审核分类', to: '/categories/pending', icon: ListChecks },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '内容工作室',
|
||||||
|
items: [
|
||||||
|
{ label: '机器人作者', to: '/studio/robot-authors', icon: Bot },
|
||||||
|
{ label: '文章系列', to: '/studio/series', icon: LayoutList },
|
||||||
|
{ label: '文章', to: '/studio/articles', icon: Newspaper },
|
||||||
|
{ label: '子话题', to: '/studio/subtopics', icon: Sparkles },
|
||||||
|
{ label: '分类简报', to: '/studio/category-briefs', icon: ScrollText },
|
||||||
|
{ label: '风格预设', to: '/studio/style-presets', icon: Palette },
|
||||||
|
{
|
||||||
|
label: 'Agent 接入',
|
||||||
|
to: '/studio/agent-tokens',
|
||||||
|
icon: KeyRound,
|
||||||
|
permission: 'admin:studio:agent-token:issue',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '用户与系统',
|
||||||
|
items: [
|
||||||
|
{ label: 'SID 管理', to: '/sid', icon: Hash },
|
||||||
|
{ label: '语言设置', to: '/locales', icon: Languages },
|
||||||
|
{ label: '推送设备', to: '/devices', icon: Smartphone },
|
||||||
|
{ label: '机器人会话', to: '/robots', icon: Inbox },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
17
src/components/page-header.tsx
Normal file
17
src/components/page-header.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
interface PageHeaderProps {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
actions?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
||||||
|
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
src/components/show-once-token-dialog.tsx
Normal file
76
src/components/show-once-token-dialog.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Check, Copy } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
interface ShowOnceTokenDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
token: string | null
|
||||||
|
/** extra command/snippet shown below the token (e.g. claude mcp add …) */
|
||||||
|
snippet?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a freshly issued token exactly once. The value is not retrievable
|
||||||
|
* again after closing — the copy affordance is the whole point.
|
||||||
|
*/
|
||||||
|
export function ShowOnceTokenDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description = '此令牌仅完整显示一次,请立即复制保存。',
|
||||||
|
token,
|
||||||
|
snippet,
|
||||||
|
}: ShowOnceTokenDialogProps) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const copy = async (value: string) => {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
setCopied(true)
|
||||||
|
toast.success('已复制到剪贴板')
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-start gap-2 rounded-md border bg-muted/50 p-3">
|
||||||
|
<code className="min-w-0 flex-1 break-all font-mono text-xs">{token}</code>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => token && copy(token)}
|
||||||
|
aria-label="复制令牌"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{snippet && (
|
||||||
|
<pre className="overflow-x-auto rounded-md border bg-muted/50 p-3 font-mono text-xs">
|
||||||
|
{snippet}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => onOpenChange(false)}>我已保存</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
src/components/status-pill.tsx
Normal file
52
src/components/status-pill.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const TONES: Record<string, string> = {
|
||||||
|
// generic
|
||||||
|
active: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-400',
|
||||||
|
approved: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-400',
|
||||||
|
published: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-400',
|
||||||
|
ready: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-400',
|
||||||
|
complete: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-400',
|
||||||
|
pending: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
||||||
|
drafting: 'bg-blue-500/12 text-blue-700 dark:text-blue-400',
|
||||||
|
drafted: 'bg-blue-500/12 text-blue-700 dark:text-blue-400',
|
||||||
|
in_progress: 'bg-blue-500/12 text-blue-700 dark:text-blue-400',
|
||||||
|
profiling: 'bg-blue-500/12 text-blue-700 dark:text-blue-400',
|
||||||
|
scheduled: 'bg-violet-500/12 text-violet-700 dark:text-violet-400',
|
||||||
|
planned: 'bg-muted text-muted-foreground',
|
||||||
|
paused: 'bg-muted text-muted-foreground',
|
||||||
|
archived: 'bg-muted text-muted-foreground',
|
||||||
|
rejected: 'bg-red-500/12 text-red-700 dark:text-red-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABELS: Record<string, string> = {
|
||||||
|
active: '启用',
|
||||||
|
approved: '已通过',
|
||||||
|
published: '已发布',
|
||||||
|
ready: '就绪',
|
||||||
|
complete: '已完成',
|
||||||
|
pending: '待处理',
|
||||||
|
drafting: '撰写中',
|
||||||
|
drafted: '已完稿',
|
||||||
|
in_progress: '进行中',
|
||||||
|
profiling: '设定中',
|
||||||
|
scheduled: '已排期',
|
||||||
|
planned: '已规划',
|
||||||
|
paused: '已暂停',
|
||||||
|
archived: '已归档',
|
||||||
|
rejected: '已驳回',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusPill({ status, className }: { status: string; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full px-2 py-0.5 font-mono text-xs font-medium',
|
||||||
|
TONES[status] ?? 'bg-muted text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{LABELS[status] ?? status}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/lib/errors.ts
Normal file
24
src/lib/errors.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { toast } from 'sonner'
|
||||||
|
import type { FieldValues, Path, UseFormSetError } from 'react-hook-form'
|
||||||
|
import { ApiError } from '@/api/client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard error handling: 422 field errors land on the form (when given),
|
||||||
|
* everything else surfaces as a toast with the backend's localized message.
|
||||||
|
*/
|
||||||
|
export function handleApiError<T extends FieldValues>(
|
||||||
|
error: unknown,
|
||||||
|
setError?: UseFormSetError<T>,
|
||||||
|
): void {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
if (error.status === 422 && error.errors && setError) {
|
||||||
|
for (const [field, messages] of Object.entries(error.errors)) {
|
||||||
|
setError(field as Path<T>, { type: 'server', message: messages[0] })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.error(error.message || `请求失败(${error.code})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.error('网络异常,请稍后重试')
|
||||||
|
}
|
||||||
41
src/lib/list-page.ts
Normal file
41
src/lib/list-page.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { ListParams, PaginatedResponse } from '@/api/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard list-page state: page / page_size wired to the backend's
|
||||||
|
* Pagination envelope; changing filters snaps back to page 1.
|
||||||
|
*/
|
||||||
|
export function useListPage<T>(
|
||||||
|
key: string,
|
||||||
|
fetcher: (params: ListParams) => Promise<PaginatedResponse<T>>,
|
||||||
|
filters: ListParams = {},
|
||||||
|
) {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
|
const filterKey = JSON.stringify(filters)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1)
|
||||||
|
}, [filterKey])
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: [key, filterKey, page, pageSize],
|
||||||
|
queryFn: () => fetcher({ ...filters, page, page_size: pageSize }),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
rows: query.data?.data ?? [],
|
||||||
|
pagination: query.data?.pagination,
|
||||||
|
loading: query.isPending,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
setPageSize: (size: number) => {
|
||||||
|
setPageSize(size)
|
||||||
|
setPage(1)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,14 +1,33 @@
|
|||||||
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
|
||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from './routeTree.gen'
|
||||||
|
|
||||||
export function getRouter() {
|
export function getRouter() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: (failureCount, error) => {
|
||||||
|
// Never retry auth/permission/validation errors.
|
||||||
|
const status = (error as { status?: number }).status
|
||||||
|
if (status === 401 || status === 403 || status === 404 || status === 422) return false
|
||||||
|
return failureCount < 2
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const router = createTanStackRouter({
|
const router = createTanStackRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
|
context: { queryClient },
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
defaultPreload: 'intent',
|
defaultPreload: 'intent',
|
||||||
defaultPreloadStaleTime: 0,
|
defaultPreloadStaleTime: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setupRouterSsrQueryIntegration({ router, queryClient })
|
||||||
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,59 +1,43 @@
|
|||||||
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
|
import {
|
||||||
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
HeadContent,
|
||||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
Outlet,
|
||||||
import Footer from '../components/Footer'
|
Scripts,
|
||||||
import Header from '../components/Header'
|
createRootRouteWithContext,
|
||||||
|
} from '@tanstack/react-router'
|
||||||
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
|
||||||
import appCss from '../styles.css?url'
|
import appCss from '../styles.css?url'
|
||||||
|
|
||||||
const THEME_INIT_SCRIPT = `(function(){try{var stored=window.localStorage.getItem('theme');var mode=(stored==='light'||stored==='dark'||stored==='auto')?stored:'auto';var prefersDark=window.matchMedia('(prefers-color-scheme: dark)').matches;var resolved=mode==='auto'?(prefersDark?'dark':'light'):mode;var root=document.documentElement;root.classList.remove('light','dark');root.classList.add(resolved);if(mode==='auto'){root.removeAttribute('data-theme')}else{root.setAttribute('data-theme',mode)}root.style.colorScheme=resolved;}catch(e){}})();`
|
const THEME_INIT_SCRIPT = `(function(){try{var stored=window.localStorage.getItem('theme');var prefersDark=window.matchMedia('(prefers-color-scheme: dark)').matches;var resolved=stored==='dark'||(stored!=='light'&&prefersDark)?'dark':'light';document.documentElement.classList.toggle('dark',resolved==='dark');document.documentElement.style.colorScheme=resolved;}catch(e){}})();`
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
interface RouterContext {
|
||||||
|
queryClient: QueryClient
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{
|
{ charSet: 'utf-8' },
|
||||||
charSet: 'utf-8',
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||||
},
|
{ title: 'Gig 管理后台' },
|
||||||
{
|
|
||||||
name: 'viewport',
|
|
||||||
content: 'width=device-width, initial-scale=1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'TanStack Start Starter',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
rel: 'stylesheet',
|
|
||||||
href: appCss,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
|
links: [{ rel: 'stylesheet', href: appCss }],
|
||||||
}),
|
}),
|
||||||
shellComponent: RootDocument,
|
shellComponent: RootDocument,
|
||||||
|
component: () => <Outlet />,
|
||||||
})
|
})
|
||||||
|
|
||||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="zh-CN" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
|
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
|
||||||
<HeadContent />
|
<HeadContent />
|
||||||
</head>
|
</head>
|
||||||
<body className="font-sans antialiased [overflow-wrap:anywhere] selection:bg-[rgba(79,184,178,0.24)]">
|
<body className="font-sans antialiased">
|
||||||
<Header />
|
|
||||||
{children}
|
{children}
|
||||||
<Footer />
|
<Toaster position="top-center" richColors />
|
||||||
<TanStackDevtools
|
|
||||||
config={{
|
|
||||||
position: 'bottom-right',
|
|
||||||
}}
|
|
||||||
plugins={[
|
|
||||||
{
|
|
||||||
name: 'Tanstack Router',
|
|
||||||
render: <TanStackRouterDevtoolsPanel />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
145
src/routes/_authed.tsx
Normal file
145
src/routes/_authed.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { Link, Outlet, createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { LogOut, Moon, Sun } from 'lucide-react'
|
||||||
|
import { fetchMe } from '@/api/modules/auth'
|
||||||
|
import { isAuthenticated, useAuthStore } from '@/auth/store'
|
||||||
|
import { NAV_GROUPS } from '@/components/layout/nav'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authed')({
|
||||||
|
beforeLoad: () => {
|
||||||
|
if (typeof window !== 'undefined' && !isAuthenticated()) {
|
||||||
|
throw redirect({ to: '/login' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: AuthedLayout,
|
||||||
|
})
|
||||||
|
|
||||||
|
function AuthedLayout() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
const roles = useAuthStore((s) => s.roles)
|
||||||
|
const permissions = useAuthStore((s) => s.permissions)
|
||||||
|
|
||||||
|
// Re-validate the session on mount / focus; keeps roles+permissions fresh.
|
||||||
|
const me = useQuery({
|
||||||
|
queryKey: ['auth', 'me'],
|
||||||
|
queryFn: fetchMe,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (me.data) {
|
||||||
|
useAuthStore
|
||||||
|
.getState()
|
||||||
|
.setSession(me.data.data, me.data.meta?.roles ?? [], me.data.meta?.permissions ?? [])
|
||||||
|
}
|
||||||
|
}, [me.data])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (me.isError && !isAuthenticated()) {
|
||||||
|
void navigate({ to: '/login' })
|
||||||
|
}
|
||||||
|
}, [me.isError, navigate])
|
||||||
|
|
||||||
|
const isSuper = roles.includes('super-admin')
|
||||||
|
const canSee = (permission?: string) =>
|
||||||
|
!permission || isSuper || permissions.includes(permission)
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
useAuthStore.getState().clear()
|
||||||
|
void navigate({ to: '/login' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const dark = document.documentElement.classList.toggle('dark')
|
||||||
|
document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
|
||||||
|
localStorage.setItem('theme', dark ? 'dark' : 'light')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh">
|
||||||
|
<aside className="fixed inset-y-0 left-0 z-20 flex w-56 flex-col border-r bg-sidebar text-sidebar-foreground">
|
||||||
|
<div className="flex h-14 items-center gap-2 border-b px-4 font-semibold">
|
||||||
|
<span className="flex size-6 items-center justify-center rounded bg-primary text-xs font-bold text-primary-foreground">
|
||||||
|
G
|
||||||
|
</span>
|
||||||
|
Gig 管理后台
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 overflow-y-auto p-3">
|
||||||
|
{NAV_GROUPS.map((group) => {
|
||||||
|
const items = group.items.filter((item) => canSee(item.permission))
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div key={group.label} className="mb-4">
|
||||||
|
<div className="px-2 pb-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{group.label}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
activeOptions={{ exact: item.to === '/categories' }}
|
||||||
|
className="flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&.active]:bg-sidebar-accent [&.active]:font-medium [&.active]:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
<item.icon className="size-4 shrink-0" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col pl-56">
|
||||||
|
<header className="sticky top-0 z-10 flex h-14 items-center justify-end gap-2 border-b bg-background/80 px-6 backdrop-blur">
|
||||||
|
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label="切换主题">
|
||||||
|
<Sun className="size-4 dark:hidden" />
|
||||||
|
<Moon className="hidden size-4 dark:block" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className="flex items-center gap-2 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||||
|
<Avatar className="size-8">
|
||||||
|
<AvatarImage src={user?.avatar ?? undefined} alt="" />
|
||||||
|
<AvatarFallback>
|
||||||
|
{(user?.display_name ?? user?.username ?? 'A').slice(0, 1).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-52">
|
||||||
|
<DropdownMenuLabel className="flex flex-col">
|
||||||
|
<span>{user?.display_name ?? user?.username ?? '管理员'}</span>
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">
|
||||||
|
{roles.join(' · ') || '—'}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={logout}>
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
退出登录
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
src/routes/_authed/index.tsx
Normal file
7
src/routes/_authed/index.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authed/')({
|
||||||
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: '/categories' })
|
||||||
|
},
|
||||||
|
})
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/about')({
|
|
||||||
component: About,
|
|
||||||
})
|
|
||||||
|
|
||||||
function About() {
|
|
||||||
return (
|
|
||||||
<main className="page-wrap px-4 py-12">
|
|
||||||
<section className="island-shell rounded-2xl p-6 sm:p-8">
|
|
||||||
<p className="island-kicker mb-2">About</p>
|
|
||||||
<h1 className="display-title mb-3 text-4xl font-bold text-[var(--sea-ink)] sm:text-5xl">
|
|
||||||
A small starter with room to grow.
|
|
||||||
</h1>
|
|
||||||
<p className="m-0 max-w-3xl text-base leading-8 text-[var(--sea-ink-soft)]">
|
|
||||||
TanStack Start gives you type-safe routing, server functions, and
|
|
||||||
modern SSR defaults. Use this as a clean foundation, then layer in
|
|
||||||
your own routes, styling, and add-ons.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({ component: App })
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<main className="page-wrap px-4 pb-8 pt-14">
|
|
||||||
<section className="island-shell rise-in relative overflow-hidden rounded-[2rem] px-6 py-10 sm:px-10 sm:py-14">
|
|
||||||
<div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(79,184,178,0.32),transparent_66%)]" />
|
|
||||||
<div className="pointer-events-none absolute -bottom-20 -right-20 h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(47,106,74,0.18),transparent_66%)]" />
|
|
||||||
<p className="island-kicker mb-3">TanStack Start Base Template</p>
|
|
||||||
<h1 className="display-title mb-5 max-w-3xl text-4xl leading-[1.02] font-bold tracking-tight text-[var(--sea-ink)] sm:text-6xl">
|
|
||||||
Start simple, ship quickly.
|
|
||||||
</h1>
|
|
||||||
<p className="mb-8 max-w-2xl text-base text-[var(--sea-ink-soft)] sm:text-lg">
|
|
||||||
This base starter intentionally keeps things light: two routes, clean
|
|
||||||
structure, and the essentials you need to build from scratch.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<a
|
|
||||||
href="/about"
|
|
||||||
className="rounded-full border border-[rgba(50,143,151,0.3)] bg-[rgba(79,184,178,0.14)] px-5 py-2.5 text-sm font-semibold text-[var(--lagoon-deep)] no-underline transition hover:-translate-y-0.5 hover:bg-[rgba(79,184,178,0.24)]"
|
|
||||||
>
|
|
||||||
About This Starter
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://tanstack.com/router"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="rounded-full border border-[rgba(23,58,64,0.2)] bg-white/50 px-5 py-2.5 text-sm font-semibold text-[var(--sea-ink)] no-underline transition hover:-translate-y-0.5 hover:border-[rgba(23,58,64,0.35)]"
|
|
||||||
>
|
|
||||||
Router Guide
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{[
|
|
||||||
[
|
|
||||||
'Type-Safe Routing',
|
|
||||||
'Routes and links stay in sync across every page.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'Server Functions',
|
|
||||||
'Call server code from your UI without creating API boilerplate.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'Streaming by Default',
|
|
||||||
'Ship progressively rendered responses for faster experiences.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'Tailwind Native',
|
|
||||||
'Design quickly with utility-first styling and reusable tokens.',
|
|
||||||
],
|
|
||||||
].map(([title, desc], index) => (
|
|
||||||
<article
|
|
||||||
key={title}
|
|
||||||
className="island-shell feature-card rise-in rounded-2xl p-5"
|
|
||||||
style={{ animationDelay: `${index * 90 + 80}ms` }}
|
|
||||||
>
|
|
||||||
<h2 className="mb-2 text-base font-semibold text-[var(--sea-ink)]">
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
<p className="m-0 text-sm text-[var(--sea-ink-soft)]">{desc}</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="island-shell mt-8 rounded-2xl p-6">
|
|
||||||
<p className="island-kicker mb-2">Quick Start</p>
|
|
||||||
<ul className="m-0 list-disc space-y-2 pl-5 text-sm text-[var(--sea-ink-soft)]">
|
|
||||||
<li>
|
|
||||||
Edit <code>src/routes/index.tsx</code> to customize the home page.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Update <code>src/components/Header.tsx</code> and{' '}
|
|
||||||
<code>src/components/Footer.tsx</code> for brand links.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Add routes in <code>src/routes</code> and tweak visual tokens in{' '}
|
|
||||||
<code>src/styles.css</code>.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
103
src/routes/login.tsx
Normal file
103
src/routes/login.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { loginWithPassword, fetchMe } from '@/api/modules/auth'
|
||||||
|
import { ApiError } from '@/api/client'
|
||||||
|
import { useAuthStore, isAuthenticated } from '@/auth/store'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/login')({
|
||||||
|
beforeLoad: () => {
|
||||||
|
if (typeof window !== 'undefined' && isAuthenticated()) {
|
||||||
|
throw redirect({ to: '/' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: LoginPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
identity: z.string().min(1, '请输入邮箱或手机号'),
|
||||||
|
password: z.string().min(1, '请输入密码'),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<FormValues>({ resolver: zodResolver(schema) })
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit(async (values) => {
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const grant = await loginWithPassword(values.identity, values.password)
|
||||||
|
useAuthStore.getState().setTokens(grant.data)
|
||||||
|
|
||||||
|
const me = await fetchMe()
|
||||||
|
const roles = me.meta?.roles ?? []
|
||||||
|
if (!roles.includes('admin') && !roles.includes('super-admin')) {
|
||||||
|
useAuthStore.getState().clear()
|
||||||
|
toast.error('该账号不是管理员,无法登录管理后台')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
useAuthStore.getState().setSession(me.data, roles, me.meta?.permissions ?? [])
|
||||||
|
|
||||||
|
await navigate({ to: '/' })
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
toast.error(error.code === 'INVALID_CREDENTIALS' ? '账号或密码错误' : error.message)
|
||||||
|
} else {
|
||||||
|
toast.error('网络异常,请稍后重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh items-center justify-center bg-muted/40 px-4">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">Gig 管理后台</CardTitle>
|
||||||
|
<CardDescription>使用管理员账号登录</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="identity">邮箱 / 手机号</Label>
|
||||||
|
<Input id="identity" autoComplete="username" {...register('identity')} />
|
||||||
|
{errors.identity && (
|
||||||
|
<p className="text-sm text-destructive">{errors.identity.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="password">密码</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={submitting} className="mt-2">
|
||||||
|
{submitting ? '登录中…' : '登录'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -7,13 +7,15 @@ import viteReact from '@vitejs/plugin-react'
|
|||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import { nitro } from 'nitro/vite'
|
import { nitro } from 'nitro/vite'
|
||||||
|
|
||||||
|
// SPA mode: auth is Bearer-token client-side (localStorage), so route guards
|
||||||
|
// and data fetching must not run during SSR.
|
||||||
const config = defineConfig({
|
const config = defineConfig({
|
||||||
resolve: { tsconfigPaths: true },
|
resolve: { tsconfigPaths: true },
|
||||||
plugins: [
|
plugins: [
|
||||||
devtools(),
|
devtools(),
|
||||||
nitro({ rollupConfig: { external: [/^@sentry\//] } }),
|
nitro({ rollupConfig: { external: [/^@sentry\//] } }),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
tanstackStart(),
|
tanstackStart({ spa: { enabled: true } }),
|
||||||
viteReact(),
|
viteReact(),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user