简体中文 | English
HaloLight multi-framework admin dashboard documentation site, built with VitePress, supporting bilingual (Chinese & English).
halolight/docs: The single source of truth for documentation and specifications, defining cross-framework design, interfaces, and best practiceshalolight/halolight: Next.js 14 reference implementation, validating the React pathhalolight/halolight-vue: Vue 3.5 reference implementation, validating the Vue pathSpecification updates land here first, then sync to corresponding implementation repos to ensure documentation and code consistency.
HaloLight is an enterprise-grade admin dashboard solution with multi-framework implementations. Reference implementations:
All 14 frontend frameworks, 7 backend APIs, and 8 deployment solutions have been implemented and deployed. See each repo's README for preview URLs.
| Framework | Status | Preview | Repo | Docs |
|---|---|---|---|---|
| 🟦 Next.js 14 | ✅ Deployed | Preview | GitHub | Guide |
| ⚛️ React (Vite) | ✅ Deployed | Preview | GitHub | Guide |
| 💚 Vue 3.5 | ✅ Deployed | Preview | GitHub | Guide |
| 🔺 Angular 21 | ✅ Deployed | Preview | GitHub | Guide |
| 🌿 Nuxt 4 | ✅ Deployed | Preview | GitHub | Guide |
| 🧡 SvelteKit 2 | ✅ Deployed | Preview | GitHub | Guide |
| 🪐 Astro 5 | ✅ Deployed | Preview | GitHub | Guide |
| 💠 Solid.js | ✅ Deployed | Preview | GitHub | Guide |
| ⚡ Qwik | ✅ Deployed | Preview | GitHub | Guide |
| 🎸 Remix | ✅ Deployed | Preview | GitHub | Guide |
| 🪶 Preact | ✅ Deployed | Preview | GitHub | Guide |
| 🔥 Lit | ✅ Deployed | Preview | GitHub | Guide |
| 🦖 Fresh (Deno) | ✅ Deployed | Preview | GitHub | Guide |
| 🦕 Deno | ✅ Deployed | Preview | GitHub | Guide |
| Backend Tech | Status | Preview | Repo | Docs |
|---|---|---|---|---|
| 🦜 NestJS 11 | ✅ Deployed | API Docs | GitHub | Guide |
| 🐍 Python FastAPI | ✅ Deployed | API Docs | GitHub | Guide |
| ☕ Java Spring Boot | ✅ Deployed | API Docs | GitHub | Guide |
| 🐹 Go Fiber | ✅ Deployed | API Docs | GitHub | Guide |
| 🟩 Node.js Express | ✅ Deployed | - | GitHub | Guide |
| 🐘 PHP Laravel | ✅ Deployed | - | GitHub | Guide |
| 🍞 Bun + Hono | ✅ Deployed | - | GitHub | Guide |
| Project | Status | Description | Repo | Docs |
|---|---|---|---|---|
| 🔗 tRPC BFF | ✅ Deployed | Type-safe API Gateway | GitHub | Guide |
| ⚡ Next.js Action | ✅ Deployed | Server Actions full-stack | GitHub | Guide |
| Platform | Status | Preview | Repo | Docs |
|---|---|---|---|---|
| Cloudflare | Deployed | Preview | GitHub | Guide |
| Vercel | Deployed | Preview | GitHub | Guide |
| Netlify | Deployed | Preview | GitHub | Guide |
| Docker | Deployed | - | GitHub | Guide |
| Railway | Deployed | Preview | GitHub | Guide |
| Fly.io | Deployed | Preview | GitHub | Guide |
| Azure | ✅ Deployed | Preview | GitHub | Guide |
| AWS | ✅ Deployed | Preview | GitHub | Guide |
| Project | Status | Description | Repo | Docs |
|---|---|---|---|---|
| 🎨 UI Components | ✅ Deployed | Stencil Web Components | GitHub | Guide |
| 🤖 AI Assistant | 🚧 In Development | RAG + Action Execution | GitHub | Guide |
| ₿ Web3 Integration | 🚧 In Development | Wallet Login + On-chain Data | GitHub | Guide |
# Install dependencies
pnpm install
# Start development server
pnpm dev
# Build for production
pnpm build
# Preview build
pnpm preview
docs/
├── .vitepress/ # VitePress configuration
│ ├── config.ts # Main config
│ ├── nav.ts # Navigation config
│ ├── sidebar.ts # Sidebar config
│ ├── head.ts # HTML head config
│ └── pwa.ts # PWA config
├── guide/ # User guides (Chinese)
├── en/guide/ # User guides (English)
├── development/ # Development docs (Chinese)
├── en/development/ # Development docs (English)
├── public/ # Static assets
├── index.md # Homepage (Chinese)
└── en/index.md # Homepage (English)
Contributions are welcome! Feel free to submit Issues and Pull Requests.
git checkout -b feature/amazing-feature)git commit -m 'feat: add amazing feature')git push origin feature/amazing-feature)English | 简体中文
HaloLight 多框架管理后台项目文档站点,基于 VitePress 构建,支持中英文双语。
halolight/docs:文档与规范的唯一来源,定义跨框架的设计、接口和最佳实践halolight/halolight:Next.js 14 参考实现,验证规范的 React 路径halolight/halolight-vue:Vue 3.5 参考实现,验证规范的 Vue 路径规范更新优先在本仓库落地,再同步到对应实现仓库,确保文档与代码一致。
HaloLight 是一套多框架实现的企业级管理后台解决方案。参考实现:
所有 14 个前端框架、7 个后端 API、8 个部署方案均已实现并部署,预览地址见各仓库 README。
| 框架 | 状态 | 预览 | 仓库 | 文档 |
|---|---|---|---|---|
| 🟦 Next.js 14 | ✅ 已部署 | 预览 | GitHub | 指南 |
| ⚛️ React (Vite) | ✅ 已部署 | 预览 | GitHub | 指南 |
| 💚 Vue 3.5 | ✅ 已部署 | 预览 | GitHub | 指南 |
| 🔺 Angular 21 | ✅ 已部署 | 预览 | GitHub | 指南 |
| 🌿 Nuxt 4 | ✅ 已部署 | 预览 | GitHub | 指南 |
| 🧡 SvelteKit 2 | ✅ 已部署 | 预览 | GitHub | 指南 |
| 🪐 Astro 5 | ✅ 已部署 | 预览 | GitHub | 指南 |
| 💠 Solid.js | ✅ 已部署 | 预览 | GitHub | 指南 |
| ⚡ Qwik | ✅ 已部署 | 预览 | GitHub | 指南 |
| 🎸 Remix | ✅ 已部署 | 预览 | GitHub | 指南 |
| 🪶 Preact | ✅ 已部署 | 预览 | GitHub | 指南 |
| 🔥 Lit | ✅ 已部署 | 预览 | GitHub | 指南 |
| 🦖 Fresh (Deno) | ✅ 已部署 | 预览 | GitHub | 指南 |
| 🦕 Deno | ✅ 已部署 | 预览 | GitHub | 指南 |
| 后端技术 | 状态 | 预览 | 仓库 | 文档 |
|---|---|---|---|---|
| 🦜 NestJS 11 | ✅ 已部署 | API Docs | GitHub | 指南 |
| 🐍 Python FastAPI | ✅ 已部署 | API Docs | GitHub | 指南 |
| ☕ Java Spring Boot | ✅ 已部署 | API Docs | GitHub | 指南 |
| 🐹 Go Fiber | ✅ 已部署 | API Docs | GitHub | 指南 |
| 🟩 Node.js Express | ✅ 已部署 | - | GitHub | 指南 |
| 🐘 PHP Laravel | ✅ 已部署 | - | GitHub | 指南 |
| 🍞 Bun + Hono | ✅ 已部署 | - | GitHub | 指南 |
| 项目 | 状态 | 说明 | 仓库 | 文档 |
|---|---|---|---|---|
| 🔗 tRPC BFF | ✅ 已部署 | 类型安全 API 网关 | GitHub | 指南 |
| ⚡ Next.js Action | ✅ 已部署 | Server Actions 全栈方案 | GitHub | 指南 |
| 平台 | 状态 | 预览 | 仓库 | 文档 |
|---|---|---|---|---|
| ☁️ Cloudflare | ✅ 已部署 | 预览 | GitHub | 指南 |
| ▲ Vercel | ✅ 已部署 | 预览 | GitHub | 指南 |
| 🔷 Netlify | ✅ 已部署 | 预览 | GitHub | 指南 |
| 🐳 Docker | ✅ 已部署 | - | GitHub | 指南 |
| 🚂 Railway | ✅ 已部署 | 预览 | GitHub | 指南 |
| ✈️ Fly.io | ✅ 已部署 | 预览 | GitHub | 指南 |
| ☁️ Azure | ✅ 已部署 | 预览 | GitHub | 指南 |
| 🟠 AWS | ✅ 已部署 | 预览 | GitHub | 指南 |
| 项目 | 状态 | 说明 | 仓库 | 文档 |
|---|---|---|---|---|
| 🎨 UI 组件库 | ✅ 已部署 | Stencil Web Components | GitHub | 指南 |
| 🤖 AI 助理 | 🚧 开发中 | RAG + 动作执行 | GitHub | 指南 |
| ₿ Web3 集成 | 🚧 开发中 | 钱包登录 + 链上数据 | GitHub | 指南 |
# 安装依赖
pnpm install
# 启动开发服务器
pnpm dev
# 构建生产版本
pnpm build
# 预览构建结果
pnpm preview
docs/
├── .vitepress/ # VitePress 配置
│ ├── config.ts # 主配置
│ ├── nav.ts # 导航栏配置
│ ├── sidebar.ts # 侧边栏配置
│ ├── head.ts # HTML head 配置
│ └── pwa.ts # PWA 配置
├── guide/ # 使用指南
│ ├── index.md # 简介
│ ├── getting-started.md # 快速开始
│ ├── nextjs.md # 🟦 Next.js
│ ├── vue.md # 💚 Vue
│ ├── angular.md # 🔺 Angular
│ ├── nuxt.md # 🌿 Nuxt
│ ├── sveltekit.md # 🧡 SvelteKit
│ ├── astro.md # 🪐 Astro
│ ├── solidjs.md # 💠 Solid.js
│ ├── qwik.md # ⚡ Qwik
│ ├── remix.md # 🎸 Remix
│ ├── preact.md # 🪶 Preact
│ ├── lit.md # 🔥 Lit
│ ├── fresh.md # 🦖 Fresh (Deno)
│ ├── deno.md # 🦕 Deno + Hono
│ ├── api-go.md # 🐹 Go API
│ ├── api-node.md # 🟩 Node.js API
│ ├── admin.md # 🛠️ Admin 面板
│ ├── cloudflare.md # ☁️ Cloudflare
│ ├── vercel.md # ▲ Vercel
│ ├── netlify.md # 🔷 Netlify
│ ├── docker.md # 🐳 Docker
│ ├── railway.md # 🚂 Railway
│ ├── fly.md # ✈️ Fly.io
│ ├── azure.md # ☁️ Azure
│ └── aws.md # 🟠 AWS
├── development/ # 开发文档
│ ├── index.md # 开发概览
│ ├── architecture.md # 整体架构
│ ├── components.md # 组件规范
│ ├── state-management.md # 状态管理
│ ├── api-patterns.md # API 设计
│ ├── authentication.md # 认证系统
│ ├── dashboard.md # 仪表盘
│ ├── theming.md # 主题系统
│ └── implementation-guide.md # 实现指南
├── public/ # 静态资源
└── index.md # 首页
欢迎提交 Issue 和 Pull Request!
git checkout -b feature/amazing-feature)git commit -m 'feat: add amazing feature')git push origin feature/amazing-feature)本文档描述 HaloLight 项目的 API 服务层架构和数据获取策略。
| 框架 | HTTP 客户端 | 缓存层 |
|---|---|---|
| React/Next.js | Axios | TanStack Query |
| Vue 3 | Axios | TanStack Query |
| Svelte | Fetch | TanStack Query |
| Angular | HttpClient | RxJS |
services/
├── api.ts # Axios 实例配置
├── auth.ts # 认证服务
├── users.ts # 用户服务
├── roles.ts # 角色服务
├── permissions.ts # 权限服务
├── dashboard.ts # 仪表盘服务
├── settings.ts # 设置服务
└── index.ts # 统一导出
import axios from 'axios'
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
})
// 请求拦截器
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器
api.interceptors.response.use(
(response) => response.data,
async (error) => {
if (error.response?.status === 401) {
useAuthStore.getState().logout()
window.location.href = '/login'
}
return Promise.reject(error)
}
)
// services/users.ts
export const userService = {
getList: (params: UserQueryParams) =>
api.get<PaginatedResponse<User>>('/users', { params }),
getById: (id: string) =>
api.get<User>(`/users/${id}`),
create: (data: CreateUserDto) =>
api.post<User>('/users', data),
update: (id: string, data: UpdateUserDto) =>
api.put<User>(`/users/${id}`, data),
delete: (id: string) =>
api.delete(`/users/${id}`),
}
// hooks/useUsers.ts
export function useUsers(params: UserQueryParams) {
return useQuery({
queryKey: ['users', params],
queryFn: () => userService.getList(params),
staleTime: 5 * 60 * 1000, // 5分钟
})
}
export function useUser(id: string) {
return useQuery({
queryKey: ['users', id],
queryFn: () => userService.getById(id),
enabled: !!id,
})
}
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: userService.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success('用户创建成功')
},
})
}
// mocks/index.ts
import Mock from 'mockjs'
import './modules/auth'
import './modules/users'
import './modules/dashboard'
Mock.setup({ timeout: '200-600' })
// mocks/modules/users.ts
Mock.mock(/\/api\/users(\?.*)?$/, 'get', (options) => {
const params = parseQuery(options.url)
return {
code: 200,
data: {
list: Mock.mock({
[`list|${params.pageSize || 10}`]: [{
'id': '@guid',
'name': '@cname',
'email': '@email',
'status|1': ['active', 'inactive'],
'createdAt': '@datetime',
}]
}).list,
total: 100,
}
}
})
interface ApiError {
code: string
message: string
details?: Record<string, string[]>
}
// 统一错误处理
function handleApiError(error: ApiError) {
switch (error.code) {
case 'VALIDATION_ERROR':
// 表单验证错误
break
case 'UNAUTHORIZED':
// 未授权
break
case 'FORBIDDEN':
// 无权限
break
default:
toast.error(error.message)
}
}
interface PaginatedResponse<T> {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
interface PaginationParams {
page?: number
pageSize?: number
sortBy?: string
sortOrder?: 'asc' | 'desc'
}
本文档描述 HaloLight 项目的整体架构设计,包括目录结构、分层设计和核心模式。
src/
├── app/ # 页面路由 (Next.js) 或 views/ (Vue)
│ ├── (admin)/ # 管理后台路由组
│ │ ├── dashboard/ # 仪表盘
│ │ ├── users/ # 用户管理
│ │ ├── roles/ # 角色管理
│ │ ├── permissions/ # 权限管理
│ │ ├── settings/ # 系统设置
│ │ └── profile/ # 个人中心
│ └── (auth)/ # 认证路由组
│ ├── login/ # 登录
│ ├── register/ # 注册
│ ├── forgot-password/
│ └── reset-password/
├── components/ # 可复用组件
│ ├── ui/ # 基础 UI 组件 (shadcn/ui)
│ ├── layout/ # 布局组件
│ ├── dashboard/ # 仪表盘组件
│ ├── charts/ # 图表组件
│ └── shared/ # 通用业务组件
├── hooks/ (composables/) # 可复用逻辑
├── stores/ # 状态管理
├── services/ # API 服务层
├── lib/ # 工具库
├── types/ # TypeScript 类型定义
├── styles/ # 全局样式
└── mocks/ # Mock 数据
┌─────────────────────────────────────────────────┐
│ 视图层 (Views) │
│ Pages / Views / Routes │
├─────────────────────────────────────────────────┤
│ 组件层 (Components) │
│ UI Components / Layout / Dashboard │
├─────────────────────────────────────────────────┤
│ 状态层 (State) │
│ Stores / Composables / Hooks │
├─────────────────────────────────────────────────┤
│ 服务层 (Services) │
│ API Services / Data Fetching / Cache │
└─────────────────────────────────────────────────┘
| 层级 | 职责 | 框架实现 |
|---|---|---|
| 视图层 | 页面路由、布局组装 | Next.js Pages / Vue Views |
| 组件层 | UI 渲染、用户交互 | React/Vue/Svelte Components |
| 状态层 | 应用状态、业务逻辑 | Zustand / Pinia / Stores |
| 服务层 | API 调用、数据缓存 | TanStack Query / Axios |
<ThemeProvider>
<MockProvider>
<QueryClientProvider>
<AuthProvider>
<PermissionProvider>
<WebSocketProvider>
<ErrorProvider>
<ToastProvider>
{children}
</ToastProvider>
</ErrorProvider>
</WebSocketProvider>
</PermissionProvider>
</AuthProvider>
</QueryClientProvider>
</MockProvider>
</ThemeProvider>
app.use(pinia)
app.use(router)
app.use(i18n)
app.use(mockPlugin)
app.use(queryPlugin)
app.use(permissionPlugin)
┌──────────────────────────────────────────────────────┐
│ Header │
│ [Logo] [Breadcrumb] [Search] [User] [Settings]
├────────────┬─────────────────────────────────────────┤
│ │ │
│ Sidebar │ Content │
│ │ │
│ - Menu │ ┌──────────────────────────────────┐ │
│ - Nav │ │ Page Content │ │
│ │ │ │ │
│ │ └──────────────────────────────────┘ │
│ │ │
├────────────┴─────────────────────────────────────────┤
│ Footer │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ │
│ ┌────────────┐ │
│ │ │ │
│ │ Auth Form │ │
│ │ │ │
│ └────────────┘ │
│ │
└──────────────────────────────────────────────────────┘
interface RouteMeta {
title: string // 页面标题
icon?: string // 菜单图标
permission?: string // 所需权限
hidden?: boolean // 是否在菜单中隐藏
keepAlive?: boolean // 是否缓存组件
breadcrumb?: boolean // 是否显示面包屑
}
| 路径 | 页面 | 权限 |
|---|---|---|
/dashboard |
仪表盘 | dashboard:view |
/users |
用户列表 | users:list |
/users/create |
创建用户 | users:create |
/users/:id |
用户详情 | users:view |
/users/:id/edit |
编辑用户 | users:update |
/roles |
角色列表 | roles:list |
/permissions |
权限管理 | permissions:list |
/settings |
系统设置 | settings:view |
/profile |
个人中心 | - (登录即可) |
| 前缀 | 框架 | 说明 |
|---|---|---|
NEXT_PUBLIC_ |
Next.js | 客户端可见 |
VITE_ |
Vue/Vite | 客户端可见 |
PUBLIC_ |
SvelteKit | 客户端可见 |
| 无前缀 | 所有 | 仅服务端可见 |
# API 配置
*_API_BASE_URL=http://localhost:3000/api
*_API_TIMEOUT=30000
# 认证配置
*_AUTH_SECRET=your-secret-key
*_TOKEN_EXPIRES=7d
# Mock 开关
*_ENABLE_MOCK=true
# 功能开关
*_ENABLE_WEBSOCKET=true
*_ENABLE_ANALYTICS=false
按功能模块组织代码,而非按文件类型:
# 推荐 ✅
features/
├── users/
│ ├── components/
│ ├── hooks/
│ ├── services/
│ └── types/
# 避免 ❌
components/
├── UserList.tsx
├── UserForm.tsx
hooks/
├── useUsers.ts
services/
├── userService.ts
组件专用的样式、类型、工具放在组件目录下:
components/
└── UserCard/
├── index.tsx
├── UserCard.module.css
├── UserCard.types.ts
└── useUserCard.ts
多处使用的代码提取到共享位置:
# 2个以上组件使用 → 提取到 components/shared/
# 3个以上地方使用 → 提取到 lib/ 或 utils/
新增或调整功能时,先在 halolight/docs 明确接口、约束和目录,再同步到 halolight 与 halolight-vue,避免各实现分叉。
本文档描述 HaloLight 项目的用户认证和权限控制实现。
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ 登录 │ ──► │ 验证 │ ──► │ 获取 │ ──► │ 存储 │
│ 表单 │ │ 凭证 │ │ Token │ │ 状态 │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
| 页面 | 路径 | 功能 |
|---|---|---|
| 登录 | /login |
用户名/密码登录 |
| 注册 | /register |
新用户注册 |
| 忘记密码 | /forgot-password |
发送重置邮件 |
| 重置密码 | /reset-password |
设置新密码 |
interface TokenPair {
accessToken: string // 短期有效 (15分钟)
refreshToken: string // 长期有效 (7天)
}
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true
try {
const { refreshToken } = useAuthStore.getState()
const newTokens = await authService.refresh(refreshToken)
useAuthStore.getState().setTokens(newTokens)
error.config.headers.Authorization = `Bearer ${newTokens.accessToken}`
return api(error.config)
} catch {
useAuthStore.getState().logout()
}
}
return Promise.reject(error)
}
)
// 格式: resource:action
const permissions = [
'users:list', // 查看用户列表
'users:create', // 创建用户
'users:update', // 更新用户
'users:delete', // 删除用户
'users:*', // 用户所有权限
'*', // 超级管理员
]
function hasPermission(userPerms: string[], required: string): boolean {
return userPerms.some((p) =>
p === '*' ||
p === required ||
(p.endsWith(':*') && required.startsWith(p.slice(0, -1)))
)
}
// React
function PermissionGuard({ permission, children, fallback }) {
const hasPermission = useAuthStore((s) => s.hasPermission)
return hasPermission(permission) ? children : fallback
}
// 使用
<PermissionGuard permission="users:delete" fallback={null}>
<DeleteButton />
</PermissionGuard>
<!-- Vue -->
<template>
<slot v-if="hasPermission(permission)" />
<slot v-else name="fallback" />
</template>
// v-permission 指令
app.directive('permission', {
mounted(el, binding) {
const authStore = useAuthStore()
if (!authStore.hasPermission(binding.value)) {
el.parentNode?.removeChild(el)
}
},
})
// 使用
<button v-permission="'users:delete'">删除</button>
// middleware.ts
export function middleware(request: NextRequest) {
const token = request.cookies.get('token')
const isAuthPage = request.nextUrl.pathname.startsWith('/login')
if (!token && !isAuthPage) {
return NextResponse.redirect(new URL('/login', request.url))
}
if (token && isAuthPage) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
}
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({ name: 'login', query: { redirect: to.fullPath } })
return
}
if (to.meta.permission && !authStore.hasPermission(to.meta.permission)) {
next({ name: '403' })
return
}
next()
})
const PERMISSIONS = {
// 仪表盘
'dashboard:view': '查看仪表盘',
'dashboard:edit': '编辑仪表盘',
// 用户管理
'users:list': '查看用户列表',
'users:view': '查看用户详情',
'users:create': '创建用户',
'users:update': '更新用户',
'users:delete': '删除用户',
// 角色管理
'roles:list': '查看角色列表',
'roles:create': '创建角色',
'roles:update': '更新角色',
'roles:delete': '删除角色',
// 权限管理
'permissions:list': '查看权限列表',
'permissions:assign': '分配权限',
// 系统设置
'settings:view': '查看设置',
'settings:update': '更新设置',
}
const ROLES = {
admin: {
name: '管理员',
permissions: ['*'],
},
manager: {
name: '经理',
permissions: ['dashboard:*', 'users:list', 'users:view'],
},
user: {
name: '普通用户',
permissions: ['dashboard:view'],
},
}
本文档定义 HaloLight 项目的 UI 组件库规范,基于 shadcn/ui 设计系统。
所有框架版本都使用对应的 shadcn/ui 实现:
| 框架 | 组件库 | 仓库 |
|---|---|---|
| React/Next.js | shadcn/ui | shadcn/ui |
| Vue 3 | shadcn-vue | shadcn-vue |
| Svelte | shadcn-svelte | shadcn-svelte |
| Angular | spartan/ui | spartan |
| Solid.js | solid-ui | solid-ui |
ui/
├── accordion.tsx # 折叠面板
├── alert-dialog.tsx # 确认对话框
├── alert.tsx # 警告提示
├── avatar.tsx # 头像
├── badge.tsx # 徽章
├── breadcrumb.tsx # 面包屑
├── button.tsx # 按钮
├── calendar.tsx # 日历
├── card.tsx # 卡片
├── checkbox.tsx # 复选框
├── collapsible.tsx # 可折叠区域
├── command.tsx # 命令面板
├── data-table.tsx # 数据表格
├── date-picker.tsx # 日期选择器
├── dialog.tsx # 对话框
├── dropdown-menu.tsx # 下拉菜单
├── form.tsx # 表单
├── input.tsx # 输入框
├── label.tsx # 标签
├── pagination.tsx # 分页
├── popover.tsx # 弹出层
├── progress.tsx # 进度条
├── radio-group.tsx # 单选组
├── scroll-area.tsx # 滚动区域
├── select.tsx # 选择器
├── separator.tsx # 分隔线
├── sheet.tsx # 侧边抽屉
├── skeleton.tsx # 骨架屏
├── slider.tsx # 滑块
├── switch.tsx # 开关
├── table.tsx # 表格
├── tabs.tsx # 标签页
├── textarea.tsx # 文本域
├── toast.tsx # 轻提示
├── tooltip.tsx # 工具提示
└── sonner.tsx # Toast 通知
layout/
├── AdminLayout.tsx # 管理后台主布局
├── AuthLayout.tsx # 认证页面布局
├── Sidebar.tsx # 侧边栏
├── Header.tsx # 顶部栏
├── Footer.tsx # 底部栏
├── Breadcrumb.tsx # 面包屑导航
├── TabsNav.tsx # 标签页导航
└── PageContainer.tsx # 页面容器
dashboard/
├── DashboardGrid.tsx # 可拖拽网格容器
├── WidgetWrapper.tsx # Widget 包装器
├── StatsWidget.tsx # 统计数据卡片
├── ChartWidget.tsx # 图表 Widget
├── TableWidget.tsx # 表格 Widget
├── CalendarWidget.tsx # 日历 Widget
├── TasksWidget.tsx # 任务列表
└── QuickActionsWidget.tsx # 快捷操作
charts/
├── LineChart.tsx # 折线图
├── BarChart.tsx # 柱状图
├── PieChart.tsx # 饼图
├── AreaChart.tsx # 面积图
├── RadarChart.tsx # 雷达图
└── GaugeChart.tsx # 仪表盘
// 基础 Props 结构
interface ComponentProps {
// 必需属性在前
children: React.ReactNode
// 可选属性按字母排序
className?: string
disabled?: boolean
loading?: boolean
size?: 'sm' | 'md' | 'lg'
variant?: 'default' | 'outline' | 'ghost'
// 事件处理函数
onChange?: (value: T) => void
onClick?: () => void
}
使用 cva (class-variance-authority) 定义变体:
import { cva, type VariantProps } from 'class-variance-authority'
const buttonVariants = cva(
// 基础样式
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
outline: 'border border-input bg-background hover:bg-accent',
ghost: 'hover:bg-accent hover:text-accent-foreground',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
},
size: {
sm: 'h-8 px-3 text-xs',
md: 'h-9 px-4 text-sm',
lg: 'h-10 px-6 text-base',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
loading?: boolean
}
所有组件必须支持:
// ARIA 属性
<button
role="button"
aria-label={ariaLabel}
aria-disabled={disabled}
aria-busy={loading}
>
// 键盘导航
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
onClick?.()
}
if (e.key === 'Escape') {
onClose?.()
}
}
// 焦点管理
const focusRef = useRef<HTMLElement>(null)
useEffect(() => {
if (open) {
focusRef.current?.focus()
}
}, [open])
// Tailwind 断点
const breakpoints = {
sm: '640px', // 手机横屏
md: '768px', // 平板
lg: '1024px', // 小型桌面
xl: '1280px', // 桌面
'2xl': '1536px' // 大屏
}
// 响应式类名示例
<div className="
grid
grid-cols-1
sm:grid-cols-2
md:grid-cols-3
lg:grid-cols-4
gap-4
">
interface AdminLayoutProps {
children: React.ReactNode
}
// 布局结构
<div className="min-h-screen bg-background">
<Sidebar />
<div className="flex flex-col lg:ml-64">
<Header />
<main className="flex-1 p-6">
<PageContainer>
{children}
</PageContainer>
</main>
<Footer />
</div>
</div>
interface SidebarState {
collapsed: boolean // 是否折叠
mobileOpen: boolean // 移动端是否展开
activeMenu: string // 当前激活菜单
openMenus: string[] // 展开的子菜单
}
// 折叠宽度
const SIDEBAR_WIDTH = 256 // 展开时 16rem
const SIDEBAR_COLLAPSED = 64 // 折叠时 4rem
interface HeaderProps {
showBreadcrumb?: boolean
showSearch?: boolean
showNotifications?: boolean
showUserMenu?: boolean
}
// 组成部分
<header className="h-16 border-b bg-background/95 backdrop-blur">
<div className="flex items-center justify-between px-4 h-full">
{/* 左侧 */}
<div className="flex items-center gap-4">
<SidebarTrigger />
<Breadcrumb />
</div>
{/* 右侧 */}
<div className="flex items-center gap-2">
<GlobalSearch />
<ThemeToggle />
<NotificationDropdown />
<UserDropdown />
</div>
</div>
</header>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>邮箱</FormLabel>
<FormControl>
<Input placeholder="请输入邮箱" {...field} />
</FormControl>
<FormDescription>
我们不会分享你的邮箱
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
// Zod Schema
const formSchema = z.object({
username: z.string().min(2, '用户名至少2个字符').max(50),
email: z.string().email('请输入有效的邮箱地址'),
password: z.string().min(8, '密码至少8个字符'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: '两次密码输入不一致',
path: ['confirmPassword'],
})
interface DataTableProps<T> {
columns: ColumnDef<T>[]
data: T[]
// 分页
pagination?: boolean
pageSize?: number
// 排序
sorting?: boolean
defaultSort?: { id: string; desc: boolean }
// 筛选
filtering?: boolean
globalFilter?: boolean
// 选择
selection?: boolean
onSelectionChange?: (rows: T[]) => void
// 操作
actions?: (row: T) => React.ReactNode
}
const columns: ColumnDef<User>[] = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
/>
),
},
{
accessorKey: 'name',
header: '姓名',
cell: ({ row }) => <span className="font-medium">{row.getValue('name')}</span>,
},
{
accessorKey: 'email',
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting()}>
邮箱
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
},
{
accessorKey: 'status',
header: '状态',
cell: ({ row }) => (
<Badge variant={row.getValue('status') === 'active' ? 'default' : 'secondary'}>
{row.getValue('status')}
</Badge>
),
filterFn: (row, id, value) => value.includes(row.getValue(id)),
},
{
id: 'actions',
cell: ({ row }) => <RowActions row={row.original} />,
},
]
本文档描述 HaloLight 可拖拽仪表盘的实现规范。
| 框架 | 拖拽库 |
|---|---|
| React/Next.js | react-grid-layout |
| Vue 3 | grid-layout-plus |
| Svelte | svelte-grid |
| Angular | angular-gridster2 |
| ID | 类型 | 默认尺寸 | 描述 |
|---|---|---|---|
| stats | 统计卡片 | 3x2 | 数字统计展示 |
| chart-line | 折线图 | 6x4 | 趋势数据 |
| chart-bar | 柱状图 | 6x4 | 对比数据 |
| chart-pie | 饼图 | 4x4 | 占比数据 |
| recent-users | 最近用户 | 4x4 | 用户列表 |
| notifications | 通知 | 4x4 | 消息列表 |
| tasks | 任务 | 4x4 | 待办事项 |
| calendar | 日历 | 4x4 | 日程安排 |
| quick-actions | 快捷操作 | 3x2 | 常用功能 |
const breakpoints = { lg: 1200, md: 996, sm: 768 }
const cols = { lg: 12, md: 8, sm: 4 }
interface GridLayout {
i: string // Widget ID
x: number // 列位置 (0-based)
y: number // 行位置
w: number // 宽度 (列数)
h: number // 高度 (行数)
minW?: number // 最小宽度
minH?: number // 最小高度
static?: boolean // 是否固定
}
const defaultLayouts = {
lg: [
{ i: 'stats-1', x: 0, y: 0, w: 3, h: 2 },
{ i: 'stats-2', x: 3, y: 0, w: 3, h: 2 },
{ i: 'stats-3', x: 6, y: 0, w: 3, h: 2 },
{ i: 'stats-4', x: 9, y: 0, w: 3, h: 2 },
{ i: 'chart-line', x: 0, y: 2, w: 8, h: 4 },
{ i: 'chart-pie', x: 8, y: 2, w: 4, h: 4 },
{ i: 'recent-users', x: 0, y: 6, w: 4, h: 4 },
{ i: 'tasks', x: 4, y: 6, w: 4, h: 4 },
{ i: 'notifications', x: 8, y: 6, w: 4, h: 4 },
],
}
import GridLayout, { Responsive, WidthProvider } from 'react-grid-layout'
const ResponsiveGridLayout = WidthProvider(Responsive)
function Dashboard() {
const { layouts, updateLayout, isEditing } = useDashboardStore()
return (
<ResponsiveGridLayout
layouts={layouts}
breakpoints={{ lg: 1200, md: 996, sm: 768 }}
cols={{ lg: 12, md: 8, sm: 4 }}
rowHeight={80}
isDraggable={isEditing}
isResizable={isEditing}
onLayoutChange={(layout, allLayouts) => {
updateLayout(allLayouts)
}}
>
{widgets.map((widget) => (
<div key={widget.id}>
<WidgetWrapper widget={widget} />
</div>
))}
</ResponsiveGridLayout>
)
}
<template>
<GridLayout
v-model:layout="layout"
:col-num="12"
:row-height="80"
:is-draggable="isEditing"
:is-resizable="isEditing"
>
<GridItem
v-for="item in layout"
:key="item.i"
:x="item.x"
:y="item.y"
:w="item.w"
:h="item.h"
>
<WidgetWrapper :widget="getWidget(item.i)" />
</GridItem>
</GridLayout>
</template>
<script setup>
import { GridLayout, GridItem } from 'grid-layout-plus'
</script>
interface WidgetWrapperProps {
widget: WidgetConfig
onRemove?: () => void
}
function WidgetWrapper({ widget, onRemove }: WidgetWrapperProps) {
const { isEditing } = useDashboardStore()
return (
<Card className="h-full flex flex-col">
<CardHeader className="flex-row items-center justify-between py-2">
<CardTitle className="text-sm">{widget.title}</CardTitle>
{isEditing && (
<Button variant="ghost" size="icon" onClick={onRemove}>
<X className="h-4 w-4" />
</Button>
)}
</CardHeader>
<CardContent className="flex-1 overflow-hidden">
<WidgetContent type={widget.type} settings={widget.settings} />
</CardContent>
</Card>
)
}
function StatsWidget({ title, value, change, icon: Icon }) {
return (
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{title}</p>
<p className="text-2xl font-bold">{value}</p>
<p className={cn('text-xs', change > 0 ? 'text-green-500' : 'text-red-500')}>
{change > 0 ? '+' : ''}{change}%
</p>
</div>
<Icon className="h-8 w-8 text-muted-foreground" />
</div>
)
}
const getChartTheme = (isDark: boolean) => ({
backgroundColor: 'transparent',
textStyle: { color: isDark ? '#e5e5e5' : '#333' },
axisLine: { lineStyle: { color: isDark ? '#444' : '#ccc' } },
splitLine: { lineStyle: { color: isDark ? '#333' : '#eee' } },
})
function ChartWidget({ option }) {
const chartRef = useRef<EChartsInstance>()
useEffect(() => {
const observer = new ResizeObserver(() => {
chartRef.current?.resize()
})
observer.observe(containerRef.current)
return () => observer.disconnect()
}, [])
return <ReactECharts ref={chartRef} option={option} />
}
function DashboardToolbar() {
const { isEditing, toggleEditing, resetLayout } = useDashboardStore()
return (
<div className="flex gap-2">
<Button variant="outline" onClick={toggleEditing}>
{isEditing ? <Check /> : <Edit />}
{isEditing ? '完成' : '编辑'}
</Button>
{isEditing && (
<>
<AddWidgetButton />
<Button variant="outline" onClick={resetLayout}>
<RotateCcw /> 重置
</Button>
</>
)}
</div>
)
}
// 布局保存到 localStorage
const useDashboardStore = create(
persist(
(set) => ({
layouts: defaultLayouts,
updateLayout: (layouts) => set({ layouts }),
}),
{ name: 'dashboard-layout' }
)
)
HaloLight 是一个多框架、多平台的后台管理系统解决方案。本文档列出所有项目及其状态。
| 项目 | 框架 | 状态 | 说明 |
|---|---|---|---|
| halolight | Next.js 14 + React 18 | ✅ 已发布 | 参考实现 |
| halolight-react | React + Vite | ✅ 已发布 | 纯 SPA 版本 |
| halolight-vue | Vue 3.5 + Vite | ✅ 已发布 | Vue 参考实现 |
| halolight-angular | Angular 21 | ✅ 已发布 | Angular 实现 |
| halolight-nuxt | Nuxt 3 | ✅ 已发布 | Vue SSR 版本 |
| halolight-svelte | SvelteKit | ✅ 已发布 | Svelte 实现 |
| halolight-astro | Astro | ✅ 已发布 | 静态优先 |
| halolight-solid | SolidJS | ✅ 已发布 | 高性能响应式 |
| halolight-qwik | Qwik | ✅ 已发布 | 可恢复式 |
| halolight-remix | Remix | ✅ 已发布 | 全栈 React |
| halolight-preact | Preact | ✅ 已发布 | 轻量级 React |
| halolight-lit | Lit | ✅ 已发布 | Web Components |
| halolight-fresh | Fresh (Deno) | ✅ 已发布 | Deno 原生 |
| halolight-deno | Fresh (Deno) | ✅ 已发布 | Deno 实现 |
| 项目 | 平台 | 状态 | 特性 |
|---|---|---|---|
| halolight-cloudflare | Cloudflare Pages/Workers | ✅ 已发布 | 边缘运行时, Next.js 15 |
| halolight-vercel | Vercel | ✅ 已发布 | Edge Functions |
| halolight-netlify | Netlify | ✅ 已发布 | Edge Functions |
| halolight-aws | AWS Amplify | ✅ 已发布 | Lambda@Edge |
| halolight-azure | Azure Static Web Apps | ✅ 已发布 | Azure Functions |
| halolight-fly | Fly.io | ✅ 已发布 | 全球部署 |
| halolight-railway | Railway | ✅ 已发布 | 一键部署 |
| halolight-docker | Docker 自托管 | ✅ 已发布 | Traefik 反向代理 |
| 项目 | 技术栈 | 状态 | 特性 |
|---|---|---|---|
| halolight-api-nestjs | NestJS + Prisma + TypeScript | ✅ 已发布 | Node.js 企业级 |
| halolight-api-node | Express + Prisma + TypeScript | ✅ 已发布 | Node.js 参考实现 |
| halolight-api-go | Gin + GORM | ✅ 已发布 | 高性能 |
| halolight-api-python | FastAPI + SQLAlchemy + Alembic | ✅ 已发布 | Python 生态 |
| halolight-api-bun | Hono + Drizzle ORM | ✅ 已发布 | Bun 运行时 |
| halolight-api-java | Spring Boot 3.4 + JPA | ✅ 已发布 | 企业级 Java |
| halolight-api-php | Laravel + Eloquent | ✅ 已发布 | PHP 生态 |
| 项目 | 用途 | 状态 | 特性 |
|---|---|---|---|
| halolight-bff | tRPC 网关 | ✅ 已发布 | 类型安全 API |
| halolight-action | Next.js 全栈 | ✅ 已发布 | Server Actions |
| halolight-ui | Stencil Web Components | ✅ 已发布 | 跨框架组件库 |
| 项目 | 用途 | 状态 | 特性 |
|---|---|---|---|
| halolight-ai | AI 智能助理 | ✅ 已发布 | RAG + 动作执行 |
| halolight-web3 | Web3 集成 | ✅ 已发布 | EVM + Solana + IPFS |
React 系: Next.js → Remix → Preact → React (Vite)
Vue 系: Vue 3.5 → Nuxt 3
其他: Angular → SvelteKit → SolidJS → Qwik → Lit → Astro → Fresh → Deno
Node.js: NestJS (Prisma) → Express (Prisma) → Hono (Drizzle)
Go: Gin (GORM)
Python: FastAPI (SQLAlchemy)
Java: Spring Boot (JPA)
PHP: Laravel (Eloquent)
边缘运行时: Cloudflare → Vercel → Netlify
云平台: AWS Amplify → Azure SWA → Fly.io → Railway
自托管: Docker + Traefik
# React (推荐)
git clone https://github.com/halolight/halolight
cd halolight && pnpm install && pnpm dev
# Vue
git clone https://github.com/halolight/halolight-vue
cd halolight-vue && pnpm install && pnpm dev
# Angular
git clone https://github.com/halolight/halolight-angular
cd halolight-angular && pnpm install && pnpm start
# Node.js (推荐)
git clone https://github.com/halolight/halolight-api-node
cd halolight-api-node && pnpm install && pnpm dev
# Go
git clone https://github.com/halolight/halolight-api-go
cd halolight-api-go && go run main.go
# Python
git clone https://github.com/halolight/halolight-api-python
cd halolight-api-python && pip install -r requirements.txt && uvicorn main:app --reload
# Java
git clone https://github.com/halolight/halolight-api-java
cd halolight-api-java && mvn spring-boot:run
# Cloudflare (推荐边缘)
git clone https://github.com/halolight/halolight-cloudflare
cd halolight-cloudflare && pnpm install && pnpm deploy
# Docker (推荐自托管)
git clone https://github.com/halolight/halolight-docker
cd halolight-docker && docker-compose up -d
npm install @halolight/ui
<script type="module" src="https://unpkg.com/@halolight/ui/dist/halolight-ui.esm.js"></script>
<hl-button variant="primary">Click me</hl-button>
<hl-input label="Email" type="email"></hl-input>
<hl-card>
<h3 slot="header">Card Title</h3>
<p>Card content</p>
</hl-card>
import { defineCustomElements } from '@halolight/ui/loader';
defineCustomElements();
function App() {
return <hl-button variant="primary">Click me</hl-button>;
}
<script setup>
import { defineCustomElements } from '@halolight/ui/loader';
defineCustomElements();
</script>
<template>
<hl-button variant="primary">Click me</hl-button>
</template>
git clone https://github.com/halolight/halolight-ai
cd halolight-ai
cp .env.example .env
# 配置 OPENAI_API_KEY 或其他 LLM 密钥
docker-compose up -d
# 发送消息
curl -X POST http://localhost:3000/api/ai/chat \
-H "Content-Type: application/json" \
-H "X-Tenant-ID: default" \
-H "X-User-ID: user1" \
-d '{"message": "帮我分析今日数据"}'
# 执行动作
curl -X POST http://localhost:3000/api/ai/actions/execute \
-H "Content-Type: application/json" \
-d '{"action": "query_users", "params": {"role": "admin"}}'
# 核心包
npm install @halolight/web3-core
# React 组件
npm install @halolight/web3-react
# Vue 组件
npm install @halolight/web3-vue
import { Web3Provider, WalletButton, TokenBalance } from '@halolight/web3-react';
function App() {
return (
<Web3Provider>
<WalletButton />
<TokenBalance />
</Web3Provider>
);
}
git checkout -b feature/xxxgit commit -m 'feat: xxx'git push origin feature/xxx所有 HaloLight 项目均采用 MIT 许可证。
]]>本指南帮助开发者为 HaloLight 创建新的框架版本实现。
fetch 拦截,行为与 halolight/src/mock 对齐 (响应格式、延时、错误码)halolight/src/app/(auth) 的页面流与校验逻辑*_API_URL、*_USE_MOCK、*_DEMO_*、*_BRAND_NAME),保持文档一致@/)halolight/src/mock 数据结构)# Next.js
npx create-next-app@latest halolight --typescript --tailwind --app
# Vue
npm create vue@latest halolight-vue
# SvelteKit
npx sv create halolight-svelte
# Angular
ng new halolight-angular --routing --style=scss
# 通用依赖
npm install axios @tanstack/react-query zustand
npm install -D tailwindcss postcss autoprefixer
# shadcn/ui
npx shadcn@latest init
src/
├── app/ # 页面
├── components/
│ ├── ui/ # shadcn 组件
│ ├── layout/ # 布局组件
│ └── dashboard/ # 仪表盘组件
├── hooks/ # 自定义 hooks
├── stores/ # 状态管理
├── services/ # API 服务
├── lib/ # 工具函数
├── types/ # 类型定义
└── mocks/ # Mock 数据
| 概念 | React | Vue | Svelte |
|---|---|---|---|
| 组件 | function Component() |
<script setup> |
<script> |
| Props | props: Props |
defineProps<Props>() |
export let prop |
| State | useState() |
ref() |
let state = $state() |
| 计算 | useMemo() |
computed() |
$derived() |
| 副作用 | useEffect() |
watch() |
$effect() |
| 上下文 | useContext() |
provide/inject |
setContext() |
| 概念 | Next.js | Vue Router | SvelteKit |
|---|---|---|---|
| 文件路由 | app/page.tsx |
- | routes/+page.svelte |
| 动态路由 | [id]/page.tsx |
:id |
[id]/+page.svelte |
| 布局 | layout.tsx |
- | +layout.svelte |
| 守卫 | middleware.ts |
beforeEach |
+page.server.ts |
| 框架 | 推荐方案 | 持久化 |
|---|---|---|
| React | Zustand | zustand/middleware |
| Vue | Pinia | pinia-plugin-persistedstate |
| Svelte | Svelte Stores | - |
| Angular | Signals | localStorage |
// services/users.ts
import { api } from './api'
export const userService = {
getList: (params) => api.get('/users', { params }),
getById: (id) => api.get(`/users/${id}`),
create: (data) => api.post('/users', data),
update: (id, data) => api.put(`/users/${id}`, data),
delete: (id) => api.delete(`/users/${id}`),
}
// hooks/useUsers.ts
export function useUsers(params) {
return useQuery({
queryKey: ['users', params],
queryFn: () => userService.getList(params),
})
}
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: userService.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}
// stores/auth.ts
export const useAuthStore = create(
persist(
(set, get) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (credentials) => {
const response = await authService.login(credentials)
set({
user: response.user,
token: response.token,
isAuthenticated: true,
})
},
logout: () => {
set({ user: null, token: null, isAuthenticated: false })
},
hasPermission: (permission) => {
const { permissions } = get().user || {}
return permissions?.includes(permission) || permissions?.includes('*')
},
}),
{ name: 'auth' }
)
)
| 类型 | 规范 | 示例 |
|---|---|---|
| 组件 | PascalCase | UserCard.tsx |
| Hook | camelCase + use | useUsers.ts |
| Store | camelCase + use | useAuthStore.ts |
| 服务 | camelCase + Service | userService.ts |
| 类型 | PascalCase | User, UserQueryParams |
| 常量 | UPPER_SNAKE | API_BASE_URL |
# 组件目录
components/
└── UserCard/
├── index.tsx # 主组件
├── UserCard.types.ts # 类型定义
└── useUserCard.ts # 组件逻辑
# 页面目录
app/users/
├── page.tsx # 列表页
├── [id]/
│ └── page.tsx # 详情页
└── create/
└── page.tsx # 创建页
本文档集合了 HaloLight 多框架管理后台项目的共有模式和实现规范,用于指导各框架版本的开发。
全部框架版本均已实现并部署 (预览地址见各仓库 README)。参考实现 (用于规范校验):
其他框架:Angular · Nuxt · SvelteKit · Astro · Solid.js · Qwik · Remix · Preact · Lit · Fresh (Deno)。
| 框架 | 状态 | 预览 | 仓库 |
|---|---|---|---|
| Next.js 14 | ✅ 已部署 | 预览 | GitHub |
| Vue 3.5 | ✅ 已部署 | 预览 | GitHub |
| Angular 21 | ✅ 已部署 | 预览 | GitHub |
| Nuxt 4 | ✅ 已部署 | 预览 | GitHub |
| SvelteKit 2 | ✅ 已部署 | 预览 | GitHub |
| Astro 5 | ✅ 已部署 | 预览 | GitHub |
| Solid.js | ✅ 已部署 | 预览 | GitHub |
| Qwik | ✅ 已部署 | 预览 | GitHub |
| Remix | ✅ 已部署 | 预览 | GitHub |
| Preact | ✅ 已部署 | 预览 | GitHub |
| Lit | ✅ 已部署 | 预览 | GitHub |
| Fresh (Deno) | ✅ 已部署 | 预览 | GitHub |
| 功能 | React/Next.js | Vue 3 | Angular | Svelte |
|---|---|---|---|---|
| 状态管理 | Zustand | Pinia | Signals/RxJS | Svelte Stores |
| 数据获取 | TanStack Query | TanStack Query | RxJS | TanStack Query |
| 路由 | Next.js App Router | Vue Router | Angular Router | SvelteKit |
| 表单 | React Hook Form | VeeValidate | Reactive Forms | Superforms |
| 拖拽布局 | react-grid-layout | grid-layout-plus | angular-gridster2 | svelte-grid |
本文档描述 HaloLight 项目的状态管理模式,涵盖不同框架的实现方案。
| 框架 | 状态库 | 特点 |
|---|---|---|
| React/Next.js | Zustand | 简洁、无样板代码 |
| Vue 3 | Pinia | 官方推荐、类型安全 |
| Svelte | Svelte Stores | 原生响应式 |
| Angular | Signals + RxJS | 细粒度响应式 |
| Solid.js | createStore | 细粒度响应式 |
stores/
├── auth.ts # 认证状态
├── ui-settings.ts # UI 设置
├── dashboard.ts # 仪表盘布局
├── navigation.ts # 导航菜单
├── tabs.ts # 多标签页
└── error.ts # 错误状态
interface AuthState {
user: User | null
token: string | null
refreshToken: string | null
permissions: string[]
roles: string[]
isAuthenticated: boolean
isLoading: boolean
}
export const useAuthStore = create<AuthStore>()(
persist(
(set, get) => ({
user: null,
token: null,
permissions: [],
isAuthenticated: false,
login: async (credentials) => {
const response = await authService.login(credentials)
set({
user: response.user,
token: response.token,
permissions: response.permissions,
isAuthenticated: true,
})
},
hasPermission: (permission) => {
const { permissions } = get()
return permissions.some((p) =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
},
}),
{ name: 'auth-storage' }
)
)
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(null)
const permissions = ref<string[]>([])
const isAuthenticated = computed(() => !!token.value)
function hasPermission(permission: string): boolean {
return permissions.value.some((p) =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
}
return { user, token, permissions, isAuthenticated, hasPermission }
}, { persist: { paths: ['token', 'user'] } })
interface DashboardState {
layouts: { lg: GridLayout[]; md: GridLayout[]; sm: GridLayout[] }
widgets: WidgetConfig[]
isEditing: boolean
}
interface GridLayout {
i: string; x: number; y: number; w: number; h: number
}
| 数据类型 | 存储位置 | 示例 |
|---|---|---|
| 用户偏好 | localStorage | 主题、语言 |
| UI 状态 | localStorage | 侧边栏、布局 |
| 临时数据 | sessionStorage | 表单草稿 |
| 服务端数据 | TanStack Query | API 响应 |
本文档描述 HaloLight 的主题切换和皮肤预设系统。
| 模式 | 描述 |
|---|---|
| light | 浅色主题 |
| dark | 深色主题 |
| system | 跟随系统 |
共 11 种颜色皮肤:
| 皮肤 | Primary | 适用场景 |
|---|---|---|
| default | 蓝色 | 通用 |
| zinc | 灰色 | 简约 |
| slate | 蓝灰 | 专业 |
| stone | 棕灰 | 温暖 |
| gray | 中性灰 | 通用 |
| neutral | 黑白 | 极简 |
| red | 红色 | 警示 |
| rose | 玫红 | 时尚 |
| orange | 橙色 | 活力 |
| green | 绿色 | 自然 |
| blue | 蓝色 | 科技 |
| yellow | 黄色 | 明亮 |
| violet | 紫色 | 优雅 |
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
/* ... */
}
[data-skin="rose"] {
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
}
[data-skin="green"] {
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
}
interface ThemeContextValue {
theme: 'light' | 'dark' | 'system'
skin: SkinPreset
setTheme: (theme: string) => void
setSkin: (skin: SkinPreset) => void
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState('system')
const [skin, setSkin] = useState<SkinPreset>('default')
useEffect(() => {
const root = document.documentElement
root.classList.remove('light', 'dark')
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
root.classList.add(systemTheme)
} else {
root.classList.add(theme)
}
root.setAttribute('data-skin', skin)
}, [theme, skin])
return (
<ThemeContext.Provider value={{ theme, skin, setTheme, setSkin }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const theme = ref<'light' | 'dark' | 'system'>('system')
const skin = ref<SkinPreset>('default')
const actualTheme = computed(() => {
if (theme.value === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
return theme.value
})
watch([theme, skin], () => {
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(actualTheme.value)
document.documentElement.setAttribute('data-skin', skin.value)
}, { immediate: true })
return { theme, skin, actualTheme }
}
async function toggleTheme() {
if (!document.startViewTransition) {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
return
}
await document.startViewTransition(() => {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
}).ready
// 圆形展开动画
const { clientX, clientY } = event
const radius = Math.hypot(
Math.max(clientX, window.innerWidth - clientX),
Math.max(clientY, window.innerHeight - clientY)
)
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${clientX}px ${clientY}px)`,
`circle(${radius}px at ${clientX}px ${clientY}px)`,
],
},
{
duration: 500,
easing: 'ease-in-out',
pseudoElement: '::view-transition-new(root)',
}
)
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 9999;
}
function ThemeSelector() {
const { theme, setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Sun className="h-4 w-4 rotate-0 scale-100 dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 dark:rotate-0 dark:scale-100" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setTheme('light')}>
<Sun className="mr-2 h-4 w-4" /> 浅色
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
<Moon className="mr-2 h-4 w-4" /> 深色
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
<Monitor className="mr-2 h-4 w-4" /> 系统
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
function SkinSelector() {
const { skin, setSkin } = useTheme()
const skins: SkinPreset[] = [
'default', 'zinc', 'slate', 'stone', 'gray',
'neutral', 'red', 'rose', 'orange', 'green',
'blue', 'yellow', 'violet'
]
return (
<div className="grid grid-cols-5 gap-2">
{skins.map((s) => (
<button
key={s}
onClick={() => setSkin(s)}
className={cn(
'h-8 w-8 rounded-full border-2',
skin === s ? 'border-primary' : 'border-transparent'
)}
style={{ backgroundColor: `hsl(var(--skin-${s}))` }}
/>
))}
</div>
)
}
const echartTheme = computed(() => ({
backgroundColor: 'transparent',
textStyle: {
color: actualTheme.value === 'dark' ? '#e5e5e5' : '#333',
},
title: {
textStyle: {
color: actualTheme.value === 'dark' ? '#fff' : '#333',
},
},
legend: {
textStyle: {
color: actualTheme.value === 'dark' ? '#e5e5e5' : '#333',
},
},
xAxis: {
axisLine: { lineStyle: { color: actualTheme.value === 'dark' ? '#444' : '#ccc' } },
splitLine: { lineStyle: { color: actualTheme.value === 'dark' ? '#333' : '#eee' } },
},
yAxis: {
axisLine: { lineStyle: { color: actualTheme.value === 'dark' ? '#444' : '#ccc' } },
splitLine: { lineStyle: { color: actualTheme.value === 'dark' ? '#333' : '#eee' } },
},
}))
This document describes the API service layer architecture and data fetching strategies for the HaloLight project.
| Framework | HTTP Client | Cache Layer |
|---|---|---|
| React/Next.js | Axios | TanStack Query |
| Vue 3 | Axios | TanStack Query |
| Svelte | Fetch | TanStack Query |
| Angular | HttpClient | RxJS |
services/
├── api.ts # Axios instance configuration
├── auth.ts # Authentication service
├── users.ts # User service
├── roles.ts # Role service
├── permissions.ts # Permission service
├── dashboard.ts # Dashboard service
├── settings.ts # Settings service
└── index.ts # Unified export
import axios from 'axios'
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
})
// Request interceptor
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Response interceptor
api.interceptors.response.use(
(response) => response.data,
async (error) => {
if (error.response?.status === 401) {
useAuthStore.getState().logout()
window.location.href = '/login'
}
return Promise.reject(error)
}
)
// services/users.ts
export const userService = {
getList: (params: UserQueryParams) =>
api.get<PaginatedResponse<User>>('/users', { params }),
getById: (id: string) =>
api.get<User>(`/users/${id}`),
create: (data: CreateUserDto) =>
api.post<User>('/users', data),
update: (id: string, data: UpdateUserDto) =>
api.put<User>(`/users/${id}`, data),
delete: (id: string) =>
api.delete(`/users/${id}`),
}
// hooks/useUsers.ts
export function useUsers(params: UserQueryParams) {
return useQuery({
queryKey: ['users', params],
queryFn: () => userService.getList(params),
staleTime: 5 * 60 * 1000, // 5 minutes
})
}
export function useUser(id: string) {
return useQuery({
queryKey: ['users', id],
queryFn: () => userService.getById(id),
enabled: !!id,
})
}
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: userService.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success('User created successfully')
},
})
}
// mocks/index.ts
import Mock from 'mockjs'
import './modules/auth'
import './modules/users'
import './modules/dashboard'
Mock.setup({ timeout: '200-600' })
// mocks/modules/users.ts
Mock.mock(/\/api\/users(\?.*)?$/, 'get', (options) => {
const params = parseQuery(options.url)
return {
code: 200,
data: {
list: Mock.mock({
[`list|${params.pageSize || 10}`]: [{
'id': '@guid',
'name': '@name',
'email': '@email',
'status|1': ['active', 'inactive'],
'createdAt': '@datetime',
}]
}).list,
total: 100,
}
}
})
interface ApiError {
code: string
message: string
details?: Record<string, string[]>
}
// Unified error handling
function handleApiError(error: ApiError) {
switch (error.code) {
case 'VALIDATION_ERROR':
// Form validation error
break
case 'UNAUTHORIZED':
// Unauthorized
break
case 'FORBIDDEN':
// Forbidden
break
default:
toast.error(error.message)
}
}
interface PaginatedResponse<T> {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
interface PaginationParams {
page?: number
pageSize?: number
sortBy?: string
sortOrder?: 'asc' | 'desc'
}
This document describes the overall architecture design of the HaloLight project, including directory structure, layered design, and core patterns.
src/
├── app/ # Page routes (Next.js) or views/ (Vue)
│ ├── (admin)/ # Admin route group
│ │ ├── dashboard/ # Dashboard
│ │ ├── users/ # User management
│ │ ├── roles/ # Role management
│ │ ├── permissions/ # Permission management
│ │ ├── settings/ # System settings
│ │ └── profile/ # User profile
│ └── (auth)/ # Auth route group
│ ├── login/ # Login
│ ├── register/ # Register
│ ├── forgot-password/
│ └── reset-password/
├── components/ # Reusable components
│ ├── ui/ # Basic UI components (shadcn/ui)
│ ├── layout/ # Layout components
│ ├── dashboard/ # Dashboard components
│ ├── charts/ # Chart components
│ └── shared/ # Shared business components
├── hooks/ (composables/) # Reusable logic
├── stores/ # State management
├── services/ # API service layer
├── lib/ # Utility library
├── types/ # TypeScript type definitions
├── styles/ # Global styles
└── mocks/ # Mock data
┌─────────────────────────────────────────────────┐
│ View Layer │
│ Pages / Views / Routes │
├─────────────────────────────────────────────────┤
│ Component Layer │
│ UI Components / Layout / Dashboard │
├─────────────────────────────────────────────────┤
│ State Layer │
│ Stores / Composables / Hooks │
├─────────────────────────────────────────────────┤
│ Service Layer │
│ API Services / Data Fetching / Cache │
└─────────────────────────────────────────────────┘
| Layer | Responsibility | Framework Implementation |
|---|---|---|
| View Layer | Page routing, layout assembly | Next.js Pages / Vue Views |
| Component Layer | UI rendering, user interaction | React/Vue/Svelte Components |
| State Layer | Application state, business logic | Zustand / Pinia / Stores |
| Service Layer | API calls, data caching | TanStack Query / Axios |
<ThemeProvider>
<MockProvider>
<QueryClientProvider>
<AuthProvider>
<PermissionProvider>
<WebSocketProvider>
<ErrorProvider>
<ToastProvider>
{children}
</ToastProvider>
</ErrorProvider>
</WebSocketProvider>
</PermissionProvider>
</AuthProvider>
</QueryClientProvider>
</MockProvider>
</ThemeProvider>
app.use(pinia)
app.use(router)
app.use(i18n)
app.use(mockPlugin)
app.use(queryPlugin)
app.use(permissionPlugin)
┌──────────────────────────────────────────────────────┐
│ Header │
│ [Logo] [Breadcrumb] [Search] [User] [Settings]
├────────────┬─────────────────────────────────────────┤
│ │ │
│ Sidebar │ Content │
│ │ │
│ - Menu │ ┌──────────────────────────────────┐ │
│ - Nav │ │ Page Content │ │
│ │ │ │ │
│ │ └──────────────────────────────────┘ │
│ │ │
├────────────┴─────────────────────────────────────────┤
│ Footer │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ │
│ ┌────────────┐ │
│ │ │ │
│ │ Auth Form │ │
│ │ │ │
│ └────────────┘ │
│ │
└──────────────────────────────────────────────────────┘
interface RouteMeta {
title: string // Page title
icon?: string // Menu icon
permission?: string // Required permission
hidden?: boolean // Hidden in menu
keepAlive?: boolean // Cache component
breadcrumb?: boolean // Show breadcrumb
}
| Path | Page | Permission |
|---|---|---|
/dashboard |
Dashboard | dashboard:view |
/users |
User list | users:list |
/users/create |
Create user | users:create |
/users/:id |
User detail | users:view |
/users/:id/edit |
Edit user | users:update |
/roles |
Role list | roles:list |
/permissions |
Permission management | permissions:list |
/settings |
System settings | settings:view |
/profile |
User profile | - (authenticated) |
| Prefix | Framework | Description |
|---|---|---|
NEXT_PUBLIC_ |
Next.js | Client-side visible |
VITE_ |
Vue/Vite | Client-side visible |
PUBLIC_ |
SvelteKit | Client-side visible |
| No prefix | All | Server-side only |
# API Configuration
*_API_BASE_URL=http://localhost:3000/api
*_API_TIMEOUT=30000
# Authentication Configuration
*_AUTH_SECRET=your-secret-key
*_TOKEN_EXPIRES=7d
# Mock Toggle
*_ENABLE_MOCK=true
# Feature Toggles
*_ENABLE_WEBSOCKET=true
*_ENABLE_ANALYTICS=false
Organize code by feature modules, not by file types:
# Recommended ✅
features/
├── users/
│ ├── components/
│ ├── hooks/
│ ├── services/
│ └── types/
# Avoid ❌
components/
├── UserList.tsx
├── UserForm.tsx
hooks/
├── useUsers.ts
services/
├── userService.ts
Place component-specific styles, types, and utilities in the component directory:
components/
└── UserCard/
├── index.tsx
├── UserCard.module.css
├── UserCard.types.ts
└── useUserCard.ts
Extract code used in multiple places to shared locations:
# Used by 2+ components → Extract to components/shared/
# Used in 3+ places → Extract to lib/ or utils/
When adding or modifying features, first clarify interfaces, constraints, and directory structure in halolight/docs, then sync to halolight and halolight-vue to avoid implementation divergence.
This document describes the user authentication and permission control implementation for the HaloLight project.
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Login │ ──► │ Verify │ ──► │ Get │ ──► │ Store │
│ Form │ │Credentials│ │ Token │ │ State │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
| Page | Path | Function |
|---|---|---|
| Login | /login |
Username/password login |
| Register | /register |
New user registration |
| Forgot Password | /forgot-password |
Send reset email |
| Reset Password | /reset-password |
Set new password |
interface TokenPair {
accessToken: string // Short-lived (15 minutes)
refreshToken: string // Long-lived (7 days)
}
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true
try {
const { refreshToken } = useAuthStore.getState()
const newTokens = await authService.refresh(refreshToken)
useAuthStore.getState().setTokens(newTokens)
error.config.headers.Authorization = `Bearer ${newTokens.accessToken}`
return api(error.config)
} catch {
useAuthStore.getState().logout()
}
}
return Promise.reject(error)
}
)
// Format: resource:action
const permissions = [
'users:list', // View user list
'users:create', // Create user
'users:update', // Update user
'users:delete', // Delete user
'users:*', // All user permissions
'*', // Super admin
]
function hasPermission(userPerms: string[], required: string): boolean {
return userPerms.some((p) =>
p === '*' ||
p === required ||
(p.endsWith(':*') && required.startsWith(p.slice(0, -1)))
)
}
// React
function PermissionGuard({ permission, children, fallback }) {
const hasPermission = useAuthStore((s) => s.hasPermission)
return hasPermission(permission) ? children : fallback
}
// Usage
<PermissionGuard permission="users:delete" fallback={null}>
<DeleteButton />
</PermissionGuard>
<!-- Vue -->
<template>
<slot v-if="hasPermission(permission)" />
<slot v-else name="fallback" />
</template>
// v-permission directive
app.directive('permission', {
mounted(el, binding) {
const authStore = useAuthStore()
if (!authStore.hasPermission(binding.value)) {
el.parentNode?.removeChild(el)
}
},
})
// Usage
<button v-permission="'users:delete'">Delete</button>
// middleware.ts
export function middleware(request: NextRequest) {
const token = request.cookies.get('token')
const isAuthPage = request.nextUrl.pathname.startsWith('/login')
if (!token && !isAuthPage) {
return NextResponse.redirect(new URL('/login', request.url))
}
if (token && isAuthPage) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
}
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({ name: 'login', query: { redirect: to.fullPath } })
return
}
if (to.meta.permission && !authStore.hasPermission(to.meta.permission)) {
next({ name: '403' })
return
}
next()
})
const PERMISSIONS = {
// Dashboard
'dashboard:view': 'View dashboard',
'dashboard:edit': 'Edit dashboard',
// User Management
'users:list': 'View user list',
'users:view': 'View user details',
'users:create': 'Create user',
'users:update': 'Update user',
'users:delete': 'Delete user',
// Role Management
'roles:list': 'View role list',
'roles:create': 'Create role',
'roles:update': 'Update role',
'roles:delete': 'Delete role',
// Permission Management
'permissions:list': 'View permission list',
'permissions:assign': 'Assign permissions',
// System Settings
'settings:view': 'View settings',
'settings:update': 'Update settings',
}
const ROLES = {
admin: {
name: 'Administrator',
permissions: ['*'],
},
manager: {
name: 'Manager',
permissions: ['dashboard:*', 'users:list', 'users:view'],
},
user: {
name: 'User',
permissions: ['dashboard:view'],
},
}
This document defines the UI component library specification for the HaloLight project, based on the shadcn/ui design system.
All framework versions use the corresponding shadcn/ui implementation:
| Framework | Component Library | Repository |
|---|---|---|
| React/Next.js | shadcn/ui | shadcn/ui |
| Vue 3 | shadcn-vue | shadcn-vue |
| Svelte | shadcn-svelte | shadcn-svelte |
| Angular | spartan/ui | spartan |
| Solid.js | solid-ui | solid-ui |
ui/
├── accordion.tsx # Accordion
├── alert-dialog.tsx # Alert dialog
├── alert.tsx # Alert
├── avatar.tsx # Avatar
├── badge.tsx # Badge
├── breadcrumb.tsx # Breadcrumb
├── button.tsx # Button
├── calendar.tsx # Calendar
├── card.tsx # Card
├── checkbox.tsx # Checkbox
├── collapsible.tsx # Collapsible
├── command.tsx # Command palette
├── data-table.tsx # Data table
├── date-picker.tsx # Date picker
├── dialog.tsx # Dialog
├── dropdown-menu.tsx # Dropdown menu
├── form.tsx # Form
├── input.tsx # Input
├── label.tsx # Label
├── pagination.tsx # Pagination
├── popover.tsx # Popover
├── progress.tsx # Progress
├── radio-group.tsx # Radio group
├── scroll-area.tsx # Scroll area
├── select.tsx # Select
├── separator.tsx # Separator
├── sheet.tsx # Sheet
├── skeleton.tsx # Skeleton
├── slider.tsx # Slider
├── switch.tsx # Switch
├── table.tsx # Table
├── tabs.tsx # Tabs
├── textarea.tsx # Textarea
├── toast.tsx # Toast
├── tooltip.tsx # Tooltip
└── sonner.tsx # Toast notification
layout/
├── AdminLayout.tsx # Admin main layout
├── AuthLayout.tsx # Auth page layout
├── Sidebar.tsx # Sidebar
├── Header.tsx # Header
├── Footer.tsx # Footer
├── Breadcrumb.tsx # Breadcrumb navigation
├── TabsNav.tsx # Tabs navigation
└── PageContainer.tsx # Page container
dashboard/
├── DashboardGrid.tsx # Draggable grid container
├── WidgetWrapper.tsx # Widget wrapper
├── StatsWidget.tsx # Stats card
├── ChartWidget.tsx # Chart widget
├── TableWidget.tsx # Table widget
├── CalendarWidget.tsx # Calendar widget
├── TasksWidget.tsx # Task list
└── QuickActionsWidget.tsx # Quick actions
charts/
├── LineChart.tsx # Line chart
├── BarChart.tsx # Bar chart
├── PieChart.tsx # Pie chart
├── AreaChart.tsx # Area chart
├── RadarChart.tsx # Radar chart
└── GaugeChart.tsx # Gauge
// Basic Props structure
interface ComponentProps {
// Required props first
children: React.ReactNode
// Optional props in alphabetical order
className?: string
disabled?: boolean
loading?: boolean
size?: 'sm' | 'md' | 'lg'
variant?: 'default' | 'outline' | 'ghost'
// Event handlers
onChange?: (value: T) => void
onClick?: () => void
}
Use cva (class-variance-authority) to define variants:
import { cva, type VariantProps } from 'class-variance-authority'
const buttonVariants = cva(
// Base styles
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
outline: 'border border-input bg-background hover:bg-accent',
ghost: 'hover:bg-accent hover:text-accent-foreground',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
},
size: {
sm: 'h-8 px-3 text-xs',
md: 'h-9 px-4 text-sm',
lg: 'h-10 px-6 text-base',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
loading?: boolean
}
All components must support:
// ARIA attributes
<button
role="button"
aria-label={ariaLabel}
aria-disabled={disabled}
aria-busy={loading}
>
// Keyboard navigation
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
onClick?.()
}
if (e.key === 'Escape') {
onClose?.()
}
}
// Focus management
const focusRef = useRef<HTMLElement>(null)
useEffect(() => {
if (open) {
focusRef.current?.focus()
}
}, [open])
// Tailwind breakpoints
const breakpoints = {
sm: '640px', // Mobile landscape
md: '768px', // Tablet
lg: '1024px', // Small desktop
xl: '1280px', // Desktop
'2xl': '1536px' // Large screen
}
// Responsive class example
<div className="
grid
grid-cols-1
sm:grid-cols-2
md:grid-cols-3
lg:grid-cols-4
gap-4
">
interface AdminLayoutProps {
children: React.ReactNode
}
// Layout structure
<div className="min-h-screen bg-background">
<Sidebar />
<div className="flex flex-col lg:ml-64">
<Header />
<main className="flex-1 p-6">
<PageContainer>
{children}
</PageContainer>
</main>
<Footer />
</div>
</div>
interface SidebarState {
collapsed: boolean // Is collapsed
mobileOpen: boolean // Mobile open state
activeMenu: string // Current active menu
openMenus: string[] // Expanded submenus
}
// Collapse widths
const SIDEBAR_WIDTH = 256 // Expanded 16rem
const SIDEBAR_COLLAPSED = 64 // Collapsed 4rem
interface HeaderProps {
showBreadcrumb?: boolean
showSearch?: boolean
showNotifications?: boolean
showUserMenu?: boolean
}
// Component parts
<header className="h-16 border-b bg-background/95 backdrop-blur">
<div className="flex items-center justify-between px-4 h-full">
{/* Left side */}
<div className="flex items-center gap-4">
<SidebarTrigger />
<Breadcrumb />
</div>
{/* Right side */}
<div className="flex items-center gap-2">
<GlobalSearch />
<ThemeToggle />
<NotificationDropdown />
<UserDropdown />
</div>
</div>
</header>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="Enter email" {...field} />
</FormControl>
<FormDescription>
We won't share your email
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
// Zod Schema
const formSchema = z.object({
username: z.string().min(2, 'Username must be at least 2 characters').max(50),
email: z.string().email('Please enter a valid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
interface DataTableProps<T> {
columns: ColumnDef<T>[]
data: T[]
// Pagination
pagination?: boolean
pageSize?: number
// Sorting
sorting?: boolean
defaultSort?: { id: string; desc: boolean }
// Filtering
filtering?: boolean
globalFilter?: boolean
// Selection
selection?: boolean
onSelectionChange?: (rows: T[]) => void
// Actions
actions?: (row: T) => React.ReactNode
}
const columns: ColumnDef<User>[] = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
/>
),
},
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => <span className="font-medium">{row.getValue('name')}</span>,
},
{
accessorKey: 'email',
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting()}>
Email
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => (
<Badge variant={row.getValue('status') === 'active' ? 'default' : 'secondary'}>
{row.getValue('status')}
</Badge>
),
filterFn: (row, id, value) => value.includes(row.getValue(id)),
},
{
id: 'actions',
cell: ({ row }) => <RowActions row={row.original} />,
},
]
This document describes the implementation specification for the HaloLight draggable dashboard.
| Framework | Drag Library |
|---|---|
| React/Next.js | react-grid-layout |
| Vue 3 | grid-layout-plus |
| Svelte | svelte-grid |
| Angular | angular-gridster2 |
| ID | Type | Default Size | Description |
|---|---|---|---|
| stats | Stats card | 3x2 | Numeric statistics |
| chart-line | Line chart | 6x4 | Trend data |
| chart-bar | Bar chart | 6x4 | Comparison data |
| chart-pie | Pie chart | 4x4 | Proportion data |
| recent-users | Recent users | 4x4 | User list |
| notifications | Notifications | 4x4 | Message list |
| tasks | Tasks | 4x4 | Todo items |
| calendar | Calendar | 4x4 | Schedule |
| quick-actions | Quick actions | 3x2 | Common functions |
const breakpoints = { lg: 1200, md: 996, sm: 768 }
const cols = { lg: 12, md: 8, sm: 4 }
interface GridLayout {
i: string // Widget ID
x: number // Column position (0-based)
y: number // Row position
w: number // Width (columns)
h: number // Height (rows)
minW?: number // Minimum width
minH?: number // Minimum height
static?: boolean // Is static
}
const defaultLayouts = {
lg: [
{ i: 'stats-1', x: 0, y: 0, w: 3, h: 2 },
{ i: 'stats-2', x: 3, y: 0, w: 3, h: 2 },
{ i: 'stats-3', x: 6, y: 0, w: 3, h: 2 },
{ i: 'stats-4', x: 9, y: 0, w: 3, h: 2 },
{ i: 'chart-line', x: 0, y: 2, w: 8, h: 4 },
{ i: 'chart-pie', x: 8, y: 2, w: 4, h: 4 },
{ i: 'recent-users', x: 0, y: 6, w: 4, h: 4 },
{ i: 'tasks', x: 4, y: 6, w: 4, h: 4 },
{ i: 'notifications', x: 8, y: 6, w: 4, h: 4 },
],
}
import GridLayout, { Responsive, WidthProvider } from 'react-grid-layout'
const ResponsiveGridLayout = WidthProvider(Responsive)
function Dashboard() {
const { layouts, updateLayout, isEditing } = useDashboardStore()
return (
<ResponsiveGridLayout
layouts={layouts}
breakpoints={{ lg: 1200, md: 996, sm: 768 }}
cols={{ lg: 12, md: 8, sm: 4 }}
rowHeight={80}
isDraggable={isEditing}
isResizable={isEditing}
onLayoutChange={(layout, allLayouts) => {
updateLayout(allLayouts)
}}
>
{widgets.map((widget) => (
<div key={widget.id}>
<WidgetWrapper widget={widget} />
</div>
))}
</ResponsiveGridLayout>
)
}
<template>
<GridLayout
v-model:layout="layout"
:col-num="12"
:row-height="80"
:is-draggable="isEditing"
:is-resizable="isEditing"
>
<GridItem
v-for="item in layout"
:key="item.i"
:x="item.x"
:y="item.y"
:w="item.w"
:h="item.h"
>
<WidgetWrapper :widget="getWidget(item.i)" />
</GridItem>
</GridLayout>
</template>
<script setup>
import { GridLayout, GridItem } from 'grid-layout-plus'
</script>
interface WidgetWrapperProps {
widget: WidgetConfig
onRemove?: () => void
}
function WidgetWrapper({ widget, onRemove }: WidgetWrapperProps) {
const { isEditing } = useDashboardStore()
return (
<Card className="h-full flex flex-col">
<CardHeader className="flex-row items-center justify-between py-2">
<CardTitle className="text-sm">{widget.title}</CardTitle>
{isEditing && (
<Button variant="ghost" size="icon" onClick={onRemove}>
<X className="h-4 w-4" />
</Button>
)}
</CardHeader>
<CardContent className="flex-1 overflow-hidden">
<WidgetContent type={widget.type} settings={widget.settings} />
</CardContent>
</Card>
)
}
function StatsWidget({ title, value, change, icon: Icon }) {
return (
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{title}</p>
<p className="text-2xl font-bold">{value}</p>
<p className={cn('text-xs', change > 0 ? 'text-green-500' : 'text-red-500')}>
{change > 0 ? '+' : ''}{change}%
</p>
</div>
<Icon className="h-8 w-8 text-muted-foreground" />
</div>
)
}
const getChartTheme = (isDark: boolean) => ({
backgroundColor: 'transparent',
textStyle: { color: isDark ? '#e5e5e5' : '#333' },
axisLine: { lineStyle: { color: isDark ? '#444' : '#ccc' } },
splitLine: { lineStyle: { color: isDark ? '#333' : '#eee' } },
})
function ChartWidget({ option }) {
const chartRef = useRef<EChartsInstance>()
useEffect(() => {
const observer = new ResizeObserver(() => {
chartRef.current?.resize()
})
observer.observe(containerRef.current)
return () => observer.disconnect()
}, [])
return <ReactECharts ref={chartRef} option={option} />
}
function DashboardToolbar() {
const { isEditing, toggleEditing, resetLayout } = useDashboardStore()
return (
<div className="flex gap-2">
<Button variant="outline" onClick={toggleEditing}>
{isEditing ? <Check /> : <Edit />}
{isEditing ? 'Done' : 'Edit'}
</Button>
{isEditing && (
<>
<AddWidgetButton />
<Button variant="outline" onClick={resetLayout}>
<RotateCcw /> Reset
</Button>
</>
)}
</div>
)
}
// Save layout to localStorage
const useDashboardStore = create(
persist(
(set) => ({
layouts: defaultLayouts,
updateLayout: (layouts) => set({ layouts }),
}),
{ name: 'dashboard-layout' }
)
)
HaloLight is a multi-framework, multi-platform admin dashboard solution. This document lists all projects and their status.
| Project | Framework | Status | Description |
|---|---|---|---|
| halolight | Next.js 14 + React 18 | ✅ Released | Reference implementation |
| halolight-react | React + Vite | ✅ Released | Pure SPA version |
| halolight-vue | Vue 3.5 + Vite | ✅ Released | Vue reference implementation |
| halolight-angular | Angular 21 | ✅ Released | Angular implementation |
| halolight-nuxt | Nuxt 3 | ✅ Released | Vue SSR version |
| halolight-svelte | SvelteKit | ✅ Released | Svelte implementation |
| halolight-astro | Astro | ✅ Released | Static-first |
| halolight-solid | SolidJS | ✅ Released | High-performance reactive |
| halolight-qwik | Qwik | ✅ Released | Resumable |
| halolight-remix | Remix | ✅ Released | Full-stack React |
| halolight-preact | Preact | ✅ Released | Lightweight React |
| halolight-lit | Lit | ✅ Released | Web Components |
| halolight-fresh | Fresh (Deno) | ✅ Released | Deno native |
| halolight-deno | Fresh (Deno) | ✅ Released | Deno implementation |
| Project | Platform | Status | Features |
|---|---|---|---|
| halolight-cloudflare | Cloudflare Pages/Workers | ✅ Released | Edge runtime, Next.js 15 |
| halolight-vercel | Vercel | ✅ Released | Edge Functions |
| halolight-netlify | Netlify | ✅ Released | Edge Functions |
| halolight-aws | AWS Amplify | ✅ Released | Lambda@Edge |
| halolight-azure | Azure Static Web Apps | ✅ Released | Azure Functions |
| halolight-fly | Fly.io | ✅ Released | Global deployment |
| halolight-railway | Railway | ✅ Released | One-click deploy |
| halolight-docker | Docker self-hosted | ✅ Released | Traefik reverse proxy |
| Project | Tech Stack | Status | Features |
|---|---|---|---|
| halolight-api-nestjs | NestJS + Prisma + TypeScript | ✅ Released | Node.js enterprise |
| halolight-api-node | Express + Prisma + TypeScript | ✅ Released | Node.js reference implementation |
| halolight-api-go | Gin + GORM | ✅ Released | High performance |
| halolight-api-python | FastAPI + SQLAlchemy + Alembic | ✅ Released | Python ecosystem |
| halolight-api-bun | Hono + Drizzle ORM | ✅ Released | Bun runtime |
| halolight-api-java | Spring Boot 3.4 + JPA | ✅ Released | Enterprise Java |
| halolight-api-php | Laravel + Eloquent | ✅ Released | PHP ecosystem |
| Project | Purpose | Status | Features |
|---|---|---|---|
| halolight-bff | tRPC Gateway | ✅ Released | Type-safe API |
| halolight-action | Next.js Full-stack | ✅ Released | Server Actions |
| halolight-ui | Stencil Web Components | ✅ Released | Cross-framework component library |
| Project | Purpose | Status | Features |
|---|---|---|---|
| halolight-ai | AI Assistant | ✅ Released | RAG + Action execution |
| halolight-web3 | Web3 Integration | ✅ Released | EVM + Solana + IPFS |
React family: Next.js → Remix → Preact → React (Vite)
Vue family: Vue 3.5 → Nuxt 3
Others: Angular → SvelteKit → SolidJS → Qwik → Lit → Astro → Fresh → Deno
Node.js: NestJS (Prisma) → Express (Prisma) → Hono (Drizzle)
Go: Gin (GORM)
Python: FastAPI (SQLAlchemy)
Java: Spring Boot (JPA)
PHP: Laravel (Eloquent)
Edge runtime: Cloudflare → Vercel → Netlify
Cloud platforms: AWS Amplify → Azure SWA → Fly.io → Railway
Self-hosted: Docker + Traefik
# React (Recommended)
git clone https://github.com/halolight/halolight
cd halolight && pnpm install && pnpm dev
# Vue
git clone https://github.com/halolight/halolight-vue
cd halolight-vue && pnpm install && pnpm dev
# Angular
git clone https://github.com/halolight/halolight-angular
cd halolight-angular && pnpm install && pnpm start
# Node.js (Recommended)
git clone https://github.com/halolight/halolight-api-node
cd halolight-api-node && pnpm install && pnpm dev
# Go
git clone https://github.com/halolight/halolight-api-go
cd halolight-api-go && go run main.go
# Python
git clone https://github.com/halolight/halolight-api-python
cd halolight-api-python && pip install -r requirements.txt && uvicorn main:app --reload
# Java
git clone https://github.com/halolight/halolight-api-java
cd halolight-api-java && mvn spring-boot:run
# Cloudflare (Recommended for edge)
git clone https://github.com/halolight/halolight-cloudflare
cd halolight-cloudflare && pnpm install && pnpm deploy
# Docker (Recommended for self-hosted)
git clone https://github.com/halolight/halolight-docker
cd halolight-docker && docker-compose up -d
npm install @halolight/ui
<script type="module" src="https://unpkg.com/@halolight/ui/dist/halolight-ui.esm.js"></script>
<hl-button variant="primary">Click me</hl-button>
<hl-input label="Email" type="email"></hl-input>
<hl-card>
<h3 slot="header">Card Title</h3>
<p>Card content</p>
</hl-card>
import { defineCustomElements } from '@halolight/ui/loader';
defineCustomElements();
function App() {
return <hl-button variant="primary">Click me</hl-button>;
}
<script setup>
import { defineCustomElements } from '@halolight/ui/loader';
defineCustomElements();
</script>
<template>
<hl-button variant="primary">Click me</hl-button>
</template>
git clone https://github.com/halolight/halolight-ai
cd halolight-ai
cp .env.example .env
# Configure OPENAI_API_KEY or other LLM keys
docker-compose up -d
# Send message
curl -X POST http://localhost:3000/api/ai/chat \
-H "Content-Type: application/json" \
-H "X-Tenant-ID: default" \
-H "X-User-ID: user1" \
-d '{"message": "Help me analyze today'\''s data"}'
# Execute action
curl -X POST http://localhost:3000/api/ai/actions/execute \
-H "Content-Type: application/json" \
-d '{"action": "query_users", "params": {"role": "admin"}}'
# Core package
npm install @halolight/web3-core
# React components
npm install @halolight/web3-react
# Vue components
npm install @halolight/web3-vue
import { Web3Provider, WalletButton, TokenBalance } from '@halolight/web3-react';
function App() {
return (
<Web3Provider>
<WalletButton />
<TokenBalance />
</Web3Provider>
);
}
git checkout -b feature/xxxgit commit -m 'feat: xxx'git push origin feature/xxxAll HaloLight projects are licensed under MIT License.
]]>This guide helps developers create new framework version implementations for HaloLight.
fetch interception, aligned with halolight/src/mock behavior (response format, delay, error codes)halolight/src/app/(auth) page flows and validation logic*_API_URL, *_USE_MOCK, *_DEMO_*, *_BRAND_NAME) for documentation consistency@/)halolight/src/mock data structure)# Next.js
npx create-next-app@latest halolight --typescript --tailwind --app
# Vue
npm create vue@latest halolight-vue
# SvelteKit
npx sv create halolight-svelte
# Angular
ng new halolight-angular --routing --style=scss
# Common dependencies
npm install axios @tanstack/react-query zustand
npm install -D tailwindcss postcss autoprefixer
# shadcn/ui
npx shadcn@latest init
src/
├── app/ # Pages
├── components/
│ ├── ui/ # shadcn components
│ ├── layout/ # Layout components
│ └── dashboard/ # Dashboard components
├── hooks/ # Custom hooks
├── stores/ # State management
├── services/ # API services
├── lib/ # Utility functions
├── types/ # Type definitions
└── mocks/ # Mock data
| Concept | React | Vue | Svelte |
|---|---|---|---|
| Component | function Component() |
<script setup> |
<script> |
| Props | props: Props |
defineProps<Props>() |
export let prop |
| State | useState() |
ref() |
let state = $state() |
| Computed | useMemo() |
computed() |
$derived() |
| Side Effect | useEffect() |
watch() |
$effect() |
| Context | useContext() |
provide/inject |
setContext() |
| Concept | Next.js | Vue Router | SvelteKit |
|---|---|---|---|
| File Routing | app/page.tsx |
- | routes/+page.svelte |
| Dynamic Route | [id]/page.tsx |
:id |
[id]/+page.svelte |
| Layout | layout.tsx |
- | +layout.svelte |
| Guard | middleware.ts |
beforeEach |
+page.server.ts |
| Framework | Recommended Solution | Persistence |
|---|---|---|
| React | Zustand | zustand/middleware |
| Vue | Pinia | pinia-plugin-persistedstate |
| Svelte | Svelte Stores | - |
| Angular | Signals | localStorage |
// services/users.ts
import { api } from './api'
export const userService = {
getList: (params) => api.get('/users', { params }),
getById: (id) => api.get(`/users/${id}`),
create: (data) => api.post('/users', data),
update: (id, data) => api.put(`/users/${id}`, data),
delete: (id) => api.delete(`/users/${id}`),
}
// hooks/useUsers.ts
export function useUsers(params) {
return useQuery({
queryKey: ['users', params],
queryFn: () => userService.getList(params),
})
}
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: userService.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}
// stores/auth.ts
export const useAuthStore = create(
persist(
(set, get) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (credentials) => {
const response = await authService.login(credentials)
set({
user: response.user,
token: response.token,
isAuthenticated: true,
})
},
logout: () => {
set({ user: null, token: null, isAuthenticated: false })
},
hasPermission: (permission) => {
const { permissions } = get().user || {}
return permissions?.includes(permission) || permissions?.includes('*')
},
}),
{ name: 'auth' }
)
)
| Type | Standard | Example |
|---|---|---|
| Component | PascalCase | UserCard.tsx |
| Hook | camelCase + use | useUsers.ts |
| Store | camelCase + use | useAuthStore.ts |
| Service | camelCase + Service | userService.ts |
| Type | PascalCase | User, UserQueryParams |
| Constant | UPPER_SNAKE | API_BASE_URL |
# Component directory
components/
└── UserCard/
├── index.tsx # Main component
├── UserCard.types.ts # Type definitions
└── useUserCard.ts # Component logic
# Page directory
app/users/
├── page.tsx # List page
├── [id]/
│ └── page.tsx # Detail page
└── create/
└── page.tsx # Create page
This documentation collection covers the shared patterns and implementation specifications for the HaloLight multi-framework admin dashboard project, guiding the development of each framework version.
All framework versions have been implemented and deployed (preview links available in respective repository READMEs). Reference implementations (for specification validation):
Other frameworks:Angular · Nuxt · SvelteKit · Astro · Solid.js · Qwik · Remix · Preact · Lit · Fresh (Deno)。
| Framework | Status | Preview | Repository |
|---|---|---|---|
| Next.js 14 | ✅ Deployed | Preview | GitHub |
| Vue 3.5 | ✅ Deployed | Preview | GitHub |
| Angular 21 | ✅ Deployed | Preview | GitHub |
| Nuxt 4 | ✅ Deployed | Preview | GitHub |
| SvelteKit 2 | ✅ Deployed | Preview | GitHub |
| Astro 5 | ✅ Deployed | Preview | GitHub |
| Solid.js | ✅ Deployed | Preview | GitHub |
| Qwik | ✅ Deployed | Preview | GitHub |
| Remix | ✅ Deployed | Preview | GitHub |
| Preact | ✅ Deployed | Preview | GitHub |
| Lit | ✅ Deployed | Preview | GitHub |
| Fresh (Deno) | ✅ Deployed | Preview | GitHub |
| Feature | React/Next.js | Vue 3 | Angular | Svelte |
|---|---|---|---|---|
| State Management | Zustand | Pinia | Signals/RxJS | Svelte Stores |
| Data Fetching | TanStack Query | TanStack Query | RxJS | TanStack Query |
| Routing | Next.js App Router | Vue Router | Angular Router | SvelteKit |
| Forms | React Hook Form | VeeValidate | Reactive Forms | Superforms |
| Drag Layout | react-grid-layout | grid-layout-plus | angular-gridster2 | svelte-grid |
This document describes the state management patterns for the HaloLight project, covering implementation solutions for different frameworks.
| Framework | State Library | Features |
|---|---|---|
| React/Next.js | Zustand | Simple, no boilerplate |
| Vue 3 | Pinia | Official recommendation, type-safe |
| Svelte | Svelte Stores | Native reactivity |
| Angular | Signals + RxJS | Fine-grained reactivity |
| Solid.js | createStore | Fine-grained reactivity |
stores/
├── auth.ts # Authentication state
├── ui-settings.ts # UI settings
├── dashboard.ts # Dashboard layout
├── navigation.ts # Navigation menu
├── tabs.ts # Multi-tab page
└── error.ts # Error state
interface AuthState {
user: User | null
token: string | null
refreshToken: string | null
permissions: string[]
roles: string[]
isAuthenticated: boolean
isLoading: boolean
}
export const useAuthStore = create<AuthStore>()(
persist(
(set, get) => ({
user: null,
token: null,
permissions: [],
isAuthenticated: false,
login: async (credentials) => {
const response = await authService.login(credentials)
set({
user: response.user,
token: response.token,
permissions: response.permissions,
isAuthenticated: true,
})
},
hasPermission: (permission) => {
const { permissions } = get()
return permissions.some((p) =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
},
}),
{ name: 'auth-storage' }
)
)
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(null)
const permissions = ref<string[]>([])
const isAuthenticated = computed(() => !!token.value)
function hasPermission(permission: string): boolean {
return permissions.value.some((p) =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
}
return { user, token, permissions, isAuthenticated, hasPermission }
}, { persist: { paths: ['token', 'user'] } })
interface DashboardState {
layouts: { lg: GridLayout[]; md: GridLayout[]; sm: GridLayout[] }
widgets: WidgetConfig[]
isEditing: boolean
}
interface GridLayout {
i: string; x: number; y: number; w: number; h: number
}
| Data Type | Storage Location | Example |
|---|---|---|
| User Preferences | localStorage | Theme, language |
| UI State | localStorage | Sidebar, layout |
| Temporary Data | sessionStorage | Form drafts |
| Server Data | TanStack Query | API responses |
This document describes the theme switching and skin preset system for HaloLight.
| Mode | Description |
|---|---|
| light | Light theme |
| dark | Dark theme |
| system | Follow system |
11 color skins available:
| Skin | Primary | Use Case |
|---|---|---|
| default | Blue | General |
| zinc | Gray | Minimalist |
| slate | Blue-gray | Professional |
| stone | Brown-gray | Warm |
| gray | Neutral gray | General |
| neutral | Black & white | Minimal |
| red | Red | Alert |
| rose | Rose | Fashion |
| orange | Orange | Energetic |
| green | Green | Natural |
| blue | Blue | Tech |
| yellow | Yellow | Bright |
| violet | Violet | Elegant |
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
/* ... */
}
[data-skin="rose"] {
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
}
[data-skin="green"] {
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
}
interface ThemeContextValue {
theme: 'light' | 'dark' | 'system'
skin: SkinPreset
setTheme: (theme: string) => void
setSkin: (skin: SkinPreset) => void
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState('system')
const [skin, setSkin] = useState<SkinPreset>('default')
useEffect(() => {
const root = document.documentElement
root.classList.remove('light', 'dark')
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
root.classList.add(systemTheme)
} else {
root.classList.add(theme)
}
root.setAttribute('data-skin', skin)
}, [theme, skin])
return (
<ThemeContext.Provider value={{ theme, skin, setTheme, setSkin }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const theme = ref<'light' | 'dark' | 'system'>('system')
const skin = ref<SkinPreset>('default')
const actualTheme = computed(() => {
if (theme.value === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
return theme.value
})
watch([theme, skin], () => {
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(actualTheme.value)
document.documentElement.setAttribute('data-skin', skin.value)
}, { immediate: true })
return { theme, skin, actualTheme }
}
async function toggleTheme() {
if (!document.startViewTransition) {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
return
}
await document.startViewTransition(() => {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
}).ready
// Circular expand animation
const { clientX, clientY } = event
const radius = Math.hypot(
Math.max(clientX, window.innerWidth - clientX),
Math.max(clientY, window.innerHeight - clientY)
)
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${clientX}px ${clientY}px)`,
`circle(${radius}px at ${clientX}px ${clientY}px)`,
],
},
{
duration: 500,
easing: 'ease-in-out',
pseudoElement: '::view-transition-new(root)',
}
)
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 9999;
}
function ThemeSelector() {
const { theme, setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Sun className="h-4 w-4 rotate-0 scale-100 dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 dark:rotate-0 dark:scale-100" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setTheme('light')}>
<Sun className="mr-2 h-4 w-4" /> Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
<Moon className="mr-2 h-4 w-4" /> Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
<Monitor className="mr-2 h-4 w-4" /> System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
function SkinSelector() {
const { skin, setSkin } = useTheme()
const skins: SkinPreset[] = [
'default', 'zinc', 'slate', 'stone', 'gray',
'neutral', 'red', 'rose', 'orange', 'green',
'blue', 'yellow', 'violet'
]
return (
<div className="grid grid-cols-5 gap-2">
{skins.map((s) => (
<button
key={s}
onClick={() => setSkin(s)}
className={cn(
'h-8 w-8 rounded-full border-2',
skin === s ? 'border-primary' : 'border-transparent'
)}
style={{ backgroundColor: `hsl(var(--skin-${s}))` }}
/>
))}
</div>
)
}
const echartTheme = computed(() => ({
backgroundColor: 'transparent',
textStyle: {
color: actualTheme.value === 'dark' ? '#e5e5e5' : '#333',
},
title: {
textStyle: {
color: actualTheme.value === 'dark' ? '#fff' : '#333',
},
},
legend: {
textStyle: {
color: actualTheme.value === 'dark' ? '#e5e5e5' : '#333',
},
},
xAxis: {
axisLine: { lineStyle: { color: actualTheme.value === 'dark' ? '#444' : '#ccc' } },
splitLine: { lineStyle: { color: actualTheme.value === 'dark' ? '#333' : '#eee' } },
},
yAxis: {
axisLine: { lineStyle: { color: actualTheme.value === 'dark' ? '#444' : '#ccc' } },
splitLine: { lineStyle: { color: actualTheme.value === 'dark' ? '#333' : '#eee' } },
},
}))
HaloLight Action is a modern check-in scheduler platform built with Next.js 14 App Router and Supabase, supporting multi-platform automatic check-ins, task scheduling, execution tracking, and push notifications.
Live Preview: https://halolight-action.h7ml.cn
GitHub: https://github.com/halolight/halolight-action
| Technology | Version | Description |
|---|---|---|
| Next.js | 14.2 | App Router architecture |
| TypeScript | 5.7 | Type safety |
| Supabase | latest | PostgreSQL + Auth + RLS |
| Tailwind CSS | 3.4 | Utility-first CSS |
| shadcn/ui | latest | Radix UI component library |
| TanStack Query | 5.x | Server state management |
| Zustand | 5.x | Client state management |
| Framer Motion | latest | Smooth animations |
| Vitest | latest | Unit testing |
halolight-action/
├── app/ # Next.js 14 App Router
│ ├── (auth)/ # Authentication pages
│ │ ├── login/
│ │ ├── register/
│ │ ├── forgot-password/
│ │ └── reset-password/
│ ├── (dashboard)/ # Dashboard pages (15 feature pages)
│ │ ├── dashboard/ # Dashboard home
│ │ ├── signin-tasks/ # Check-in task management
│ │ ├── signin-records/ # Check-in records
│ │ ├── push-logs/ # Push logs
│ │ ├── scheduled-tasks/# Scheduled tasks
│ │ ├── notifications/ # Notification center
│ │ ├── data-dictionary/# Data dictionary
│ │ ├── users/ # User management
│ │ └── settings/ # Settings
│ │ ├── profile/
│ │ ├── appearance/
│ │ ├── push-channels/
│ │ └── api-proxy/
│ └── api/ # API routes
│ ├── cron/ # Cron job execution
│ ├── signin/ # Check-in execution
│ └── push/test/ # Push testing
├── components/ # React components
│ ├── ui/ # shadcn/ui base components
│ ├── layout/ # Layout components
│ └── auth/ # Auth components
├── hooks/ # React Query Hooks
├── lib/ # Utilities
│ ├── supabase/ # Supabase client
│ ├── dal/ # Data access layer
│ ├── cron/ # Cron executor
│ └── push/ # Push service wrapper
├── providers/ # Context Providers
├── stores/ # Zustand state management
├── types/ # TypeScript type definitions
└── supabase/migrations/ # Database migration scripts
# Clone repository
git clone https://github.com/halolight/halolight-action.git
cd halolight-action
# Install dependencies
pnpm install
cat > .env.local <<'EOF'
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
NEXT_PUBLIC_APP_NAME=HaloLight Action
NEXT_PUBLIC_APP_URL=http://localhost:3000
EOF
supabase/migrations/init.sql in SQL Editor.env.localpnpm generate:types to generate type definitionspnpm dev
# Visit http://localhost:3000
[email protected]Admin@123| Table | Description | Key Features |
|---|---|---|
| signin_tasks | Check-in task config | Cron scheduling, priority, credentials |
| signin_records | Check-in execution records | History tracking, success rate stats |
| push_channels | Push channel config | 12 push services, testing & default channel |
| push_logs | Push execution logs | Push history, error tracking |
| data_dictionary | Data dictionary config | System config, multi-type support |
| notifications | System notifications | User messages, notification center |
| cron_jobs | Cron job config | HTTP request scheduling |
| users | User info | Auth, role management |
| user_tokens | API tokens | Access control, token management |
# Development
pnpm dev # Start development server
pnpm build # Production build
pnpm start # Start production server
# Quality Checks
pnpm lint # ESLint check
pnpm type-check # TypeScript type check
pnpm format # Prettier format
pnpm ci # Full CI check
# Testing
pnpm test # Run tests (watch)
pnpm test:run # Run tests (once)
pnpm test:coverage # Test coverage report
# Supabase
pnpm generate:types # Generate types from Supabase
One-click deploy:
# Build
pnpm build
# Start production server
pnpm start
Add in Vercel project settings:
0 8 * * * (daily at 8 AM)/api/cronDon't commit secrets:
.env.local to GitUse correct keys:
anon public key (safe)service_role key (dangerous)Credential storage:
RLS policies:
| Project | Backend | Features |
|---|---|---|
| halolight | Mock.js | Frontend demo, no backend needed |
| halolight-action | Supabase | Check-in scheduler, real backend |
| halolight-vue | Mock.js | Vue 3.5 implementation |
| halolight-angular | Mock.js | Angular implementation |
HaloLight Admin is a powerful super admin panel for managing multiple HaloLight instances and tenants.
# Clone repository
git clone https://github.com/halolight/halolight-admin.git
cd halolight-admin
# Install dependencies
pnpm install
# Run development server
pnpm dev
HaloLight AI service is built with Hono + LangChain.js, providing RAG retrieval-augmented generation, action execution, and multi-model automatic fallback.
Live Preview: Internal API service (no standalone demo)
GitHub: https://github.com/halolight/halolight-ai
| Technology | Version | Description |
|---|---|---|
| Hono | latest | Lightweight HTTP framework |
| LangChain.js | latest | LLM orchestration + RAG pipeline |
| pgvector | latest | PostgreSQL vector search extension |
| Drizzle ORM | latest | TypeScript ORM & migrations |
| PostgreSQL | 14+ | Persistence and vector storage |
| Node.js | 22+ | Runtime |
| Zod | latest | Data validation |
halolight-ai/
├── src/
│ ├── index.ts # Application entry
│ ├── routes/ # API routes
│ │ ├── chat.ts # Chat interface
│ │ ├── actions.ts # Action execution
│ │ ├── history.ts # History records
│ │ └── knowledge.ts # Knowledge base management
│ ├── services/
│ │ ├── llm/ # LLM service layer
│ │ │ ├── openai.ts
│ │ │ ├── anthropic.ts
│ │ │ └── factory.ts # Model factory (auto fallback)
│ │ ├── rag/ # RAG pipeline
│ │ │ ├── embeddings.ts # Document chunking & embedding
│ │ │ ├── retriever.ts # Vector retrieval
│ │ │ └── pipeline.ts # RAG pipeline
│ │ ├── actions/ # Action execution system
│ │ │ ├── executor.ts # Action executor
│ │ │ ├── registry.ts # Action registry
│ │ │ └── permissions.ts # Permission validation
│ │ └── memory/
│ │ └── conversation.ts # Conversation memory management
│ ├── db/
│ │ ├── schema.ts # Drizzle Schema (with pgvector)
│ │ └── client.ts # Database client
│ ├── middleware/
│ │ ├── auth.ts # Authentication middleware
│ │ └── tenant.ts # Tenant isolation
│ └── types/
│ └── index.ts # TypeScript type definitions
├── drizzle/ # Migration files
├── drizzle.config.ts
├── Dockerfile
├── docker-compose.yml
└── package.json
# Clone repository
git clone https://github.com/halolight/halolight-ai.git
cd halolight-ai
# Install dependencies
pnpm install
cp .env.example .env
Key configurations:
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/halolight_ai
# LLM Providers (configure at least one)
OPENAI_API_KEY=sk-...
# or
ANTHROPIC_API_KEY=sk-ant-...
# or
AZURE_OPENAI_API_KEY=...
# RAG Configuration
CHUNK_SIZE=1000 # Document chunk size
CHUNK_OVERLAP=200 # Chunk overlap
RETRIEVAL_TOP_K=5 # Retrieval count
# Conversation Configuration
MAX_CONVERSATION_HISTORY=20 # Conversation history length
ENABLE_STREAMING=true # Enable streaming response
ENABLE_AUDIT_LOG=true # Enable audit logging
# Production
JWT_SECRET=your-secret-key
CORS_ORIGINS=https://your-domain.com
# Generate migration files
pnpm db:generate
# Run migrations
pnpm db:migrate
# Or push schema directly (development)
pnpm db:push
# Open Drizzle Studio
pnpm db:studio
pnpm dev
Service will start at http://localhost:3000.
pnpm build
pnpm start
The system automatically detects available LLM providers and falls back by priority:
Azure OpenAI (1) → OpenAI (2) → Anthropic (3) → Ollama (4)
| Step | Description | Configuration |
|---|---|---|
| Document Chunking | RecursiveCharacterTextSplitter | 1000 chars, 200 overlap |
| Vector Embedding | OpenAI Embeddings | text-embedding-3-small |
| Vector Storage | pgvector | 1536 dimensions |
| Retrieval | Cosine similarity | Top-K (default 5) |
| Context Injection | Inject retrieval results into LLM prompt | - |
Enable SSE streaming output to reduce first-token latency:
POST /api/ai/chat/stream
Content-Type: application/json
{
"message": "Hello",
"streaming": true
}
Role-based access control (RBAC):
| Role | Permission Level |
|---|---|
super_admin |
Highest permission |
admin |
Admin permission |
user |
Regular user |
guest |
Guest |
Sensitive operations require confirmation (_confirmed: true).
conversations and messages tablesAll data operations are based on TenantContext:
interface TenantContext {
tenantId: string;
userId: string;
role: UserRole;
}
Extracted from request headers:
X-Tenant-IDX-User-IDX-User-RoleGET /health
GET /health/ready
GET /api/ai/info
# Send message
POST /api/ai/chat
Content-Type: application/json
{
"message": "Hello, introduce HaloLight",
"conversationId": "uuid", // optional
"includeContext": true,
"maxContextDocs": 5
}
# Streaming response
POST /api/ai/chat/stream
# Execute action
POST /api/ai/actions/execute
Content-Type: application/json
{
"action": "query_users",
"params": {
"role": "admin",
"limit": 10
}
}
# Get available actions
GET /api/ai/actions/available
# Get action details
GET /api/ai/actions/:name
GET /api/ai/history?limit=10
GET /api/ai/history/:id
DELETE /api/ai/history/:id
PATCH /api/ai/history/:id
# Import document
POST /api/ai/knowledge/ingest
Content-Type: application/json
{
"content": "Document content...",
"metadata": {
"title": "Document Title",
"category": "Technical Documentation"
},
"source": "manual",
"sourceId": "doc-001"
}
# Batch import
POST /api/ai/knowledge/batch-ingest
# List documents
GET /api/ai/knowledge?limit=50&offset=0
# Delete document
DELETE /api/ai/knowledge/:id
All /api/* endpoints require the following headers (optional in development):
X-Tenant-ID: your-tenant-id
X-User-ID: your-user-id
X-User-Role: admin | user | guest
# Build image
docker build -t halolight-ai .
# Run container
docker run -p 3000:3000 \
-e DATABASE_URL=postgresql://... \
-e OPENAI_API_KEY=sk-... \
halolight-ai
docker-compose up -d
DATABASE_URL: PostgreSQL connection stringNODE_ENV=productionJWT_SECRET: Secret key for authenticationCORS_ORIGINS: Allowed cross-origin sources# Check if PostgreSQL is running
psql $DATABASE_URL -c "SELECT 1"
# Check pgvector extension
psql $DATABASE_URL -c "CREATE EXTENSION IF NOT EXISTS vector"
# Check available providers
curl http://localhost:3000/api/ai/info
RETRIEVAL_TOP_K valueCHUNK_SIZE and CHUNK_OVERLAPHaloLight Angular version is built on Angular 21 with Signals + Standalone Components + TypeScript.
Live Preview: https://halolight-angular.h7ml.cn/
GitHub: https://github.com/halolight/halolight-angular
| Technology | Version | Description |
|---|---|---|
| Angular | 21.x | Enterprise framework |
| TypeScript | 5.x | Type safety |
| Tailwind CSS | 4.x | Atomic CSS |
| spartan/ui | latest | UI component library (Radix-style) |
| NgRx Signals | 21.x | Reactive state management |
| TanStack Query | 5.x | Server state |
| Mock.js | 1.x | Data mocking |
halolight-angular/
├── src/
│ ├── app/
│ │ ├── pages/ # Page components
│ │ │ ├── admin/ # Admin pages
│ │ │ │ ├── dashboard/ # Dashboard
│ │ │ │ ├── users/ # User management
│ │ │ │ ├── roles/ # Role management
│ │ │ │ ├── permissions/ # Permission management
│ │ │ │ ├── settings/ # System settings
│ │ │ │ └── profile/ # User profile
│ │ │ └── auth/ # Auth pages
│ │ │ ├── login/
│ │ │ ├── register/
│ │ │ ├── forgot-password/
│ │ │ └── reset-password/
│ │ ├── components/
│ │ │ ├── ui/ # spartan/ui components
│ │ │ ├── layout/ # Layout components
│ │ │ ├── dashboard/ # Dashboard components
│ │ │ └── shared/ # Shared components
│ │ ├── services/ # Service layer
│ │ ├── stores/ # NgRx Signals Stores
│ │ ├── guards/ # Route guards
│ │ ├── interceptors/ # HTTP interceptors
│ │ ├── directives/ # Directives
│ │ ├── pipes/ # Pipes
│ │ ├── lib/ # Utility library
│ │ ├── types/ # Type definitions
│ │ ├── mocks/ # Mock data
│ │ ├── app.routes.ts # Route configuration
│ │ ├── app.config.ts # App configuration
│ │ └── app.component.ts # Root component
│ ├── environments/ # Environment configuration
│ └── styles.css # Global styles
├── public/ # Static assets
├── angular.json
├── tailwind.config.js
├── tsconfig.json
└── package.json
git clone https://github.com/halolight/halolight-angular.git
cd halolight-angular
pnpm install
cp src/environments/environment.example.ts src/environments/environment.development.ts
// src/environments/environment.development.ts
export const environment = {
production: false,
apiUrl: '/api',
useMock: true,
appTitle: 'Admin Pro',
brandName: 'Halolight',
demoEmail: '[email protected]',
demoPassword: '123456',
showDemoHint: true,
};
pnpm start
Visit http://localhost:4200
pnpm build
ng build --configuration production
| Role | Password | |
|---|---|---|
| Admin | [email protected] | 123456 |
| User | [email protected] | 123456 |
// stores/auth.store.ts
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
import { computed, inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
interface AuthState {
user: User | null;
token: string | null;
loading: boolean;
}
const initialState: AuthState = {
user: null,
token: null,
loading: false,
};
export const AuthStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed((store) => ({
isAuthenticated: computed(() => !!store.token() && !!store.user()),
permissions: computed(() => store.user()?.permissions ?? []),
})),
withMethods((store, authService = inject(AuthService)) => ({
async login(credentials: LoginCredentials) {
patchState(store, { loading: true });
try {
const response = await authService.login(credentials);
patchState(store, {
user: response.user,
token: response.token,
loading: false,
});
} catch (error) {
patchState(store, { loading: false });
throw error;
}
},
logout() {
patchState(store, { user: null, token: null });
},
hasPermission(permission: string): boolean {
const permissions = store.permissions();
return permissions.some(p =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
);
},
}))
);
// services/users.service.ts
import { Injectable, inject } from '@angular/core';
import { injectQuery, injectMutation, injectQueryClient } from '@tanstack/angular-query-experimental';
import { ApiService } from './api.service';
@Injectable({ providedIn: 'root' })
export class UsersService {
private api = inject(ApiService);
private queryClient = injectQueryClient();
getUsers(params?: UserQueryParams) {
return injectQuery(() => ({
queryKey: ['users', params],
queryFn: () => this.api.get<UserListResponse>('/users', { params }),
}));
}
createUser() {
return injectMutation(() => ({
mutationFn: (data: CreateUserDto) => this.api.post<User>('/users', data),
onSuccess: () => {
this.queryClient.invalidateQueries({ queryKey: ['users'] });
},
}));
}
}
// directives/permission.directive.ts
import { Directive, Input, TemplateRef, ViewContainerRef, inject, effect } from '@angular/core';
import { AuthStore } from '../stores/auth.store';
@Directive({
selector: '[appPermission]',
standalone: true,
})
export class PermissionDirective {
private templateRef = inject(TemplateRef<unknown>);
private viewContainer = inject(ViewContainerRef);
private authStore = inject(AuthStore);
@Input() set appPermission(permission: string) {
effect(() => {
const hasPermission = this.authStore.hasPermission(permission);
this.viewContainer.clear();
if (hasPermission) {
this.viewContainer.createEmbeddedView(this.templateRef);
}
});
}
}
<!-- Using directive -->
<button *appPermission="'users:delete'">Delete</button>
// components/permission-guard.component.ts
import { Component, Input, inject, computed } from '@angular/core';
import { AuthStore } from '../../stores/auth.store';
@Component({
selector: 'app-permission-guard',
standalone: true,
template: `
@if (hasPermission()) {
<ng-content />
} @else {
<ng-content select="[fallback]" />
}
`,
})
export class PermissionGuardComponent {
@Input({ required: true }) permission!: string;
private authStore = inject(AuthStore);
hasPermission = computed(() => this.authStore.hasPermission(this.permission));
}
<!-- Using component -->
<app-permission-guard permission="users:delete">
<app-delete-button />
<span fallback>No permission</span>
</app-permission-guard>
// components/dashboard/dashboard-grid.component.ts
import { Component, inject, computed } from '@angular/core';
import { GridsterModule, GridsterConfig, GridsterItem } from 'angular-gridster2';
import { DashboardStore } from '../../stores/dashboard.store';
@Component({
selector: 'app-dashboard-grid',
standalone: true,
imports: [GridsterModule, WidgetWrapperComponent],
template: `
<gridster [options]="options()">
@for (widget of widgets(); track widget.id) {
<gridster-item [item]="widget">
<app-widget-wrapper [widget]="widget" />
</gridster-item>
}
</gridster>
`,
})
export class DashboardGridComponent {
private dashboardStore = inject(DashboardStore);
widgets = this.dashboardStore.widgets;
isEditing = this.dashboardStore.isEditing;
options = computed<GridsterConfig>(() => ({
gridType: 'fit',
displayGrid: this.isEditing() ? 'always' : 'none',
draggable: { enabled: this.isEditing() },
resizable: { enabled: this.isEditing() },
pushItems: true,
minCols: 12,
maxCols: 12,
minRows: 4,
defaultItemCols: 3,
defaultItemRows: 2,
itemChangeCallback: (item) => this.dashboardStore.updateWidget(item),
}));
}
Supports 11 preset skins, switch via the quick settings panel:
| Skin | Primary Color | CSS Variable |
|---|---|---|
| Default | Purple | --primary: 51.1% 0.262 276.97 |
| Blue | Blue | --primary: 54.8% 0.243 264.05 |
| Emerald | Emerald | --primary: 64.6% 0.178 142.49 |
| Rose | Rose | --primary: 59.3% 0.214 12.76 |
| Orange | Orange | --primary: 65.4% 0.194 35.76 |
| Amber | Amber | --primary: 74.2% 0.167 83.25 |
| Yellow | Yellow | --primary: 84.5% 0.181 99.58 |
| Lime | Lime | --primary: 76.5% 0.165 128.35 |
| Teal | Teal | --primary: 59.8% 0.134 179.61 |
| Cyan | Cyan | --primary: 68.3% 0.148 192.18 |
| Sky | Sky | --primary: 68.5% 0.171 227.08 |
/* Example variable definitions */
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 98% 0 0;
--secondary: 96.1% 0.002 286.08;
--secondary-foreground: 14.9% 0.017 285.75;
--muted: 96.1% 0.002 286.08;
--muted-foreground: 55.4% 0.009 285.82;
--accent: 96.1% 0.002 286.08;
--accent-foreground: 14.9% 0.017 285.75;
--border: 92.2% 0.004 285.86;
--input: 92.2% 0.004 285.86;
--ring: 51.1% 0.262 276.97;
}
.dark {
--background: 14.9% 0.017 285.75;
--foreground: 98% 0 0;
--primary: 56.1% 0.287 277.04;
/* ... */
}
// Toggle theme
const uiSettingsStore = inject(UiSettingsStore);
uiSettingsStore.setTheme('dark'); // 'light' | 'dark' | 'system'
// Change skin
uiSettingsStore.setSkin('rose'); // 11 skin presets
| Path | Page | Permission |
|---|---|---|
/ |
Redirect to /dashboard |
- |
/login |
Login | Public |
/register |
Register | Public |
/forgot-password |
Forgot password | Public |
/reset-password |
Reset password | Public |
/dashboard |
Dashboard | dashboard:view |
/users |
User list | users:list |
/users/create |
Create user | users:create |
/users/:id |
User details | users:view |
/users/:id/edit |
Edit user | users:update |
/roles |
Role management | roles:list |
/permissions |
Permission management | permissions:list |
/settings |
System settings | settings:view |
/profile |
User profile | Authenticated |
// src/environments/environment.development.ts
export const environment = {
production: false,
apiUrl: '/api',
useMock: true,
appTitle: 'Admin Pro',
brandName: 'Halolight',
demoEmail: '[email protected]',
demoPassword: '123456',
showDemoHint: true,
};
| Variable | Description | Default Value |
|---|---|---|
production |
Production environment | false |
apiUrl |
API base path | /api |
useMock |
Use Mock data | true |
appTitle |
Application title | Admin Pro |
brandName |
Brand name | Halolight |
demoEmail |
Demo account email | [email protected] |
demoPassword |
Demo account password | 123456 |
showDemoHint |
Show demo hint | true |
import { inject } from '@angular/core';
import { environment } from '../environments/environment';
// Use in components or services
export class ApiService {
private apiUrl = environment.apiUrl;
private useMock = environment.useMock;
// ...
}
pnpm start # Start development server
pnpm build # Production build
pnpm lint # Lint code
pnpm lint:fix # Auto fix
pnpm type-check # Type check
pnpm test # Run tests
pnpm test:coverage # Test coverage
pnpm test # Run tests (watch mode)
pnpm test:run # Single run
pnpm test:coverage # Coverage report
pnpm test:ui # Vitest UI
// auth.store.spec.ts
import { TestBed } from '@angular/core/testing';
import { AuthStore } from './auth.store';
import { AuthService } from '../services/auth.service';
describe('AuthStore', () => {
let store: InstanceType<typeof AuthStore>;
let authService: jasmine.SpyObj<AuthService>;
beforeEach(() => {
const authServiceSpy = jasmine.createSpyObj('AuthService', ['login', 'logout']);
TestBed.configureTestingModule({
providers: [
AuthStore,
{ provide: AuthService, useValue: authServiceSpy },
],
});
store = TestBed.inject(AuthStore);
authService = TestBed.inject(AuthService) as jasmine.SpyObj<AuthService>;
});
it('should initialize with default state', () => {
expect(store.user()).toBeNull();
expect(store.token()).toBeNull();
expect(store.isAuthenticated()).toBe(false);
});
it('should login successfully', async () => {
const mockResponse = {
user: { id: '1', email: '[email protected]', permissions: ['users:view'] },
token: 'mock-token',
};
authService.login.and.returnValue(Promise.resolve(mockResponse));
await store.login({ email: '[email protected]', password: '123456' });
expect(store.user()).toEqual(mockResponse.user);
expect(store.token()).toBe('mock-token');
expect(store.isAuthenticated()).toBe(true);
});
it('should check permissions correctly', async () => {
const mockResponse = {
user: { id: '1', email: '[email protected]', permissions: ['users:*', 'dashboard:view'] },
token: 'mock-token',
};
authService.login.and.returnValue(Promise.resolve(mockResponse));
await store.login({ email: '[email protected]', password: '123456' });
expect(store.hasPermission('users:view')).toBe(true);
expect(store.hasPermission('users:delete')).toBe(true);
expect(store.hasPermission('dashboard:view')).toBe(true);
expect(store.hasPermission('settings:view')).toBe(false);
});
});
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideQueryClient } from '@tanstack/angular-query-experimental';
import { QueryClient } from '@tanstack/query-core';
import { routes } from './app.routes';
import { authInterceptor } from './interceptors/auth.interceptor';
import { errorInterceptor } from './interceptors/error.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
provideAnimations(),
provideQueryClient(new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes
},
},
})),
],
};
// tailwind.config.js
import { fontFamily } from 'tailwindcss/defaultTheme';
export default {
darkMode: ['class'],
content: ['./src/**/*.{html,ts}'],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
fontFamily: {
sans: ['Inter var', ...fontFamily.sans],
},
colors: {
border: 'oklch(var(--border))',
input: 'oklch(var(--input))',
ring: 'oklch(var(--ring))',
background: 'oklch(var(--background))',
foreground: 'oklch(var(--foreground))',
primary: {
DEFAULT: 'oklch(var(--primary))',
foreground: 'oklch(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'oklch(var(--secondary))',
foreground: 'oklch(var(--secondary-foreground))',
},
// ... more color definitions
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
};
vercel
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:alpine
COPY --from=builder /app/dist/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
docker build -t halolight-angular .
docker run -p 3000:80 halolight-angular
The project is configured with a complete GitHub Actions CI workflow:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
// guards/auth.guard.ts
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { AuthStore } from '../stores/auth.store';
export const authGuard: CanActivateFn = (route, state) => {
const authStore = inject(AuthStore);
const router = inject(Router);
if (!authStore.isAuthenticated()) {
router.navigate(['/login'], { queryParams: { redirect: state.url } });
return false;
}
return true;
};
// guards/permission.guard.ts
export const permissionGuard: CanActivateFn = (route) => {
const authStore = inject(AuthStore);
const router = inject(Router);
const permission = route.data['permission'] as string;
if (permission && !authStore.hasPermission(permission)) {
router.navigate(['/403']);
return false;
}
return true;
};
// interceptors/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthStore } from '../stores/auth.store';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authStore = inject(AuthStore);
const token = authStore.token();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
}
return next(req);
};
// interceptors/error.interceptor.ts
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
const authStore = inject(AuthStore);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
authStore.logout();
router.navigate(['/login']);
}
return throwError(() => error);
})
);
};
// stores/ui-settings.store.ts
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { computed } from '@angular/core';
interface UiSettingsState {
theme: 'light' | 'dark' | 'system';
skin: string;
sidebarCollapsed: boolean;
}
const initialState: UiSettingsState = {
theme: 'system',
skin: 'default',
sidebarCollapsed: false,
};
export const UiSettingsStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed((store) => ({
effectiveTheme: computed(() => {
if (store.theme() === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return store.theme();
}),
isDarkMode: computed(() => store.effectiveTheme() === 'dark'),
})),
withMethods((store) => ({
setTheme(theme: 'light' | 'dark' | 'system') {
patchState(store, { theme });
document.documentElement.classList.toggle('dark', store.isDarkMode());
},
setSkin(skin: string) {
patchState(store, { skin });
document.documentElement.setAttribute('data-theme', skin);
},
toggleSidebar() {
patchState(store, { sidebarCollapsed: !store.sidebarCollapsed() });
},
}))
);
// Using NgOptimizedImage
import { NgOptimizedImage } from '@angular/common';
@Component({
imports: [NgOptimizedImage],
template: `
<img
ngSrc="proxy.php?url=https%3A%2F%2Fhalolight.docs.h7ml.cn%2Fassets%2Fimages%2Fhero.jpg"
width="1200"
height="600"
priority
alt="Hero image"
/>
`,
})
export class HeroComponent {}
// app.routes.ts
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () => import('./pages/admin/dashboard/dashboard.component')
.then(m => m.DashboardComponent),
},
{
path: 'users',
loadChildren: () => import('./pages/admin/users/users.routes')
.then(m => m.USERS_ROUTES),
},
];
// app.config.ts
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withPreloading(PreloadAllModules),
withComponentInputBinding()
),
],
};
import { Component, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-user-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (user of users(); track user.id) {
<app-user-card [user]="user" />
}
`,
})
export class UserListComponent {
users = signal<User[]>([]);
}
A: Set useMock: true in environment.ts and define Mock data in the src/mocks directory:
// mocks/users.mock.ts
import Mock from 'mockjs';
Mock.mock('/api/users', 'get', {
'data|10-20': [{
'id|+1': 1,
'name': '@cname',
'email': '@email',
'avatar': '@image(100x100)',
'role': '@pick(["admin", "user", "guest"])',
'status': '@pick(["active", "inactive"])',
'createdAt': '@datetime',
}],
total: '@integer(10, 100)',
});
A: Use permissionGuard and specify the required permission in the route configuration:
// app.routes.ts
{
path: 'users',
loadComponent: () => import('./pages/admin/users/users.component'),
data: { permission: 'users:view' },
canActivate: [authGuard, permissionGuard],
}
A: Override CSS variables in styles.css:
:root {
--primary: 51.1% 0.262 276.97; /* Custom primary color */
--primary-foreground: 98% 0 0;
}
.dark {
--primary: 56.1% 0.287 277.04;
--primary-foreground: 98% 0 0;
}
A: spartan/ui is already integrated. To add other components, extend with Angular CDK:
import { CdkDrag, CdkDropList } from '@angular/cdk/drag-drop';
@Component({
imports: [CdkDrag, CdkDropList],
template: `
<div cdkDropList (cdkDropListDropped)="drop($event)">
@for (item of items(); track item.id) {
<div cdkDrag>{{ item.name }}</div>
}
</div>
`,
})
export class DraggableListComponent {}
| Feature | Angular Version | Next.js | Vue |
|---|---|---|---|
| SSR/SSG | ✅ Angular SSR | ✅ | ✅ (Nuxt) |
| State Management | NgRx Signals | Zustand | Pinia |
| Routing | Angular Router | App Router | Vue Router |
| Build Tool | Angular CLI + esbuild | Next.js | Vite |
| Type Safety | TypeScript (enforced) | TypeScript | TypeScript |
| Enterprise Support | Vercel | Community |
HaloLight Bun Backend API is built on Bun + Hono + Drizzle ORM, providing ultra-high-performance backend service.
API Documentation: https://halolight-api-bun.h7ml.cn/docs
GitHub: https://github.com/halolight/halolight-api-bun
| Technology | Version | Description |
|---|---|---|
| Bun | 1.1+ | Runtime |
| Hono | 4.x | Web Framework |
| Drizzle ORM | 0.36+ | Database ORM |
| PostgreSQL | 15+ | Data Storage |
| Zod | 3.x | Data Validation |
| JWT | - | Authentication |
| Swagger | - | API Documentation |
# Clone repository
git clone https://github.com/halolight/halolight-api-bun.git
cd halolight-api-bun
# Install dependencies
pnpm install
cp .env.example .env
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/halolight
# JWT Secrets
JWT_SECRET=your-super-secret-key
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
# Service Config
PORT=3002
NODE_ENV=development
CORS_ORIGIN=http://localhost:3000
API_PREFIX=/api
bun run db:push
bun run db:seed
# Development mode
bun run dev
# Production mode
bun run build
bun run start
Visit http://localhost:3002
halolight-api-bun/
├── src/
│ ├── routes/ # Controllers/Route handlers
│ ├── services/ # Business logic layer
│ ├── db/ # Data models
│ ├── middleware/ # Middleware
│ ├── utils/ # Utility functions
│ └── index.ts # Application entry
├── test/ # Test files
├── Dockerfile # Docker configuration
├── docker-compose.yml
└── package.json
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /api/auth/login |
User login | Public |
| POST | /api/auth/register |
User registration | Public |
| POST | /api/auth/refresh |
Refresh token | Public |
| POST | /api/auth/logout |
User logout | Required |
| POST | /api/auth/forgot-password |
Forgot password | Public |
| POST | /api/auth/reset-password |
Reset password | Public |
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /api/users |
Get user list | users:view |
| GET | /api/users/:id |
Get user details | users:view |
| POST | /api/users |
Create user | users:create |
| PUT | /api/users/:id |
Update user | users:update |
| DELETE | /api/users/:id |
Delete user | users:delete |
| GET | /api/users/me |
Get current user | Required |
| Method | Path | Description |
|---|---|---|
| GET | /api/documents |
Get document list |
| GET | /api/documents/:id |
Get document details |
| POST | /api/documents |
Create document |
| PUT | /api/documents/:id |
Update document |
| DELETE | /api/documents/:id |
Delete document |
| Method | Path | Description |
|---|---|---|
| GET | /api/files |
Get file list |
| GET | /api/files/:id |
Get file details |
| POST | /api/files/upload |
Upload file |
| PUT | /api/files/:id |
Update file info |
| DELETE | /api/files/:id |
Delete file |
| Method | Path | Description |
|---|---|---|
| GET | /api/messages |
Get message list |
| GET | /api/messages/:id |
Get message details |
| POST | /api/messages |
Send message |
| PUT | /api/messages/:id/read |
Mark as read |
| DELETE | /api/messages/:id |
Delete message |
| Method | Path | Description |
|---|---|---|
| GET | /api/notifications |
Get notification list |
| PUT | /api/notifications/:id/read |
Mark as read |
| PUT | /api/notifications/read-all |
Mark all as read |
| DELETE | /api/notifications/:id |
Delete notification |
| Method | Path | Description |
|---|---|---|
| GET | /api/calendar/events |
Get event list |
| GET | /api/calendar/events/:id |
Get event details |
| POST | /api/calendar/events |
Create event |
| PUT | /api/calendar/events/:id |
Update event |
| DELETE | /api/calendar/events/:id |
Delete event |
| Method | Path | Description |
|---|---|---|
| GET | /api/dashboard/stats |
Statistics data |
| GET | /api/dashboard/visits |
Visit trends |
| GET | /api/dashboard/sales |
Sales data |
| GET | /api/dashboard/pie |
Pie chart data |
| GET | /api/dashboard/tasks |
Pending tasks |
| GET | /api/dashboard/calendar |
Today's events |
Access Token: 15 minutes validity, for API requests
Refresh Token: 7 days validity, for refreshing Access Token
Authorization: Bearer <access_token>
// Refresh token example
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
refreshToken: 'your_refresh_token'
})
});
const { accessToken, refreshToken } = await response.json();
| Role | Description | Permissions |
|---|---|---|
super_admin |
Super Administrator | * (all permissions) |
admin |
Administrator | users:*, documents:*, ... |
user |
Regular User | documents:view, files:view, ... |
guest |
Guest | dashboard:view |
{resource}:{action}
Examples:
- users:view # View users
- users:create # Create users
- users:* # All user operations
- * # All permissions
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{ "field": "email", "message": "Invalid email format" }
]
}
}
| Status | Error Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR |
Validation failed |
| 401 | UNAUTHORIZED |
Unauthorized |
| 403 | FORBIDDEN |
Forbidden |
| 404 | NOT_FOUND |
Resource not found |
| 409 | CONFLICT |
Resource conflict |
| 500 | INTERNAL_ERROR |
Server error |
# Development
bun run dev # Start development server
bun run build # Production build
bun run start # Run production build
# Build
bun run build # Build production version
# Testing
bun test # Run unit tests
bun test --coverage # Generate coverage report
# Database
bun run db:push # Push schema to database
bun run db:generate # Generate migration files
bun run db:migrate # Run database migrations
bun run db:seed # Seed test data
bun run db:studio # Open Drizzle Studio
# Code Quality
bun run lint # ESLint check
bun run lint:fix # ESLint auto-fix
bun run type-check # TypeScript type check
docker build -t halolight-api-bun .
docker run -p 3002:3002 halolight-api-bun
docker-compose up -d
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3002:3002"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: halolight
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
NODE_ENV=production
DATABASE_URL=postgresql://user:pass@host:5432/db
JWT_SECRET=your-production-secret
bun test # Run all tests
bun test --coverage # Generate coverage report
// Authentication test example
import { describe, test, expect } from 'bun:test';
describe('Auth API', () => {
test('should login successfully', async () => {
const response = await fetch('http://localhost:3002/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: '[email protected]',
password: 'admin123'
})
});
const data = await response.json();
expect(data.success).toBe(true);
expect(data.data.accessToken).toBeDefined();
});
});
| Metric | Value | Condition |
|---|---|---|
| Request Throughput | ~50,000 req/s | Single core, simple route |
| Average Response Time | <5ms | Local database |
| Memory Usage | ~30MB | Cold start |
| CPU Usage | <10% | Idle state |
// Logging configuration example
import { logger } from './utils/logger';
logger.info('User logged in', { userId: user.id });
logger.error('Database error', { error: err.message });
// GET /health
app.get('/health', (c) => {
return c.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime()
});
});
// Prometheus metrics endpoint
app.get('/metrics', async (c) => {
return c.text(await register.metrics());
});
A: Set DATABASE_URL in .env file:
DATABASE_URL=postgresql://user:password@localhost:5432/halolight
A: Use Bun.password API:
// Hash password
const hash = await Bun.password.hash(password, {
algorithm: 'bcrypt',
cost: 10
});
// Verify password
const isValid = await Bun.password.verify(password, hash, 'bcrypt');
| Feature | Bun + Hono | NestJS | FastAPI | Spring Boot |
|---|---|---|---|---|
| Language | TypeScript | TypeScript | Python | Java |
| ORM | Drizzle | Prisma | SQLAlchemy | JPA |
| Performance | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| Learning Curve | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
HaloLight Go Fiber backend API is built on Fiber 3.0, providing high-performance Go backend service with complete JWT dual-token authentication.
API Documentation: https://halolight-api-go.h7ml.cn/docs
GitHub: https://github.com/halolight/halolight-api-go
| Technology | Version | Description |
|---|---|---|
| Go | 1.22+ | Runtime |
| Fiber | 3.0 | Web Framework |
| GORM | 2.0 | Database ORM |
| PostgreSQL | 16 | Data Storage |
| go-playground/validator | v10 | Data Validation |
| JWT | golang-jwt/jwt/v5 | Authentication |
| Swagger UI | - | API Documentation |
# Clone repository
git clone https://github.com/halolight/halolight-api-go.git
cd halolight-api-go
# Install dependencies
go mod download
cp .env.example .env
# Database
DATABASE_URL=postgresql://user:pass@localhost:5432/halolight?sslmode=disable
# JWT Secret
JWT_SECRET=your-super-secret-key
JWT_ACCESS_EXPIRES=7d
JWT_REFRESH_EXPIRES=30d
# Service Configuration
PORT=8080
APP_ENV=development
# GORM auto-migration
go run cmd/server/main.go
# Or use Makefile
make migrate
# Development mode
go run cmd/server/main.go
# Production mode
make build
./bin/server
Visit http://localhost:8080
halolight-api-go/
├── cmd/
│ └── server/
│ └── main.go # Application entry
├── internal/
│ ├── handlers/ # Controllers/route handlers
│ │ ├── auth_handler.go # Authentication endpoints
│ │ ├── user_handler.go # User management
│ │ ├── role_handler.go # Role management
│ │ ├── permission_handler.go # Permission management
│ │ ├── team_handler.go # Team management
│ │ ├── document_handler.go # Document management
│ │ ├── file_handler.go # File management
│ │ ├── folder_handler.go # Folder management
│ │ ├── calendar_handler.go # Calendar events
│ │ ├── notification_handler.go # Notification management
│ │ ├── message_handler.go # Message management
│ │ ├── dashboard_handler.go # Dashboard statistics
│ │ └── home_handler.go # Homepage + health check
│ ├── services/ # Business logic layer
│ │ ├── auth_service.go
│ │ ├── user_service.go
│ │ └── ...
│ ├── models/ # Data models
│ │ ├── user.go
│ │ ├── role.go
│ │ └── ...
│ ├── middleware/ # Middleware
│ │ ├── auth.go
│ │ └── cors.go
│ └── routes/ # Route configuration
│ └── router.go
├── pkg/
│ ├── config/ # Configuration management
│ ├── database/ # Database connection
│ └── utils/ # Utility functions
├── docs/ # Documentation
├── .github/workflows/ # GitHub Actions
├── Dockerfile # Docker configuration
├── docker-compose.yml
└── go.mod
| Method | Path | Description | Permission |
|---|---|---|---|
| POST | /api/auth/login |
User login | Public |
| POST | /api/auth/register |
User registration | Public |
| POST | /api/auth/refresh |
Refresh token | Public |
| POST | /api/auth/logout |
Logout | Authenticated |
| POST | /api/auth/forgot-password |
Forgot password | Public |
| POST | /api/auth/reset-password |
Reset password | Public |
| GET | /api/auth/me |
Get current user | Authenticated |
| Method | Path | Description | Permission |
|---|---|---|---|
| GET | /api/users |
List users | users:view |
| GET | /api/users/:id |
Get user details | users:view |
| POST | /api/users |
Create user | users:create |
| PUT | /api/users/:id |
Update user | users:update |
| DELETE | /api/users/:id |
Delete user | users:delete |
| PATCH | /api/users/:id/status |
Update user status | users:update |
| POST | /api/users/batch-delete |
Batch delete users | users:delete |
| Method | Path | Description |
|---|---|---|
| GET | /api/roles |
List roles |
| GET | /api/roles/:id |
Get role details |
| POST | /api/roles |
Create role |
| PUT | /api/roles/:id |
Update role |
| POST | /api/roles/:id/permissions |
Assign permissions |
| DELETE | /api/roles/:id |
Delete role |
| Method | Path | Description |
|---|---|---|
| GET | /api/permissions |
List permissions |
| GET | /api/permissions/:id |
Get permission details |
| POST | /api/permissions |
Create permission |
| DELETE | /api/permissions/:id |
Delete permission |
| Method | Path | Description |
|---|---|---|
| GET | /api/teams |
List teams |
| GET | /api/teams/:id |
Get team details |
| POST | /api/teams |
Create team |
| PATCH | /api/teams/:id |
Update team |
| DELETE | /api/teams/:id |
Delete team |
| POST | /api/teams/:id/members |
Add member |
| DELETE | /api/teams/:id/members/:userId |
Remove member |
| Method | Path | Description |
|---|---|---|
| GET | /api/documents |
List documents |
| GET | /api/documents/:id |
Get document details |
| POST | /api/documents |
Create document |
| PUT | /api/documents/:id |
Update document |
| PATCH | /api/documents/:id/rename |
Rename document |
| POST | /api/documents/:id/move |
Move document |
| POST | /api/documents/:id/tags |
Update tags |
| POST | /api/documents/:id/share |
Share document |
| POST | /api/documents/:id/unshare |
Unshare document |
| POST | /api/documents/batch-delete |
Batch delete |
| DELETE | /api/documents/:id |
Delete document |
| Method | Path | Description |
|---|---|---|
| POST | /api/files/upload |
Upload file |
| POST | /api/files/folder |
Create folder |
| GET | /api/files |
List files |
| GET | /api/files/storage |
Get storage info |
| GET | /api/files/:id |
Get file details |
| GET | /api/files/:id/download-url |
Get download URL |
| PATCH | /api/files/:id/rename |
Rename file |
| POST | /api/files/:id/move |
Move file |
| POST | /api/files/:id/copy |
Copy file |
| PATCH | /api/files/:id/favorite |
Toggle favorite |
| POST | /api/files/:id/share |
Share file |
| POST | /api/files/batch-delete |
Batch delete |
| DELETE | /api/files/:id |
Delete file |
| Method | Path | Description |
|---|---|---|
| GET | /api/folders |
List folders |
| GET | /api/folders/tree |
Get folder tree |
| GET | /api/folders/:id |
Get folder details |
| POST | /api/folders |
Create folder |
| DELETE | /api/folders/:id |
Delete folder |
| Method | Path | Description |
|---|---|---|
| GET | /api/messages/conversations |
List conversations |
| GET | /api/messages/conversations/:id |
Get conversation details |
| POST | /api/messages |
Send message |
| PUT | /api/messages/:id/read |
Mark as read |
| DELETE | /api/messages/:id |
Delete message |
| Method | Path | Description |
|---|---|---|
| GET | /api/notifications |
List notifications |
| GET | /api/notifications/unread-count |
Get unread count |
| PUT | /api/notifications/:id/read |
Mark as read |
| PUT | /api/notifications/read-all |
Mark all as read |
| DELETE | /api/notifications/:id |
Delete notification |
| Method | Path | Description |
|---|---|---|
| GET | /api/calendar/events |
List events |
| GET | /api/calendar/events/:id |
Get event details |
| POST | /api/calendar/events |
Create event |
| PUT | /api/calendar/events/:id |
Update event |
| PATCH | /api/calendar/events/:id/reschedule |
Reschedule event |
| POST | /api/calendar/events/:id/attendees |
Add attendee |
| DELETE | /api/calendar/events/:id/attendees/:attendeeId |
Remove attendee |
| POST | /api/calendar/events/batch-delete |
Batch delete |
| DELETE | /api/calendar/events/:id |
Delete event |
| Method | Path | Description |
|---|---|---|
| GET | /api/dashboard/stats |
Get statistics |
| GET | /api/dashboard/visits |
Get visit data |
| GET | /api/dashboard/sales |
Get sales data |
| GET | /api/dashboard/products |
Get product data |
| GET | /api/dashboard/orders |
Get order data |
| GET | /api/dashboard/activities |
Get activity data |
| GET | /api/dashboard/pie |
Get pie chart data |
| GET | /api/dashboard/tasks |
Get task data |
| GET | /api/dashboard/overview |
Get overview data |
Access Token: 7 days validity, used for API requests
Refresh Token: 30 days validity, used to refresh Access Token
Authorization: Bearer <access_token>
// Token refresh example
func RefreshToken(c *fiber.Ctx) error {
type RefreshRequest struct {
RefreshToken string `json:"refreshToken"`
}
var req RefreshRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{
"success": false,
"message": "Invalid request",
})
}
// Validate refresh token
claims, err := utils.ValidateToken(req.RefreshToken)
if err != nil {
return c.Status(401).JSON(fiber.Map{
"success": false,
"message": "Invalid refresh token",
})
}
// Generate new access token
accessToken, err := utils.GenerateAccessToken(claims.UserID)
if err != nil {
return c.Status(500).JSON(fiber.Map{
"success": false,
"message": "Failed to generate token",
})
}
return c.JSON(fiber.Map{
"success": true,
"accessToken": accessToken,
})
}
| Role | Description | Permissions |
|---|---|---|
super_admin |
Super Administrator | * (all permissions) |
admin |
Administrator | users:*, documents:*, files:*, teams:* |
user |
Regular User | documents:view, documents:create, files:* |
guest |
Guest | dashboard:view |
{resource}:{action}
Examples:
- users:view # View users
- users:create # Create users
- users:* # All user operations
- * # All permissions
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request parameter validation failed",
"details": [
{ "field": "email", "message": "Invalid email format" }
]
}
}
| Status Code | Error Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR |
Parameter validation failed |
| 401 | UNAUTHORIZED |
Unauthorized |
| 403 | FORBIDDEN |
Forbidden |
| 404 | NOT_FOUND |
Resource not found |
| 409 | CONFLICT |
Resource conflict |
| 500 | INTERNAL_ERROR |
Internal server error |
# Development
go run cmd/server/main.go
# Build
go build -o bin/server cmd/server/main.go
# Testing
go test ./...
# Database
make migrate
# Code Quality
go vet ./...
golangci-lint run
docker build -t halolight-api-go .
docker run -p 8080:8080 halolight-api-go
docker-compose up -d
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- APP_ENV=production
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: halolight
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
APP_ENV=production
DATABASE_URL=postgresql://user:pass@host:5432/db
JWT_SECRET=your-production-secret
# Unit tests
go test ./...
# Test coverage
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
func TestUserLogin(t *testing.T) {
app := fiber.New()
// Setup routes
app.Post("/api/auth/login", handlers.Login)
// Prepare test data
reqBody := `{"email":"[email protected]","password":"password123"}`
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
// Execute request
resp, _ := app.Test(req)
// Verify response
assert.Equal(t, 200, resp.StatusCode)
}
| Metric | Value | Conditions |
|---|---|---|
| Request Throughput | 10,000+ QPS | Single machine, 8-core CPU |
| Average Response Time | < 10ms | Simple queries |
| Memory Usage | ~50MB | Idle state |
| CPU Usage | < 10% | Idle state |
// Logging configuration
logger := log.New(os.Stdout, "API: ", log.LstdFlags)
app.Use(func(c *fiber.Ctx) error {
start := time.Now()
err := c.Next()
logger.Printf("%s %s %s %v",
c.Method(),
c.Path(),
c.IP(),
time.Since(start),
)
return err
})
// Health check endpoint
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "healthy",
"timestamp": time.Now(),
"database": db.Ping() == nil,
})
})
// Prometheus metrics
import "github.com/prometheus/client_golang/prometheus"
var (
requestCounter = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "api_requests_total",
Help: "Total API requests",
},
[]string{"method", "path", "status"},
)
)
A: JWT secret must be at least 32 characters. Recommend using 64+ character random strings.
# Generate secure key
openssl rand -base64 64
A: Check database configuration and network connection.
# Check PostgreSQL status
docker-compose ps postgres
# Test connection
psql -h localhost -U postgres -d halolight
| Feature | Go Fiber | NestJS | FastAPI | Spring Boot |
|---|---|---|---|---|
| Language | Go | TypeScript | Python | Java |
| ORM | GORM | Prisma | SQLAlchemy | JPA |
| Performance | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| Learning Curve | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
HaloLight Spring Boot backend API is built on Spring Boot 3.4.1, providing enterprise-grade backend service with complete JWT dual-token authentication.
API Documentation: https://halolight-api-java.h7ml.cn/api/swagger-ui
GitHub: https://github.com/halolight/halolight-api-java
| Technology | Version | Description |
|---|---|---|
| Java | 23 | Runtime |
| Spring Boot | 3.4.1 | Web Framework |
| Spring Data JPA | 3.4.1 | Database ORM |
| PostgreSQL | 16 | Data Storage |
| Bean Validation | jakarta.validation | Data Validation |
| JWT | JJWT | Authentication |
| Springdoc OpenAPI | 2.7.0 | API Documentation |
# Clone repository
git clone https://github.com/halolight/halolight-api-java.git
cd halolight-api-java
# Install dependencies
./mvnw clean install
cp .env.example .env
# Database
DATABASE_URL=jdbc:postgresql://localhost:5432/halolight_db
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=your-password
# JWT Secret
JWT_SECRET=your-super-secret-jwt-key-change-in-production-min-32-chars
JWT_EXPIRATION=86400000
JWT_REFRESH_EXPIRATION=604800000
# Service Configuration
PORT=8080
SPRING_PROFILES_ACTIVE=production
# Auto-create tables (first run)
./mvnw spring-boot:run
# Run seed data (optional)
./mvnw exec:java -Dexec.mainClass="com.halolight.seed.DataSeeder"
# Development mode
./mvnw spring-boot:run
# Production mode
./mvnw clean package -DskipTests
java -jar target/halolight-api-java-1.0.0.jar
Visit http://localhost:8080
halolight-api-java/
├── src/main/java/com/halolight/
│ ├── controller/ # Controllers/Route handlers
│ │ ├── AuthController.java
│ │ ├── UserController.java
│ │ └── ...
│ ├── service/ # Business logic layer
│ │ ├── AuthService.java
│ │ └── ...
│ ├── domain/ # Data models
│ │ ├── entity/ # JPA Entities
│ │ └── repository/ # Repository interfaces
│ ├── config/ # Middleware/Configuration
│ │ ├── SecurityConfig.java
│ │ └── ...
│ ├── web/dto/ # Request validation DTOs
│ ├── security/ # Security components
│ └── HalolightApplication.java # Application entry
├── src/main/resources/ # Resource files
│ ├── application.yml
│ └── application-*.yml
├── src/test/ # Test files
├── Dockerfile # Docker configuration
├── docker-compose.yml
└── pom.xml # Maven configuration
| Method | Path | Description | Permission |
|---|---|---|---|
| POST | /api/auth/login |
User login | Public |
| POST | /api/auth/register |
User registration | Public |
| POST | /api/auth/refresh |
Refresh token | Public |
| POST | /api/auth/logout |
Logout | Authenticated |
| POST | /api/auth/forgot-password |
Forgot password | Public |
| POST | /api/auth/reset-password |
Reset password | Public |
| GET | /api/auth/me |
Get current user | Authenticated |
| Method | Path | Description | Permission |
|---|---|---|---|
| GET | /api/users |
Get user list | users:view |
| GET | /api/users/{id} |
Get user details | users:view |
| POST | /api/users |
Create user | users:create |
| PUT | /api/users/{id} |
Update user | users:update |
| PUT | /api/users/{id}/status |
Update user status | users:update |
| DELETE | /api/users/{id} |
Delete user | users:delete |
| Method | Path | Description |
|---|---|---|
| GET | /api/roles |
Get role list |
| GET | /api/roles/{id} |
Get role details |
| POST | /api/roles |
Create role |
| PUT | /api/roles/{id} |
Update role |
| POST | /api/roles/{id}/permissions |
Assign permissions |
| DELETE | /api/roles/{id} |
Delete role |
| Method | Path | Description |
|---|---|---|
| GET | /api/permissions |
Get permission list |
| POST | /api/permissions |
Create permission |
| PUT | /api/permissions/{id} |
Update permission |
| DELETE | /api/permissions/{id} |
Delete permission |
| Method | Path | Description |
|---|---|---|
| GET | /api/documents |
Get document list |
| GET | /api/documents/{id} |
Get document details |
| POST | /api/documents |
Create document |
| PUT | /api/documents/{id} |
Update document |
| PUT | /api/documents/{id}/rename |
Rename document |
| POST | /api/documents/{id}/move |
Move document |
| POST | /api/documents/{id}/tags |
Update tags |
| POST | /api/documents/{id}/share |
Share document |
| POST | /api/documents/{id}/unshare |
Unshare document |
| DELETE | /api/documents/{id} |
Delete document |
| Method | Path | Description |
|---|---|---|
| POST | /api/files/upload |
Upload file |
| GET | /api/files |
Get file list |
| GET | /api/files/storage |
Get storage quota |
| GET | /api/files/{id} |
Get file details |
| GET | /api/files/{id}/download |
Download file |
| PUT | /api/files/{id}/rename |
Rename file |
| POST | /api/files/{id}/move |
Move file |
| PUT | /api/files/{id}/favorite |
Toggle favorite |
| POST | /api/files/{id}/share |
Share file |
| DELETE | /api/files/{id} |
Delete file |
| Method | Path | Description |
|---|---|---|
| GET | /api/teams |
Get team list |
| GET | /api/teams/{id} |
Get team details |
| POST | /api/teams |
Create team |
| PUT | /api/teams/{id} |
Update team |
| POST | /api/teams/{id}/members |
Add member |
| DELETE | /api/teams/{id}/members/{userId} |
Remove member |
| Method | Path | Description |
|---|---|---|
| GET | /api/messages/conversations |
Get conversation list |
| GET | /api/messages/conversations/{userId} |
Get conversation messages |
| POST | /api/messages |
Send message |
| PUT | /api/messages/{id}/read |
Mark as read |
| DELETE | /api/messages/{id} |
Delete message |
| Method | Path | Description |
|---|---|---|
| GET | /api/notifications |
Get notification list |
| GET | /api/notifications/unread-count |
Get unread count |
| PUT | /api/notifications/{id}/read |
Mark single as read |
| PUT | /api/notifications/read-all |
Mark all as read |
| DELETE | /api/notifications/{id} |
Delete notification |
| Method | Path | Description |
|---|---|---|
| GET | /api/calendar/events |
Get event list |
| GET | /api/calendar/events/{id} |
Get event details |
| POST | /api/calendar/events |
Create event |
| PUT | /api/calendar/events/{id} |
Update event |
| PUT | /api/calendar/events/{id}/reschedule |
Reschedule |
| POST | /api/calendar/events/{id}/attendees |
Add attendee |
| DELETE | /api/calendar/events/{id}/attendees/{attendeeId} |
Remove attendee |
| DELETE | /api/calendar/events/{id} |
Delete event |
| Method | Path | Description |
|---|---|---|
| GET | /api/dashboard/stats |
Statistics data |
| GET | /api/dashboard/visits |
Visit trends |
| GET | /api/dashboard/sales |
Sales data |
| GET | /api/dashboard/pie |
Pie chart data |
| GET | /api/dashboard/tasks |
Pending tasks |
Access Token: 24-hour validity, used for API requests
Refresh Token: 7-day validity, used to refresh Access Token
Authorization: Bearer <access_token>
// Frontend auto-refresh example
@Component
public class JwtTokenInterceptor {
public Response intercept(Chain chain) throws IOException {
Request request = chain.request();
Response response = chain.proceed(request);
// 401 auto refresh
if (response.code() == 401) {
String newToken = refreshToken(refreshToken);
Request newRequest = request.newBuilder()
.header("Authorization", "Bearer " + newToken)
.build();
return chain.proceed(newRequest);
}
return response;
}
}
| Role | Description | Permissions |
|---|---|---|
super_admin |
Super Administrator | * (all permissions) |
admin |
Administrator | users:*, documents:*, roles:* |
user |
Regular User | documents:view, files:view |
guest |
Guest | dashboard:view |
{resource}:{action}
Examples:
- users:view # View users
- users:create # Create users
- users:* # All user operations
- * # All permissions
@RestController
@RequestMapping("/api/users")
public class UserController {
@PreAuthorize("hasPermission('users:view')")
@GetMapping
public Page<UserDTO> getUsers(Pageable pageable) {
return userService.findAll(pageable);
}
@PreAuthorize("hasPermission('users:create')")
@PostMapping
public UserDTO createUser(@Valid @RequestBody CreateUserRequest request) {
return userService.create(request);
}
}
{
"timestamp": "2025-12-04T12:00:00.000Z",
"status": 400,
"error": "Bad Request",
"message": "Validation failed",
"path": "/api/users",
"details": [
{ "field": "email", "message": "must be a valid email address" }
]
}
| Status Code | Error Code | Description |
|---|---|---|
| 400 | Bad Request |
Parameter validation failed |
| 401 | Unauthorized |
Unauthorized |
| 403 | Forbidden |
No permission |
| 404 | Not Found |
Resource not found |
| 409 | Conflict |
Resource conflict |
| 422 | Unprocessable Entity |
Business logic error |
| 429 | Too Many Requests |
Rate limit exceeded |
| 500 | Internal Server Error |
Server error |
Spring Data JPA entities include 17 models:
// User Entity
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String name;
private String password;
private String avatar;
@Enumerated(EnumType.STRING)
private UserStatus status = UserStatus.ACTIVE;
@ManyToMany
@JoinTable(name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id"))
private Set<Role> roles;
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
// Role Entity
@Entity
@Table(name = "roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String name;
private String description;
@ManyToMany
@JoinTable(name = "role_permissions")
private Set<Permission> permissions;
}
// Permission Entity
@Entity
@Table(name = "permissions")
public class Permission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String name; // Format: "users:create", "users:*", "*"
private String description;
}
Complete Entity List:
| Variable Name | Description | Default Value |
|---|---|---|
SPRING_PROFILES_ACTIVE |
Runtime environment | development |
PORT |
Service port | 8080 |
DATABASE_URL |
Database connection | jdbc:postgresql://localhost:5432/halolight_db |
DATABASE_USERNAME |
Database username | postgres |
DATABASE_PASSWORD |
Database password | - |
JWT_SECRET |
JWT secret (min 32 chars) | - |
JWT_EXPIRATION |
AccessToken expiration (ms) | 86400000 (24h) |
JWT_REFRESH_EXPIRATION |
RefreshToken expiration (ms) | 604800000 (7d) |
CORS_ALLOWED_ORIGINS |
CORS allowed origins | http://localhost:3000 |
# application.yml
spring:
datasource:
url: ${DATABASE_URL}
username: ${DATABASE_USERNAME}
password: ${DATABASE_PASSWORD}
jwt:
secret: ${JWT_SECRET}
expiration: ${JWT_EXPIRATION:86400000}
refreshExpiration: ${JWT_REFRESH_EXPIRATION:604800000}
# Development
./mvnw spring-boot:run # Start development server
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev # Specify environment
# Build
./mvnw clean package # Build JAR
./mvnw clean package -DskipTests # Build without tests
./mvnw clean install # Install to local repository
# Testing
./mvnw test # Run all tests
./mvnw test -Dtest=UserServiceTest # Run specific test
./mvnw verify # Run integration tests
./mvnw test jacoco:report # Generate coverage report
# Database
./mvnw flyway:migrate # Run migrations (if using Flyway)
./mvnw liquibase:update # Update schema (if using Liquibase)
# Code Quality
./mvnw checkstyle:check # Code style check
./mvnw spotbugs:check # Static analysis
docker build -t halolight-api-java .
docker run -p 8080:8080 --env-file .env halolight-api-java
docker-compose up -d
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=production
- DATABASE_URL=jdbc:postgresql://db:5432/halolight
- DATABASE_USERNAME=postgres
- DATABASE_PASSWORD=${DB_PASSWORD}
- JWT_SECRET=${JWT_SECRET}
depends_on:
- db
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: halolight
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
postgres_data:
SPRING_PROFILES_ACTIVE=production
DATABASE_URL=jdbc:postgresql://prod-db.example.com:5432/halolight
DATABASE_USERNAME=halolight_user
DATABASE_PASSWORD=your-production-password
JWT_SECRET=your-production-secret-min-32-chars
CORS_ALLOWED_ORIGINS=https://halolight.h7ml.cn
./mvnw test # Run unit tests
./mvnw test jacoco:report # Generate coverage report
./mvnw verify # Run integration tests
@SpringBootTest
@AutoConfigureMockMvc
public class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
public void testLogin() throws Exception {
LoginRequest request = new LoginRequest();
request.setEmail("[email protected]");
request.setPassword("123456");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.accessToken").exists())
.andExpect(jsonPath("$.refreshToken").exists());
}
@Test
@WithMockUser(authorities = {"users:view"})
public void testGetUsers() throws Exception {
mockMvc.perform(get("/api/users")
.param("page", "0")
.param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray());
}
}
| Metric | Value | Description |
|---|---|---|
| Request Throughput | ~3000 QPS | Simple queries, 4 cores 8GB |
| Average Response Time | 15-30ms | P50, database queries |
| P95 Response Time | 50-100ms | Including complex queries |
| Memory Usage | 256-512 MB | Stable running state |
| CPU Usage | 10-30% | Medium load |
# Using Apache Bench
ab -n 10000 -c 100 -H "Authorization: Bearer TOKEN" \
http://localhost:8080/api/users
# Using wrk
wrk -t4 -c100 -d30s -H "Authorization: Bearer TOKEN" \
http://localhost:8080/api/users
// Logback configuration
@Slf4j
@RestController
public class UserController {
@GetMapping("/api/users/{id}")
public UserDTO getUser(@PathVariable Long id) {
log.info("Fetching user with id: {}", id);
try {
return userService.findById(id);
} catch (Exception e) {
log.error("Error fetching user {}: {}", id, e.getMessage(), e);
throw e;
}
}
}
@Component
public class CustomHealthIndicator implements HealthIndicator {
@Override
public Health health() {
// Check database connection
boolean dbUp = checkDatabase();
if (dbUp) {
return Health.up()
.withDetail("database", "Available")
.build();
}
return Health.down()
.withDetail("database", "Unavailable")
.build();
}
}
Endpoint: GET /actuator/health
{
"status": "UP",
"components": {
"db": { "status": "UP" },
"diskSpace": { "status": "UP" }
}
}
# application.yml
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
metrics:
export:
prometheus:
enabled: true
Prometheus Endpoint: GET /actuator/prometheus
A: Configure in .env or application.yml:
JWT_EXPIRATION=3600000 # 1 hour (milliseconds)
JWT_REFRESH_EXPIRATION=86400000 # 1 day (milliseconds)
A: Generate certificate and configure Spring Boot:
# application.yml
server:
port: 8443
ssl:
enabled: true
key-store: classpath:keystore.p12
key-store-password: your-password
key-store-type: PKCS12
# Generate self-signed certificate (development)
keytool -genkeypair -alias halolight -keyalg RSA -keysize 2048 \
-storetype PKCS12 -keystore keystore.p12 -validity 365
A: Use HikariCP (Spring Boot default):
spring:
datasource:
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
A: Use Spring Data JPA Pageable:
@GetMapping("/api/users")
public Page<UserDTO> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "id,desc") String sort
) {
String[] sortParams = sort.split(",");
Sort.Direction direction = sortParams.length > 1 &&
sortParams[1].equals("desc") ? Sort.Direction.DESC : Sort.Direction.ASC;
Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sortParams[0]));
return userService.findAll(pageable);
}
| Feature | Spring Boot | NestJS | FastAPI | Go Fiber |
|---|---|---|---|---|
| Language | Java | TypeScript | Python | Go |
| ORM | JPA/Hibernate | Prisma | SQLAlchemy | GORM |
| Performance | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Learning Curve | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| Enterprise-Grade | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
| Ecosystem | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| Community Support | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
HaloLight NestJS backend API is built on NestJS 11, providing enterprise-grade backend services with complete RBAC permission system.
API Documentation: https://halolight-api-nestjs.h7ml.cn/docs
GitHub: https://github.com/halolight/halolight-api-nestjs
| Technology | Version | Description |
|---|---|---|
| TypeScript | 5.7 | Runtime |
| NestJS | 11 | Web Framework |
| Prisma | 5 | Database ORM |
| PostgreSQL | 16 | Data Storage |
| class-validator | - | Data Validation |
| JWT | - | Authentication |
| Swagger | - | API Documentation |
# Clone repository
git clone https://github.com/halolight/halolight-api-nestjs.git
cd halolight-api-nestjs
# Install dependencies
pnpm install
cp .env.example .env
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/halolight_db
# JWT Keys
JWT_SECRET=your-super-secret-key
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
# Service Configuration
PORT=3000
NODE_ENV=development
pnpm prisma:generate
pnpm prisma:migrate
pnpm prisma:seed
# Development mode
pnpm dev
# Production mode
pnpm build
pnpm start:prod
Visit http://localhost:3000
halolight-api-nestjs/
├── src/
│ ├── common/ # Shared modules
│ ├── configs/ # Configuration modules
│ ├── infrastructure/ # Infrastructure layer
│ ├── modules/ # Business modules (12 modules)
│ │ ├── auth/ # Authentication
│ │ ├── users/ # User management
│ │ ├── roles/ # Role management
│ │ ├── permissions/ # Permission management
│ │ ├── teams/ # Team management
│ │ ├── documents/ # Document management
│ │ ├── files/ # File management
│ │ ├── folders/ # Folder management
│ │ ├── calendar/ # Calendar management
│ │ ├── notifications/ # Notification management
│ │ ├── messages/ # Message management
│ │ └── dashboard/ # Dashboard statistics
│ ├── app.controller.ts # Root controller
│ ├── app.service.ts # Root service
│ ├── app.module.ts # Root module
│ └── main.ts # Application entry
├── prisma/
│ ├── schema.prisma # Database model definitions
│ └── migrations/ # Database migration history
├── test/ # Test files
├── Dockerfile # Docker configuration
├── docker-compose.yml
└── package.json
| Method | Path | Description | Permission |
|---|---|---|---|
| POST | /api/auth/login |
User login | Public |
| POST | /api/auth/register |
User registration | Public |
| POST | /api/auth/refresh |
Refresh token | Public |
| POST | /api/auth/logout |
User logout | Required |
| POST | /api/auth/forgot-password |
Forgot password | Public |
| POST | /api/auth/reset-password |
Reset password | Public |
| Method | Path | Description | Permission |
|---|---|---|---|
| GET | /api/users |
Get user list | users:view |
| GET | /api/users/:id |
Get user details | users:view |
| POST | /api/users |
Create user | users:create |
| PUT | /api/users/:id |
Update user | users:update |
| DELETE | /api/users/:id |
Delete user | users:delete |
| GET | /api/users/me |
Get current user | Required |
| Method | Path | Description |
|---|---|---|
| GET | /api/documents |
Get document list |
| GET | /api/documents/:id |
Get document details |
| POST | /api/documents |
Create document |
| PUT | /api/documents/:id |
Update document |
| DELETE | /api/documents/:id |
Delete document |
| Method | Path | Description |
|---|---|---|
| GET | /api/files |
Get file list |
| GET | /api/files/:id |
Get file details |
| POST | /api/files/upload |
Upload file |
| PUT | /api/files/:id |
Update file info |
| DELETE | /api/files/:id |
Delete file |
| Method | Path | Description |
|---|---|---|
| GET | /api/messages |
Get message list |
| GET | /api/messages/:id |
Get message details |
| POST | /api/messages |
Send message |
| PUT | /api/messages/:id/read |
Mark as read |
| DELETE | /api/messages/:id |
Delete message |
| Method | Path | Description |
|---|---|---|
| GET | /api/notifications |
Get notification list |
| PUT | /api/notifications/:id/read |
Mark as read |
| PUT | /api/notifications/read-all |
Mark all as read |
| DELETE | /api/notifications/:id |
Delete notification |
| Method | Path | Description |
|---|---|---|
| GET | /api/calendar/events |
Get event list |
| GET | /api/calendar/events/:id |
Get event details |
| POST | /api/calendar/events |
Create event |
| PUT | /api/calendar/events/:id |
Update event |
| DELETE | /api/calendar/events/:id |
Delete event |
| Method | Path | Description |
|---|---|---|
| GET | /api/dashboard/stats |
Statistics data |
| GET | /api/dashboard/visits |
Visit trends |
| GET | /api/dashboard/sales |
Sales data |
| GET | /api/dashboard/pie |
Pie chart data |
| GET | /api/dashboard/tasks |
Task list |
| GET | /api/dashboard/overview |
System overview |
Access Token: 15 minutes validity, used for API requests
Refresh Token: 7 days validity, used to refresh Access Token
Authorization: Bearer <access_token>
// Token refresh example
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
});
const { accessToken, refreshToken: newRefreshToken } = await response.json();
| Role | Description | Permissions |
|---|---|---|
super_admin |
Super Administrator | * (all permissions) |
admin |
Administrator | users:*, documents:*, ... |
user |
Regular User | documents:view, files:view, ... |
guest |
Guest | dashboard:view |
{resource}:{action}
Examples:
- users:view # View users
- users:create # Create users
- users:* # All user operations
- * # All permissions
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{ "field": "email", "message": "Invalid email format" }
]
}
}
| Status Code | Error Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR |
Validation failed |
| 401 | UNAUTHORIZED |
Unauthorized |
| 403 | FORBIDDEN |
No permission |
| 404 | NOT_FOUND |
Resource not found |
| 409 | CONFLICT |
Resource conflict |
| 500 | INTERNAL_ERROR |
Server error |
# Development
pnpm dev # Start development server
pnpm start:debug # Debug mode
# Build
pnpm build # Build for production
pnpm start:prod # Run production build
# Testing
pnpm test # Run unit tests
pnpm test:e2e # Run E2E tests
pnpm test:cov # Generate coverage report
# Database
pnpm prisma:generate # Generate Prisma Client
pnpm prisma:migrate # Run migrations
pnpm prisma:studio # Prisma Studio GUI
pnpm prisma:seed # Run seed data
# Code Quality
pnpm lint # ESLint check
pnpm lint:fix # Auto-fix issues
pnpm format # Prettier formatting
docker build -t halolight-api-nestjs .
docker run -p 3000:3000 halolight-api-nestjs
docker-compose up -d
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: halolight
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
NODE_ENV=production
DATABASE_URL=postgresql://user:pass@host:5432/db
JWT_SECRET=your-production-secret
pnpm test # Unit tests
pnpm test:e2e # E2E tests
pnpm test:cov # Coverage report
describe('AuthController', () => {
it('should login user', async () => {
const response = await request(app.getHttpServer())
.post('/api/auth/login')
.send({ email: '[email protected]', password: 'password' })
.expect(200);
expect(response.body).toHaveProperty('accessToken');
});
});
| Metric | Value | Conditions |
|---|---|---|
| Request Throughput | 5000+ QPS | Single CPU core |
| Average Response Time | <50ms | Simple queries |
| Memory Usage | ~150MB | After startup |
| CPU Usage | <30% | Normal load |
// Logging configuration
import { WinstonModule } from 'nest-winston';
WinstonModule.forRoot({
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// GET /health
{
"status": "ok",
"info": {
"database": { "status": "up" },
"redis": { "status": "up" }
}
}
// Prometheus metrics endpoint
// GET /metrics
http_requests_total{method="GET",status="200"} 1234
http_request_duration_seconds{quantile="0.99"} 0.052
A: Set DATABASE_URL in .env file
DATABASE_URL="postgresql://user:password@localhost:5432/halolight"
A: Use FileInterceptor from @nestjs/platform-express
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
uploadFile(@UploadedFile() file: Express.Multer.File) {
return { filename: file.originalname };
}
| Feature | NestJS | FastAPI | Spring Boot | Laravel |
|---|---|---|---|---|
| Language | TypeScript | Python | Java | PHP |
| ORM | Prisma | SQLAlchemy | JPA | Eloquent |
| Performance | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
| Learning Curve | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
HaloLight Node.js backend API is built on Express 5 + TypeScript + Prisma, providing enterprise-grade RESTful API service.
API Documentation: https://halolight-api-node.h7ml.cn/docs
GitHub: https://github.com/halolight/halolight-api-node
| Technology | Version | Description |
|---|---|---|
| Node.js | 20+ | JavaScript runtime |
| Express | 5.x | Web framework |
| Prisma | 6.x | Database ORM |
| PostgreSQL | 16 | Data storage |
| Zod | 3.x | Data validation |
| JWT | 9.x | Authentication |
| Pino | 9.x | Logging system |
| Swagger UI | 5.x | API documentation |
# Clone repository
git clone https://github.com/halolight/halolight-api-node.git
cd halolight-api-node
# Install dependencies
pnpm install
cp .env.example .env
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/halolight?schema=public"
# JWT Secrets (must be ≥32 characters)
JWT_SECRET="your-super-secret-jwt-key-minimum-32-characters-long"
JWT_EXPIRES_IN=7d
REFRESH_TOKEN_SECRET="your-refresh-secret-key-minimum-32-characters-long"
REFRESH_TOKEN_EXPIRES_IN=30d
# Service Configuration
PORT=3001
NODE_ENV=development
# CORS
CORS_ORIGIN="http://localhost:3000"
# Generate Prisma Client
pnpm db:generate
# Push database changes
pnpm db:push
# Seed database (optional)
pnpm db:seed
# Development mode
pnpm dev
# Production mode
pnpm build
pnpm start
Visit http://localhost:3001
halolight-api-node/
├── src/
│ ├── routes/ # Controllers/Route handlers
│ │ ├── auth.ts # Authentication routes
│ │ ├── users.ts # User management
│ │ ├── roles.ts # Role management
│ │ ├── permissions.ts # Permission management
│ │ ├── teams.ts # Team management
│ │ ├── documents.ts # Document management
│ │ ├── files.ts # File management
│ │ ├── folders.ts # Folder management
│ │ ├── calendar.ts # Calendar events
│ │ ├── notifications.ts # Notification management
│ │ ├── messages.ts # Message management
│ │ └── dashboard.ts # Dashboard statistics
│ ├── services/ # Business logic layer
│ ├── middleware/ # Middleware
│ │ ├── auth.ts # JWT auth + RBAC
│ │ ├── validate.ts # Zod request validation
│ │ └── error.ts # Global error handling
│ ├── utils/ # Utility functions
│ ├── config/ # Configuration files
│ │ ├── env.ts # Environment variables
│ │ └── swagger.ts # Swagger configuration
│ └── index.ts # Application entry
├── prisma/ # Database migrations/Schema
│ └── schema.prisma # Database models (17+ models)
├── tests/ # Test files
├── Dockerfile # Docker configuration
├── docker-compose.yml
└── package.json
| Method | Path | Description | Permission |
|---|---|---|---|
| POST | /api/auth/login |
User login | Public |
| POST | /api/auth/register |
User registration | Public |
| POST | /api/auth/refresh |
Refresh token | Public |
| POST | /api/auth/logout |
User logout | Authenticated |
| POST | /api/auth/forgot-password |
Forgot password | Public |
| POST | /api/auth/reset-password |
Reset password | Public |
| Method | Path | Description | Permission |
|---|---|---|---|
| GET | /api/users |
Get user list | users:view |
| GET | /api/users/:id |
Get user details | users:view |
| POST | /api/users |
Create user | users:create |
| PUT | /api/users/:id |
Update user | users:update |
| DELETE | /api/users/:id |
Delete user | users:delete |
| GET | /api/users/me |
Get current user | Authenticated |
| Method | Path | Description |
|---|---|---|
| GET | /api/roles |
Get role list |
| GET | /api/roles/:id |
Get role details |
| POST | /api/roles |
Create role |
| PUT | /api/roles/:id |
Update role |
| DELETE | /api/roles/:id |
Delete role |
| PUT | /api/roles/:id/permissions |
Assign permissions |
| Method | Path | Description |
|---|---|---|
| GET | /api/permissions |
Get permission list |
| GET | /api/permissions/:id |
Get permission details |
| POST | /api/permissions |
Create permission |
| DELETE | /api/permissions/:id |
Delete permission |
| Method | Path | Description |
|---|---|---|
| GET | /api/teams |
Get team list |
| GET | /api/teams/:id |
Get team details |
| POST | /api/teams |
Create team |
| PUT | /api/teams/:id |
Update team |
| DELETE | /api/teams/:id |
Delete team |
| POST | /api/teams/:id/members |
Add member |
| DELETE | /api/teams/:id/members/:userId |
Remove member |
| Method | Path | Description |
|---|---|---|
| GET | /api/documents |
Get document list |
| GET | /api/documents/:id |
Get document details |
| POST | /api/documents |
Create document |
| PUT | /api/documents/:id |
Update document |
| DELETE | /api/documents/:id |
Delete document |
| POST | /api/documents/:id/share |
Share document |
| DELETE | /api/documents/:id/share/:shareId |
Unshare document |
| POST | /api/documents/:id/tags |
Add tag |
| DELETE | /api/documents/:id/tags/:tagId |
Remove tag |
| POST | /api/documents/:id/move |
Move document |
| POST | /api/documents/:id/copy |
Copy document |
| Method | Path | Description |
|---|---|---|
| GET | /api/files |
Get file list |
| GET | /api/files/:id |
Get file details |
| POST | /api/files/upload |
Upload file |
| GET | /api/files/:id/download |
Download file |
| PUT | /api/files/:id |
Update file info |
| DELETE | /api/files/:id |
Delete file |
| POST | /api/files/:id/move |
Move file |
| POST | /api/files/:id/copy |
Copy file |
| POST | /api/files/:id/share |
Share file |
| GET | /api/files/:id/versions |
Get version history |
| GET | /api/files/storage |
Get storage info |
| POST | /api/files/batch-delete |
Batch delete |
| POST | /api/files/batch-move |
Batch move |
| POST | /api/files/search |
Search files |
| Method | Path | Description |
|---|---|---|
| GET | /api/folders |
Get folder tree |
| POST | /api/folders |
Create folder |
| PUT | /api/folders/:id |
Update folder |
| DELETE | /api/folders/:id |
Delete folder |
| POST | /api/folders/:id/move |
Move folder |
| Method | Path | Description |
|---|---|---|
| GET | /api/calendar/events |
Get event list |
| GET | /api/calendar/events/:id |
Get event details |
| POST | /api/calendar/events |
Create event |
| PUT | /api/calendar/events/:id |
Update event |
| DELETE | /api/calendar/events/:id |
Delete event |
| POST | /api/calendar/events/:id/attendees |
Add attendee |
| DELETE | /api/calendar/events/:id/attendees/:userId |
Remove attendee |
| POST | /api/calendar/events/:id/reminder |
Set reminder |
| GET | /api/calendar/events/upcoming |
Get upcoming events |
| Method | Path | Description |
|---|---|---|
| GET | /api/notifications |
Get notification list |
| GET | /api/notifications/:id |
Get notification details |
| PUT | /api/notifications/:id/read |
Mark as read |
| PUT | /api/notifications/read-all |
Mark all as read |
| DELETE | /api/notifications/:id |
Delete notification |
| Method | Path | Description |
|---|---|---|
| GET | /api/messages/conversations |
Get conversation list |
| GET | /api/messages/conversations/:id |
Get conversation details |
| POST | /api/messages |
Send message |
| PUT | /api/messages/:id/read |
Mark as read |
| DELETE | /api/messages/:id |
Delete message |
| Method | Path | Description |
|---|---|---|
| GET | /api/dashboard/stats |
Statistics data |
| GET | /api/dashboard/visits |
Visit trends |
| GET | /api/dashboard/sales |
Sales data |
| GET | /api/dashboard/pie |
Pie chart data |
| GET | /api/dashboard/tasks |
To-do tasks |
| GET | /api/dashboard/calendar |
Today's events |
| GET | /api/dashboard/activities |
Recent activities |
| GET | /api/dashboard/notifications |
Unread notifications |
| GET | /api/dashboard/users |
User statistics |
Access Token: 7 days validity, used for API requests
Refresh Token: 30 days validity, used to refresh Access Token
Authorization: Bearer <access_token>
// POST /api/auth/refresh
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: 'your_refresh_token' })
});
const { accessToken, refreshToken } = await response.json();
// Update locally stored tokens
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
| Role | Description | Permissions |
|---|---|---|
super_admin |
Super Administrator | * (all permissions) |
admin |
Administrator | users:*, documents:*, files:*, teams:* |
user |
Regular User | documents:view, files:view, calendar:* |
guest |
Guest | dashboard:view |
{resource}:{action}
Examples:
- users:view # View users
- users:create # Create users
- users:* # All user operations
- * # All permissions
// Using permission middleware in routes
import { requireAuth, requirePermission } from './middleware/auth';
// Requires authentication
router.get('/api/users/me', requireAuth, getUserProfile);
// Requires specific permission
router.post('/api/users', requireAuth, requirePermission('users:create'), createUser);
// Requires one of multiple permissions
router.put('/api/users/:id', requireAuth, requirePermission(['users:update', 'users:*']), updateUser);
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{ "field": "email", "message": "Invalid email format" }
]
}
}
| Status Code | Error Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR |
Validation failed |
| 401 | UNAUTHORIZED |
Unauthorized |
| 403 | FORBIDDEN |
Forbidden |
| 404 | NOT_FOUND |
Resource not found |
| 409 | CONFLICT |
Resource conflict |
| 500 | INTERNAL_ERROR |
Internal server error |
# Development
pnpm dev # Start dev server (hot reload)
pnpm build # TypeScript compilation
pnpm start # Start production server
# Testing
pnpm test # Run tests
pnpm test:coverage # Test coverage
# Database
pnpm db:generate # Generate Prisma Client
pnpm db:push # Push database changes
pnpm db:migrate # Run migrations
pnpm db:studio # Prisma Studio (database GUI)
pnpm db:seed # Seed database
# Code Quality
pnpm lint # ESLint check
pnpm lint:fix # Auto fix
pnpm format # Prettier formatting
docker build -t halolight-api-node .
docker run -p 3001:3001 halolight-api-node
docker-compose up -d
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
- REFRESH_TOKEN_SECRET=${REFRESH_TOKEN_SECRET}
restart: unless-stopped
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: halolight
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
postgres_data:
NODE_ENV=production
PORT=3001
DATABASE_URL=postgresql://user:pass@host:5432/halolight
JWT_SECRET=your-production-secret-minimum-32-characters
REFRESH_TOKEN_SECRET=your-refresh-secret-minimum-32-characters
CORS_ORIGIN=https://yourdomain.com
# Run all tests
pnpm test
# Run test coverage
pnpm test:coverage
# Watch mode
pnpm test:watch
// tests/auth.test.ts
import request from 'supertest';
import app from '../src/index';
describe('Authentication', () => {
it('should login successfully', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
email: '[email protected]',
password: 'password123'
});
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty('accessToken');
expect(response.body.data).toHaveProperty('refreshToken');
});
it('should reject invalid credentials', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
email: '[email protected]',
password: 'wrongpassword'
});
expect(response.status).toBe(401);
expect(response.body.success).toBe(false);
});
});
| Metric | Value | Condition |
|---|---|---|
| Request Throughput | ~8,000 req/s | Single core, simple queries |
| Average Response Time | <10ms | Local database, no complex queries |
| Memory Usage | ~80MB | Base memory after startup |
| CPU Usage | <5% | Idle state |
// Using Pino logging
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'yyyy-mm-dd HH:MM:ss',
ignore: 'pid,hostname'
}
}
});
// Log requests
app.use((req, res, next) => {
logger.info({
method: req.method,
url: req.url,
ip: req.ip
}, 'Incoming request');
next();
});
// GET /health
app.get('/health', async (req, res) => {
const health = {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
database: 'connected'
};
try {
// Check database connection
await prisma.$queryRaw`SELECT 1`;
} catch (error) {
health.status = 'unhealthy';
health.database = 'disconnected';
return res.status(503).json(health);
}
res.json(health);
});
// Prometheus metrics integration
import promClient from 'prom-client';
const register = new promClient.Registry();
// Default metrics
promClient.collectDefaultMetrics({ register });
// Custom metrics
const httpRequestDuration = new promClient.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code'],
registers: [register]
});
// Expose metrics endpoint
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType);
res.send(await register.metrics());
});
A: Configure the same DATABASE_URL and ensure identical Prisma Schema.
# All services use the same database connection
DATABASE_URL="postgresql://user:pass@shared-db:5432/halolight"
# Ensure Schema consistency
pnpm db:push
A: Detect 401 errors in frontend interceptor and automatically call refresh endpoint.
// Axios interceptor example
axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
const { data } = await axios.post('/api/auth/refresh', { refreshToken });
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
originalRequest.headers['Authorization'] = `Bearer ${data.accessToken}`;
return axios(originalRequest);
} catch (err) {
// Refresh failed, redirect to login
window.location.href = '/login';
return Promise.reject(err);
}
}
return Promise.reject(error);
}
);
A: Use multer middleware to configure file size and type limits.
import multer from 'multer';
const upload = multer({
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
},
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Unsupported file type'));
}
}
});
router.post('/api/files/upload', upload.single('file'), uploadFile);
A: Use Nginx reverse proxy or configure SSL certificates in Express.
// Enable HTTPS in Express
import https from 'https';
import fs from 'fs';
const options = {
key: fs.readFileSync('path/to/private-key.pem'),
cert: fs.readFileSync('path/to/certificate.pem')
};
https.createServer(options, app).listen(443, () => {
console.log('HTTPS server running on port 443');
});
pnpm db:studio)// .vscode/settings.json
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"typescript.tsdk": "node_modules/typescript/lib"
}
| Feature | Express | NestJS | Fastify | Koa |
|---|---|---|---|---|
| Language | JavaScript/TypeScript | TypeScript | JavaScript/TypeScript | JavaScript/TypeScript |
| Performance | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| Learning Curve | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| Ecosystem | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| Built-in Features | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
| Community Support | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
HaloLight PHP enterprise-grade backend API service, built with Laravel 11 + PostgreSQL + Redis, providing a complete RESTful API.
API Documentation: https://halolight-api-php.h7ml.cn/docs
GitHub: https://github.com/halolight/halolight-api-php
| Technology | Version | Description |
|---|---|---|
| PHP | 8.2+ | Runtime |
| Laravel | 11.x | Web Framework |
| Eloquent | 11.x | Database ORM |
| PostgreSQL | 16 | Data Storage |
| Redis | 7 | Cache/Queue |
| Form Request | 11.x | Data Validation |
| JWT | tymon/jwt-auth | Authentication |
| L5-Swagger | 8.x | API Documentation |
# Clone repository
git clone https://github.com/halolight/halolight-api-php.git
cd halolight-api-php
# Install dependencies
composer install
cp .env.example .env
# Database
DB_CONNECTION=pgsql
DB_HOST=localhost
DB_PORT=5432
DB_DATABASE=halolight
DB_USERNAME=postgres
DB_PASSWORD=your_password
# JWT Secret
JWT_SECRET=your-super-secret-key-min-32-chars
JWT_TTL=10080 # 7 days, in minutes
# Service Config
APP_PORT=8080
APP_ENV=development
APP_DEBUG=true
# Generate application key
php artisan key:generate
# Run migrations
php artisan migrate
# Seed data
php artisan db:seed
# Development mode
php artisan serve --port=8080
# Production mode
php artisan optimize
php artisan serve --port=8080 --env=production
Visit http://localhost:8080
halolight-api-php/
├── app/
│ ├── Http/
│ │ ├── Controllers/ # Controllers/Route handlers
│ │ ├── Middleware/ # Middleware
│ │ └── Requests/ # Request validation
│ ├── Services/ # Business logic layer
│ ├── Models/ # Data models
│ ├── Enums/ # Enum types
│ └── Providers/ # Service providers
├── database/
│ ├── migrations/ # Database migrations
│ └── seeders/ # Seed data
├── tests/ # Test files
├── Dockerfile # Docker configuration
├── docker-compose.yml
└── composer.json
| Method | Path | Description | Permission |
|---|---|---|---|
| POST | /api/auth/login |
User login | Public |
| POST | /api/auth/register |
User registration | Public |
| POST | /api/auth/refresh |
Refresh token | Public |
| POST | /api/auth/logout |
Logout | Authenticated |
| POST | /api/auth/forgot-password |
Forgot password | Public |
| POST | /api/auth/reset-password |
Reset password | Public |
| GET | /api/auth/me |
Get current user | Authenticated |
| Method | Path | Description | Permission |
|---|---|---|---|
| GET | /api/users |
Get user list | users:view |
| GET | /api/users/:id |
Get user details | users:view |
| POST | /api/users |
Create user | users:create |
| PUT | /api/users/:id |
Update user | users:update |
| DELETE | /api/users/:id |
Delete user | users:delete |
| GET | /api/users/me |
Get current user | Authenticated |
| PATCH | /api/users/:id/status |
Update user status | users:update |
| Method | Path | Description |
|---|---|---|
| GET | /api/roles |
Get role list |
| GET | /api/roles/:id |
Get role details |
| POST | /api/roles |
Create role |
| PUT | /api/roles/:id |
Update role |
| DELETE | /api/roles/:id |
Delete role |
| POST | /api/roles/:id/permissions |
Assign permissions |
| Method | Path | Description |
|---|---|---|
| GET | /api/permissions |
Get permission list |
| GET | /api/permissions/:id |
Get permission details |
| POST | /api/permissions |
Create permission |
| DELETE | /api/permissions/:id |
Delete permission |
| Method | Path | Description |
|---|---|---|
| GET | /api/teams |
Get team list |
| GET | /api/teams/:id |
Get team details |
| POST | /api/teams |
Create team |
| PUT | /api/teams/:id |
Update team |
| DELETE | /api/teams/:id |
Delete team |
| POST | /api/teams/:id/members |
Add member |
| DELETE | /api/teams/:id/members/:userId |
Remove member |
| Method | Path | Description |
|---|---|---|
| GET | /api/documents |
Get document list |
| GET | /api/documents/:id |
Get document details |
| POST | /api/documents |
Create document |
| PUT | /api/documents/:id |
Update document |
| DELETE | /api/documents/:id |
Delete document |
| POST | /api/documents/:id/share |
Share document |
| GET | /api/documents/:id/versions |
Get version history |
| POST | /api/documents/:id/restore |
Restore version |
| POST | /api/documents/:id/duplicate |
Duplicate document |
| Method | Path | Description |
|---|---|---|
| GET | /api/files |
Get file list |
| GET | /api/files/:id |
Get file details |
| POST | /api/files/upload |
Upload file |
| PUT | /api/files/:id |
Update file info |
| DELETE | /api/files/:id |
Delete file |
| GET | /api/files/:id/download |
Download file |
| POST | /api/files/:id/move |
Move file |
| POST | /api/files/:id/copy |
Copy file |
| GET | /api/files/:id/preview |
Preview file |
| Method | Path | Description |
|---|---|---|
| GET | /api/folders |
Get folder list |
| GET | /api/folders/:id |
Get folder details |
| POST | /api/folders |
Create folder |
| PUT | /api/folders/:id |
Update folder |
| DELETE | /api/folders/:id |
Delete folder |
| Method | Path | Description |
|---|---|---|
| GET | /api/messages |
Get message list |
| GET | /api/messages/:id |
Get message details |
| POST | /api/messages |
Send message |
| PUT | /api/messages/:id/read |
Mark as read |
| DELETE | /api/messages/:id |
Delete message |
| Method | Path | Description |
|---|---|---|
| GET | /api/notifications |
Get notification list |
| GET | /api/notifications/:id |
Get notification details |
| PUT | /api/notifications/:id/read |
Mark as read |
| PUT | /api/notifications/read-all |
Mark all as read |
| DELETE | /api/notifications/:id |
Delete notification |
| Method | Path | Description |
|---|---|---|
| GET | /api/calendar/events |
Get event list |
| GET | /api/calendar/events/:id |
Get event details |
| POST | /api/calendar/events |
Create event |
| PUT | /api/calendar/events/:id |
Update event |
| DELETE | /api/calendar/events/:id |
Delete event |
| POST | /api/calendar/events/:id/attendees |
Add attendee |
| DELETE | /api/calendar/events/:id/attendees/:userId |
Remove attendee |
| GET | /api/calendar/availability |
Check availability |
| Method | Path | Description |
|---|---|---|
| GET | /api/dashboard/stats |
Statistics data |
| GET | /api/dashboard/visits |
Visit trends |
| GET | /api/dashboard/sales |
Sales data |
| GET | /api/dashboard/pie |
Pie chart data |
| GET | /api/dashboard/tasks |
Todo tasks |
| GET | /api/dashboard/calendar |
Today's schedule |
| GET | /api/dashboard/notifications |
Latest notifications |
| GET | /api/dashboard/activity |
Activity log |
| GET | /api/dashboard/overview |
Overview data |
Access Token: 7 days validity, for API requests
Refresh Token: 30 days validity, for refreshing Access Token
Authorization: Bearer <access_token>
<?php
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class AuthController extends Controller
{
public function refresh(Request $request)
{
$refreshToken = $request->input('refreshToken');
// Validate Refresh Token
try {
auth()->setToken($refreshToken)->authenticate();
// Generate new Access Token
$newAccessToken = auth()->refresh();
return response()->json([
'accessToken' => $newAccessToken,
'refreshToken' => $refreshToken, // Optional: can also generate new Refresh Token
'expiresIn' => auth()->factory()->getTTL() * 60
]);
} catch (\Exception $e) {
return response()->json([
'error' => 'Invalid refresh token'
], 401);
}
}
}
| Role | Description | Permissions |
|---|---|---|
super_admin |
Super Administrator | * (all permissions) |
admin |
Administrator | users:*, documents:*, files:*, teams:* |
user |
Regular User | documents:view, files:view, calendar:* |
guest |
Guest | dashboard:view |
{resource}:{action}
Examples:
- users:view # View users
- users:create # Create user
- users:* # All user operations
- * # All permissions
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request parameter validation failed",
"details": [
{ "field": "email", "message": "Invalid email format" }
]
}
}
| Status | Error Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR |
Parameter validation failed |
| 401 | UNAUTHORIZED |
Unauthorized |
| 403 | FORBIDDEN |
No permission |
| 404 | NOT_FOUND |
Resource not found |
| 409 | CONFLICT |
Resource conflict |
| 422 | UNPROCESSABLE_ENTITY |
Unprocessable entity |
| 500 | INTERNAL_ERROR |
Server error |
# Development
php artisan serve --port=8080 # Start development server
php artisan tinker # Enter REPL environment
# Build
php artisan optimize # Optimize application
php artisan config:cache # Cache configuration
php artisan route:cache # Cache routes
# Testing
php artisan test # Run tests
php artisan test --coverage # Run tests with coverage report
# Database
php artisan migrate # Run migrations
php artisan migrate:fresh --seed # Reset and seed data
php artisan db:seed # Seed data
# Code quality
composer lint # Laravel Pint code style check
composer analyse # PHPStan static analysis
docker build -t halolight-api-php .
docker run -p 8080:8080 halolight-api-php
docker-compose up -d
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- APP_ENV=production
- APP_DEBUG=false
- DB_CONNECTION=pgsql
- DB_HOST=db
- DB_DATABASE=halolight
- DB_USERNAME=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- REDIS_HOST=redis
- JWT_SECRET=${JWT_SECRET}
depends_on:
- db
- redis
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: halolight
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
postgres_data:
redis_data:
APP_ENV=production
APP_DEBUG=false
APP_URL=https://halolight-api-php.h7ml.cn
DB_CONNECTION=pgsql
DB_HOST=your-db-host
DB_DATABASE=halolight
DB_USERNAME=your-db-user
DB_PASSWORD=your-db-password
REDIS_HOST=your-redis-host
REDIS_PASSWORD=your-redis-password
JWT_SECRET=your-production-secret-min-32-chars
JWT_TTL=10080
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
php artisan test
php artisan test --coverage
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
class AuthTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_login_with_correct_credentials()
{
$user = User::factory()->create([
'email' => '[email protected]',
'password' => bcrypt('password123')
]);
$response = $this->postJson('/api/auth/login', [
'email' => '[email protected]',
'password' => 'password123'
]);
$response->assertStatus(200)
->assertJsonStructure([
'accessToken',
'refreshToken',
'user'
]);
}
public function test_user_cannot_login_with_incorrect_password()
{
$user = User::factory()->create([
'email' => '[email protected]',
'password' => bcrypt('password123')
]);
$response = $this->postJson('/api/auth/login', [
'email' => '[email protected]',
'password' => 'wrong-password'
]);
$response->assertStatus(401)
->assertJson([
'error' => 'Unauthorized'
]);
}
}
| Metric | Value | Description |
|---|---|---|
| Request Throughput | ~1,500 QPS | Single core, using Swoole/Octane |
| Average Response Time | ~15ms | Simple query, PostgreSQL |
| Memory Usage | ~50MB | Single process, no cache |
| CPU Usage | ~40% | High load, 4 cores |
<?php
use Illuminate\Support\Facades\Log;
// Configure log channels
// config/logging.php
return [
'default' => env('LOG_CHANNEL', 'stack'),
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['single', 'daily'],
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'days' => 14,
],
],
];
// Use logging
Log::info('User logged in', ['user_id' => $user->id]);
Log::error('Payment failed', ['error' => $exception->getMessage()]);
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
class HealthController extends Controller
{
public function check()
{
$status = [
'status' => 'healthy',
'timestamp' => now()->toIso8601String(),
'services' => []
];
// Check database
try {
DB::connection()->getPdo();
$status['services']['database'] = 'healthy';
} catch (\Exception $e) {
$status['status'] = 'unhealthy';
$status['services']['database'] = 'unhealthy';
}
// Check Redis
try {
Redis::ping();
$status['services']['redis'] = 'healthy';
} catch (\Exception $e) {
$status['status'] = 'unhealthy';
$status['services']['redis'] = 'unhealthy';
}
return response()->json($status);
}
}
<?php
namespace App\Http\Middleware;
use Illuminate\Support\Facades\Cache;
class MetricsMiddleware
{
public function handle($request, $next)
{
$start = microtime(true);
$response = $next($request);
$duration = microtime(true) - $start;
// Record request metrics
Cache::increment('metrics:requests_total');
Cache::increment("metrics:requests_{$response->status()}");
// Record response time (can use Prometheus or other tools)
$this->recordDuration($request->path(), $duration);
return $response;
}
private function recordDuration($path, $duration)
{
// Implement metrics recording logic
}
}
A: Laravel provides convenient file upload handling:
<?php
namespace App\Http\Controllers\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class FileController extends Controller
{
public function upload(Request $request)
{
$request->validate([
'file' => 'required|file|max:10240', // 10MB
]);
$file = $request->file('file');
$path = $file->store('uploads', 'public');
return response()->json([
'path' => Storage::url($path),
'name' => $file->getClientOriginalName(),
'size' => $file->getSize(),
'mime' => $file->getMimeType()
]);
}
}
A: Use Laravel's DB facade or Eloquent models:
<?php
use Illuminate\Support\Facades\DB;
use App\Models\Order;
use App\Models\Payment;
// Method 1: DB facade
DB::transaction(function () {
$order = Order::create([...]);
$payment = Payment::create([
'order_id' => $order->id,
...
]);
});
// Method 2: Manual control
DB::beginTransaction();
try {
$order = Order::create([...]);
$payment = Payment::create([...]);
DB::commit();
} catch (\Exception $e) {
DB::rollback();
throw $e;
}
| Feature | Laravel | NestJS | FastAPI | Spring Boot |
|---|---|---|---|---|
| Language | PHP | TypeScript | Python | Java |
| ORM | Eloquent | Prisma | SQLAlchemy | JPA |
| Performance | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| Learning Curve | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
| Ecosystem | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Development Speed | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
HaloLight FastAPI backend API is built on FastAPI 0.115+, providing modern asynchronous Python backend service.
API Documentation: https://halolight-api-python.h7ml.cn/api/docs
GitHub: https://github.com/halolight/halolight-api-python
| Technology | Version | Description |
|---|---|---|
| Python | 3.11+ | Runtime |
| FastAPI | 0.115+ | Web Framework |
| SQLAlchemy | 2.0+ | Database ORM |
| PostgreSQL | 16 | Data Storage |
| Pydantic | v2 | Data Validation |
| JWT | python-jose | Authentication |
| Swagger UI | - | API Documentation |
# Clone repository
git clone https://github.com/halolight/halolight-api-python.git
cd halolight-api-python
# Create virtual environment
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# Install dependencies
pip install -e .
cp .env.example .env
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/halolight_db
# JWT Secret
JWT_SECRET=your-super-secret-key
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
# Service Config
PORT=8000
NODE_ENV=development
alembic upgrade head # Run migrations
python scripts/seed.py # Seed data
# Development mode
uvicorn app.main:app --reload --port 8000
# Production mode
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
Visit http://localhost:8000
halolight-api-python/
├── app/
│ ├── api/ # Controllers/Route handlers
│ │ ├── auth.py # Auth endpoints
│ │ ├── users.py # User management
│ │ └── ...
│ ├── services/ # Business logic layer
│ ├── models/ # Data models
│ ├── schemas/ # Request validation
│ ├── core/ # Utility functions
│ └── main.py # App entry
├── alembic/ # Database migrations/Schema
├── tests/ # Test files
├── Dockerfile # Docker config
├── docker-compose.yml
└── pyproject.toml
| Method | Path | Description | Permission |
|---|---|---|---|
| POST | /api/auth/login |
User login | Public |
| POST | /api/auth/register |
User registration | Public |
| POST | /api/auth/refresh |
Refresh token | Public |
| POST | /api/auth/logout |
Logout | Authenticated |
| POST | /api/auth/forgot-password |
Forgot password | Public |
| POST | /api/auth/reset-password |
Reset password | Public |
| Method | Path | Description | Permission |
|---|---|---|---|
| GET | /api/users |
Get user list | users:view |
| GET | /api/users/:id |
Get user details | users:view |
| POST | /api/users |
Create user | users:create |
| PUT | /api/users/:id |
Update user | users:update |
| DELETE | /api/users/:id |
Delete user | users:delete |
| GET | /api/users/me |
Get current user | Authenticated |
| Method | Path | Description |
|---|---|---|
| GET | /api/documents |
Get document list |
| GET | /api/documents/:id |
Get document details |
| POST | /api/documents |
Create document |
| PUT | /api/documents/:id |
Update document |
| DELETE | /api/documents/:id |
Delete document |
| Method | Path | Description |
|---|---|---|
| GET | /api/files |
Get file list |
| GET | /api/files/:id |
Get file details |
| POST | /api/files/upload |
Upload file |
| PUT | /api/files/:id |
Update file info |
| DELETE | /api/files/:id |
Delete file |
| Method | Path | Description |
|---|---|---|
| GET | /api/messages |
Get message list |
| GET | /api/messages/:id |
Get message details |
| POST | /api/messages |
Send message |
| PUT | /api/messages/:id/read |
Mark as read |
| DELETE | /api/messages/:id |
Delete message |
| Method | Path | Description |
|---|---|---|
| GET | /api/notifications |
Get notification list |
| PUT | /api/notifications/:id/read |
Mark as read |
| PUT | /api/notifications/read-all |
Mark all as read |
| DELETE | /api/notifications/:id |
Delete notification |
| Method | Path | Description |
|---|---|---|
| GET | /api/calendar/events |
Get event list |
| GET | /api/calendar/events/:id |
Get event details |
| POST | /api/calendar/events |
Create event |
| PUT | /api/calendar/events/:id |
Update event |
| DELETE | /api/calendar/events/:id |
Delete event |
| Method | Path | Description |
|---|---|---|
| GET | /api/dashboard/stats |
Statistics data |
| GET | /api/dashboard/visits |
Visit trends |
| GET | /api/dashboard/sales |
Sales data |
| GET | /api/dashboard/pie |
Pie chart data |
| GET | /api/dashboard/tasks |
Task list |
| GET | /api/dashboard/calendar |
Today's schedule |
Access Token: 15 minutes validity, used for API requests
Refresh Token: 7 days validity, used to refresh Access Token
Authorization: Bearer <access_token>
# Token refresh example
import requests
response = requests.post(
'http://localhost:8000/api/auth/refresh',
json={'refreshToken': refresh_token}
)
new_tokens = response.json()
| Role | Description | Permissions |
|---|---|---|
super_admin |
Super Admin | * (all permissions) |
admin |
Administrator | users:*, documents:*, ... |
user |
Regular User | documents:view, files:view, ... |
guest |
Guest | dashboard:view |
{resource}:{action}
Examples:
- users:view # View users
- users:create # Create user
- users:* # All user operations
- * # All permissions
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request parameter validation failed",
"details": [
{ "field": "email", "message": "Invalid email format" }
]
}
}
| Status Code | Error Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR |
Parameter validation failed |
| 401 | UNAUTHORIZED |
Unauthorized |
| 403 | FORBIDDEN |
Forbidden |
| 404 | NOT_FOUND |
Resource not found |
| 409 | CONFLICT |
Resource conflict |
| 500 | INTERNAL_ERROR |
Server error |
# app/models/user.py
from sqlalchemy import Column, Integer, String, DateTime
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String, unique=True, nullable=False)
username = Column(String, unique=True, nullable=False)
hashed_password = Column(String, nullable=False)
role = Column(String, default="user")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, onupdate=func.now())
# app/models/document.py
from sqlalchemy import Column, Integer, String, Text, ForeignKey
from sqlalchemy.orm import relationship
from app.core.database import Base
class Document(Base):
__tablename__ = "documents"
id = Column(Integer, primary_key=True)
title = Column(String, nullable=False)
content = Column(Text)
author_id = Column(Integer, ForeignKey("users.id"))
author = relationship("User", back_populates="documents")
| Variable | Description | Default |
|---|---|---|
DATABASE_URL |
Database connection string | sqlite:///./halolight.db |
JWT_SECRET |
JWT signing key | - |
JWT_ACCESS_EXPIRES |
Access Token expiry | 15m |
JWT_REFRESH_EXPIRES |
Refresh Token expiry | 7d |
PORT |
Service port | 8000 |
NODE_ENV |
Runtime environment | development |
CORS_ORIGINS |
CORS allowed origins | ["http://localhost:3000"] |
{
"success": true,
"data": {
"id": 1,
"name": "Example data"
},
"message": "Operation successful"
}
{
"success": true,
"data": {
"items": [...],
"total": 100,
"page": 1,
"pageSize": 10,
"totalPages": 10
}
}
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "Error description",
"details": []
}
}
docker build -t halolight-api-python .
docker run -p 8000:8000 halolight-api-python
docker-compose up -d
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "8000:8000"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: halolight
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
NODE_ENV=production
DATABASE_URL=postgresql://user:pass@host:5432/db
JWT_SECRET=your-production-secret
pytest
pytest --cov=app tests/
def test_login_success(client):
response = client.post(
"/api/auth/login",
json={"email": "[email protected]", "password": "123456"}
)
assert response.status_code == 200
assert "accessToken" in response.json()
def test_get_users_with_permission(client, admin_token):
response = client.get(
"/api/users",
headers={"Authorization": f"Bearer {admin_token}"}
)
assert response.status_code == 200
assert isinstance(response.json()["data"], list)
| Metric | Value | Description |
|---|---|---|
| Request Throughput | 5000+ QPS | Single-core uvicorn |
| Avg Response Time | < 10ms | Simple queries |
| Memory Usage | ~100MB | Base runtime |
| CPU Usage | 30-50% | High load |
import logging
logger = logging.getLogger(__name__)
logger.info("User logged in", extra={"user_id": user.id})
@app.get("/health")
async def health_check():
return {"status": "ok", "timestamp": datetime.now()}
# Prometheus metrics endpoint
from prometheus_fastapi_instrumentator import Instrumentator
Instrumentator().instrument(app).expose(app)
# Development
uvicorn app.main:app --reload --port 8000
# Build
pip install -e .
# Testing
pytest
pytest --cov=app tests/
# Database
alembic upgrade head
alembic revision --autogenerate -m "description"
# Code Quality
black app tests
ruff check app tests --fix
A: Configure SQLAlchemy connection pool in core/database.py
engine = create_engine(
DATABASE_URL,
pool_size=10,
max_overflow=20,
pool_timeout=30
)
A: Configure CORS middleware in main.py
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
A: Use FastAPI's UploadFile type
from fastapi import UploadFile, File
@app.post("/api/upload")
async def upload_file(file: UploadFile = File(...)):
contents = await file.read()
# Process file contents
return {"filename": file.filename}
FastAPI is based on Python's asyncio, supporting high-concurrency asynchronous operations:
@app.get("/api/async-example")
async def async_endpoint():
result = await async_database_query()
return result
FastAPI automatically generates OpenAPI (Swagger) documentation with no extra configuration:
/docs/redoc/openapi.jsonfrom fastapi import Depends
def get_current_user(token: str = Depends(oauth2_scheme)):
return verify_token(token)
@app.get("/api/protected")
async def protected_route(user = Depends(get_current_user)):
return {"user": user}
| Feature | FastAPI | NestJS | Go Fiber | Spring Boot |
|---|---|---|---|---|
| Language | Python | TypeScript | Go | Java |
| ORM | SQLAlchemy | Prisma | GORM | JPA |
| Performance | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| Learning Curve | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
HaloLight Astro version is built on Astro 5, featuring Islands architecture with zero JS initial load and ultimate performance, supporting multi-framework component integration.
Live Preview: https://halolight-astro.h7ml.cn/
GitHub: https://github.com/halolight/halolight-astro
| Technology | Version | Description |
|---|---|---|
| Astro | 5.x | Islands architecture framework |
| TypeScript | 5.x | Type safety |
| Tailwind CSS | 3.x | Atomic CSS |
| Vite | Built-in | Build tool |
| @astrojs/node | 9.x | Node.js adapter |
| Vitest | 4.x | Unit testing |
halolight-astro/
├── src/
│ ├── pages/ # File-based routing
│ │ ├── index.astro # Home
│ │ ├── privacy.astro # Privacy Policy
│ │ ├── terms.astro # Terms of Service
│ │ ├── auth/ # Auth pages
│ │ │ ├── login.astro
│ │ │ ├── register.astro
│ │ │ ├── forgot-password.astro
│ │ │ └── reset-password.astro
│ │ ├── dashboard/ # Dashboard pages
│ │ │ ├── index.astro # Dashboard home
│ │ │ ├── analytics.astro # Analytics
│ │ │ ├── users.astro # User management
│ │ │ ├── accounts.astro # Account management
│ │ │ ├── documents.astro # Document management
│ │ │ ├── files.astro # File management
│ │ │ ├── messages.astro # Message center
│ │ │ ├── notifications.astro
│ │ │ ├── calendar.astro # Calendar
│ │ │ ├── profile.astro # Profile
│ │ │ └── settings/ # Settings
│ │ └── api/ # API endpoints
│ │ └── auth/
│ │ ├── login.ts
│ │ ├── register.ts
│ │ ├── forgot-password.ts
│ │ └── reset-password.ts
│ ├── layouts/ # Layout components
│ │ ├── Layout.astro # Base layout
│ │ ├── AuthLayout.astro # Auth layout
│ │ ├── DashboardLayout.astro # Dashboard layout
│ │ └── LegalLayout.astro # Legal pages layout
│ ├── components/ # UI components
│ │ └── dashboard/
│ │ ├── Sidebar.astro # Sidebar
│ │ └── Header.astro # Top navigation
│ ├── styles/ # Global styles
│ │ └── globals.css
│ └── assets/ # Static assets
├── public/ # Public assets
├── tests/ # Test files
├── astro.config.mjs # Astro config
├── tailwind.config.mjs # Tailwind config
├── vitest.config.ts # Test config
└── package.json
git clone https://github.com/halolight/halolight-astro.git
cd halolight-astro
pnpm install
cp .env.example .env.local
# .env.local example
PUBLIC_API_URL=/api
PUBLIC_MOCK=true
[email protected]
PUBLIC_DEMO_PASSWORD=123456
PUBLIC_SHOW_DEMO_HINT=true
PUBLIC_APP_TITLE=Admin Pro
PUBLIC_BRAND_NAME=Halolight
pnpm dev
Visit http://localhost:4321
pnpm build
pnpm preview
| Role | Password | |
|---|---|---|
| Admin | [email protected] | 123456 |
| User | [email protected] | 123456 |
Astro's Islands architecture allows pages to be static HTML by default, with JavaScript only added to interactive components:
---
// Static import, no JS
import StaticCard from '../components/StaticCard.astro';
// Interactive component (can be from React/Vue/Svelte)
import Counter from '../components/Counter.tsx';
---
<!-- Pure static, zero JS -->
<StaticCard title="Statistics" />
<!-- Hydrate on page load -->
<Counter client:load />
<!-- Hydrate when visible (lazy load) -->
<Counter client:visible />
<!-- Hydrate when browser is idle -->
<Counter client:idle />
Client Directives:
| Directive | Behavior | Use Case |
|---|---|---|
client:load |
Hydrate immediately on page load | Critical interactions |
client:idle |
Hydrate when browser is idle | Non-critical interactions |
client:visible |
Hydrate when element is visible | Lazy-loaded components |
client:only |
Client-side rendering only | Browser API dependent |
client:media |
Hydrate when media query matches | Responsive components |
---
// layouts/DashboardLayout.astro
import Layout from './Layout.astro';
import Sidebar from '../components/dashboard/Sidebar.astro';
import Header from '../components/dashboard/Header.astro';
interface Props {
title: string;
description?: string;
}
const { title, description } = Astro.props;
const currentPath = Astro.url.pathname;
---
<Layout title={title} description={description}>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<Sidebar currentPath={currentPath} />
<div class="lg:pl-64">
<Header title={title} />
<main class="p-4 lg:p-6">
<slot />
</main>
</div>
</div>
</Layout>
Astro natively supports creating API endpoints:
// src/pages/api/auth/login.ts
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
const { email, password } = body;
// Validation logic
if (!email || !password) {
return new Response(
JSON.stringify({ success: false, message: 'Email and password are required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Authentication logic...
return new Response(
JSON.stringify({
success: true,
message: 'Login successful',
user: { id: 1, name: 'Admin', role: 'admin' },
token: 'mock_token',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
};
| File Path | URL | Description |
|---|---|---|
src/pages/index.astro |
/ |
Home |
src/pages/auth/login.astro |
/auth/login |
Login |
src/pages/dashboard/index.astro |
/dashboard |
Dashboard |
src/pages/dashboard/[id].astro |
/dashboard/:id |
Dynamic route |
src/pages/api/auth/login.ts |
/api/auth/login |
API endpoint |
| Path | Page | Permission |
|---|---|---|
/ |
Home | Public |
/auth/login |
Login | Public |
/auth/register |
Register | Public |
/auth/forgot-password |
Forgot Password | Public |
/auth/reset-password |
Reset Password | Public |
/dashboard |
Dashboard | dashboard:view |
/dashboard/analytics |
Analytics | analytics:view |
/dashboard/users |
User Management | users:view |
/dashboard/accounts |
Account Management | accounts:view |
/dashboard/documents |
Document Management | documents:view |
/dashboard/files |
File Management | files:view |
/dashboard/messages |
Message Center | messages:view |
/dashboard/notifications |
Notification Center | notifications:view |
/dashboard/calendar |
Calendar | calendar:view |
/dashboard/profile |
Profile | settings:view |
/dashboard/settings |
Settings | settings:view |
/privacy |
Privacy Policy | Public |
/terms |
Terms of Service | Public |
# .env
PUBLIC_API_URL=/api
PUBLIC_MOCK=true
PUBLIC_DEMO_EMAIL=[email protected]
PUBLIC_DEMO_PASSWORD=123456
PUBLIC_SHOW_DEMO_HINT=true
PUBLIC_APP_TITLE=Admin Pro
PUBLIC_BRAND_NAME=Halolight
| Variable | Description | Default |
|---|---|---|
PUBLIC_API_URL |
API base URL | /api |
PUBLIC_MOCK |
Enable mock data | true |
PUBLIC_APP_TITLE |
App title | Admin Pro |
PUBLIC_BRAND_NAME |
Brand name | Halolight |
PUBLIC_DEMO_EMAIL |
Demo account email | - |
PUBLIC_DEMO_PASSWORD |
Demo account password | - |
PUBLIC_SHOW_DEMO_HINT |
Show demo hint | false |
---
// In .astro files
const apiUrl = import.meta.env.PUBLIC_API_URL;
const isMock = import.meta.env.PUBLIC_MOCK === 'true';
---
// In .ts files
const apiUrl = import.meta.env.PUBLIC_API_URL;
# Development
pnpm dev # Start dev server (default port 4321)
pnpm dev --port 3000 # Specify port
# Build
pnpm build # Production build
pnpm preview # Preview production build
# Checks
pnpm astro check # Type check
pnpm lint # ESLint check
pnpm lint:fix # ESLint autofix
# Tests
pnpm test # Run tests
pnpm test:run # Single run
pnpm test:coverage # Coverage report
# Astro CLI
pnpm astro add react # Add React integration
pnpm astro add vue # Add Vue integration
pnpm astro add tailwind # Add Tailwind
pnpm astro add mdx # Add MDX support
# Run tests
pnpm test
# Generate coverage report
pnpm test --coverage
// tests/components/Counter.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from '../../src/components/Counter';
describe('Counter', () => {
it('renders with initial count', () => {
render(<Counter />);
expect(screen.getByText('0')).toBeInTheDocument();
});
it('increments count on button click', () => {
render(<Counter />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('1')).toBeInTheDocument();
});
});
// astro.config.mjs
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import node from '@astrojs/node';
export default defineConfig({
integrations: [tailwind()],
output: 'server', // SSR mode
adapter: node({
mode: 'standalone',
}),
server: {
port: 4321,
host: true,
},
});
| Mode | Description | Use Case |
|---|---|---|
static |
Static site generation (SSG) | Blogs, documentation |
server |
Server-side rendering (SSR) | Dynamic applications |
hybrid |
Hybrid mode | Partially dynamic |
# Install adapter
pnpm add @astrojs/vercel
# astro.config.mjs
import vercel from '@astrojs/vercel/serverless';
export default defineConfig({
output: 'server',
adapter: vercel(),
});
FROM node:20-alpine AS builder
RUN npm install -g pnpm
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]
Complete GitHub Actions CI workflow configuration:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm astro check
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
Astro's built-in content management system with type-safe Markdown/MDX content.
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blogCollection = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
author: z.string(),
tags: z.array(z.string()).optional(),
image: z.string().optional(),
}),
});
export const collections = {
blog: blogCollection,
};
---
// src/pages/blog/[...slug].astro
import { getCollection } from 'astro:content';
import BlogLayout from '../../layouts/BlogLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<BlogLayout title={post.data.title}>
<article>
<h1>{post.data.title}</h1>
<time>{post.data.pubDate.toLocaleDateString()}</time>
<Content />
</article>
</BlogLayout>
Native View Transitions API support for smooth page animations.
---
// src/layouts/Layout.astro
import { ViewTransitions } from 'astro:transitions';
---
<html>
<head>
<ViewTransitions />
</head>
<body>
<slot />
</body>
</html>
---
// Custom transition animations
---
<div transition:name="hero">
<h1 transition:animate="slide">Welcome</h1>
</div>
<style>
/* Custom animations */
@keyframes slide-in {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
::view-transition-old(hero) {
animation: slide-out 0.3s ease-out;
}
::view-transition-new(hero) {
animation: slide-in 0.3s ease-out;
}
</style>
Request interception and processing.
// src/middleware.ts
import { defineMiddleware, sequence } from 'astro:middleware';
// Authentication middleware
const auth = defineMiddleware(async (context, next) => {
const token = context.cookies.get('token')?.value;
// Protected routes
const protectedPaths = ['/dashboard', '/profile', '/settings'];
const isProtected = protectedPaths.some(path =>
context.url.pathname.startsWith(path)
);
if (isProtected && !token) {
return context.redirect('/auth/login');
}
// Pass user info to pages
if (token) {
context.locals.user = await verifyToken(token);
}
return next();
});
// Logger middleware
const logger = defineMiddleware(async (context, next) => {
const start = Date.now();
const response = await next();
const duration = Date.now() - start;
console.log(`${context.request.method} ${context.url.pathname} - ${duration}ms`);
return response;
});
// Compose middleware
export const onRequest = sequence(logger, auth);
---
import { Image } from 'astro:assets';
import myImage from '../assets/hero.png';
---
<!-- Auto-optimized images -->
<Image src={myImage} alt="Hero" width={800} height={600} />
<!-- Remote images -->
<Image
src="https://example.com/image.jpg"
alt="Remote"
width={400}
height={300}
inferSize
/>
---
// Use client:visible for lazy loading
import HeavyComponent from '../components/HeavyComponent';
---
<!-- Load only when element is visible -->
<HeavyComponent client:visible />
---
// Preload critical resources
---
<head>
<link rel="preload" href="/fonts/inter.woff2" as="font" crossorigin />
<link rel="preconnect" href="https://api.example.com" />
<link rel="dns-prefetch" href="https://cdn.example.com" />
</head>
---
// Dynamically import heavy components
const Chart = await import('../components/Chart.tsx');
---
<Chart.default client:visible data={data} />
A: Use nanostores or Zustand:
pnpm add nanostores @nanostores/react
// src/stores/counter.ts
import { atom } from 'nanostores';
export const $counter = atom(0);
export function increment() {
$counter.set($counter.get() + 1);
}
// React component
import { useStore } from '@nanostores/react';
import { $counter, increment } from '../stores/counter';
export function Counter() {
const count = useStore($counter);
return <button onClick={increment}>{count}</button>;
}
A: Use API endpoints:
---
// src/pages/contact.astro
---
<form method="POST" action="/api/contact">
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<button type="submit">Submit</button>
</form>
// src/pages/api/contact.ts
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
const data = await request.formData();
const email = data.get('email');
const message = data.get('message');
// Handle form data
await sendEmail({ email, message });
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
A: Use middleware + Cookies:
// src/middleware.ts
export const onRequest = defineMiddleware(async (context, next) => {
const token = context.cookies.get('auth-token')?.value;
if (context.url.pathname.startsWith('/dashboard') && !token) {
return context.redirect('/auth/login');
}
if (token) {
try {
const user = await verifyToken(token);
context.locals.user = user;
} catch {
context.cookies.delete('auth-token');
return context.redirect('/auth/login');
}
}
return next();
});
A: Optimization suggestions:
client: directive usage, prefer client:visible or client:idle@playform/compress to compress outputpnpm add @playform/compress
// astro.config.mjs
import compress from '@playform/compress';
export default defineConfig({
integrations: [compress()],
});
| Feature | Astro | Next.js | Vue |
|---|---|---|---|
| Default JS Size | 0 KB | ~80 KB | ~70 KB |
| Islands Architecture | Native support | Not supported | Not supported (Nuxt) |
| Multi-framework Components | Supported | Not supported | Not supported |
| SSG/SSR | Supported | Supported | Supported (Nuxt) |
| Learning Curve | Low | Medium | Medium |
HaloLight AWS deployment version, enterprise-grade deployment solution for AWS ecosystem.
# Install Amplify CLI
npm install -g @aws-amplify/cli
# Configure AWS credentials
amplify configure
# Initialize project
amplify init
# Add hosting
amplify add hosting
# Deploy
amplify publish
version: 1
frontend:
phases:
preBuild:
commands:
- npm ci
build:
commands:
- npm run build
artifacts:
baseDirectory: .next
files:
- '**/*'
cache:
paths:
- node_modules/**/*
- .next/cache/**/*
// edge-functions/auth.ts
export async function handler(event: any) {
const request = event.Records[0].cf.request
const headers = request.headers
// Verify authentication
if (!headers.authorization) {
return {
status: '401',
statusDescription: 'Unauthorized',
body: 'Unauthorized',
}
}
return request
}
{
"Origins": {
"Items": [
{
"DomainName": "halolight.s3.amazonaws.com",
"S3OriginConfig": {
"OriginAccessIdentity": ""
}
}
]
},
"DefaultCacheBehavior": {
"ViewerProtocolPolicy": "redirect-to-https",
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6"
}
}
Set in Amplify Console:
NEXT_PUBLIC_API_URL=https://api.example.com
AWS_REGION=ap-northeast-1
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"amplify:*",
"s3:*",
"cloudfront:*",
"lambda:*"
],
"Resource": "*"
}
]
}
HaloLight Azure deployment version, enterprise-grade deployment solution for Microsoft ecosystem.
# Login to Azure
az login
# Create resource group
az group create --name halolight-rg --location eastasia
# Create Static Web App
az staticwebapp create \
--name halolight \
--resource-group halolight-rg \
--source https://github.com/halolight/halolight-azure \
--branch main \
--app-location "/" \
--output-location ".next" \
--login-with-github
{
"navigationFallback": {
"rewrite": "/index.html",
"exclude": ["/api/*", "/_next/*", "/static/*"]
},
"routes": [
{
"route": "/api/*",
"allowedRoles": ["authenticated"]
}
],
"globalHeaders": {
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff"
},
"mimeTypes": {
".json": "application/json"
}
}
// api/hello/index.ts
import { AzureFunction, Context, HttpRequest } from "@azure/functions"
const httpTrigger: AzureFunction = async function (
context: Context,
req: HttpRequest
): Promise<void> {
context.res = {
status: 200,
body: { message: "Hello from Azure Functions!" }
}
}
export default httpTrigger
// Configure Azure AD authentication
const msalConfig = {
auth: {
clientId: process.env.AZURE_AD_CLIENT_ID,
authority: `https://login.microsoftonline.com/${process.env.AZURE_AD_TENANT_ID}`,
redirectUri: process.env.AZURE_AD_REDIRECT_URI,
},
}
Set in Azure Portal:
NEXT_PUBLIC_API_URL=https://your-app.azurestaticapps.net
AZURE_AD_CLIENT_ID=your-client-id
AZURE_AD_TENANT_ID=your-tenant-id
A type-safe API gateway built on tRPC 11 + Express 5, providing unified end-to-end type-safe interface layer for frontend applications.
API Documentation: https://halolight-bff.h7ml.cn
GitHub: https://github.com/halolight/halolight-bff
| Technology | Version | Description |
|---|---|---|
| TypeScript | 5.9 | Programming Language |
| tRPC | 11 | RPC Framework |
| Zod | - | Data Validation |
| Express | 5 | Web Server |
| SuperJSON | - | Serialization |
| JWT | - | Authentication |
| Pino | - | Logging System |
# Clone repository
git clone https://github.com/halolight/halolight-bff.git
cd halolight-bff
# Install dependencies
pnpm install
cp .env.example .env
# Server configuration
PORT=3002
HOST=0.0.0.0
NODE_ENV=development
# JWT secret (must change in production)
JWT_SECRET=your-super-secret-key-at-least-32-characters-long
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
# CORS configuration
CORS_ORIGIN=*
# Log level
LOG_LEVEL=info
# Backend service registry (configure at least one)
HALOLIGHT_API_PYTHON_URL=http://localhost:8000
HALOLIGHT_API_BUN_URL=http://localhost:3000
HALOLIGHT_API_JAVA_URL=http://localhost:8080
HALOLIGHT_API_NESTJS_URL=http://localhost:3001
HALOLIGHT_API_NODE_URL=http://localhost:3003
HALOLIGHT_API_GO_URL=http://localhost:8081
No database required (API gateway does not directly access database).
# Development mode (hot reload)
pnpm dev
# Production mode
pnpm build
pnpm start
Visit http://localhost:3002
halolight-bff/
├── src/
│ ├── index.ts # Application entry
│ ├── server.ts # Express server + tRPC adapter
│ ├── trpc.ts # tRPC instance and procedure definitions
│ ├── context.ts # Context creation (user, tracing, services)
│ ├── routers/
│ │ ├── index.ts # Root router (combining all modules)
│ │ ├── auth.ts # Authentication module (8 endpoints)
│ │ ├── users.ts # User management (8 endpoints)
│ │ ├── dashboard.ts # Dashboard statistics (9 endpoints)
│ │ ├── permissions.ts # Permission management (7 endpoints)
│ │ ├── roles.ts # Role management (8 endpoints)
│ │ ├── teams.ts # Team management (9 endpoints)
│ │ ├── folders.ts # Folder management (8 endpoints)
│ │ ├── files.ts # File management (9 endpoints)
│ │ ├── documents.ts # Document management (10 endpoints)
│ │ ├── calendar.ts # Calendar events (10 endpoints)
│ │ ├── notifications.ts # Notifications (7 endpoints)
│ │ └── messages.ts # Messaging/chat (9 endpoints)
│ ├── middleware/
│ │ └── auth.ts # JWT authentication/authorization middleware
│ ├── services/
│ │ ├── httpClient.ts # HTTP client (backend communication)
│ │ └── serviceRegistry.ts # Backend service registry
│ └── schemas/
│ ├── index.ts # Schema exports
│ └── common.ts # Common Zod schemas (pagination, sorting, response)
├── .env.example # Environment variables template
├── .github/workflows/ # CI/CD configuration
├── Dockerfile # Docker image build
├── docker-compose.yml # Docker Compose configuration
├── package.json # Dependencies configuration
└── tsconfig.json # TypeScript configuration
HaloLight BFF provides 12 core business modules covering 100+ tRPC endpoints:
| Module | Endpoints | Description |
|---|---|---|
| auth | 8 | Login, register, token refresh, logout, password management |
| users | 8 | User CRUD, role/status management, profile |
| dashboard | 9 | Statistics, visit trends, sales data, tasks, calendar |
| permissions | 7 | Permission CRUD, tree structure, module permissions, batch operations |
| roles | 8 | Role CRUD, permission assignment, user association |
| teams | 9 | Team CRUD, member management, invitations, permissions |
| folders | 8 | Folder CRUD, tree structure, move, breadcrumb |
| files | 9 | File CRUD, upload, download, move, copy, share |
| documents | 10 | Document CRUD, version control, collaboration, sharing |
| calendar | 10 | Event CRUD, attendee management, RSVP, reminders |
| notifications | 7 | Notification list, unread count, mark read, batch delete |
| messages | 9 | Conversation management, message CRUD, send, read status |
| Procedure | Type | Description | Permission |
|---|---|---|---|
auth.login |
mutation | User login | Public |
auth.register |
mutation | User registration | Public |
auth.refresh |
mutation | Refresh token | Public |
auth.logout |
mutation | Logout | Authenticated |
auth.forgotPassword |
mutation | Forgot password | Public |
auth.resetPassword |
mutation | Reset password | Public |
auth.verifyEmail |
mutation | Verify email | Public |
auth.changePassword |
mutation | Change password | Authenticated |
| Procedure | Type | Description | Permission |
|---|---|---|---|
users.list |
query | Get user list | users:view |
users.byId |
query | Get user details | users:view |
users.me |
query | Get current user | Authenticated |
users.create |
mutation | Create user | users:create |
users.update |
mutation | Update user | users:update |
users.delete |
mutation | Delete user | users:delete |
users.updateRole |
mutation | Update user role | users:update |
users.updateStatus |
mutation | Update user status | users:update |
| Procedure | Type | Description |
|---|---|---|
dashboard.getStats |
query | Statistics (users, documents, files, tasks) |
dashboard.getVisits |
query | Visit trends (7/30 days) |
dashboard.getSales |
query | Sales data (line chart) |
dashboard.getPieData |
query | Pie chart data (category distribution) |
dashboard.getTasks |
query | Todo task list |
dashboard.getCalendar |
query | Today's calendar |
dashboard.getActivities |
query | Recent activities |
dashboard.getNotifications |
query | Latest notifications |
dashboard.getProgress |
query | Project progress |
| Procedure | Type | Description |
|---|---|---|
permissions.list |
query | Get permission list |
permissions.tree |
query | Get permission tree |
permissions.byId |
query | Get permission details |
permissions.create |
mutation | Create permission |
permissions.update |
mutation | Update permission |
permissions.delete |
mutation | Delete permission |
permissions.modules |
query | Get permission modules |
| Procedure | Type | Description |
|---|---|---|
roles.list |
query | Get role list |
roles.byId |
query | Get role details |
roles.create |
mutation | Create role |
roles.update |
mutation | Update role |
roles.delete |
mutation | Delete role |
roles.assignPermissions |
mutation | Assign permissions |
roles.removePermissions |
mutation | Remove permissions |
roles.users |
query | Get users in role |
| Procedure | Type | Description |
|---|---|---|
teams.list |
query | Get team list |
teams.byId |
query | Get team details |
teams.create |
mutation | Create team |
teams.update |
mutation | Update team |
teams.delete |
mutation | Delete team |
teams.addMember |
mutation | Add member |
teams.removeMember |
mutation | Remove member |
teams.updateMemberRole |
mutation | Update member role |
teams.members |
query | Get team members |
| Procedure | Type | Description |
|---|---|---|
folders.list |
query | Get folder list |
folders.tree |
query | Get folder tree |
folders.byId |
query | Get folder details |
folders.create |
mutation | Create folder |
folders.update |
mutation | Update folder |
folders.delete |
mutation | Delete folder |
folders.move |
mutation | Move folder |
folders.breadcrumb |
query | Get breadcrumb path |
| Procedure | Type | Description |
|---|---|---|
files.list |
query | Get file list |
files.byId |
query | Get file details |
files.upload |
mutation | Upload file |
files.update |
mutation | Update file info |
files.delete |
mutation | Delete file |
files.move |
mutation | Move file |
files.copy |
mutation | Copy file |
files.download |
query | Get download link |
files.share |
mutation | Share file |
| Procedure | Type | Description |
|---|---|---|
documents.list |
query | Get document list |
documents.byId |
query | Get document details |
documents.create |
mutation | Create document |
documents.update |
mutation | Update document |
documents.delete |
mutation | Delete document |
documents.versions |
query | Get version history |
documents.restore |
mutation | Restore version |
documents.share |
mutation | Share document |
documents.unshare |
mutation | Unshare document |
documents.collaborators |
query | Get collaborators |
| Procedure | Type | Description |
|---|---|---|
calendar.events |
query | Get event list |
calendar.byId |
query | Get event details |
calendar.create |
mutation | Create event |
calendar.update |
mutation | Update event |
calendar.delete |
mutation | Delete event |
calendar.addAttendee |
mutation | Add attendee |
calendar.removeAttendee |
mutation | Remove attendee |
calendar.rsvp |
mutation | RSVP response |
calendar.setReminder |
mutation | Set reminder |
calendar.byMonth |
query | Get events by month |
| Procedure | Type | Description |
|---|---|---|
notifications.list |
query | Get notification list |
notifications.unreadCount |
query | Get unread count |
notifications.markRead |
mutation | Mark as read |
notifications.markAllRead |
mutation | Mark all as read |
notifications.delete |
mutation | Delete notification |
notifications.deleteAll |
mutation | Delete all |
notifications.preferences |
query | Get notification preferences |
| Procedure | Type | Description |
|---|---|---|
messages.conversations |
query | Get conversation list |
messages.byConversation |
query | Get conversation messages |
messages.send |
mutation | Send message |
messages.markRead |
mutation | Mark as read |
messages.delete |
mutation | Delete message |
messages.createConversation |
mutation | Create conversation |
messages.deleteConversation |
mutation | Delete conversation |
messages.search |
query | Search messages |
messages.unreadCount |
query | Get unread count |
tRPC provides three procedure types:
// Public endpoint - no authentication required
export const publicProcedure = t.procedure;
// Protected endpoint - requires valid JWT
export const protectedProcedure = t.procedure.use(isAuthenticated);
// Admin endpoint - requires admin role
export const adminProcedure = t.procedure.use(isAdmin);
Usage Example:
export const usersRouter = router({
// Query - fetch data
list: protectedProcedure
.input(z.object({
page: z.number().default(1),
limit: z.number().default(10),
keyword: z.string().optional(),
}))
.query(async ({ input, ctx }) => {
// ctx.user contains authenticated user info
const client = ctx.services.getDefault();
const data = await client.get('/api/users', { query: input });
return { code: 200, message: 'success', data };
}),
// Mutation - modify data
create: adminProcedure
.input(z.object({
name: z.string().min(2),
email: z.string().email(),
role: z.string(),
}))
.mutation(async ({ input, ctx }) => {
const client = ctx.services.getDefault();
const data = await client.post('/api/users', { body: input });
return { code: 201, message: 'Created', data };
}),
});
Each request creates an independent context:
interface Context {
req: Request; // Express request object
res: Response; // Express response object
user: JWTPayload | null; // Authenticated user (via JWT)
traceId: string; // Distributed tracing ID (UUID)
services: ServiceRegistry; // Backend service registry
}
Context Creation Flow:
Authorization headertraceId (for distributed tracing)ServiceRegistry (backend service collection)interface JWTPayload {
id: string; // User ID
name: string; // Username
email: string; // Email
role: {
id: string; // Role ID
name: string; // Role name (e.g., admin, user)
label: string; // Role display name
permissions: string[]; // Permission list (e.g., ["users:*", "documents:view"])
};
}
Token Usage:
// Client sends request
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
// Server automatically parses and injects into ctx.user
const userId = ctx.user.id;
const userPermissions = ctx.user.role.permissions;
Supports flexible wildcard permission matching:
| Permission Format | Description | Example |
|---|---|---|
* |
All permissions (super admin) | Can perform any operation |
{resource}:* |
All operations on module | users:* = all user module permissions |
{resource}:{action} |
Specific operation | users:view = view users only |
Permission Check Example:
// Check permission in middleware
export const requirePermission = (permission: string) => {
return t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const hasPermission = ctx.user.role.permissions.some(p =>
p === '*' ||
p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
);
if (!hasPermission) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return next();
});
};
// Usage
export const deleteUser = protectedProcedure
.use(requirePermission('users:delete'))
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
// Only users with users:delete permission can execute
});
Configure multiple backend services via environment variables:
# Python FastAPI
HALOLIGHT_API_PYTHON_URL=http://api-python:8000
# Bun Hono
HALOLIGHT_API_BUN_URL=http://api-bun:3000
# Java Spring Boot
HALOLIGHT_API_JAVA_URL=http://api-java:8080
# Go Fiber
HALOLIGHT_API_GO_URL=http://api-go:8081
Service Priority: By configuration order, the first available service is used as default.
Usage Example:
// Use default service (highest priority)
const client = ctx.services.getDefault();
const data = await client.get('/api/users');
// Use specific service
const pythonClient = ctx.services.get('python');
const stats = await pythonClient.get('/api/dashboard/stats');
// Failover: automatically switch to next service if default is unavailable
try {
const data = await ctx.services.getDefault().get('/api/users');
} catch (error) {
// ServiceRegistry automatically retries other services
}
All APIs follow a unified response structure:
// Standard response
interface APIResponse<T> {
code: number; // HTTP status code (200, 201, 400, 500...)
message: string; // Human-readable message (success, error, ...)
data: T | null; // Response data (on success) or null (on failure)
}
// Paginated response
interface PaginatedResponse<T> {
code: number;
message: string;
data: {
list: T[]; // Data list
total: number; // Total record count
page: number; // Current page number
limit: number; // Items per page
totalPages?: number; // Total pages (optional)
};
}
Examples:
// Success response
{
"code": 200,
"message": "success",
"data": {
"id": "1",
"name": "John Doe",
"email": "[email protected]"
}
}
// Paginated response
{
"code": 200,
"message": "success",
"data": {
"list": [{ "id": "1", "name": "User 1" }],
"total": 100,
"page": 1,
"limit": 10,
"totalPages": 10
}
}
// Error response (tRPC auto-formatted)
{
"error": {
"code": "UNAUTHORIZED",
"message": "Not authenticated"
}
}
Access Token: 15 minutes validity, used for API requests
Refresh Token: 7 days validity, used to refresh Access Token
Authorization: Bearer <access_token>
// Client example
const refreshToken = async () => {
const refreshToken = localStorage.getItem('refreshToken');
const result = await trpc.auth.refresh.mutate({ refreshToken });
localStorage.setItem('accessToken', result.data.accessToken);
localStorage.setItem('refreshToken', result.data.refreshToken);
return result.data.accessToken;
};
// tRPC client configuration - auto refresh
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3002/trpc',
async headers() {
let token = localStorage.getItem('accessToken');
// Auto refresh if token expired
if (isTokenExpired(token)) {
token = await refreshToken();
}
return {
authorization: `Bearer ${token}`,
};
},
}),
],
});
import { TRPCError } from '@trpc/server';
// 400 - Bad request
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid input',
});
// 401 - Unauthorized
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Not authenticated',
});
// 403 - Forbidden
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Insufficient permissions',
});
// 404 - Not found
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Resource not found',
});
// 409 - Conflict
throw new TRPCError({
code: 'CONFLICT',
message: 'Email already exists',
});
// 500 - Internal server error
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Something went wrong',
});
{
"error": {
"code": "UNAUTHORIZED",
"message": "Not authenticated",
"data": {
"code": "UNAUTHORIZED",
"httpStatus": 401,
"path": "auth.login"
}
}
}
import { createTRPCReact } from '@trpc/react-query';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import type { AppRouter } from 'halolight-bff';
// Create tRPC React hooks
const trpc = createTRPCReact<AppRouter>();
// Create tRPC client
const trpcClient = trpc.createClient({
transformer: superjson, // Support Date, Map, Set, etc.
links: [
httpBatchLink({
url: 'http://localhost:3002/trpc',
headers() {
return {
authorization: `Bearer ${localStorage.getItem('token')}`,
};
},
}),
],
});
// Create React Query client
const queryClient = new QueryClient();
// Root component
function App() {
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<UserList />
</QueryClientProvider>
</trpc.Provider>
);
}
// Use tRPC hooks
function UserList() {
// Query - auto-managed loading state, caching, refetching
const { data, isLoading, error } = trpc.users.list.useQuery({
page: 1,
limit: 10,
});
// Mutation - auto-managed loading state, error handling
const createUser = trpc.users.create.useMutation({
onSuccess: () => {
// Auto refresh user list
trpc.users.list.invalidate();
},
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<button onClick={() => createUser.mutate({
name: 'New User',
email: '[email protected]',
role: 'user',
})}>
Create User
</button>
{data?.data.list.map(user => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
// app/api/trpc/[trpc]/route.ts - tRPC API route
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => ({}),
});
export { handler as GET, handler as POST };
// app/providers.tsx - tRPC Provider
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query';
import superjson from 'superjson';
import type { AppRouter } from '@/server/routers';
const trpc = createTRPCReact<AppRouter>();
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
transformer: superjson,
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
// app/page.tsx - Server Component
import { createCaller } from '@/server/routers';
export default async function Page() {
const caller = createCaller({ req: {}, res: {}, user: null });
const stats = await caller.dashboard.getStats();
return <div>Total Users: {stats.data.totalUsers}</div>;
}
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query';
import superjson from 'superjson';
import type { AppRouter } from 'halolight-bff';
// Create tRPC client
const trpc = createTRPCProxyClient<AppRouter>({
transformer: superjson,
links: [
httpBatchLink({
url: 'http://localhost:3002/trpc',
headers() {
return {
authorization: `Bearer ${localStorage.getItem('token')}`,
};
},
}),
],
});
// Use in component
export default {
setup() {
const queryClient = useQueryClient();
// Query
const { data, isLoading } = useQuery({
queryKey: ['users', { page: 1 }],
queryFn: () => trpc.users.list.query({ page: 1, limit: 10 }),
});
// Mutation
const createUser = useMutation({
mutationFn: (user) => trpc.users.create.mutate(user),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
return { data, isLoading, createUser };
},
};
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from 'halolight-bff';
import superjson from 'superjson';
const client = createTRPCProxyClient<AppRouter>({
transformer: superjson,
links: [
httpBatchLink({
url: 'http://localhost:3002/trpc',
headers() {
return {
authorization: `Bearer ${localStorage.getItem('token')}`,
};
},
}),
],
});
// Usage (full type inference)
const users = await client.users.list.query({ page: 1 });
console.log(users.data.list); // TS automatically infers type
const newUser = await client.users.create.mutate({
name: 'John',
email: '[email protected]',
role: 'user',
});
// src/routers/products.ts
import { z } from 'zod';
import { router, protectedProcedure, adminProcedure } from '../trpc';
export const productsRouter = router({
// Query - get product list
list: protectedProcedure
.input(z.object({
page: z.number().default(1),
limit: z.number().default(10),
category: z.string().optional(),
}))
.query(async ({ input, ctx }) => {
const client = ctx.services.getDefault();
const data = await client.get('/api/products', { query: input });
return { code: 200, message: 'success', data };
}),
// Query - get product details
byId: protectedProcedure
.input(z.object({
id: z.string(),
}))
.query(async ({ input, ctx }) => {
const client = ctx.services.getDefault();
const data = await client.get(`/api/products/${input.id}`);
return { code: 200, message: 'success', data };
}),
// Mutation - create product (requires admin permission)
create: adminProcedure
.input(z.object({
name: z.string().min(2),
price: z.number().positive(),
category: z.string(),
}))
.mutation(async ({ input, ctx }) => {
const client = ctx.services.getDefault();
const data = await client.post('/api/products', { body: input });
return { code: 201, message: 'Created', data };
}),
// Mutation - update product
update: adminProcedure
.input(z.object({
id: z.string(),
name: z.string().min(2).optional(),
price: z.number().positive().optional(),
}))
.mutation(async ({ input, ctx }) => {
const { id, ...updateData } = input;
const client = ctx.services.getDefault();
const data = await client.put(`/api/products/${id}`, { body: updateData });
return { code: 200, message: 'Updated', data };
}),
// Mutation - delete product
delete: adminProcedure
.input(z.object({
id: z.string(),
}))
.mutation(async ({ input, ctx }) => {
const client = ctx.services.getDefault();
await client.delete(`/api/products/${input.id}`);
return { code: 200, message: 'Deleted', data: null };
}),
});
// src/routers/index.ts
import { router } from '../trpc';
import { authRouter } from './auth';
import { usersRouter } from './users';
import { productsRouter } from './products'; // Import new router
export const appRouter = router({
auth: authRouter,
users: usersRouter,
products: productsRouter, // Register new router
// ... other routers
});
export type AppRouter = typeof appRouter;
// Type auto-inference, no manual definition needed
const products = await trpc.products.list.query({ page: 1 });
const product = await trpc.products.byId.query({ id: '1' });
const newProduct = await trpc.products.create.mutate({
name: 'iPhone 15',
price: 999,
category: 'electronics',
});
// src/middleware/rateLimit.ts
import { TRPCError } from '@trpc/server';
import { t } from '../trpc';
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
export const rateLimit = (maxRequests: number, windowMs: number) => {
return t.middleware(({ ctx, next }) => {
const key = ctx.user?.id || ctx.req.ip;
const now = Date.now();
const record = rateLimitMap.get(key);
if (!record || now > record.resetAt) {
rateLimitMap.set(key, { count: 1, resetAt: now + windowMs });
return next();
}
if (record.count >= maxRequests) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Rate limit exceeded',
});
}
record.count++;
return next();
});
};
// Usage
export const limitedProcedure = protectedProcedure.use(
rateLimit(10, 60000) // Max 10 requests per minute
);
// src/schemas/product.ts
import { z } from 'zod';
export const productSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2).max(100),
price: z.number().positive(),
category: z.enum(['electronics', 'clothing', 'books']),
stock: z.number().int().nonnegative(),
createdAt: z.date(),
updatedAt: z.date(),
});
export const createProductSchema = productSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const updateProductSchema = createProductSchema.partial();
// Use in router
export const productsRouter = router({
create: adminProcedure
.input(createProductSchema)
.mutation(async ({ input, ctx }) => {
// input is already validated by Zod, type-safe
}),
update: adminProcedure
.input(z.object({
id: z.string(),
data: updateProductSchema,
}))
.mutation(async ({ input, ctx }) => {
// ...
}),
});
# Development
pnpm dev # Start dev server (hot reload)
pnpm dev:watch # Start dev server (file watch)
# Build
pnpm build # Build for production
pnpm start # Start production server
# Testing
pnpm test # Run tests
pnpm test:watch # Run tests in watch mode
pnpm test:coverage # Generate test coverage
# Code quality
pnpm lint # Run ESLint
pnpm lint:fix # Auto-fix lint errors
pnpm type-check # TypeScript type checking
pnpm format # Prettier code formatting
# Build image
docker build -t halolight-bff .
# Run container
docker run -p 3002:3002 \
-e JWT_SECRET=your-secret-key \
-e HALOLIGHT_API_PYTHON_URL=http://api-python:8000 \
halolight-bff
# docker-compose.yml
version: '3.8'
services:
bff:
build: .
ports:
- "3002:3002"
environment:
- NODE_ENV=production
- JWT_SECRET=${JWT_SECRET}
- HALOLIGHT_API_PYTHON_URL=http://api-python:8000
- HALOLIGHT_API_BUN_URL=http://api-bun:3000
- HALOLIGHT_API_JAVA_URL=http://api-java:8080
depends_on:
- api-python
- api-bun
- api-java
restart: unless-stopped
api-python:
image: halolight-api-python
ports:
- "8000:8000"
api-bun:
image: halolight-api-bun
ports:
- "3000:3000"
api-java:
image: halolight-api-java
ports:
- "8080:8080"
docker-compose up -d
NODE_ENV=production
PORT=3002
HOST=0.0.0.0
# Strong secret (at least 32 characters)
JWT_SECRET=your-production-secret-key-with-at-least-32-characters
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
# Restrict CORS
CORS_ORIGIN=https://your-frontend.com
# Production logging
LOG_LEVEL=warn
# Backend services
HALOLIGHT_API_PYTHON_URL=https://api-python.production.com
HALOLIGHT_API_BUN_URL=https://api-bun.production.com
HALOLIGHT_API_JAVA_URL=https://api-java.production.com
tRPC automatically batches multiple concurrent requests to reduce network overhead:
// Client configuration
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:3002/trpc',
maxURLLength: 2083, // Max URL length
}),
],
});
// These three requests are automatically batched into one HTTP request
const [users, stats, notifications] = await Promise.all([
trpc.users.list.query({ page: 1 }),
trpc.dashboard.getStats.query(),
trpc.notifications.unreadCount.query(),
]);
import DataLoader from 'dataloader';
// Create DataLoader
const userLoader = new DataLoader(async (ids: string[]) => {
const users = await db.user.findMany({
where: { id: { in: ids } },
});
return ids.map(id => users.find(u => u.id === id));
});
// Inject in context
export const createContext = (opts: CreateExpressContextOptions) => {
return {
...opts,
loaders: {
user: userLoader,
},
};
};
// Use in router
export const postsRouter = router({
list: protectedProcedure.query(async ({ ctx }) => {
const posts = await db.post.findMany();
// Batch load author info, avoid N+1 queries
const postsWithAuthors = await Promise.all(
posts.map(async (post) => ({
...post,
author: await ctx.loaders.user.load(post.authorId),
}))
);
return postsWithAuthors;
}),
});
// Use Redis caching
import Redis from 'ioredis';
const redis = new Redis();
export const dashboardRouter = router({
getStats: protectedProcedure.query(async ({ ctx }) => {
const cacheKey = `dashboard:stats:${ctx.user.id}`;
// Try to get from cache
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Fetch from backend service
const client = ctx.services.getDefault();
const data = await client.get('/api/dashboard/stats');
// Cache for 5 minutes
await redis.setex(cacheKey, 300, JSON.stringify(data));
return data;
}),
});
import rateLimit from 'express-rate-limit';
// Configure global rate limiting in Express
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Max 100 requests
message: 'Too many requests from this IP',
});
app.use('/trpc', limiter);
# Generate strong secret (at least 32 characters)
openssl rand -base64 32
# Configure in .env
JWT_SECRET=your-generated-secret-key-with-at-least-32-characters
// Force HTTPS in production
if (process.env.NODE_ENV === 'production') {
app.use((req, res, next) => {
if (!req.secure) {
return res.redirect(`https://${req.headers.host}${req.url}`);
}
next();
});
}
# Allow only specific origin
CORS_ORIGIN=https://your-frontend.com
# Or multiple origins (comma-separated)
CORS_ORIGIN=https://app1.com,https://app2.com
// Strictly validate all inputs with Zod
export const createUser = protectedProcedure
.input(z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
age: z.number().int().positive().max(150),
role: z.enum(['admin', 'user']),
}))
.mutation(async ({ input, ctx }) => {
// input is strictly validated
});
// Use Pino redact configuration
const logger = pino({
redact: {
paths: [
'req.headers.authorization',
'req.body.password',
'req.body.token',
'res.headers["set-cookie"]',
],
remove: true, // Completely remove sensitive fields
},
});
// Use Pino structured logging
import pino from 'pino';
import pinoHttp from 'pino-http';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
},
});
// HTTP request logging
app.use(pinoHttp({ logger }));
// Use in router
export const usersRouter = router({
create: adminProcedure.mutation(async ({ input, ctx }) => {
logger.info({ userId: ctx.user.id, input }, 'Creating user');
try {
const data = await createUser(input);
logger.info({ userId: ctx.user.id, data }, 'User created');
return data;
} catch (error) {
logger.error({ userId: ctx.user.id, error }, 'Failed to create user');
throw error;
}
}),
});
// Health check endpoint
app.get('/health', async (req, res) => {
try {
// Check backend service connections
const services = await Promise.all([
fetch(`${process.env.HALOLIGHT_API_PYTHON_URL}/health`),
fetch(`${process.env.HALOLIGHT_API_BUN_URL}/health`),
]);
const allHealthy = services.every(s => s.ok);
res.status(allHealthy ? 200 : 503).json({
status: allHealthy ? 'healthy' : 'unhealthy',
timestamp: new Date().toISOString(),
services: {
python: services[0].ok,
bun: services[1].ok,
},
});
} catch (error) {
res.status(503).json({
status: 'unhealthy',
error: error.message,
});
}
});
// Prometheus metrics
import promClient from 'prom-client';
// Create registry
const register = new promClient.Registry();
// Collect default metrics
promClient.collectDefaultMetrics({ register });
// Custom metrics
const httpRequestDuration = new promClient.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code'],
registers: [register],
});
// Expose metrics endpoint
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});
A: Change PORT in .env or terminate the process using the port:
# Find process using port
lsof -i :3002
# Kill process
kill -9 <PID>
# Or change port
echo "PORT=3003" >> .env
A: Update CORS_ORIGIN in .env to allow your origin:
# Development - allow all origins
CORS_ORIGIN=*
# Production - specify origin
CORS_ORIGIN=https://your-frontend.com
A: Ensure JWT_SECRET is consistent across all environments:
# Check JWT_SECRET consistency
echo $JWT_SECRET
# Regenerate token
curl -X POST http://localhost:3002/trpc/auth.login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"password"}'
A: Check if backend services are running and URLs are configured correctly:
# Check service health
curl http://localhost:8000/health
curl http://localhost:3000/health
# Check environment variables
echo $HALOLIGHT_API_PYTHON_URL
echo $HALOLIGHT_API_BUN_URL
# Test connection
curl http://localhost:3002/health
A: Ensure AppRouter type is correctly exported and imported in client:
// Server - src/routers/index.ts
export const appRouter = router({
// ... routers
});
export type AppRouter = typeof appRouter; // Must export type
// Client - ensure importing from correct path
import type { AppRouter } from 'halolight-bff'; // NPM package
// or
import type { AppRouter } from '@/server/routers'; // Monorepo
| Feature | tRPC BFF | GraphQL | REST API | gRPC |
|---|---|---|---|---|
| Type Safety | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
| Developer Experience | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
| Performance | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Learning Curve | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ |
| Ecosystem | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| Documentation | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
HaloLight Cloudflare version is built on Next.js 15 App Router + React 19, using @opennextjs/cloudflare adapter for Cloudflare Workers/Pages edge runtime, enabling global low-latency access.
Live Preview: https://halolight-cloudflare.h7ml.cn/
GitHub: https://github.com/halolight/halolight-cloudflare
| Feature | Original (Next.js) | Cloudflare Version |
|---|---|---|
| Next.js | 14.x | 15.5.x |
| React | 18.x | 19.x |
| Runtime | Node.js (Vercel) | Cloudflare Workers (Edge) |
| Deployment Platform | Vercel / Docker | Cloudflare Pages |
| Development Tools | webpack | Turbopack |
| Deployment Command | pnpm build && pnpm start |
pnpm deploy |
| SSR Location | Server/Serverless | Global edge nodes |
| Cold Start | Platform-dependent | < 50ms |
| Technology | Version | Description |
|---|---|---|
| Next.js | 15.5.x | React full-stack framework |
| React | 19.x | UI library |
| TypeScript | 5.x | Type safety |
| Tailwind CSS | 4.x | Atomic CSS |
| @opennextjs/cloudflare | 1.x | Cloudflare adapter layer |
| Wrangler | 4.x | Cloudflare CLI |
| shadcn/ui | latest | UI component library |
| Zustand | 5.x | State management |
| TanStack Query | 5.x | Server state |
| Vitest | 4.x | Unit testing |
| Mock.js | 1.x | Data mocking |
halolight-cloudflare/
├── src/
│ ├── app/ # App Router pages
│ │ ├── (dashboard)/ # Dashboard route group
│ │ ├── (auth)/ # Auth route group
│ │ ├── (legal)/ # Legal terms route group
│ │ ├── layout.tsx # Root layout
│ │ └── page.tsx # Homepage
│ ├── components/ # React components
│ │ ├── ui/ # shadcn/ui components
│ │ ├── layout/ # Layout components
│ │ └── dashboard/ # Dashboard components
│ ├── hooks/ # React Hooks
│ ├── stores/ # Zustand Stores
│ ├── lib/ # Utilities
│ ├── mock/ # Mock data
│ ├── providers/ # Context Providers
│ ├── config/ # Configuration files
│ └── __tests__/ # Unit tests
├── public/ # Static assets
├── .github/workflows/ # GitHub Actions CI
├── .open-next/ # OpenNext build output (auto-generated)
├── coverage/ # Test coverage (auto-generated)
├── cloudflare-env.d.ts # Cloudflare environment types
├── vitest.config.ts # Vitest test configuration
├── open-next.config.ts # OpenNext configuration
├── wrangler.jsonc # Wrangler configuration
├── next.config.ts # Next.js configuration
└── package.json
git clone https://github.com/halolight/halolight-cloudflare.git
cd halolight-cloudflare
pnpm install
cp .dev.vars.example .dev.vars
# .dev.vars example
NEXT_PUBLIC_API_URL=/api
NEXT_PUBLIC_MOCK=true
NEXT_PUBLIC_APP_TITLE=HaloLight
NEXT_PUBLIC_BRAND_NAME=HaloLight
NEXT_PUBLIC_DEMO_EMAIL=[email protected]
NEXT_PUBLIC_DEMO_PASSWORD=Admin@123
pnpm dev
Visit http://localhost:3000
pnpm preview
Simulates Cloudflare Workers environment to detect Edge Runtime compatibility issues.
wrangler login # Login required for first time
pnpm deploy # Build and deploy
pnpm dev # Start development server (Turbopack, Node.js environment)
pnpm build # Next.js production build
pnpm preview # Local preview of Cloudflare environment
pnpm deploy # Deploy to Cloudflare
pnpm upload # Upload only without deployment
pnpm lint # ESLint check
pnpm type-check # TypeScript type check
pnpm test # Run unit tests (watch mode)
pnpm test:run # Run unit tests (single run)
pnpm test:coverage # Run tests and generate coverage report
pnpm cf-typegen # Generate Cloudflare environment types
Cloudflare Workers is an edge runtime, some Node.js APIs are unavailable:
Unavailable APIs:
fs - File system operationschild_process - Child processesnet, dgram - Native network socketscrypto.createCipher and other legacy crypto APIsPartially Available (via nodejs_compat):
Buffer - Binary data processingprocess.env - Environment variablescrypto partial APIs - such as randomUUID()Note
When using @opennextjs/cloudflare, the entire application automatically runs in edge environment, no need to manually declare export const runtime = 'edge'.
| Service | Purpose | Description |
|---|---|---|
| KV | Key-value storage | Globally distributed cache |
| D1 | SQLite database | Edge SQL database |
| R2 | Object storage | S3-compatible storage |
| Queues | Message queues | Async task processing |
| Durable Objects | Stateful objects | Real-time collaboration |
| Workers AI | AI inference | Edge AI models |
import { getRequestContext } from '@opennextjs/cloudflare';
export async function GET() {
const { env } = getRequestContext();
const value = await env.MY_KV.get('key');
return Response.json({ value });
}
// wrangler.jsonc
{
"kv_namespaces": [
{ "binding": "MY_KV", "id": "xxx" }
]
}
// wrangler.jsonc
{
"d1_databases": [
{ "binding": "MY_DB", "database_id": "xxx" }
]
}
| Rendering Mode | Support Status | Description |
|---|---|---|
| SSR | ✅ Supported | Each request renders at the edge |
| SSG | ✅ Supported | Static pages generated at build time |
| ISR | ⚠️ Partial | Requires R2 cache configuration |
// open-next.config.ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
});
Project has complete GitHub Actions CI workflow configured:
| Job | Description |
|---|---|
| lint | ESLint + TypeScript type check |
| test | Vitest unit tests + Codecov coverage |
| build | OpenNext Cloudflare production build |
| security | Dependency security audit |
| dependency-review | PR dependency change review |
# .github/workflows/deploy.yml
name: Deploy to Cloudflare
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install
- run: pnpm deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
User Request → Cloudflare CDN → Workers (Edge) → KV/D1/R2/External API
↓
300+ global nodes
Nearby response < 50ms
| Limit | Free Tier | Paid Tier |
|---|---|---|
| Worker script size | 1MB (compressed) | 10MB |
| CPU time | 10-50ms | Seconds |
| Memory | 128MB | 128MB |
| Subrequests | 50 | 1000 |
| Request duration | 30s | 30s |
Reference
For actual limits, check Cloudflare official documentation.
Cloudflare Pages retains deployment history, supporting the following rollback methods:
Dashboard Rollback:
Redeploy Specific Commit:
git checkout <commit-hash>
pnpm deploy
Edge Runtime doesn't support Node.js built-in modules. Use Web APIs instead or ensure the code only runs on the client side.
The core advantage of HaloLight lies in complete frontend-backend decoupling, supporting any combination. This document helps you choose the most suitable tech stack combination.
graph TD
A[Start Selection] --> B{Team Size?}
B -->|Small <5| C{SEO Required?}
B -->|Medium 5-20| D{Tech Preference?}
B -->|Large >20| E[Angular + Spring Boot]
C -->|Yes| F[Nuxt + FastAPI]
C -->|No| G[Vue + FastAPI]
D -->|Node.js| H[Next.js + NestJS]
D -->|Python| I[Vue + FastAPI]
D -->|Java| J[Angular + Spring Boot]
D -->|Go| K[SvelteKit + Go Fiber]
Rating mainstream combinations across dimensions (max ⭐⭐⭐⭐⭐):
| Dimension | Rating | Description |
|---|---|---|
| Development Efficiency | ⭐⭐⭐⭐⭐ | TypeScript full-stack unification, type sharing |
| Performance | ⭐⭐⭐⭐ | SSR + edge caching optimization |
| Learning Curve | ⭐⭐⭐ | Need to understand React and NestJS architecture |
| Ecosystem Maturity | ⭐⭐⭐⭐⭐ | npm ecosystem extremely rich |
| Deployment | ⭐⭐⭐⭐ | Vercel + Railway/Fly.io one-click deploy |
| Overall | ⭐⭐⭐⭐ | First choice for multi-tenant SaaS, enterprise backends |
| Dimension | Rating | Description |
|---|---|---|
| Development Efficiency | ⭐⭐⭐⭐⭐ | Vue smooth learning curve, FastAPI fast dev |
| Performance | ⭐⭐⭐⭐ | Vue 3 compilation optimized, Python async efficient |
| Learning Curve | ⭐⭐⭐⭐⭐ | Both relatively easy to pick up |
| Data Processing | ⭐⭐⭐⭐⭐ | Python data science ecosystem unbeatable |
| Deployment | ⭐⭐⭐⭐ | Frontend CDN, backend containerized |
| Overall | ⭐⭐⭐⭐⭐ | First choice for data/AI-driven apps |
| Dimension | Rating | Description |
|---|---|---|
| Development Efficiency | ⭐⭐⭐ | Rigorous architecture, high initial investment |
| Performance | ⭐⭐⭐⭐ | Enterprise optimization mature |
| Learning Curve | ⭐⭐ | Both have some complexity |
| Enterprise Maturity | ⭐⭐⭐⭐⭐ | Large enterprise first choice |
| Long-term Maintenance | ⭐⭐⭐⭐⭐ | Clear architecture, highly maintainable |
| Overall | ⭐⭐⭐⭐ | Best for large enterprises, long-term projects |
| Dimension | Rating | Description |
|---|---|---|
| Development Efficiency | ⭐⭐⭐⭐ | Concise code, great dev experience |
| Performance | ⭐⭐⭐⭐⭐ | Both are performance benchmarks |
| Learning Curve | ⭐⭐⭐ | Svelte unique syntax, need to learn Go |
| Resource Usage | ⭐⭐⭐⭐⭐ | Extremely low memory and CPU usage |
| Deployment | ⭐⭐⭐⭐⭐ | Tiny container images, edge deployment ready |
| Overall | ⭐⭐⭐⭐⭐ | Best for high-performance real-time apps |
Below shows all possible frontend-backend combinations. Each cell represents a viable tech stack pairing.
| Frontend \ Backend | NestJS | Node.js | FastAPI | Spring Boot | Go | PHP | Bun | tRPC BFF |
|---|---|---|---|---|---|---|---|---|
| Next.js | ⭐ Best | ✅ | ✅ | ✅ | ✅ | ✅ | ⭐ Best | ⭐ Best |
| Nuxt | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⭐ Best |
| Vue | ✅ | ✅ | ⭐ Best | ✅ | ✅ | ✅ | ✅ | ✅ |
| Angular | ✅ | ✅ | ✅ | ⭐ Best | ⭐ Best | ✅ | ✅ | ✅ |
| SvelteKit | ✅ | ✅ | ✅ | ✅ | ⭐ Best | ✅ | ✅ | ✅ |
| Astro | ✅ | ✅ | ⭐ Best | ✅ | ✅ | ✅ | ✅ | ✅ |
| Solid.js | ✅ | ✅ | ✅ | ✅ | ⭐ Best | ✅ | ⭐ Best | ✅ |
| Qwik | ✅ | ✅ | ✅ | ✅ | ⭐ Best | ✅ | ⭐ Best | ✅ |
| Remix | ⭐ Best | ⭐ Best | ✅ | ✅ | ✅ | ✅ | ⭐ Best | ⭐ Best |
| Preact | ✅ | ✅ | ✅ | ✅ | ⭐ Best | ✅ | ⭐ Best | ✅ |
| Lit | ✅ | ✅ | ✅ | ✅ | ✅ | ⭐ Best | ✅ | ✅ |
| Fresh | ✅ | ✅ | ✅ | ✅ | ⭐ Best | ✅ | ⭐ Best | ✅ |
Legend:
| Feature | Next.js | Vue | Angular | SvelteKit | Solid | Qwik |
|---|---|---|---|---|---|---|
| Learning Curve | Medium | Low | High | Low | Medium | Medium |
| TypeScript | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| SSR/SSG | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Performance | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Ecosystem | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
| Bundle Size | Medium | Small | Large | Minimal | Minimal | Small |
| Feature | NestJS | FastAPI | Spring Boot | Go Fiber |
|---|---|---|---|---|
| Dev Efficiency | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
| Performance | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| TypeScript | ⭐⭐⭐⭐⭐ | - | - | - |
| Enterprise Maturity | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| Data Science | ⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐ |
| Resource Usage | Medium | Small | Large | Minimal |
After choosing your combination, follow these steps:
# Example with Vue
git clone https://github.com/halolight/halolight-vue.git
cd halolight-vue
pnpm install
pnpm dev
# Example with FastAPI
git clone https://github.com/halolight/halolight-api-python.git
cd halolight-api-python
pip install -e ".[dev]"
uvicorn app.main:app --reload
# Frontend project's .env.local
VITE_API_URL=http://localhost:8000/api
VITE_USE_MOCK=false # Disable Mock, use real API
HaloLight Deno backend API is built on Fresh framework and Deno KV, using the native Deno runtime to provide high-performance RESTful API services.
API Documentation: https://halolight-deno.h7ml.cn/docs
GitHub: https://github.com/halolight/halolight-deno
| Technology | Version | Description |
|---|---|---|
| Deno | 2.x | Runtime (built-in TypeScript) |
| Fresh | 1.x | Deno-native web framework |
| Preact | 10.x | Lightweight UI library |
| Deno KV | - | Built-in key-value storage (database) |
| Hono | 4.x | API routing framework (optional) |
| JWT | - | Authentication |
| Tailwind CSS | 3.x | Atomic CSS |
# Clone repository
git clone https://github.com/halolight/halolight-deno.git
cd halolight-deno
# No dependency install needed, Deno manages automatically
cp .env.example .env
# API Configuration
API_URL=/api
USE_MOCK=true
# JWT Secret
JWT_SECRET=your-super-secret-key
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
# Deno KV (optional, local by default)
DENO_KV_PATH=./data/kv.db
# Service Configuration
PORT=8000
NODE_ENV=development
# Demo Account
[email protected]
DEMO_PASSWORD=123456
SHOW_DEMO_HINT=false
# Brand Configuration
APP_TITLE=Admin Pro
BRAND_NAME=Halolight
# Deno KV requires no migration, auto-creates
# If seed data needed
deno task seed
# Development mode
deno task dev
# Production mode
deno task build
deno task start
Visit http://localhost:8000
halolight-deno/
├── routes/ # Route handlers
│ ├── api/ # API endpoints
│ │ ├── auth/ # Auth routes
│ │ ├── users/ # User routes
│ │ ├── dashboard/ # Dashboard routes
│ │ ├── documents/ # Document routes
│ │ ├── files/ # File routes
│ │ ├── messages/ # Message routes
│ │ ├── notifications/ # Notification routes
│ │ └── calendar/ # Calendar routes
│ ├── (auth)/ # Auth pages
│ │ ├── login.tsx
│ │ ├── register.tsx
│ │ └── forgot-password.tsx
│ └── (dashboard)/ # Dashboard pages
│ ├── index.tsx
│ ├── users.tsx
│ └── settings.tsx
├── utils/ # Utilities
│ ├── auth.ts # Auth utilities
│ ├── kv.ts # Deno KV wrapper
│ ├── permissions.ts # Permission checks
│ ├── jwt.ts # JWT utilities
│ └── validation.ts # Data validation
├── islands/ # Interactive components (client-side)
│ ├── AuthProvider.tsx
│ ├── ThemeToggle.tsx
│ └── Dashboard.tsx
├── components/ # UI components
│ ├── ui/
│ ├── layout/
│ └── dashboard/
├── static/ # Static assets
├── fresh.gen.ts # Fresh generated file
├── fresh.config.ts # Fresh config
├── deno.json # Deno config
└── import_map.json # Import map
| Method | Path | Description | Permission |
|---|---|---|---|
| POST | /api/auth/login |
User login | Public |
| POST | /api/auth/register |
User registration | Public |
| POST | /api/auth/refresh |
Refresh token | Public |
| POST | /api/auth/logout |
User logout | Authenticated |
| POST | /api/auth/forgot-password |
Forgot password | Public |
| POST | /api/auth/reset-password |
Reset password | Public |
| Method | Path | Description | Permission |
|---|---|---|---|
| GET | /api/users |
Get user list | users:view |
| GET | /api/users/:id |
Get user details | users:view |
| POST | /api/users |
Create user | users:create |
| PUT | /api/users/:id |
Update user | users:update |
| DELETE | /api/users/:id |
Delete user | users:delete |
| GET | /api/users/me |
Get current user | Authenticated |
| Method | Path | Description |
|---|---|---|
| GET | /api/documents |
Get document list |
| GET | /api/documents/:id |
Get document details |
| POST | /api/documents |
Create document |
| PUT | /api/documents/:id |
Update document |
| DELETE | /api/documents/:id |
Delete document |
| Method | Path | Description |
|---|---|---|
| GET | /api/files |
Get file list |
| GET | /api/files/:id |
Get file details |
| POST | /api/files/upload |
Upload file |
| PUT | /api/files/:id |
Update file info |
| DELETE | /api/files/:id |
Delete file |
| Method | Path | Description |
|---|---|---|
| GET | /api/messages |
Get message list |
| GET | /api/messages/:id |
Get message details |
| POST | /api/messages |
Send message |
| PUT | /api/messages/:id/read |
Mark as read |
| DELETE | /api/messages/:id |
Delete message |
| Method | Path | Description |
|---|---|---|
| GET | /api/notifications |
Get notification list |
| PUT | /api/notifications/:id/read |
Mark as read |
| PUT | /api/notifications/read-all |
Mark all as read |
| DELETE | /api/notifications/:id |
Delete notification |
| Method | Path | Description |
|---|---|---|
| GET | /api/calendar/events |
Get event list |
| GET | /api/calendar/events/:id |
Get event details |
| POST | /api/calendar/events |
Create event |
| PUT | /api/calendar/events/:id |
Update event |
| DELETE | /api/calendar/events/:id |
Delete event |
| Method | Path | Description |
|---|---|---|
| GET | /api/dashboard/stats |
Statistics data |
| GET | /api/dashboard/visits |
Visit trends |
| GET | /api/dashboard/sales |
Sales data |
| GET | /api/dashboard/pie |
Pie chart data |
| GET | /api/dashboard/tasks |
Todo tasks |
| GET | /api/dashboard/calendar |
Today's schedule |
Access Token: 15-minute validity for API requests
Refresh Token: 7-day validity for refreshing Access Token
Authorization: Bearer <access_token>
// utils/jwt.ts
import { create, verify } from "https://deno.land/x/[email protected]/mod.ts";
const key = await crypto.subtle.generateKey(
{ name: "HMAC", hash: "SHA-256" },
true,
["sign", "verify"],
);
export async function createAccessToken(userId: string): Promise<string> {
return await create(
{ alg: "HS256", typ: "JWT" },
{ userId, exp: Date.now() + 15 * 60 * 1000 }, // 15 minutes
key,
);
}
export async function refreshToken(refreshToken: string): Promise<string> {
const payload = await verify(refreshToken, key);
return await createAccessToken(payload.userId as string);
}
| Role | Description | Permissions |
|---|---|---|
super_admin |
Super Administrator | * (all permissions) |
admin |
Administrator | users:*, documents:*, files:*, messages:*, calendar:* |
user |
Regular User | documents:view, files:view, messages:view, calendar:view |
guest |
Guest | dashboard:view |
{resource}:{action}
Examples:
- users:view # View users
- users:create # Create users
- users:* # All user operations
- * # All permissions
// utils/permissions.ts
export function hasPermission(user: User, permission: string): boolean {
const userPermissions = user.permissions || [];
return userPermissions.some((p) => {
if (p === "*") return true;
if (p.endsWith(":*")) {
const resource = p.slice(0, -2);
return permission.startsWith(resource + ":");
}
return p === permission;
});
}
// Route middleware
export function requirePermission(permission: string) {
return async (req: Request, ctx: any) => {
const user = ctx.state.user;
if (!hasPermission(user, permission)) {
return new Response("Forbidden", { status: 403 });
}
return await ctx.next();
};
}
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request parameter validation failed",
"details": [
{ "field": "email", "message": "Invalid email format" }
]
}
}
| Status | Error Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR |
Parameter validation failed |
| 401 | UNAUTHORIZED |
Unauthorized |
| 403 | FORBIDDEN |
Forbidden |
| 404 | NOT_FOUND |
Resource not found |
| 409 | CONFLICT |
Resource conflict |
| 500 | INTERNAL_ERROR |
Server error |
# Development
deno task dev # Start dev server (hot reload)
# Build
deno task build # Build production bundle
# Testing
deno test # Run tests
deno test --coverage # Test coverage
# Database
deno task seed # Initialize seed data
# Code Quality
deno lint # Lint
deno fmt # Format
deno check **/*.ts # Type check
docker build -t halolight-deno .
docker run -p 8000:8000 halolight-deno
docker-compose up -d
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "8000:8000"
environment:
- NODE_ENV=production
- JWT_SECRET=${JWT_SECRET}
- DENO_KV_PATH=/data/kv.db
volumes:
- deno_kv_data:/data
restart: unless-stopped
volumes:
deno_kv_data:
# Install deployctl
deno install -Arf jsr:@deno/deployctl
# Deploy to Deno Deploy
deployctl deploy --project=halolight-deno main.ts
NODE_ENV=production
JWT_SECRET=your-production-secret-key-min-32-chars
DENO_KV_PATH=/data/kv.db
PORT=8000
deno test # Run all tests
deno test --coverage # Generate coverage report
deno test --watch # Watch mode
// routes/api/auth/_test.ts
import { assertEquals } from "https://deno.land/[email protected]/assert/mod.ts";
Deno.test("POST /api/auth/login - success", async () => {
const response = await fetch("http://localhost:8000/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "[email protected]",
password: "123456",
}),
});
assertEquals(response.status, 200);
const data = await response.json();
assertEquals(data.success, true);
assertEquals(typeof data.data.accessToken, "string");
});
Deno.test("POST /api/auth/login - invalid credentials", async () => {
const response = await fetch("http://localhost:8000/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "[email protected]",
password: "wrong",
}),
});
assertEquals(response.status, 401);
const data = await response.json();
assertEquals(data.success, false);
});
| Metric | Value | Notes |
|---|---|---|
| Request Throughput | ~45,000 req/s | Deno 2.0, single core, wrk test |
| Average Response Time | <5ms | Local Deno KV, no external deps |
| Memory Usage | ~30MB | Base memory after startup |
| CPU Usage | <10% | Idle state |
// utils/logger.ts
import { Logger } from "https://deno.land/[email protected]/log/mod.ts";
const logger = new Logger("app");
export function logRequest(req: Request, res: Response, duration: number) {
logger.info(
`${req.method} ${new URL(req.url).pathname} ${res.status} ${duration}ms`,
);
}
export function logError(error: Error, context?: any) {
logger.error(`Error: ${error.message}`, { error, context });
}
// routes/api/health.ts
export const handler = async (req: Request): Promise<Response> => {
try {
// Check Deno KV connection
const kv = await Deno.openKv();
await kv.get(["health"]);
await kv.close();
return Response.json({
status: "healthy",
timestamp: new Date().toISOString(),
uptime: Deno.memoryUsage(),
});
} catch (error) {
return Response.json(
{ status: "unhealthy", error: error.message },
{ status: 503 },
);
}
};
// utils/metrics.ts
const metrics = {
requests: 0,
errors: 0,
responseTime: [] as number[],
};
export function recordRequest(duration: number) {
metrics.requests++;
metrics.responseTime.push(duration);
}
export function recordError() {
metrics.errors++;
}
export function getMetrics() {
const avg = metrics.responseTime.reduce((a, b) => a + b, 0) /
metrics.responseTime.length;
return {
totalRequests: metrics.requests,
totalErrors: metrics.errors,
avgResponseTime: avg || 0,
};
}
A: Configure the DENO_KV_PATH environment variable to specify the data file path.
# .env
DENO_KV_PATH=./data/kv.db
// Use custom path
const kv = await Deno.openKv(Deno.env.get("DENO_KV_PATH"));
A: When deploying on Deno Deploy, using Deno.openKv() automatically connects to the hosted distributed KV.
// Production environment automatically uses remote KV
const kv = await Deno.openKv();
A: Use Fresh's FormData API to handle file uploads.
// routes/api/files/upload.ts
export const handler = async (req: Request): Promise<Response> => {
const formData = await req.formData();
const file = formData.get("file") as File;
if (!file) {
return Response.json({ error: "No file uploaded" }, { status: 400 });
}
// Save file to Deno KV or cloud storage
const bytes = await file.arrayBuffer();
const kv = await Deno.openKv();
await kv.set(["files", crypto.randomUUID()], {
name: file.name,
type: file.type,
size: file.size,
data: new Uint8Array(bytes),
});
return Response.json({ success: true });
};
A: Islands are client-side interactive components that call backend APIs via fetch.
// islands/UserList.tsx
import { useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";
export default function UserList() {
const users = useSignal([]);
useEffect(() => {
fetch("/api/users")
.then((res) => res.json())
.then((data) => users.value = data);
}, []);
return (
<div>
{users.value.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
| Feature | Deno Fresh | NestJS | FastAPI | Spring Boot |
|---|---|---|---|---|
| Language | TypeScript (Deno) | TypeScript | Python | Java |
| ORM | Deno KV | Prisma | SQLAlchemy | JPA |
| Performance | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| Learning Curve | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
| Startup Time | <100ms | ~2s | ~1s | ~5s |
| Memory Usage | 30MB | 80MB | 50MB | 150MB |
| Deployment | Deno Deploy | Docker/Cloud | Docker/Cloud | Docker/Cloud |
HaloLight Docker containerized deployment solution, supporting multi-stage builds and Kubernetes deployment.
# Clone repository
git clone https://github.com/halolight/halolight-docker.git
cd halolight-docker
# Start with Docker Compose
docker-compose up -d
# View logs
docker-compose logs -f
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
HEALTHCHECK --interval=30s --timeout=3s \
CMD curl -f http://localhost:3000/api/health || exit 1
CMD ["node", "server.js"]
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://postgres:password@db:5432/halolight
depends_on:
- db
- redis
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: halolight
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
depends_on:
- app
volumes:
postgres_data:
redis_data:
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: halolight
spec:
replicas: 3
selector:
matchLabels:
app: halolight
template:
metadata:
labels:
app: halolight
spec:
containers:
- name: halolight
image: halolight/halolight:latest
ports:
- containerPort: 3000
resources:
limits:
memory: "512Mi"
cpu: "500m"
HaloLight Fly.io deployment version, a global edge deployment solution that supports multi-region distributed deployments.
Live Preview: https://halolight-fly.h7ml.cn
GitHub: https://github.com/halolight/halolight-fly
# Install Fly CLI
# macOS
brew install flyctl
# Linux
curl -L https://fly.io/install.sh | sh
# Windows
powershell -Command "iwr https://fly.io/install.ps1 -useb | iex"
# Log in to Fly.io
fly auth login
# Clone the project
git clone https://github.com/halolight/halolight-fly.git
cd halolight-fly
# Initialize the app (creates fly.toml)
fly launch
# Deploy
fly deploy
# Clone the project
git clone https://github.com/halolight/halolight-fly.git
cd halolight-fly
# Log in
fly auth login
# Create the app
fly apps create halolight
# Deploy using Dockerfile
fly deploy --dockerfile Dockerfile
# .github/workflows/fly.yml
name: Fly Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: \${{ secrets.FLY_API_TOKEN }}
# App name
app = "halolight"
# Primary region (Hong Kong)
primary_region = "hkg"
# Build configuration
[build]
dockerfile = "Dockerfile"
# Environment variables
[env]
NODE_ENV = "production"
PORT = "3000"
NEXT_PUBLIC_API_URL = "/api"
NEXT_PUBLIC_MOCK = "false"
# HTTP service configuration
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = true # Automatically stop when idle
auto_start_machines = true # Automatically start on request
min_machines_running = 1 # Keep at least 1 instance running
processes = ["app"]
# TCP service port mapping
[[services]]
protocol = "tcp"
internal_port = 3000
[[services.ports]]
port = 80
handlers = ["http"]
[[services.ports]]
port = 443
handlers = ["tls", "http"]
# Health checks
[[services.http_checks]]
interval = "10s"
timeout = "2s"
grace_period = "5s"
method = "GET"
path = "/api/health"
protocol = "http"
tls_skip_verify = false
# TCP checks
[[services.tcp_checks]]
interval = "15s"
timeout = "2s"
grace_period = "1s"
# VM configuration
[[vm]]
cpu_kind = "shared"
cpus = 1
memory_mb = 512
# Process groups (multi-process optional)
[processes]
app = "pnpm start"
# worker = "pnpm run worker"
# Mount volumes
[mounts]
source = "halolight_data"
destination = "/data"
initial_size = "1gb"
# Deployment strategy
[deploy]
strategy = "rolling"
max_unavailable = 0.33
# Static assets
[[statics]]
guest_path = "/app/public"
url_prefix = "/static/"
# Build stage
FROM node:20-alpine AS builder
RUN npm install -g pnpm
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]
# Set a single variable via CLI
fly secrets set DATABASE_URL="postgresql://user:pass@host:5432/db"
# Set multiple variables via CLI
fly secrets set \\
JWT_SECRET="your-secret" \\
REDIS_URL="redis://..."
# Import from .env file
fly secrets import < .env.production
# View existing variables
fly secrets list
# Delete a variable
fly secrets unset DATABASE_URL
| Variable | Description | Example |
|---|---|---|
| `NODE_ENV` | Runtime environment | `production` |
| `PORT` | Server port | `3000` |
| `NEXT_PUBLIC_API_URL` | API base URL | `/api` |
| `NEXT_PUBLIC_MOCK` | Enable mock data | `false` |
| `DATABASE_URL` | PostgreSQL connection | `postgresql://...` |
| `REDIS_URL` | Redis connection | `redis://...` |
| `JWT_SECRET` | JWT secret | `your-secret-key` |
Fly.io automatically injects the following environment variables:
FLY_APP_NAME # App name
FLY_REGION # Current region code (e.g., hkg)
FLY_ALLOC_ID # Instance allocation ID
FLY_PUBLIC_IP # Public IP
FLY_PRIVATE_IP # Private network IP
PRIMARY_REGION # Primary region
# Create a Volume in the specified region
fly volumes create halolight_data \\
--region hkg \\
--size 10 \\
--count 1
# View Volumes
fly volumes list
# Expand size
fly volumes extend vol_xxx --size 20
# Delete Volume
fly volumes destroy vol_xxx
# fly.toml
[mounts]
source = "halolight_data"
destination = "/data"
// lib/db.ts
import Database from 'better-sqlite3';
const db = new Database('/data/app.db');
// Initialize table schema
db.exec(\`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
name TEXT
)
\`);
# Create a PostgreSQL cluster
fly postgres create --name halolight-db
# Optional configuration
fly postgres create \\
--name halolight-db \\
--region hkg \\
--vm-size shared-cpu-1x \\
--volume-size 10 \\
--initial-cluster-size 1
# Attach to the app
fly postgres attach halolight-db --app halolight
# This automatically sets the DATABASE_URL environment variable
# Connect to the database (interactive)
fly postgres connect -a halolight-db
# Use psql
fly proxy 5432 -a halolight-db
# Then in another terminal
psql "postgresql://postgres:xxx@localhost:5432/halolight"
# Create Redis (Upstash)
fly redis create
# Or use Fly-managed Redis
fly apps create halolight-redis
fly deploy --config redis.toml
# Get connection info
fly redis status halolight-redis
# redis.toml
app = "halolight-redis"
primary_region = "hkg"
[build]
image = "flyio/redis:6.2.6"
[env]
REDIS_PASSWORD = ""
[[mounts]]
source = "redis_data"
destination = "/data"
[[services]]
internal_port = 6379
protocol = "tcp"
[[services.ports]]
port = 6379
# View available regions
fly platform regions
# Add regions
fly regions add sin nrt syd
# View current regions
fly regions list
# Remove regions
fly regions remove syd
| Code | Location | Latency (Asia) |
|---|---|---|
hkg |
Hong Kong | ~5ms |
sin |
Singapore | ~30ms |
nrt |
Tokyo | ~50ms |
syd |
Sydney | ~100ms |
lax |
Los Angeles | ~150ms |
iad |
Washington | ~200ms |
lhr |
London | ~200ms |
fra |
Frankfurt | ~180ms |
# Set instance count
fly scale count 3
# Set instance count per region
fly scale count hkg=2 sin=1 nrt=1
# View current instances
fly scale show
# Adjust instance size
fly scale vm shared-cpu-2x
fly scale memory 1024
# Or configure in fly.toml
[[vm]]
cpu_kind = "shared"
cpus = 2
memory_mb = 1024
# View private IPs
fly ips private
# Use the .internal domain for app-to-app communication
# Format: <app-name>.internal
# Example for connecting to the database
DATABASE_URL=postgres://user:[email protected]:5432/db
# Create WireGuard configuration
fly wireguard create
# View configurations
fly wireguard list
# Import into the WireGuard client to access directly
# Internal service: http://halolight.internal:3000
# App management
fly apps list # List all apps
fly apps create <name> # Create an app
fly apps destroy <name> # Delete an app
# Deployment
fly deploy # Deploy
fly deploy --remote-only # Remote build only
fly deploy --local-only # Local build only
fly deploy --strategy rolling # Rolling deployment
# Status and logs
fly status # View status
fly logs # View logs
fly logs -a halolight # Logs for a specific app
# Instance management
fly machines list # List instances
fly machines start <id> # Start an instance
fly machines stop <id> # Stop an instance
fly machines destroy <id> # Destroy an instance
# SSH access
fly ssh console # SSH into an instance
fly ssh issue # Generate SSH certificate
# Proxy
fly proxy 5432 -a halolight-db # Proxy port
# Monitoring
fly dashboard # Open dashboard
fly metrics # View metrics
# Release management
fly releases # View release history
fly releases rollback # Roll back to the previous version
Fly.io automatically exposes Prometheus metrics:
# Access metrics endpoint
curl https://halolight.fly.dev/_metrics
# Configure Prometheus scraping
scrape_configs:
- job_name: 'fly'
static_configs:
- targets: ['halolight.fly.dev']
metrics_path: '/_metrics'
# Deploy Grafana
fly apps create halolight-grafana
fly deploy --config grafana.toml
# Configure data source to connect to Prometheus
// app/api/health/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
const checks = {
status: 'healthy',
timestamp: new Date().toISOString(),
region: process.env.FLY_REGION,
checks: {
database: await checkDatabase(),
redis: await checkRedis(),
},
};
const allHealthy = Object.values(checks.checks).every(c => c === 'ok');
return NextResponse.json(checks, {
status: allHealthy ? 200 : 503,
});
}
# Add a custom domain
fly certs create halolight-fly.h7ml.cn
# View certificate status
fly certs show halolight-fly.h7ml.cn
# List all certificates
fly certs list
# A record
Type: A
Name: halolight-fly
Value: <fly-app-ipv4>
# AAAA record (IPv6)
Type: AAAA
Name: halolight-fly
Value: <fly-app-ipv6>
# Or use CNAME
Type: CNAME
Name: halolight-fly
Value: halolight.fly.dev
# View app IPs
fly ips list
# Allocate dedicated IPv4 (paid)
fly ips allocate-v4
# Allocate IPv6 (free)
fly ips allocate-v6
A: Check these items:
A: Use the following commands:
# View release history
fly releases
# Roll back to the previous version
fly releases rollback
# Roll back to a specific version
fly releases rollback v5
A: Optimization tips:
A: Use SSH access:
# SSH into an instance
fly ssh console
# Run a command
fly ssh console -C "ls -la"
# View processes
fly ssh console -C "ps aux"
A: Check the following:
| Resource | Free Tier | Price Beyond |
|---|---|---|
| Shared CPU | 3 instances | $1.94/mo/instance |
| Memory | 256MB/instance | $0.01/GB/hour |
| Bandwidth | 160GB/month | $0.02/GB |
| IPv4 | - | $2/month |
| IPv6 | Unlimited | Free |
| Volumes | 3GB | $0.15/GB/month |
| PostgreSQL | - | From $6.44/month |
# Development/Test
[[vm]]
cpu_kind = "shared"
cpus = 1
memory_mb = 256
# Production
[[vm]]
cpu_kind = "shared"
cpus = 2
memory_mb = 1024
| Feature | Fly.io | Railway | Render |
|---|---|---|---|
| Global Regions | 30+ | 2 | 4 |
| Private Network | ✅ WireGuard | ✅ | ✅ |
| Managed Databases | ✅ PostgreSQL | ✅ | ✅ |
| Auto Scaling | ✅ | ✅ Pro | ✅ |
| Free Tier | 3 instances | $5/month | 750 hours |
| Docker Support | ✅ Native | ✅ | ✅ |
| Edge Computing | ✅ | ❌ | ❌ |
HaloLight Fresh version is built on Fresh 2 + Deno, using Islands architecture + Preact to deliver a zero-config, ultra-fast admin dashboard.
Live Preview: https://halolight-fresh.h7ml.cn
GitHub: https://github.com/halolight/halolight-fresh
| Technology | Version | Description |
|---|---|---|
| Fresh | 2.x | Deno full-stack framework |
| Deno | 2.x | Modern JavaScript runtime |
| Preact | 10.x | Lightweight UI library |
| @preact/signals | 2.x | Reactive state management |
| TypeScript | Built-in | Type safety |
| Tailwind CSS | Built-in | Atomic CSS |
| Zod | 3.x | Data validation |
| Chart.js | 4.x | Chart visualization |
halolight-fresh/
├── routes/ # File-based routing
│ ├── _app.tsx # Root layout
│ ├── _layout.tsx # Default layout
│ ├── _middleware.ts # Global middleware
│ ├── index.tsx # Homepage
│ ├── auth/ # Auth pages
│ │ ├── login.tsx
│ │ ├── register.tsx
│ │ ├── forgot-password.tsx
│ │ └── reset-password.tsx
│ ├── dashboard/ # Dashboard pages
│ │ ├── _layout.tsx # Dashboard layout
│ │ ├── _middleware.ts # Auth middleware
│ │ ├── index.tsx
│ │ ├── users/
│ │ │ ├── index.tsx
│ │ │ ├── create.tsx
│ │ │ └── [id].tsx
│ │ ├── roles.tsx
│ │ ├── permissions.tsx
│ │ ├── settings.tsx
│ │ └── profile.tsx
│ └── api/ # API routes
│ └── auth/
│ ├── login.ts
│ ├── register.ts
│ └── me.ts
├── islands/ # Interactive Islands
│ ├── LoginForm.tsx
│ ├── UserTable.tsx
│ ├── DashboardGrid.tsx
│ ├── ThemeToggle.tsx
│ └── Sidebar.tsx
├── components/ # Static components
│ ├── ui/ # UI components
│ │ ├── Button.tsx
│ │ ├── Input.tsx
│ │ ├── Card.tsx
│ │ └── ...
│ ├── layout/ # Layout components
│ │ ├── AdminLayout.tsx
│ │ ├── AuthLayout.tsx
│ │ └── Header.tsx
│ └── shared/ # Shared components
│ └── PermissionGuard.tsx
├── lib/ # Utilities
│ ├── auth.ts
│ ├── permission.ts
│ ├── session.ts
│ └── cn.ts
├── signals/ # State management
│ ├── auth.ts
│ ├── ui-settings.ts
│ └── dashboard.ts
├── static/ # Static assets
├── fresh.config.ts # Fresh config
├── deno.json # Deno config
└── tailwind.config.ts # Tailwind config
# macOS/Linux
curl -fsSL https://deno.land/install.sh | sh
# Windows
irm https://deno.land/install.ps1 | iex
git clone https://github.com/halolight/halolight-fresh.git
cd halolight-fresh
cp .env.example .env
# .env
API_URL=/api
USE_MOCK=true
[email protected]
DEMO_PASSWORD=123456
SHOW_DEMO_HINT=true
APP_TITLE=Admin Pro
BRAND_NAME=Halolight
SESSION_SECRET=your-secret-key
deno task dev
Visit http://localhost:8000
deno task build
deno task start
// signals/auth.ts
import { signal, computed, effect } from '@preact/signals'
import { IS_BROWSER } from '$fresh/runtime.ts'
interface User {
id: number
name: string
email: string
permissions: string[]
}
export const user = signal<User | null>(null)
export const token = signal<string | null>(null)
export const loading = signal(false)
export const isAuthenticated = computed(() => !!token.value && !!user.value)
export const permissions = computed(() => user.value?.permissions ?? [])
// Only persist in browser
if (IS_BROWSER) {
const saved = localStorage.getItem('auth')
if (saved) {
const { user: savedUser, token: savedToken } = JSON.parse(saved)
user.value = savedUser
token.value = savedToken
}
effect(() => {
if (user.value && token.value) {
localStorage.setItem('auth', JSON.stringify({
user: user.value,
token: token.value,
}))
}
})
}
export async function login(credentials: { email: string; password: string }) {
loading.value = true
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
headers: { 'Content-Type': 'application/json' },
})
const data = await response.json()
user.value = data.user
token.value = data.token
} finally {
loading.value = false
}
}
export function logout() {
user.value = null
token.value = null
if (IS_BROWSER) {
localStorage.removeItem('auth')
}
}
export function hasPermission(permission: string): boolean {
const perms = permissions.value
return perms.some((p) =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
}
// routes/api/auth/login.ts
import { Handlers } from '$fresh/server.ts'
import { z } from 'zod'
import { setCookie } from '$std/http/cookie.ts'
import { createToken } from '../../../lib/auth.ts'
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
})
export const handler: Handlers = {
async POST(req) {
try {
const body = await req.json()
const { email, password } = loginSchema.parse(body)
// Authenticate user (example)
const user = await authenticateUser(email, password)
if (!user) {
return new Response(
JSON.stringify({ error: 'Invalid email or password' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
)
}
const token = await createToken({ userId: user.id })
const response = new Response(
JSON.stringify({ user, token }),
{ headers: { 'Content-Type': 'application/json' } }
)
setCookie(response.headers, {
name: 'token',
value: token,
path: '/',
httpOnly: true,
sameSite: 'Lax',
maxAge: 60 * 60 * 24 * 7,
})
return response
} catch (e) {
if (e instanceof z.ZodError) {
return new Response(
JSON.stringify({ error: 'Validation failed', details: e.errors }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
return new Response(
JSON.stringify({ error: 'Server error' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
},
}
// components/shared/PermissionGuard.tsx
import { ComponentChildren } from 'preact'
interface Props {
permission: string
userPermissions: string[]
children: ComponentChildren
fallback?: ComponentChildren
}
function checkPermission(
userPermissions: string[],
permission: string
): boolean {
return userPermissions.some((p) =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
}
export function PermissionGuard({
permission,
userPermissions,
children,
fallback,
}: Props) {
if (!checkPermission(userPermissions, permission)) {
return fallback ?? null
}
return <>{children}</>
}
// Usage (in server-side rendering)
<PermissionGuard
permission="users:delete"
userPermissions={ctx.state.user.permissions}
fallback={<span class="text-muted-foreground">No permission</span>}
>
<Button variant="destructive">Delete</Button>
</PermissionGuard>
// islands/LoginForm.tsx
import { useSignal } from '@preact/signals'
import { login, loading } from '../signals/auth.ts'
import { Button } from '../components/ui/Button.tsx'
import { Input } from '../components/ui/Input.tsx'
interface Props {
redirectTo?: string
}
export default function LoginForm({ redirectTo = '/dashboard' }: Props) {
const email = useSignal('')
const password = useSignal('')
const error = useSignal('')
const handleSubmit = async (e: Event) => {
e.preventDefault()
error.value = ''
try {
await login({
email: email.value,
password: password.value,
})
globalThis.location.href = redirectTo
} catch (e) {
error.value = 'Invalid email or password'
}
}
return (
<form onSubmit={handleSubmit} class="space-y-4">
{error.value && (
<div class="text-destructive text-sm">{error.value}</div>
)}
<Input
type="email"
label="Email"
value={email.value}
onInput={(e) => email.value = e.currentTarget.value}
required
/>
<Input
type="password"
label="Password"
value={password.value}
onInput={(e) => password.value = e.currentTarget.value}
required
/>
<Button type="submit" class="w-full" disabled={loading.value}>
{loading.value ? 'Logging in...' : 'Login'}
</Button>
</form>
)
}
// routes/auth/login.tsx
import { Handlers, PageProps } from '$fresh/server.ts'
import { AuthLayout } from '../../components/layout/AuthLayout.tsx'
import LoginForm from '../../islands/LoginForm.tsx'
export const handler: Handlers = {
GET(req, ctx) {
const url = new URL(req.url)
const redirect = url.searchParams.get('redirect') || '/dashboard'
return ctx.render({ redirect })
},
}
export default function LoginPage({ data }: PageProps<{ redirect: string }>) {
return (
<AuthLayout>
<div class="max-w-md mx-auto">
<h1 class="text-2xl font-bold text-center mb-8">Login</h1>
<LoginForm redirectTo={data.redirect} />
</div>
</AuthLayout>
)
}
Supports 11 preset skins, switch via quick settings panel:
| Skin | Color | CSS Variable |
|---|---|---|
| Default | Purple | --primary: 51.1% 0.262 276.97 |
| Blue | Blue | --primary: 54.8% 0.243 264.05 |
| Emerald | Green | --primary: 64.6% 0.178 142.49 |
| Orange | Orange | --primary: 69.7% 0.186 37.37 |
| Rose | Rose | --primary: 62.8% 0.241 12.48 |
| Teal | Teal | --primary: 66.7% 0.151 193.65 |
| Amber | Amber | --primary: 77.5% 0.166 69.76 |
| Cyan | Cyan | --primary: 75.1% 0.146 204.66 |
| Pink | Pink | --primary: 65.7% 0.255 347.69 |
| Indigo | Indigo | --primary: 51.9% 0.235 272.75 |
| Lime | Lime | --primary: 78.1% 0.167 136.29 |
/* Theme variable definitions */
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 100% 0 0;
--secondary: 96.1% 0.006 285.75;
--secondary-foreground: 14.9% 0.017 285.75;
--muted: 96.1% 0.006 285.75;
--muted-foreground: 44.7% 0.025 285.75;
--accent: 96.1% 0.006 285.75;
--accent-foreground: 14.9% 0.017 285.75;
--destructive: 62.8% 0.241 12.48;
--destructive-foreground: 100% 0 0;
--border: 89.8% 0.011 285.75;
--input: 89.8% 0.011 285.75;
--ring: 51.1% 0.262 276.97;
--radius: 0.5rem;
}
| Path | Page | Permission |
|---|---|---|
/ |
Homepage | Public |
/auth/login |
Login | Public |
/auth/register |
Register | Public |
/auth/forgot-password |
Forgot Password | Public |
/auth/reset-password |
Reset Password | Public |
/dashboard |
Dashboard | dashboard:view |
/dashboard/users |
User List | users:list |
/dashboard/users/create |
Create User | users:create |
/dashboard/users/[id] |
User Detail | users:view |
/dashboard/roles |
Role Management | roles:list |
/dashboard/permissions |
Permission Management | permissions:list |
/dashboard/settings |
System Settings | settings:view |
/dashboard/profile |
Profile | Logged in |
deno task dev # Start development server
deno task build # Production build
deno task start # Start production server
deno task check # Format and type check
deno task fmt # Format code
deno task fmt:check # Check code format
deno task lint # Lint code
deno task test # Run tests
deno task test:watch # Test watch mode
deno task test:coverage # Test coverage
deno task ci # Run full CI check
# Install deployctl
deno install -A --no-check -r -f https://deno.land/x/deploy/deployctl.ts
# Deploy
deployctl deploy --project=halolight-fresh main.ts
FROM denoland/deno:2.0.0
WORKDIR /app
COPY . .
RUN deno cache main.ts
EXPOSE 8000
CMD ["run", "-A", "main.ts"]
docker build -t halolight-fresh .
docker run -p 8000:8000 halolight-fresh
deno task start directly| Role | Password | |
|---|---|---|
| Admin | [email protected] | 123456 |
| User | [email protected] | 123456 |
The project uses Deno's built-in testing framework, test files are located in the tests/ directory.
tests/
├── setup.ts # Test environment setup
│ ├── localStorage mock
│ ├── sessionStorage mock
│ ├── matchMedia mock
│ └── Helper functions (createMockUser, mockAuthenticatedState, etc.)
└── lib/
├── utils.test.ts # Utility function tests
├── config.test.ts # Config tests
└── stores.test.ts # State management tests
# Run all tests
deno task test
# Watch mode
deno task test:watch
# Test coverage
deno task test:coverage
# Coverage report output to coverage/lcov.info
// tests/lib/config.test.ts
import { assertEquals, assertExists } from "$std/assert/mod.ts";
import "../setup.ts";
import { hasPermission } from "../../lib/config.ts";
import type { Permission } from "../../lib/types.ts";
Deno.test("hasPermission - permission check", async (t) => {
const userPermissions: Permission[] = ["dashboard:view", "users:view"];
await t.step("should return true when user has permission", () => {
const result = hasPermission(userPermissions, "dashboard:view");
assertEquals(result, true);
});
await t.step("should support wildcard permissions", () => {
const adminPermissions: Permission[] = ["*"];
const result = hasPermission(adminPermissions, "dashboard:view");
assertEquals(result, true);
});
});
// fresh.config.ts
import { defineConfig } from '$fresh/server.ts'
import tailwind from '$fresh/plugins/tailwind.ts'
export default defineConfig({
plugins: [tailwind()],
})
// deno.json
{
"lock": false,
"tasks": {
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
"dev": "deno run -A --watch=static/,routes/ dev.ts",
"build": "deno run -A dev.ts build",
"start": "deno run -A main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update ."
},
"imports": {
"$fresh/": "https://deno.land/x/[email protected]/",
"$std/": "https://deno.land/[email protected]/",
"preact": "https://esm.sh/[email protected]",
"preact/": "https://esm.sh/[email protected]/",
"@preact/signals": "https://esm.sh/@preact/[email protected]",
"zod": "https://deno.land/x/[email protected]/mod.ts"
},
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}
The project uses GitHub Actions for continuous integration, configuration file is located at .github/workflows/ci.yml.
| Task | Description | Trigger |
|---|---|---|
| lint | Format check, code check, type check | push/PR |
| test | Run tests and upload coverage | push/PR |
| build | Production build verification | After lint/test pass |
| security | Deno security audit | push/PR |
| dependency-review | Dependency security review | PR only |
// deno.json
{
"lint": {
"rules": {
"tags": ["recommended"],
"exclude": [
"no-explicit-any",
"explicit-function-return-type",
"explicit-module-boundary-types",
"jsx-button-has-type",
"no-unused-vars"
]
}
},
"fmt": {
"lineWidth": 100,
"indentWidth": 2,
"singleQuote": false,
"semiColons": true
}
}
// routes/dashboard/_middleware.ts
import { FreshContext } from '$fresh/server.ts'
import { getCookies } from '$std/http/cookie.ts'
import { verifyToken, getUser } from '../../lib/auth.ts'
export async function handler(req: Request, ctx: FreshContext) {
const cookies = getCookies(req.headers)
const token = cookies.token
if (!token) {
const url = new URL(req.url)
return new Response(null, {
status: 302,
headers: { Location: `/auth/login?redirect=${url.pathname}` },
})
}
try {
const payload = await verifyToken(token)
const user = await getUser(payload.userId)
ctx.state.user = user
ctx.state.token = token
} catch {
return new Response(null, {
status: 302,
headers: { Location: '/auth/login' },
})
}
return ctx.next()
}
// routes/dashboard/_layout.tsx
import { PageProps } from '$fresh/server.ts'
import { AdminLayout } from '../../components/layout/AdminLayout.tsx'
import Sidebar from '../../islands/Sidebar.tsx'
export default function DashboardLayout({ Component, state }: PageProps) {
return (
<AdminLayout>
<div class="flex min-h-screen">
<Sidebar user={state.user} />
<main class="flex-1 p-6">
<Component />
</main>
</div>
</AdminLayout>
)
}
Fresh defaults to zero JS, only interactive components need hydration:
// Static component (components/) - Zero JS
export function Card({ title, content }) {
return (
<div class="card">
<h2>{title}</h2>
<p>{content}</p>
</div>
)
}
// Interactive Island (islands/) - Hydrate on demand
export default function Counter() {
const count = useSignal(0)
return (
<button onClick={() => count.value++}>
Count: {count.value}
</button>
)
}
// Leverage Deno Deploy edge runtime
export const handler: Handlers = {
async GET(req) {
// Execute at edge nodes, reduce latency
const data = await fetchFromDatabase()
return new Response(JSON.stringify(data))
}
}
// Preload critical resources
<link rel="preload" href="/api/auth/me" as="fetch" crossOrigin="anonymous" />
A: Use @preact/signals, which works on both server and client:
// signals/auth.ts
export const user = signal<User | null>(null)
// islands/UserProfile.tsx (client-side)
import { user } from '../signals/auth.ts'
export default function UserProfile() {
return <div>{user.value?.name}</div>
}
// routes/dashboard/index.tsx (server-side)
import { user } from '../signals/auth.ts'
export default function Dashboard({ data }: PageProps) {
return <div>Welcome {data.user.name}</div>
}
A: Fresh uses Deno's environment variable system:
// Read environment variable
const apiUrl = Deno.env.get('API_URL') || '/api'
// .env file (development)
// Automatically loaded with deno task dev
A: Use Deno KV (built-in key-value database):
// lib/db.ts
const kv = await Deno.openKv()
export async function saveUser(user: User) {
await kv.set(['users', user.id], user)
}
export async function getUser(id: number) {
const result = await kv.get(['users', id])
return result.value as User
}
| Feature | Fresh Version | Astro Version | Next.js Version |
|---|---|---|---|
| Runtime | Deno | Node.js | Node.js |
| State Management | @preact/signals | - | Zustand |
| Data Fetching | Handlers | Load functions | TanStack Query |
| Form Validation | Zod | Zod | React Hook Form + Zod |
| Server-side | Built-in | @astrojs/node | API Routes |
| Component Library | Custom | - | shadcn/ui |
| Islands Architecture | ✅ | ✅ | ❌ |
| Zero Config | ✅ | ❌ | ❌ |
| Edge Deployment | Deno Deploy | Cloudflare | Vercel Edge |
| Build Step | Optional | Required | Required |
Choose your preferred frontend framework and match it with a backend API to quickly start with HaloLight.
HaloLight uses a fully decoupled frontend-backend architecture, supporting 12 Frontends × 8 Backends = 96 Combinations.
| Framework | Use Cases | Characteristics |
|---|---|---|
| Next.js / Nuxt | Multi-tenant SaaS, SEO needs | SSR + Edge rendering friendly |
| Vue | Quick delivery for small-medium teams | Lightweight, smooth learning curve |
| Angular | Large-scale, long-term projects | Strong typing, clear architecture |
| SvelteKit / Solid / Qwik | High interaction, real-time scenarios | Ultimate performance & reactivity |
| Remix / Preact / Lit | Progressive enhancement, lightweight | Web Components, small bundle size |
| Astro | Content-focused admin panels | Islands architecture, zero JS by default |
| Backend Tech | Use Cases | Characteristics |
|---|---|---|
| NestJS / Express | Node.js ecosystem teams | High compatibility with frontend TS |
| FastAPI | Data/AI-driven applications | Python ecosystem, rapid iteration |
| Spring Boot | Enterprise, financial industry | Mature middleware ecosystem |
| Go Fiber | High performance, high concurrency | Low resource usage |
| PHP Laravel | Traditional web teams | Complete ecosystem, easy to learn |
| Bun + Hono | Ultimate performance pursuit | Next-gen runtime |
| tRPC BFF | Mobile/Desktop multi-platform | Type sharing, aggregation & simplification |
Recommended: Next.js + NestJS
Advantages:
Recommended: Vue + FastAPI or React + FastAPI
Advantages:
Recommended: Angular + Spring Boot
Advantages:
Recommended: SvelteKit + Go Fiber
Advantages:
Recommended: Any Frontend + tRPC BFF + Any Backend
Advantages:
# Clone repository
git clone https://github.com/halolight/halolight.git
cd halolight
# Install dependencies
pnpm install
# Start development server
pnpm dev
Visit http://localhost:3000 to see the result.
Detailed documentation: Next.js Version Guide
# Clone repository
git clone https://github.com/halolight/halolight-vue.git
cd halolight-vue
# Install dependencies
pnpm install
# Start development server
pnpm dev
Visit http://localhost:5173 to see the result.
Detailed documentation: Vue Version Guide
All versions use the same Mock credentials:
| Role | Password | |
|---|---|---|
| Admin | [email protected] | 123456 |
project-root/
├── src/
│ ├── app/ # Page routes
│ ├── components/ # Components
│ │ ├── ui/ # shadcn/ui components
│ │ ├── layout/ # Layout components
│ │ └── dashboard/ # Dashboard components
│ ├── hooks/ # Custom hooks
│ ├── stores/ # State management
│ ├── services/ # API services
│ ├── lib/ # Utility library
│ ├── types/ # Type definitions
│ └── mocks/ # Mock data
├── public/ # Static assets
└── package.json
# Terminal 1: Start frontend
git clone https://github.com/halolight/halolight.git && cd halolight
pnpm install && pnpm dev
# Terminal 2: Start backend
git clone https://github.com/halolight/halolight-api-nestjs.git && cd halolight-api-nestjs
pnpm install && pnpm dev
# Terminal 1: Start frontend
git clone https://github.com/halolight/halolight-vue.git && cd halolight-vue
pnpm install && pnpm dev
# Terminal 2: Start backend
git clone https://github.com/halolight/halolight-api-python.git && cd halolight-api-python
pip install -e ".[dev]" && uvicorn app.main:app --reload
HaloLight is a multi-framework enterprise-level admin dashboard solution.
HaloLight follows the philosophy of "one design specification, multiple framework implementations", providing developers with a unified Admin Dashboard experience. Whether you use React, Vue, Angular, or other modern frameworks, you'll get consistent functionality and design.
Custom Dashboard system based on Grid Layout, supporting:
Complete RBAC permission management system:
users:*, *)Rich visual customization capabilities:
Based on shadcn/ui design system:
All framework versions have been implemented and deployed (preview links available in respective repository READMEs). Current reference implementations:
| Framework | Status | Preview | Repository |
|---|---|---|---|
| Next.js 14 | ✅ Deployed | Preview | GitHub |
| React (Vite) | ✅ Deployed | Preview | GitHub |
| Vue 3.5 | ✅ Deployed | Preview | GitHub |
| Angular 21 | ✅ Deployed | Preview | GitHub |
| Nuxt 4 | ✅ Deployed | Preview | GitHub |
| SvelteKit 2 | ✅ Deployed | Preview | GitHub |
| Astro 5 | ✅ Deployed | Preview | GitHub |
| Solid.js | ✅ Deployed | Preview | GitHub |
| Qwik | ✅ Deployed | Preview | GitHub |
| Remix | ✅ Deployed | Preview | GitHub |
| Preact | ✅ Deployed | Preview | GitHub |
| Lit | ✅ Deployed | Preview | GitHub |
| Fresh (Deno) | ✅ Deployed | Preview | GitHub |
| Deno | ✅ Deployed | Preview | GitHub |
💡 Flexible Combinations: Frontend mainline supports 14 frameworks, any frontend can combine with 7 backend APIs, forming 98+ combination options.
| Backend Tech | Status | Preview | Repository |
|---|---|---|---|
| NestJS 11 | ✅ Deployed | API Docs | GitHub |
| Python FastAPI | ✅ Deployed | API Docs | GitHub |
| Java Spring Boot | ✅ Deployed | API Docs | GitHub |
| Go Fiber | ✅ Deployed | API Docs | GitHub |
| Node.js Express | ✅ Deployed | - | GitHub |
| PHP Laravel | ✅ Deployed | - | GitHub |
| Bun + Hono | ✅ Deployed | - | GitHub |
| Project | Status | Description | Repository |
|---|---|---|---|
| tRPC BFF | ✅ Deployed | Type-safe gateway | GitHub |
| Next.js Action | ✅ Deployed | Server Actions full-stack | GitHub |
All framework versions share the following tech stack:
HaloLight Lit version is built on Lit 3 with Web Components standards + TypeScript, providing cross-framework reusable Web Components library.
Live Preview: https://halolight-lit.h7ml.cn
GitHub: https://github.com/halolight/halolight-lit
| Technology | Version | Description |
|---|---|---|
| Lit | 3.x | Web Components framework |
| TypeScript | 5.x | Type safety |
| Tailwind CSS | 4.x | Atomic CSS |
| @lit-labs/router | 0.1.x | Client-side routing |
| @lit-labs/context | 1.x | Context state |
| Shoelace | 2.x | Web Components UI library |
| Zod | 3.x | Data validation |
| ECharts | 5.x | Chart visualization |
| Vite | 6.x | Build tool |
| Mock.js | 1.x | Data mocking |
halolight-lit/
├── src/
│ ├── pages/ # Page components
│ │ ├── hl-home.ts # Homepage
│ │ ├── auth/ # Auth pages
│ │ │ ├── hl-login.ts
│ │ │ ├── hl-register.ts
│ │ │ ├── hl-forgot-password.ts
│ │ │ └── hl-reset-password.ts
│ │ └── dashboard/ # Dashboard pages
│ │ ├── hl-dashboard.ts
│ │ ├── hl-users.ts
│ │ ├── hl-user-detail.ts
│ │ ├── hl-user-create.ts
│ │ ├── hl-roles.ts
│ │ ├── hl-permissions.ts
│ │ ├── hl-settings.ts
│ │ └── hl-profile.ts
│ ├── components/ # Component library
│ │ ├── ui/ # UI components
│ │ │ ├── hl-button.ts
│ │ │ ├── hl-input.ts
│ │ │ ├── hl-card.ts
│ │ │ └── hl-dialog.ts
│ │ ├── layout/ # Layout components
│ │ │ ├── hl-admin-layout.ts
│ │ │ ├── hl-auth-layout.ts
│ │ │ ├── hl-sidebar.ts
│ │ │ └── hl-header.ts
│ │ ├── dashboard/ # Dashboard components
│ │ │ ├── hl-dashboard-grid.ts
│ │ │ ├── hl-widget-wrapper.ts
│ │ │ └── hl-stats-widget.ts
│ │ └── shared/ # Shared components
│ │ └── hl-permission-guard.ts
│ ├── stores/ # State management
│ │ ├── auth-context.ts
│ │ ├── ui-settings-context.ts
│ │ └── dashboard-context.ts
│ ├── lib/ # Utilities
│ │ ├── api.ts
│ │ ├── permission.ts
│ │ └── styles.ts
│ ├── mock/ # Mock data
│ ├── types/ # Type definitions
│ ├── hl-app.ts # Root component
│ ├── router.ts # Route config
│ └── main.ts # Entry file
├── public/ # Static assets
├── vite.config.ts # Vite config
├── tailwind.config.ts # Tailwind config
└── package.json
git clone https://github.com/halolight/halolight-lit.git
cd halolight-lit
pnpm install
cp .env.example .env
# .env
VITE_API_URL=/api
VITE_USE_MOCK=true
[email protected]
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=false
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight
pnpm dev
Visit http://localhost:5173
pnpm build
pnpm preview
// stores/auth-context.ts
import { createContext } from '@lit-labs/context'
import { html, LitElement } from 'lit'
import { customElement, state } from 'lit/decorators.js'
import { provide } from '@lit-labs/context'
interface User {
id: number
name: string
email: string
permissions: string[]
}
interface AuthState {
user: User | null
token: string | null
loading: boolean
login: (credentials: { email: string; password: string }) => Promise<void>
logout: () => void
hasPermission: (permission: string) => boolean
}
export const authContext = createContext<AuthState>('auth')
@customElement('hl-auth-provider')
export class AuthProvider extends LitElement {
@state() private user: User | null = null
@state() private token: string | null = null
@state() private loading = false
@provide({ context: authContext })
authState: AuthState = {
user: null,
token: null,
loading: false,
login: this.login.bind(this),
logout: this.logout.bind(this),
hasPermission: this.hasPermission.bind(this),
}
connectedCallback() {
super.connectedCallback()
this.loadFromStorage()
}
private loadFromStorage() {
const saved = localStorage.getItem('auth')
if (saved) {
const { user, token } = JSON.parse(saved)
this.user = user
this.token = token
this.updateContext()
}
}
private updateContext() {
this.authState = {
...this.authState,
user: this.user,
token: this.token,
loading: this.loading,
}
}
async login(credentials: { email: string; password: string }) {
this.loading = true
this.updateContext()
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
headers: { 'Content-Type': 'application/json' },
})
const data = await response.json()
this.user = data.user
this.token = data.token
localStorage.setItem('auth', JSON.stringify({
user: this.user,
token: this.token,
}))
} finally {
this.loading = false
this.updateContext()
}
}
logout() {
this.user = null
this.token = null
localStorage.removeItem('auth')
this.updateContext()
}
hasPermission(permission: string): boolean {
const perms = this.user?.permissions ?? []
return perms.some(p =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
}
render() {
return html`<slot></slot>`
}
}
// components/ui/hl-button.ts
import { LitElement, html, css } from 'lit'
import { customElement, property } from 'lit/decorators.js'
import { classMap } from 'lit/directives/class-map.js'
@customElement('hl-button')
export class HlButton extends LitElement {
static styles = css`
:host {
display: inline-block;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.default {
background-color: var(--primary);
color: var(--primary-foreground);
}
.default:hover {
opacity: 0.9;
}
.destructive {
background-color: var(--destructive);
color: var(--destructive-foreground);
}
.outline {
border: 1px solid var(--border);
background: transparent;
}
.sm { height: 2rem; padding: 0 0.75rem; font-size: 0.875rem; }
.md { height: 2.5rem; padding: 0 1rem; }
.lg { height: 3rem; padding: 0 1.5rem; font-size: 1.125rem; }
.disabled {
opacity: 0.5;
cursor: not-allowed;
}
`
@property() variant: 'default' | 'destructive' | 'outline' | 'ghost' = 'default'
@property() size: 'sm' | 'md' | 'lg' = 'md'
@property({ type: Boolean }) disabled = false
render() {
const classes = {
[this.variant]: true,
[this.size]: true,
disabled: this.disabled,
}
return html`
<button class=${classMap(classes)} ?disabled=${this.disabled}>
<slot></slot>
</button>
`
}
}
// router.ts
import { Router } from '@lit-labs/router'
import { html } from 'lit'
// Lazy load page components
const routes = [
{
path: '/',
render: () => html`<hl-home></hl-home>`,
enter: async () => {
await import('./pages/hl-home.js')
return true
},
},
{
path: '/login',
render: () => html`<hl-login></hl-login>`,
enter: async () => {
await import('./pages/auth/hl-login.js')
return true
},
},
{
path: '/dashboard',
render: () => html`<hl-dashboard></hl-dashboard>`,
enter: async ({ router }) => {
// Route guard
const authState = document.querySelector('hl-auth-provider')?.authState
if (!authState?.token) {
router.goto('/login?redirect=/dashboard')
return false
}
await import('./pages/dashboard/hl-dashboard.js')
return true
},
},
{
path: '/users',
render: () => html`<hl-users></hl-users>`,
enter: async ({ router }) => {
const authState = document.querySelector('hl-auth-provider')?.authState
if (!authState?.hasPermission('users:list')) {
return false
}
await import('./pages/dashboard/hl-users.js')
return true
},
},
// More routes...
]
export function createRouter(host: HTMLElement) {
return new Router(host, routes)
}
// components/shared/hl-permission-guard.ts
import { LitElement, html } from 'lit'
import { customElement, property } from 'lit/decorators.js'
import { consume } from '@lit-labs/context'
import { authContext, type AuthState } from '../../stores/auth-context'
@customElement('hl-permission-guard')
export class HlPermissionGuard extends LitElement {
@property() permission = ''
@consume({ context: authContext, subscribe: true })
authState!: AuthState
render() {
const hasPermission = this.authState?.hasPermission(this.permission)
if (!hasPermission) {
return html`<slot name="fallback"></slot>`
}
return html`<slot></slot>`
}
}
Usage Example:
<hl-permission-guard permission="users:delete">
<hl-button variant="destructive">Delete</hl-button>
<span slot="fallback" class="text-muted-foreground">No permission</span>
</hl-permission-guard>
// components/dashboard/hl-dashboard-grid.ts
import { LitElement, html, css } from 'lit'
import { customElement, state } from 'lit/decorators.js'
import Sortable from 'sortablejs'
@customElement('hl-dashboard-grid')
export class HlDashboardGrid extends LitElement {
static styles = css`
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.widget {
background: var(--card);
border-radius: 0.5rem;
padding: 1rem;
cursor: move;
}
`
@state() private widgets = [
{ id: 'stats', type: 'stats' },
{ id: 'chart', type: 'chart' },
{ id: 'table', type: 'table' },
]
firstUpdated() {
const grid = this.shadowRoot?.querySelector('.grid')
if (grid) {
new Sortable(grid as HTMLElement, {
animation: 150,
onEnd: (evt) => {
const { oldIndex, newIndex } = evt
if (oldIndex !== undefined && newIndex !== undefined) {
const item = this.widgets.splice(oldIndex, 1)[0]
this.widgets.splice(newIndex, 0, item)
this.requestUpdate()
}
},
})
}
}
render() {
return html`
<div class="grid">
${this.widgets.map(widget => html`
<div class="widget" data-id=${widget.id}>
<hl-widget-wrapper type=${widget.type}></hl-widget-wrapper>
</div>
`)}
</div>
`
}
}
Supports 11 preset skins, switch via quick settings panel:
| Skin | Primary Color | CSS Variable |
|---|---|---|
| Default | Purple | --primary: 51.1% 0.262 276.97 |
| Blue | Blue | --primary: 54.8% 0.243 264.05 |
| Emerald | Emerald | --primary: 64.6% 0.178 142.49 |
| Rose | Rose | --primary: 58.5% 0.217 12.53 |
| Orange | Orange | --primary: 68.4% 0.197 41.73 |
/* Theme variable definition */
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 98% 0 0;
--card: 100% 0 0;
--card-foreground: 14.9% 0.017 285.75;
--border: 93.3% 0.011 285.88;
--radius: 0.5rem;
}
.dark {
--background: 14.9% 0.017 285.75;
--foreground: 98% 0 0;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 98% 0 0;
--card: 14.9% 0.017 285.75;
--card-foreground: 98% 0 0;
--border: 25.1% 0.025 285.82;
}
| Path | Page | Permission |
|---|---|---|
/ |
Homepage | Public |
/login |
Login | Public |
/register |
Register | Public |
/forgot-password |
Forgot Password | Public |
/reset-password |
Reset Password | Public |
/dashboard |
Dashboard | dashboard:view |
/users |
User Management | users:view |
/users/create |
Create User | users:create |
/users/:id |
User Detail | users:view |
/roles |
Role Management | roles:view |
/permissions |
Permission Management | permissions:view |
/settings |
System Settings | settings:view |
/profile |
Profile | settings:view |
import '@halolight/lit/hl-button'
function App() {
return (
<hl-button variant="default" onClick={() => console.log('clicked')}>
Click
</hl-button>
)
}
<template>
<hl-button variant="default" @click="handleClick">
Click
</hl-button>
</template>
<script setup>
import '@halolight/lit/hl-button'
function handleClick() {
console.log('clicked')
}
</script>
// app.module.ts
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'
import '@halolight/lit/hl-button'
@NgModule({
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<hl-button variant="default" (click)="handleClick()">
Click
</hl-button>
pnpm dev # Start dev server
pnpm build # Production build
pnpm preview # Preview production build
pnpm lint # Code linting
pnpm lint:fix # Auto fix
pnpm type-check # Type checking
pnpm test # Run tests
pnpm test:coverage # Test coverage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
| Role | Password | |
|---|---|---|
| Admin | [email protected] | 123456 |
| User | [email protected] | 123456 |
pnpm test # Run tests (watch mode)
pnpm test:run # Single run
pnpm test:coverage # Coverage report
pnpm test:ui # Vitest UI
// __tests__/hl-button.test.ts
import { expect, fixture, html } from '@open-wc/testing'
import '../src/components/ui/hl-button'
describe('hl-button', () => {
it('renders with default variant', async () => {
const el = await fixture(html`<hl-button>Click me</hl-button>`)
const button = el.shadowRoot?.querySelector('button')
expect(button).to.exist
expect(button?.textContent?.trim()).to.equal('Click me')
})
it('applies variant classes', async () => {
const el = await fixture(html`<hl-button variant="destructive">Delete</hl-button>`)
const button = el.shadowRoot?.querySelector('button')
expect(button?.classList.contains('destructive')).to.be.true
})
it('handles disabled state', async () => {
const el = await fixture(html`<hl-button disabled>Disabled</hl-button>`)
const button = el.shadowRoot?.querySelector('button')
expect(button?.disabled).to.be.true
})
})
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
build: {
lib: {
entry: 'src/main.ts',
formats: ['es'],
},
rollupOptions: {
external: /^lit/,
},
},
server: {
port: 5173,
},
})
// tailwind.config.ts
import type { Config } from 'tailwindcss'
export default {
content: ['./index.html', './src/**/*.{ts,js}'],
darkMode: 'class',
theme: {
extend: {
colors: {
border: 'oklch(var(--border))',
background: 'oklch(var(--background))',
foreground: 'oklch(var(--foreground))',
primary: {
DEFAULT: 'oklch(var(--primary))',
foreground: 'oklch(var(--primary-foreground))',
},
},
},
},
} satisfies Config
Complete GitHub Actions CI workflow configured:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm type-check
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
// Component lifecycle
@customElement('my-component')
export class MyComponent extends LitElement {
// First connected to DOM
connectedCallback() {
super.connectedCallback()
console.log('Component connected')
}
// First update complete
firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties)
console.log('First render complete')
}
// Each update complete
updated(changedProperties: PropertyValues) {
super.updated(changedProperties)
if (changedProperties.has('value')) {
console.log('Value changed:', this.value)
}
}
// Removed from DOM
disconnectedCallback() {
super.disconnectedCallback()
console.log('Component disconnected')
}
}
// lib/directives/tooltip.ts
import { directive, Directive } from 'lit/directive.js'
import { AsyncDirective } from 'lit/async-directive.js'
class TooltipDirective extends AsyncDirective {
render(text: string) {
return text
}
update(part: any, [text]: [string]) {
const element = part.element
element.setAttribute('title', text)
element.style.cursor = 'help'
return this.render(text)
}
}
export const tooltip = directive(TooltipDirective)
// Usage
import { tooltip } from './lib/directives/tooltip'
render() {
return html`
<span ${tooltip('Tooltip message')}>Hover to see tooltip</span>
`
}
// components/ui/hl-virtual-list.ts
import { LitElement, html } from 'lit'
import { customElement, property, state } from 'lit/decorators.js'
import { repeat } from 'lit/directives/repeat.js'
@customElement('hl-virtual-list')
export class HlVirtualList extends LitElement {
@property({ type: Array }) items: any[] = []
@property({ type: Number }) itemHeight = 50
@state() private visibleStart = 0
@state() private visibleEnd = 20
private handleScroll(e: Event) {
const target = e.target as HTMLElement
const scrollTop = target.scrollTop
this.visibleStart = Math.floor(scrollTop / this.itemHeight)
this.visibleEnd = this.visibleStart + 20
}
render() {
const visibleItems = this.items.slice(this.visibleStart, this.visibleEnd)
return html`
<div class="container" @scroll=${this.handleScroll}>
<div style="height: ${this.items.length * this.itemHeight}px">
<div style="transform: translateY(${this.visibleStart * this.itemHeight}px)">
${repeat(
visibleItems,
item => item.id,
item => html`<div class="item">${item.name}</div>`
)}
</div>
</div>
</div>
`
}
}
// Route lazy loading
{
path: '/dashboard',
enter: async () => {
await import('./pages/dashboard/hl-dashboard.js')
return true
},
}
// Dynamic import
async loadWidget(type: string) {
const module = await import(`./widgets/hl-${type}-widget.js`)
return module.default
}
// Preload critical routes
const preloadRoutes = ['/dashboard', '/users']
preloadRoutes.forEach(async (route) => {
const link = document.createElement('link')
link.rel = 'modulepreload'
link.href = `./pages${route}.js`
document.head.appendChild(link)
})
A: Use CSS custom properties or @import global styles:
static styles = css`
@import url('/global.css');
:host {
color: var(--foreground);
background: var(--background);
}
`
A: Use @input event and @state decorator:
@customElement('hl-form')
export class HlForm extends LitElement {
@state() private formData = { name: '', email: '' }
private handleInput(field: string, value: string) {
this.formData = { ...this.formData, [field]: value }
}
render() {
return html`
<input
.value=${this.formData.name}
@input=${(e: Event) =>
this.handleInput('name', (e.target as HTMLInputElement).value)}
/>
`
}
}
A: Use custom events or Context API:
// Dispatch event
this.dispatchEvent(new CustomEvent('data-changed', {
detail: { data: this.data },
bubbles: true,
composed: true, // Penetrate Shadow DOM
}))
// Listen event
@customElement('parent-component')
export class ParentComponent extends LitElement {
render() {
return html`
<child-component @data-changed=${this.handleDataChanged}></child-component>
`
}
private handleDataChanged(e: CustomEvent) {
console.log('Data:', e.detail.data)
}
}
| Feature | Lit Version | Next.js Version | Vue Version |
|---|---|---|---|
| SSR/SSG | ✅ (Experimental) | ✅ | ✅ (Nuxt) |
| State Management | @lit-labs/context | Zustand | Pinia |
| Routing | @lit-labs/router | App Router | Vue Router |
| Build Tool | Vite | Next.js | Vite |
| Cross-framework Reusable | ✅ Native Support | ❌ | ❌ |
| Shadow DOM | ✅ | ❌ | ❌ |
| Bundle Size | 5KB (gzip) | ~90KB | ~60KB |
HaloLight Netlify deployment edition, a one-click deployment solution optimized for the Netlify platform.
Live Preview: https://halolight-netlify.h7ml.cn
GitHub: https://github.com/halolight/halolight-netlify
After clicking the button:
```bash
npm install -g netlify-cli
netlify login
git clone https://github.com/halolight/halolight-netlify.git cd halolight-netlify
pnpm install
netlify init
netlify dev
netlify deploy --prod ```
```toml [build] command = "pnpm build" publish = ".next"
[build.environment] NODE_VERSION = "20" PNPM_VERSION = "9"
[[plugins]] package = "@netlify/plugin-nextjs"
[context.production] command = "pnpm build"
[context.production.environment] NEXT_PUBLIC_MOCK = "false"
[context.deploy-preview] command = "pnpm build"
[context.deploy-preview.environment] NEXT_PUBLIC_MOCK = "true"
[context.branch-deploy] command = "pnpm build"
[[redirects]] from = "/api/*" to = "/.netlify/functions/:splat" status = 200
[[redirects]] from = "/*" to = "/index.html" status = 200 conditions =
[[headers]] for = "/*" [headers.values] X-Frame-Options = "DENY" X-Content-Type-Options = "nosniff" X-XSS-Protection = "1; mode=block" Referrer-Policy = "strict-origin-when-cross-origin"
[[headers]] for = "/_next/static/*" [headers.values] Cache-Control = "public, max-age=31536000, immutable" ```
```json { "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "netlify:dev": "netlify dev", "netlify:build": "netlify build", "netlify:deploy": "netlify deploy --prod" } } ```
In Netlify console → Site settings → Environment variables:
| Variable | Description | Example |
|---|---|---|
| `NODE_ENV` | Runtime environment | `production` |
| `NEXT_PUBLIC_API_URL` | Base API URL | `/api` |
| `NEXT_PUBLIC_MOCK` | Enable mock data | `false` |
| `NEXT_PUBLIC_APP_TITLE` | App title | `Admin Pro` |
| `DATABASE_URL` | Database connection | `postgresql://...` |
Netlify supports per-context variables:
``` Production - Production environment variables Deploy Preview - PR preview variables Branch Deploy - Branch deploy variables All - Shared across all environments ```
```typescript // netlify/functions/hello.ts import type { Handler, HandlerEvent, HandlerContext } from "@netlify/functions";
export const handler: Handler = async ( event: HandlerEvent, context: HandlerContext ) => { const { httpMethod, body, queryStringParameters } = event;
return { statusCode: 200, headers: { "Content-Type": "application/json", }, body: JSON.stringify({ message: "Hello from Netlify Functions!", method: httpMethod, query: queryStringParameters, }), }; }; ```
```typescript // netlify/functions/background-task.ts import type { BackgroundHandler } from "@netlify/functions";
export const handler: BackgroundHandler = async (event) => { // Maximum runtime 15 minutes console.log("Processing background task...");
// Perform time-consuming operations await processLongRunningTask(event.body);
// Background functions don't return a response };
// Configure as background function export const config = { type: "background", }; ```
```typescript // netlify/functions/daily-report.ts import type { Handler } from "@netlify/functions";
export const handler: Handler = async () => { console.log("Generating daily report...");
await generateReport();
return { statusCode: 200, body: "Report generated", }; };
// Run daily at 9:00 UTC export const config = { schedule: "0 9 * * *", }; ```
```typescript // netlify/edge-functions/geolocation.ts import type { Context } from "@netlify/edge-functions";
export default async (request: Request, context: Context) => { const { country, city } = context.geo;
// Geo-based response return new Response( JSON.stringify({ country, city, message: `Hello from ${city}, ${country}!`, }), { headers: { "Content-Type": "application/json" }, } ); };
export const config = { path: "/api/geo", }; ```
```typescript // lib/netlify-identity.ts import netlifyIdentity from "netlify-identity-widget";
// Initialize netlifyIdentity.init({ container: "#netlify-modal", locale: "zh", });
// Login export function login() { netlifyIdentity.open("login"); }
// Signup export function signup() { netlifyIdentity.open("signup"); }
// Logout export function logout() { netlifyIdentity.logout(); }
// Get current user export function getCurrentUser() { return netlifyIdentity.currentUser(); }
// Listen for auth state netlifyIdentity.on("login", (user) => { console.log("User logged in:", user); netlifyIdentity.close(); });
netlifyIdentity.on("logout", () => { console.log("User logged out"); }); ```
```typescript // netlify/functions/protected.ts import type { Handler } from "@netlify/functions";
export const handler: Handler = async (event) => { const { user } = event.context.clientContext || {};
if (!user) { return { statusCode: 401, body: JSON.stringify({ error: "Unauthorized" }), }; }
return {
statusCode: 200,
body: JSON.stringify({
message: Hello \${user.email}!,
roles: user.app_metadata?.roles || [],
}),
};
};
## Form Handling
### HTML Form
\`\`\`html
<form name="contact" method="POST" data-netlify="true" netlify-honeypot="bot-field">
<input type="hidden" name="form-name" value="contact" />
<p class="hidden">
<label>Don't fill this out: <input name="bot-field" /></label>
</p>
<p>
<label>Email: <input type="email" name="email" required /></label>
</p>
<p>
<label>Message: <textarea name="message" required></textarea></label>
</p>
<p>
<button type="submit">Send</button>
</p>
</form>
\`\`\`
### React Form
\`\`\`tsx
// components/ContactForm.tsx
"use client";
import { useState } from "react";
export function ContactForm() {
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setStatus("loading");
const formData = new FormData(e.currentTarget);
try {
const response = await fetch("/", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(formData as any).toString(),
});
if (response.ok) {
setStatus("success");
} else {
setStatus("error");
}
} catch {
setStatus("error");
}
};
return (
<form
name="contact"
method="POST"
data-netlify="true"
onSubmit={handleSubmit}
>
<input type="hidden" name="form-name" value="contact" />
{/* Form fields */}
<button type="submit" disabled={status === "loading"}>
{status === "loading" ? "Sending..." : "Send"}
</button>
{status === "success" && <p>Message sent!</p>}
{status === "error" && <p>Error sending message</p>}
</form>
);
}
\`\`\`
## Common Commands
\`\`\`bash
# Sign in
netlify login
# View site status
netlify status
# Local development
netlify dev
# Build
netlify build
# Deploy preview
netlify deploy
# Deploy to production
netlify deploy --prod
# Open site
netlify open
# Open dashboard
netlify open:admin
# View logs
netlify logs
# Environment variables
netlify env:list
netlify env:set KEY value
netlify env:unset KEY
# Link to site
netlify link
# Unlink
netlify unlink
\`\`\`
## Monitoring and Logs
### View Logs
\`\`\`bash
# CLI view logs
netlify logs
# Live log stream
netlify logs --live
# Function logs
netlify logs:function hello
\`\`\`
### Build Plugins
\`\`\`toml
# netlify.toml
# Performance analysis
[[plugins]]
package = "netlify-plugin-lighthouse"
# Cache optimization
[[plugins]]
package = "netlify-plugin-cache"
[plugins.inputs]
paths = [".next/cache", "node_modules/.cache"]
# Commit status notifications
[[plugins]]
package = "netlify-plugin-checklinks"
\`\`\`
## Custom Domains
### Add Domain
1. Go to Site settings → Domain management
2. Click "Add custom domain"
3. Enter your domain
### DNS Configuration
\`\`\`
# A record (root)
Type: A
Name: @
Value: 75.2.60.5
# CNAME record (subdomain)
Type: CNAME
Name: www
Value: your-site.netlify.app
\`\`\`
### HTTPS
Netlify configures HTTPS automatically:
- Automatically requests Let's Encrypt certificates
- Auto renewal
- Enforces HTTPS redirects
## Branch Deploys and Previews
### Deploy Contexts
| Context | Trigger | URL format |
|--------|----------|----------|
| Production | main branch push | \`your-site.netlify.app\` |
| Deploy Preview | PR created/updated | \`deploy-preview-123--your-site.netlify.app\` |
| Branch Deploy | Other branch push | \`branch-name--your-site.netlify.app\` |
### Lock Deploys
\`\`\`bash
# Lock current deploy (stop auto deploys)
netlify deploy:lock
# Unlock
netlify deploy:unlock
\`\`\`
## FAQ
### Q: What if the build fails?
A: Check the following:
1. Review build logs and ensure dependencies install correctly
2. Confirm \`pnpm-lock.yaml\` is committed
3. Check Node.js version (build.environment.NODE_VERSION)
4. Verify the build command is correct
### Q: How to roll back a deployment?
A: On the Deploys page:
1. Find a previous successful deploy
2. Click "Publish deploy"
3. Or use CLI: \`netlify rollback\`
### Q: Functions cold starts are slow?
A: Optimization tips:
1. Reduce function bundle size
2. Use Edge Functions (no cold start)
3. Use Background Functions for heavy tasks
### Q: How to set redirects?
A: Configure in \`netlify.toml\` or \`_redirects\`:
\`\`\`toml
# netlify.toml
[[redirects]]
from = "/old-path"
to = "/new-path"
status = 301
# Proxy
[[redirects]]
from = "/api/*"
to = "https://api.example.com/:splat"
status = 200
\`\`\`
## Pricing
| Plan | Price | Features |
|------|------|------|
| Starter | Free | 100GB bandwidth, 300 build minutes |
| Pro | $19/member/month | 1TB bandwidth, 25000 build minutes |
| Business | $99/member/month | Custom SLA, SSO |
| Enterprise | Contact sales | Dedicated support, compliance |
### Functions Quotas
| Plan | Invocations | Runtime |
|------|----------|----------|
| Starter | 125K/month | 100 hours |
| Pro | Unlimited | 1000 hours |
## Comparison With Other Platforms
| Feature | Netlify | Vercel | Cloudflare |
|------|---------|--------|------------|
| One-click deploy | ✅ | ✅ | ✅ |
| Edge Functions | ✅ | ✅ | ✅ |
| Form handling | ✅ Built-in | ❌ External | ❌ External |
| Identity | ✅ Built-in | ❌ External | ✅ Access |
| Free bandwidth | 100GB | 100GB | Unlimited |
| Free builds | 300 minutes | 6000 minutes | 500 runs |
| Split Testing | ✅ | ⚠️ Limited | ❌ |
## Related Links
- [Live Preview](https://halolight-netlify.h7ml.cn)
- [GitHub Repository](https://github.com/halolight/halolight-netlify)
- [Netlify Docs](https://docs.netlify.com)
- [Netlify CLI Docs](https://cli.netlify.com)
- [Netlify Functions Docs](https://docs.netlify.com/functions/overview)
- [HaloLight Docs](https://docs.halolight.h7ml.cn)
HaloLight Next.js version is built on Next.js 14 App Router with React 18 + TypeScript.
Live Preview: https://halolight.h7ml.cn/
GitHub: https://github.com/halolight/halolight
| Technology | Version | Description |
|---|---|---|
| Next.js | 14.x | React full-stack framework (App Router) |
| React | 18.x | UI library |
| TypeScript | 5.x | Type safety |
| Tailwind CSS | 4.x | Atomic CSS |
| shadcn/ui | latest | UI component library (28 components) |
| Zustand | 5.x | State management (6 Stores) |
| TanStack Query | 5.x | Server state |
| React Hook Form | 7.x | Form handling |
| Zod | 4.x | Data validation |
| react-grid-layout | 1.x | Drag-and-drop layout |
| Recharts | 3.x | Chart visualization |
| Framer Motion | 12.x | Animation effects |
| Mock.js | 1.x | Data mocking |
| next-pwa | 5.x | PWA support |
halolight/
├── src/
│ ├── app/ # App Router pages
│ │ ├── (auth)/ # Auth route group
│ │ │ ├── login/ # Login
│ │ │ ├── register/ # Register
│ │ │ ├── forgot-password/ # Forgot password
│ │ │ ├── reset-password/ # Reset password
│ │ │ └── layout.tsx # Auth layout
│ │ ├── (dashboard)/ # Dashboard route group
│ │ │ ├── page.tsx # Dashboard home (configurable)
│ │ │ ├── accounts/ # Account & permissions
│ │ │ ├── analytics/ # Analytics
│ │ │ ├── calendar/ # Calendar
│ │ │ ├── docs/ # Help docs
│ │ │ ├── documents/ # Document management
│ │ │ ├── files/ # File storage
│ │ │ ├── messages/ # Message center
│ │ │ ├── notifications/ # Notification center
│ │ │ ├── profile/ # User profile
│ │ │ ├── users/ # User management
│ │ │ ├── settings/ # System settings
│ │ │ │ └── teams/ # Team management
│ │ │ │ └── roles/ # Role management
│ │ │ └── layout.tsx # Dashboard layout
│ │ ├── (legal)/ # Legal route group
│ │ │ ├── privacy/ # Privacy policy
│ │ │ ├── terms/ # Terms of service
│ │ │ └── layout.tsx
│ │ ├── layout.tsx # Root layout
│ │ ├── error.tsx # Error page
│ │ └── not-found.tsx # 404 page
│ ├── components/
│ │ ├── ui/ # shadcn/ui components (28)
│ │ ├── layout/ # Layout components (11)
│ │ │ ├── admin-layout.tsx # Admin layout
│ │ │ ├── sidebar.tsx # Collapsible sidebar
│ │ │ ├── header.tsx # Header (notifications/errors/user menu)
│ │ │ ├── footer.tsx # Footer
│ │ │ ├── tab-bar.tsx # Multi-tab navigation
│ │ │ ├── command-menu.tsx # Command palette (⌘K)
│ │ │ ├── quick-settings.tsx # UI settings panel
│ │ │ ├── theme-toggle.tsx # Theme toggle
│ │ │ └── pending-overlay.tsx # Loading overlay
│ │ ├── dashboard/ # Dashboard components
│ │ │ ├── configurable-dashboard.tsx # Configurable dashboard
│ │ │ ├── charts.tsx # Chart components
│ │ │ ├── stats-card.tsx # Stats card
│ │ │ └── recent-activity.tsx # Recent activity
│ │ └── shared/ # Shared components
│ ├── hooks/ # React Hooks (15)
│ │ ├── use-users.ts # User CRUD
│ │ ├── use-teams.ts # Team management
│ │ ├── use-messages.ts # Message management
│ │ ├── use-notifications.ts # Notification management
│ │ ├── use-calendar.ts # Calendar data
│ │ ├── use-documents.ts # Document management
│ │ ├── use-files.ts # File management
│ │ ├── use-dashboard.ts # Dashboard state
│ │ ├── use-dashboard-data.ts # Dashboard data Hook collection
│ │ ├── use-chart-palette.ts # Chart palette (theme-aware)
│ │ ├── use-action-mutation.ts # Server Action wrapper
│ │ ├── use-keep-alive.tsx # Page state caching
│ │ ├── use-tdk.ts # TDK management
│ │ └── use-title.ts # Page title
│ ├── stores/ # Zustand Stores (6)
│ │ ├── auth-store.ts # Auth state (with multi-account)
│ │ ├── ui-settings-store.ts # UI settings
│ │ ├── dashboard-store.ts # Dashboard state
│ │ ├── navigation-store.ts # Navigation state
│ │ ├── tabs-store.ts # Tab state
│ │ └── error-store.ts # Error collection
│ ├── providers/ # React Providers (8)
│ │ ├── app-providers.tsx # Provider aggregation
│ │ ├── auth-provider.tsx # Auth Provider
│ │ ├── theme-provider.tsx # Theme Provider
│ │ ├── query-provider.tsx # TanStack Query
│ │ ├── error-provider.tsx # Error handling
│ │ ├── permission-provider.tsx # Permission check
│ │ ├── websocket-provider.tsx # WebSocket real-time notifications
│ │ └── keep-alive-provider.tsx # Page keep-alive
│ ├── actions/ # Server Actions
│ ├── config/ # Configuration
│ │ ├── routes.ts # Routes & permissions config
│ │ └── tdk.ts # TDK config
│ ├── lib/ # Utility library
│ │ └── api/ # API client
│ ├── mock/ # Mock data (9 modules)
│ └── middleware.ts # Middleware (auth + security headers)
├── public/
│ ├── manifest.json # PWA manifest
│ ├── sw.js # Service Worker
│ ├── icons/ # PWA icons (8 sizes)
│ ├── screenshots/ # PWA screenshots
│ └── fonts/ # Self-hosted fonts
├── next.config.mjs # Next.js + PWA config
├── tailwind.config.js
├── tsconfig.json
└── package.json
git clone https://github.com/halolight/halolight.git
cd halolight
pnpm install
cp .env.example .env.local
# .env.local example
NEXT_PUBLIC_API_URL=/api
NEXT_PUBLIC_MOCK=true # Enable Mock data
[email protected]
NEXT_PUBLIC_DEMO_PASSWORD=123456
NEXT_PUBLIC_SHOW_DEMO_HINT=false
NEXT_PUBLIC_WS_URL= # WebSocket URL
NEXT_PUBLIC_APP_TITLE=Admin Pro
NEXT_PUBLIC_BRAND_NAME=Halolight
pnpm dev
Visit http://localhost:3000
pnpm build
pnpm start
| Role | Password | |
|---|---|---|
| Admin | [email protected] | 123456 |
| User | [email protected] | 123456 |
// stores/auth-store.ts
interface AuthState {
user: AccountWithToken | null
accounts: AccountWithToken[] // Multi-account list
activeAccountId: string | null // Current account
login: (data: LoginRequest) => Promise<void>
register: (data: RegisterRequest) => Promise<void>
logout: () => Promise<void>
switchAccount: (accountId: string) => Promise<void> // Quick account switch
forgotPassword: (email: string) => Promise<void>
resetPassword: (token: string, password: string) => Promise<void>
checkAuth: () => Promise<void>
}
// Cookie-based token storage with "Remember me" support (7 days / 1 day)
Cookies.set("token", response.token, {
expires: data.remember ? 7 : 1,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
})
// hooks/use-users.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
export function useUsers() {
const queryClient = useQueryClient()
// Query user list
const { data, isLoading } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
// Create user
const createUser = useMutation({
mutationFn: createUserApi,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
return { data, isLoading, createUser }
}
// Route permission config
export const ROUTE_PERMISSIONS: Record<string, Permission> = {
"/": "dashboard:view",
"/users": "users:view",
"/analytics": "analytics:view",
// ...
}
// Permission check
const { hasPermission } = usePermission()
if (hasPermission("users:delete")) {
// Show delete button
}
// Permission guard component
<PermissionGuard permission="users:delete" fallback={<Disabled />}>
<DeleteButton />
</PermissionGuard>
// Dashboard edit mode
const { isEditing, setIsEditing, addWidget, removeWidget, resetToDefault } = useDashboardStore()
// Responsive layout (columns auto-adapt)
// lg: 12 cols, md: 8 cols, sm: 4 cols, xs: 2 cols, mobile: 1 col
Supports 9 widget types:
| Widget Type | Description | Data Source |
|---|---|---|
stats |
Statistics card (4 metrics) | useDashboardStats |
chart-line |
Line chart (visit trends) | useDashboardVisits |
chart-bar |
Bar chart (sales statistics) | useDashboardSales |
chart-pie |
Pie chart (traffic distribution) | useDashboardPie |
recent-users |
Recent users list | useDashboardUsers |
notifications |
Notifications list | useDashboardNotifications |
tasks |
Todo tasks | useDashboardTasks |
calendar |
Today's schedule | useDashboardCalendar |
quick-actions |
Quick action shortcuts | Static config |
Supports 11 skin presets with live preview and smooth transition animations:
| Preset | Name | Description |
|---|---|---|
default |
Shadcn · Neutral | Official default neutral colors |
blue |
Shadcn · Blue | Blue primary + cool-toned charts |
emerald |
Shadcn · Emerald | Fresh green, ideal for data display |
amber |
Shadcn · Amber | Amber/orange, warm and vibrant |
violet |
Shadcn · Violet | High-saturation purple, tech feel |
rose |
Shadcn · Rose | Rose primary, contrasting charts |
teal |
Shadcn · Teal | Teal primary, modern feel |
slate |
Shadcn · Slate | Low-saturation gray-blue, utilitarian |
ocean |
Legacy · Ocean Blue | Blue-green gradient |
sunset |
Legacy · Sunset Orange | Orange-pink contrast |
aurora |
Legacy · Aurora Green | Cyan-green + purple |
/* Example variable definitions */
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 100% 0 0;
--muted: 96.4% 0.004 285.75;
--accent: 96.4% 0.004 285.75;
/* ... */
}
| Path | Page | Permission |
|---|---|---|
/ |
Redirect to dashboard | - |
/login |
Login | Public |
/register |
Register | Public |
/forgot-password |
Forgot password | Public |
/reset-password |
Reset password | Public |
/dashboard |
Configurable dashboard | dashboard:view |
/accounts |
Account & permissions | settings:view |
/analytics |
Analytics | analytics:view |
/calendar |
Calendar | calendar:view |
/documents |
Document management | documents:view |
/files |
File storage | files:view |
/messages |
Message center | messages:view |
/notifications |
Notification center | notifications:view |
/users |
User management | users:view |
/settings |
System settings | settings:view |
/settings/teams |
Team settings | settings:view |
/settings/teams/roles |
Role management | settings:view |
/profile |
User profile | settings:view |
/docs |
Help docs | documents:view |
/privacy |
Privacy policy | Public |
/terms |
Terms of service | Public |
cp .env.example .env.local
# .env.local
NEXT_PUBLIC_API_URL=/api
NEXT_PUBLIC_MOCK=true
[email protected]
NEXT_PUBLIC_DEMO_PASSWORD=123456
NEXT_PUBLIC_SHOW_DEMO_HINT=false
NEXT_PUBLIC_WS_URL=
NEXT_PUBLIC_APP_TITLE=Admin Pro
NEXT_PUBLIC_BRAND_NAME=Halolight
| Variable | Description | Default |
|---|---|---|
NEXT_PUBLIC_API_URL |
API base path | /api |
NEXT_PUBLIC_MOCK |
Enable Mock data | true |
NEXT_PUBLIC_DEMO_EMAIL |
Demo account email | [email protected] |
NEXT_PUBLIC_DEMO_PASSWORD |
Demo account password | 123456 |
NEXT_PUBLIC_SHOW_DEMO_HINT |
Show demo hint | false |
NEXT_PUBLIC_WS_URL |
WebSocket URL | - |
NEXT_PUBLIC_APP_TITLE |
Application title | Admin Pro |
NEXT_PUBLIC_BRAND_NAME |
Brand name | Halolight |
// Use in client components
const apiUrl = process.env.NEXT_PUBLIC_API_URL
const isMock = process.env.NEXT_PUBLIC_MOCK === 'true'
pnpm dev # Start development server
pnpm build # Production build
pnpm start # Preview production build
pnpm lint # Code linting
pnpm lint:fix # Auto fix
pnpm type-check # Type checking
pnpm test # Run tests
pnpm test:coverage # Test coverage
pnpm test # Run tests (watch mode)
pnpm test:run # Single run
pnpm test:coverage # Coverage report
pnpm test:ui # Vitest UI interface
// __tests__/components/button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from '@/components/ui/button'
describe('Button Component', () => {
it('renders correctly', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('handles click events', () => {
const handleClick = vi.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
// next.config.mjs
import withPWA from "next-pwa"
const nextConfig = {
// Package import optimization - reduce bundle size
experimental: {
optimizePackageImports: [
"@radix-ui/react-*",
"lucide-react",
"framer-motion",
"@tanstack/react-query",
"recharts",
"zustand",
],
},
// Remove console in production
compiler: {
removeConsole: { exclude: ["error", "warn"] },
},
// Disable source maps
productionBrowserSourceMaps: false,
// Image optimization
images: {
formats: ["image/avif", "image/webp"],
},
}
const pwaConfig = withPWA({
dest: "public",
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === "development",
})
export default pwaConfig(nextConfig)
vercel
FROM node:18-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
EXPOSE 3000
CMD ["pnpm", "start"]
docker build -t halolight-nextjs .
docker run -p 3000:3000 halolight-nextjs
The project has a complete GitHub Actions CI workflow configured:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm type-check
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
// stores/tabs-store.ts
interface Tab {
id: string
title: string
path: string
icon?: string
closable?: boolean // Home tab not closable
}
// Context menu features
- Refresh page
- Close tab
- Close others
- Close right tabs
- Close all
// hooks/use-keep-alive.tsx
// Auto save/restore scroll position
useScrollRestore()
// Save form state
const [values, saveValues, clearCache] = useFormCache('filter-form', initialValues)
// Save custom state
const [state, setState] = useStateCache('my-key', initialValue)
// components/layout/command-menu.tsx
// Supports keyboard quick navigation, theme switching, account switching, logout, etc.
Shortcuts:
- ⌘K / Ctrl+K - Open command palette
- Search pages - Quick navigation to any page
- Switch theme - Toggle dark/light mode
- Switch account - Quick account switching
// providers/websocket-provider.tsx
const { status, lastMessage, sendMessage, reconnect } = useWebSocket()
// Listen for new notifications
useRealtimeNotifications((notification) => {
console.log('New notification:', notification)
})
// Connection status
status === 'Open' // Connected
status === 'Connecting' // Connecting
status === 'Closed' // Disconnected
// next.config.mjs
const pwaConfig = withPWA({
dest: "public",
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === "development",
runtimeCaching: [
// Font caching (1 year)
{ urlPattern: /\.(?:woff|woff2|ttf)$/i, handler: "CacheFirst" },
// Image caching (24 hours)
{ urlPattern: /\.(?:jpg|png|svg|webp)$/i, handler: "StaleWhileRevalidate" },
// Next.js static assets (1 year)
{ urlPattern: /\/_next\/static\/.+\.(js|css)$/i, handler: "CacheFirst" },
// Page data (1 hour)
{ urlPattern: /\/_next\/data\/.+\.json$/i, handler: "NetworkFirst" },
],
})
Features:
// Use Next.js Image component
import Image from 'next/image'
<Image
src="/images/hero.png"
alt="Hero"
width={800}
height={600}
priority // Priority loading
placeholder="blur" // Blur placeholder
/>
// next.config.mjs
images: {
formats: ["image/avif", "image/webp"],
}
// Dynamic import components
import dynamic from 'next/dynamic'
const DashboardChart = dynamic(
() => import('@/components/dashboard/chart'),
{
loading: () => <Skeleton />,
ssr: false // Disable SSR
}
)
// Route preloading
import Link from 'next/link'
<Link href="/dashboard" prefetch>
Dashboard
</Link>
// Data preloading
queryClient.prefetchQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
// next.config.mjs
experimental: {
optimizePackageImports: [
"@radix-ui/react-*",
"lucide-react",
"framer-motion",
"@tanstack/react-query",
"recharts",
"zustand",
],
}
A: Set NEXT_PUBLIC_MOCK=false in .env.local and configure real API address.
NEXT_PUBLIC_MOCK=false
NEXT_PUBLIC_API_URL=https://api.example.com
A: Create a new directory and page.tsx file under src/app/(dashboard).
// src/app/(dashboard)/my-page/page.tsx
export default function MyPage() {
return <div>My Page</div>
}
// Add route permission
// src/config/routes.ts
export const ROUTE_PERMISSIONS = {
// ...
"/my-page": "my-page:view",
}
A: Modify CSS variables in tailwind.config.js.
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
primary: {
DEFAULT: 'oklch(var(--primary))',
foreground: 'oklch(var(--primary-foreground))',
},
},
},
},
}
/* app/globals.css */
:root {
--primary: 51.1% 0.262 276.97; /* Change to your color */
}
A: Set disable: true in next.config.mjs.
const pwaConfig = withPWA({
dest: "public",
disable: true, // Disable PWA
})
A: Configure static export mode.
// next.config.mjs
export default {
output: 'export',
images: {
unoptimized: true, // Need to disable image optimization for static export
},
}
pnpm build
# Output to out/ directory
| Feature | Next.js | Vue | Angular |
|---|---|---|---|
| SSR/SSG | ✅ | ✅ (Nuxt) | ✅ (Universal) |
| State Management | Zustand | Pinia | Services + RxJS |
| Router | App Router | Vue Router | Angular Router |
| Build Tool | Next.js | Vite | esbuild + Vite |
| Component Library | shadcn/ui | shadcn-vue | Angular Material |
| Learning Curve | Medium | Low | High |
| Performance | Excellent | Excellent | Excellent |
Halolight Nuxt version is built on Nuxt 3 with Vue 3.5 + Composition API + TypeScript, providing an out-of-the-box full-stack development experience.
Live Preview: https://halolight-nuxt.h7ml.cn/
GitHub: https://github.com/halolight/halolight-nuxt
⌘/Ctrl + K quick navigation| Technology | Version | Description |
|---|---|---|
| Nuxt | 3.10 | Vue full-stack framework |
| Vue | 3.5+ | Progressive framework |
| TypeScript | 5.7 | Type safety |
| Tailwind CSS | 3.x (CDN) | Atomic CSS |
| Pinia | 0.5 | State management |
| VueUse | 10.x | Composables utility library |
| Mock.js | 1.x | Data mocking |
⌘/Ctrl + K for quick navigationhalolight-nuxt/
├── nuxt.config.ts # Nuxt configuration
├── app.vue # App root component
├── pages/ # File-based routing
│ ├── index.vue # Home page
│ ├── login.vue # Login
│ ├── register.vue # Register
│ ├── forgot-password.vue # Forgot password
│ ├── reset-password.vue # Reset password
│ ├── terms.vue # Terms of service
│ ├── privacy.vue # Privacy policy
│ ├── dashboard/ # Dashboard
│ │ └── index.vue
│ ├── users/ # User management
│ │ └── index.vue
│ ├── messages/ # Messages
│ │ └── index.vue
│ ├── analytics/ # Analytics
│ │ └── index.vue
│ ├── profile/ # User profile
│ │ └── index.vue
│ └── settings/ # System settings
│ └── index.vue
├── components/ # Auto-imported components
│ └── common/ # Common components
│ ├── AppHeader.vue
│ ├── AppSidebar.vue
│ ├── AppFooter.vue
│ ├── AppTabs.vue
│ ├── CommandMenu.vue
│ └── ToastContainer.vue
├── composables/ # Composable functions
│ ├── useTheme.ts
│ ├── useToast.ts
│ └── useCommandMenu.ts
├── layouts/ # Layouts
│ ├── default.vue # Admin layout
│ └── auth.vue # Auth layout
├── middleware/ # Middleware
│ └── auth.global.ts
├── plugins/ # Plugins
│ └── pinia-persist.client.ts
├── stores/ # Pinia stores
│ ├── auth.ts
│ ├── ui-settings.ts
│ ├── dashboard.ts
│ ├── layout.ts
│ └── tabs.ts
├── utils/ # Utility functions
│ ├── index.ts
│ └── mock.ts
├── assets/css/ # Style files
│ ├── main.css
│ └── tailwind.css
├── tests/ # Test files
│ └── unit/
├── .github/ # GitHub Actions
│ └── workflows/
│ ├── ci.yml
│ └── deploy.yml
└── public/ # Static assets
git clone https://github.com/halolight/halolight-nuxt.git
cd halolight-nuxt
pnpm install
cp .env.example .env.local
# .env.local
NUXT_PUBLIC_API_BASE=/api
NUXT_PUBLIC_MOCK=true
[email protected]
NUXT_PUBLIC_DEMO_PASSWORD=123456
NUXT_PUBLIC_SHOW_DEMO_HINT=true
NUXT_PUBLIC_APP_TITLE=Admin Pro
NUXT_PUBLIC_BRAND_NAME=Halolight
pnpm dev
Visit http://localhost:3000
pnpm build
pnpm preview
| Role | Password | |
|---|---|---|
| Admin | [email protected] | 123456 |
| User | [email protected] | 123456 |
// stores/auth.ts
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref('')
const loading = ref(false)
const error = ref('')
const isAuthenticated = computed(() => !!token.value && !!user.value)
async function login(credentials: LoginCredentials) {
loading.value = true
error.value = ''
try {
// Login logic
const result = await mockLogin(credentials)
user.value = result.user
token.value = result.token
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
function logout() {
user.value = null
token.value = ''
navigateTo('/login')
}
return { user, token, loading, error, isAuthenticated, login, logout }
})
<script setup lang="ts">
// Using useFetch with automatic SSR handling
const { data: users, pending, error, refresh } = await useFetch('/api/users', {
query: { page: 1, limit: 10 },
})
// Using useAsyncData with custom key
const { data: stats } = await useAsyncData('dashboard-stats', () =>
$fetch('/api/dashboard/stats')
)
</script>
// middleware/auth.global.ts
export default defineNuxtRouteMiddleware((to) => {
const authStore = useAuthStore()
// Public pages list
const publicPages = ['/login', '/register', '/forgot-password', '/reset-password']
if (publicPages.includes(to.path)) {
return
}
if (!authStore.isAuthenticated) {
return navigateTo({
path: '/login',
query: { redirect: to.fullPath },
})
}
})
<script setup lang="ts">
// Dashboard configuration
const dashboardStore = useDashboardStore()
const widgets = computed(() => dashboardStore.widgets)
// Drag implementation
function handleDragEnd(event) {
dashboardStore.updateLayout(event.newLayout)
}
</script>
| Path | Page | Permission |
|---|---|---|
/ |
Home | Public |
/login |
Login | Public |
/register |
Register | Public |
/forgot-password |
Forgot password | Public |
/reset-password |
Reset password | Public |
/terms |
Terms of service | Public |
/privacy |
Privacy policy | Public |
/dashboard |
Dashboard | dashboard:view |
/users |
User management | users:view |
/messages |
Messages | messages:view |
/analytics |
Analytics | analytics:view |
/profile |
User profile | settings:view |
/settings |
System settings | settings:view |
# .env
NUXT_PUBLIC_API_BASE=/api
NUXT_PUBLIC_MOCK=true
NUXT_PUBLIC_DEMO_EMAIL=[email protected]
NUXT_PUBLIC_DEMO_PASSWORD=123456
NUXT_PUBLIC_SHOW_DEMO_HINT=true
NUXT_PUBLIC_APP_TITLE=Admin Pro
NUXT_PUBLIC_BRAND_NAME=Halolight
# Server private variables
NUXT_JWT_SECRET=your-jwt-secret
NUXT_DATABASE_URL=postgresql://localhost:5432/halolight
| Variable Name | Description | Default |
|---|---|---|
NUXT_PUBLIC_API_BASE |
API base URL | /api |
NUXT_PUBLIC_MOCK |
Enable mock data | true |
NUXT_PUBLIC_APP_TITLE |
App title | Admin Pro |
NUXT_PUBLIC_BRAND_NAME |
Brand name | Halolight |
NUXT_PUBLIC_DEMO_EMAIL |
Demo account email | - |
NUXT_PUBLIC_DEMO_PASSWORD |
Demo account password | - |
NUXT_JWT_SECRET |
JWT secret (server) | - |
NUXT_DATABASE_URL |
Database connection (server) | - |
<script setup lang="ts">
// In components
const config = useRuntimeConfig();
// Public variables
const apiBase = config.public.apiBase;
// Private variables (server only)
// const jwtSecret = config.jwtSecret; // Not accessible on client
</script>
// In server/api
export default defineEventHandler((event) => {
const config = useRuntimeConfig();
const jwtSecret = config.jwtSecret; // Can access private variables
});
# Development
pnpm dev # Start dev server
pnpm dev -o # Start and open browser
# Build
pnpm build # Production build
pnpm preview # Preview production build
pnpm generate # Static site generation (SSG)
# Checks
pnpm typecheck # TypeScript type check
pnpm lint # ESLint check
pnpm lint:fix # ESLint autofix
# Tests
pnpm test # Run tests
pnpm test:run # Single run
pnpm test:coverage # Coverage report
# Nuxt CLI
npx nuxi add page <name> # Add page
npx nuxi add component <name> # Add component
npx nuxi add composable <name> # Add composable
npx nuxi add plugin <name> # Add plugin
npx nuxi add middleware <name> # Add middleware
npx nuxi add api <name> # Add API route
npx nuxi upgrade # Upgrade Nuxt
pnpm test # Run tests (watch mode)
pnpm test:run # Single run
pnpm test:coverage # Coverage report
pnpm test:ui # Vitest UI
// tests/unit/stores/auth.spec.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '~/stores/auth'
describe('Auth Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('should have correct initial state', () => {
const store = useAuthStore()
expect(store.user).toBeNull()
expect(store.token).toBe('')
expect(store.isAuthenticated).toBe(false)
})
it('should login successfully', async () => {
const store = useAuthStore()
await store.login({ email: '[email protected]', password: '123456' })
expect(store.isAuthenticated).toBe(true)
})
})
// nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: '2025-11-30',
devtools: { enabled: false },
modules: [
'@pinia/nuxt',
'@vueuse/nuxt',
],
css: ['~/assets/css/main.css'],
runtimeConfig: {
public: {
apiBase: '/api',
mock: true,
demoEmail: '[email protected]',
demoPassword: '123456',
showDemoHint: true,
appTitle: 'Admin Pro',
brandName: 'Halolight',
},
},
app: {
head: {
title: 'Admin Pro',
script: [
{ src: 'https://cdn.tailwindcss.com' },
],
},
},
})
npx vercel
Or use the Vercel button for one-click deployment:
FROM node:20-alpine AS builder
RUN npm install -g pnpm
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.output ./.output
ENV HOST=0.0.0.0
ENV PORT=3000
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
nitro.preset: 'cloudflare-pages'nitro.preset: 'netlify'pnpm build && node .output/server/index.mjsComplete GitHub Actions CI workflow configured:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
Nuxt 3 has a built-in Nitro server for creating server-side APIs.
// server/api/users/index.get.ts
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const { page = 1, limit = 10 } = query;
// Mock database query
const users = await getUsersFromDB({ page: Number(page), limit: Number(limit) });
return {
success: true,
data: users,
pagination: {
page: Number(page),
limit: Number(limit),
total: 100,
},
};
});
// server/middleware/auth.ts
export default defineEventHandler((event) => {
const url = getRequestURL(event);
// Protect API routes
if (url.pathname.startsWith('/api/admin')) {
const token = getHeader(event, 'authorization')?.replace('Bearer ', '');
if (!token) {
throw createError({
statusCode: 401,
message: 'Unauthorized',
});
}
try {
const user = verifyToken(token);
event.context.user = user;
} catch {
throw createError({
statusCode: 401,
message: 'Invalid or expired token',
});
}
}
});
// plugins/api.ts
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig();
const api = $fetch.create({
baseURL: config.public.apiBase,
onRequest({ options }) {
const authStore = useAuthStore();
if (authStore.token) {
options.headers = {
...options.headers,
Authorization: `Bearer ${authStore.token}`,
};
}
},
onResponseError({ response }) {
if (response.status === 401) {
const authStore = useAuthStore();
authStore.logout();
navigateTo('/login');
}
},
});
return {
provide: {
api,
},
};
});
// composables/useUsers.ts
export function useUsers() {
const { $api } = useNuxtApp();
const users = ref<User[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
async function fetchUsers(params?: { page?: number; limit?: number }) {
loading.value = true;
error.value = null;
try {
const response = await $api<ApiResponse<User[]>>('/api/users', {
params,
});
users.value = response.data;
return response;
} catch (e: any) {
error.value = e.message;
throw e;
} finally {
loading.value = false;
}
}
return {
users,
loading,
error,
fetchUsers,
};
}
<script setup lang="ts">
// Using @nuxt/image
</script>
<template>
<NuxtImg
src="/hero.jpg"
alt="Hero"
width="800"
height="600"
loading="lazy"
format="webp"
/>
<NuxtPicture
src="/hero.jpg"
alt="Hero"
width="800"
height="600"
sizes="sm:100vw md:50vw lg:33vw"
/>
</template>
<script setup lang="ts">
// Lazy-load heavy components
const Chart = defineAsyncComponent(() => import('~/components/Chart.vue'));
</script>
<template>
<ClientOnly>
<Chart :data="chartData" />
<template #fallback>
<div class="h-80 animate-pulse bg-gray-200" />
</template>
</ClientOnly>
</template>
<script setup lang="ts">
// Prefetch critical data
const { data } = await useFetch('/api/critical-data', {
key: 'critical',
lazy: false,
});
</script>
A: Update nuxt.config.ts:
export default defineNuxtConfig({
ssr: true,
nitro: {
prerender: {
routes: ['/', '/about', '/contact'],
crawlLinks: true,
},
},
});
Run pnpm generate to create the static site.
A: Disable SSR:
export default defineNuxtConfig({
ssr: false,
});
A:
useFetch is a composable that automatically handles SSR data syncing$fetch is the low-level method and does not handle SSR<script setup lang="ts">
// Recommended: handles SSR automatically
const { data } = await useFetch('/api/users');
// Manual call
const fetchData = async () => {
const data = await $fetch('/api/users');
};
</script>
A: Configure it in nuxt.config.ts:
export default defineNuxtConfig({
css: ['~/assets/css/main.css'],
});
A: Use nitro.routeRules:
export default defineNuxtConfig({
nitro: {
routeRules: {
'/api/external/**': {
proxy: 'https://api.example.com/**',
},
},
},
});
A: Use middleware + Pinia:
// middleware/auth.global.ts
export default defineNuxtRouteMiddleware((to) => {
const authStore = useAuthStore();
const publicPages = ['/login', '/register', '/forgot-password'];
if (!publicPages.includes(to.path) && !authStore.isAuthenticated) {
return navigateTo({
path: '/login',
query: { redirect: to.fullPath },
});
}
});
A: Optimization suggestions:
export default defineNuxtConfig({
// On-demand component import
components: {
dirs: ['~/components'],
global: false,
},
// Experimental features
experimental: {
treeshakeClientOnly: true,
},
// Vite optimization
vite: {
build: {
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
},
},
},
},
},
});
| Feature | Nuxt Version | Vue Version | Next.js Version |
|---|---|---|---|
| State Management | Pinia | Pinia | Zustand |
| Data Fetching | useFetch | Axios | TanStack Query |
| Form Validation | Native | VeeValidate + Zod | React Hook Form + Zod |
| Server | Built-in Nitro | Separate backend | API Routes |
| Styling | Tailwind CDN | Tailwind | Tailwind |
| Routing | File-based routing | Vue Router | App Router |
| SSR | Built-in support | Requires configuration | Built-in support |
HaloLight Preact version is built on Preact + Vite, using Signals + TypeScript to deliver a lightweight, high-performance admin dashboard.
Live Preview: https://halolight-preact.h7ml.cn
GitHub: https://github.com/halolight/halolight-preact
| Technology | Version | Description |
|---|---|---|
| Preact | 10.x | Lightweight React alternative |
| @preact/signals | 2.x | Reactive state management |
| TypeScript | 5.9 | Type safety |
| Tailwind CSS | 4.x | Atomic CSS |
| shadcn/ui | latest | UI component library (compat layer) |
| Vite | 7.2 | Build tool |
| preact-router | 4.x | Client-side routing |
| TanStack Query | 5.x | Server state |
| Mock.js | 1.x | Data mocking |
halolight-preact/
├── src/
│ ├── pages/ # Page components
│ │ ├── Home.tsx # Homepage
│ │ ├── auth/ # Auth pages
│ │ │ ├── Login.tsx
│ │ │ ├── Register.tsx
│ │ │ ├── ForgotPassword.tsx
│ │ │ └── ResetPassword.tsx
│ │ └── dashboard/ # Dashboard pages
│ │ ├── Dashboard.tsx
│ │ ├── Users.tsx
│ │ ├── UserDetail.tsx
│ │ ├── UserCreate.tsx
│ │ ├── Roles.tsx
│ │ ├── Permissions.tsx
│ │ ├── Settings.tsx
│ │ └── Profile.tsx
│ ├── components/ # Component library
│ │ ├── ui/ # UI components
│ │ │ ├── Button.tsx
│ │ │ ├── Input.tsx
│ │ │ ├── Card.tsx
│ │ │ └── Dialog.tsx
│ │ ├── layout/ # Layout components
│ │ │ ├── AdminLayout.tsx
│ │ │ ├── AuthLayout.tsx
│ │ │ ├── Sidebar.tsx
│ │ │ └── Header.tsx
│ │ ├── dashboard/ # Dashboard components
│ │ │ ├── DashboardGrid.tsx
│ │ │ ├── WidgetWrapper.tsx
│ │ │ └── StatsWidget.tsx
│ │ └── shared/ # Shared components
│ │ └── PermissionGuard.tsx
│ ├── stores/ # State management
│ │ ├── auth.ts
│ │ ├── ui-settings.ts
│ │ └── dashboard.ts
│ ├── hooks/ # Custom Hooks
│ │ ├── useAuth.ts
│ │ └── usePermission.ts
│ ├── lib/ # Utilities
│ │ ├── api.ts
│ │ ├── permission.ts
│ │ └── cn.ts
│ ├── mock/ # Mock data
│ │ ├── index.ts
│ │ └── handlers/
│ ├── types/ # Type definitions
│ ├── App.tsx # Root component
│ ├── routes.tsx # Route config
│ └── main.tsx # Entry file
├── public/ # Static assets
├── vite.config.ts # Vite config
├── tailwind.config.ts # Tailwind config
└── package.json
git clone https://github.com/halolight/halolight-preact.git
cd halolight-preact
pnpm install
cp .env.example .env
# .env
VITE_API_URL=/api
VITE_USE_MOCK=true
[email protected]
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=false
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight
pnpm dev
Visit http://localhost:5173
pnpm build
pnpm preview
// stores/auth.ts
import { signal, computed, effect } from '@preact/signals'
interface User {
id: number
name: string
email: string
permissions: string[]
}
// Reactive state
export const user = signal<User | null>(null)
export const token = signal<string | null>(null)
export const loading = signal(false)
// Computed properties
export const isAuthenticated = computed(() => !!token.value && !!user.value)
export const permissions = computed(() => user.value?.permissions ?? [])
// Persistence
effect(() => {
if (user.value && token.value) {
localStorage.setItem('auth', JSON.stringify({
user: user.value,
token: token.value,
}))
}
})
// Initialization
const saved = localStorage.getItem('auth')
if (saved) {
const { user: savedUser, token: savedToken } = JSON.parse(saved)
user.value = savedUser
token.value = savedToken
}
// Methods
export async function login(credentials: { email: string; password: string }) {
loading.value = true
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
headers: { 'Content-Type': 'application/json' },
})
const data = await response.json()
user.value = data.user
token.value = data.token
} finally {
loading.value = false
}
}
export function logout() {
user.value = null
token.value = null
localStorage.removeItem('auth')
}
export function hasPermission(permission: string): boolean {
const perms = permissions.value
return perms.some(p =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
}
Signals Features:
// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { token } from '../stores/auth'
export function useUsers(page = 1) {
return useQuery({
queryKey: ['users', page],
queryFn: async () => {
const response = await fetch(`/api/users?page=${page}`, {
headers: { Authorization: `Bearer ${token.value}` },
})
return response.json()
},
enabled: !!token.value,
})
}
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: CreateUserDto) => {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token.value}`,
},
})
return response.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}
// Usage
import { useUsers } from '../hooks/useUsers'
export function UsersPage() {
const { data, isLoading, error } = useUsers(1)
if (isLoading) return <div>Loading...</div>
if (error) return <div>Failed to load</div>
return (
<ul>
{data.data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
// hooks/usePermission.ts
import { hasPermission } from '../stores/auth'
export function usePermission() {
return {
hasPermission,
can: (permission: string) => hasPermission(permission),
}
}
// components/shared/PermissionGuard.tsx
import { ComponentChildren } from 'preact'
import { hasPermission } from '../../stores/auth'
interface Props {
permission: string
children: ComponentChildren
fallback?: ComponentChildren
}
export function PermissionGuard({ permission, children, fallback }: Props) {
if (!hasPermission(permission)) {
return fallback ?? null
}
return children
}
// Usage
<PermissionGuard
permission="users:delete"
fallback={<span class="text-muted-foreground">No permission</span>}
>
<Button variant="destructive">Delete</Button>
</PermissionGuard>
// routes.tsx
import Router, { Route } from 'preact-router'
import { isAuthenticated, hasPermission } from './stores/auth'
// Page components
import Home from './pages/Home'
import Login from './pages/auth/Login'
import Register from './pages/auth/Register'
import Dashboard from './pages/dashboard/Dashboard'
import Users from './pages/dashboard/Users'
// Route guard HOC
function ProtectedRoute({ component: Component, permission, ...rest }) {
if (!isAuthenticated.value) {
route('/login?redirect=' + rest.path)
return null
}
if (permission && !hasPermission(permission)) {
return <div>No permission</div>
}
return <Component {...rest} />
}
export function AppRouter() {
return (
<Router>
<Route path="/" component={Home} />
<Route path="/login" component={Login} />
<Route path="/register" component={Register} />
<ProtectedRoute
path="/dashboard"
component={Dashboard}
permission="dashboard:view"
/>
<ProtectedRoute
path="/users"
component={Users}
permission="users:list"
/>
</Router>
)
}
Support 11 preset skins, switch via quick settings panel:
| Skin | Color | CSS Variable |
|---|---|---|
| Default | Purple | --primary: 51.1% 0.262 276.97 |
| Blue | Blue | --primary: 54.8% 0.243 264.05 |
| Emerald | Emerald | --primary: 64.6% 0.178 142.49 |
| Amber | Amber | --primary: 78.3% 0.177 74.21 |
| Rose | Rose | --primary: 62.8% 0.243 12.48 |
| Slate | Slate | --primary: 51.4% 0.032 257.42 |
| Zinc | Zinc | --primary: 50.7% 0.017 285.96 |
| Stone | Stone | --primary: 53.4% 0.015 69.82 |
| Neutral | Neutral | --primary: 50.9% 0.016 286.13 |
| Red | Red | --primary: 55.5% 0.238 25.33 |
| Orange | Orange | --primary: 72.3% 0.187 56.24 |
/* Light mode */
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 100% 0 0;
--secondary: 96.5% 0.006 286.32;
--secondary-foreground: 21.7% 0.026 285.88;
--accent: 96.5% 0.006 286.32;
--accent-foreground: 21.7% 0.026 285.88;
}
/* Dark mode */
.dark {
--background: 15.5% 0.018 285.88;
--foreground: 98.3% 0.006 286.32;
--primary: 74.1% 0.196 275.74;
--primary-foreground: 21.7% 0.043 286.07;
--secondary: 20.7% 0.021 286.05;
--secondary-foreground: 98.3% 0.006 286.32;
}
// stores/ui-settings.ts
import { signal, effect } from '@preact/signals'
export const theme = signal<'light' | 'dark'>('light')
export const skin = signal<string>('default')
// Persistence
effect(() => {
localStorage.setItem('theme', theme.value)
document.documentElement.classList.toggle('dark', theme.value === 'dark')
})
effect(() => {
localStorage.setItem('skin', skin.value)
document.documentElement.dataset.skin = skin.value
})
// Initialization
theme.value = (localStorage.getItem('theme') as any) || 'light'
skin.value = localStorage.getItem('skin') || 'default'
export function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
export function setSkin(newSkin: string) {
skin.value = newSkin
}
| Path | Page | Permission |
|---|---|---|
/ |
Homepage | Public |
/login |
Login | Public |
/register |
Register | Public |
/forgot-password |
Forgot Password | Public |
/reset-password |
Reset Password | Public |
/dashboard |
Dashboard | dashboard:view |
/users |
User List | users:list |
/users/create |
Create User | users:create |
/users/:id |
User Detail | users:view |
/roles |
Role Management | roles:list |
/permissions |
Permission Management | permissions:list |
/settings |
System Settings | settings:view |
/profile |
Profile | Logged in |
pnpm dev # Start dev server
pnpm build # Production build
pnpm preview # Preview production build
pnpm lint # Code linting
pnpm lint:fix # Auto fix
pnpm type-check # Type checking
pnpm test # Run tests
pnpm test:coverage # Test coverage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
docker build -t halolight-preact .
docker run -p 80:80 halolight-preact
| Role | Password | |
|---|---|---|
| Admin | [email protected] | 123456 |
| User | [email protected] | 123456 |
pnpm test # Run tests (watch mode)
pnpm test:run # Single run
pnpm test:coverage # Coverage report
pnpm test:ui # Vitest UI
Test files are placed together with source files, using .test.ts or .test.tsx suffix:
src/components/ui/
├── Button.tsx
├── Button.test.tsx # Button component test
├── Input.tsx
└── Input.test.tsx # Input component test
// src/components/ui/Button.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/preact'
import { Button } from './Button'
describe('Button', () => {
it('renders default button', () => {
render(<Button>Click</Button>)
expect(screen.getByRole('button')).toHaveTextContent('Click')
})
it('renders different variants', () => {
render(<Button variant="destructive">Delete</Button>)
expect(screen.getByRole('button')).toHaveClass('bg-destructive')
})
it('handles disabled state', () => {
render(<Button disabled>Disabled</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
})
// vite.config.ts
import { defineConfig } from 'vite'
import preact from '@preact/preset-vite'
import path from 'path'
export default defineConfig({
plugins: [preact()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
// React compatibility
react: 'preact/compat',
'react-dom': 'preact/compat',
'react/jsx-runtime': 'preact/jsx-runtime',
},
},
build: {
target: 'esnext',
minify: 'terser',
rollupOptions: {
output: {
manualChunks: {
vendor: ['preact', 'preact/hooks'],
router: ['preact-router'],
query: ['@tanstack/react-query'],
},
},
},
},
})
// tailwind.config.ts
import type { Config } from 'tailwindcss'
export default {
darkMode: ['class'],
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
border: 'oklch(var(--border) / <alpha-value>)',
input: 'oklch(var(--input) / <alpha-value>)',
ring: 'oklch(var(--ring) / <alpha-value>)',
background: 'oklch(var(--background) / <alpha-value>)',
foreground: 'oklch(var(--foreground) / <alpha-value>)',
primary: {
DEFAULT: 'oklch(var(--primary) / <alpha-value>)',
foreground: 'oklch(var(--primary-foreground) / <alpha-value>)',
},
},
},
},
plugins: [require('tailwindcss-animate')],
} satisfies Config
The project is configured with complete GitHub Actions CI workflow:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm type-check
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
// components/ui/Button.tsx
import { ComponentChildren } from 'preact'
import { cn } from '../../lib/cn'
interface Props {
variant?: 'default' | 'destructive' | 'outline' | 'ghost'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
class?: string
children: ComponentChildren
onClick?: () => void
}
export function Button({
variant = 'default',
size = 'md',
disabled,
class: className,
children,
onClick,
}: Props) {
return (
<button
class={cn(
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
{
'bg-primary text-primary-foreground hover:bg-primary/90':
variant === 'default',
'bg-destructive text-destructive-foreground hover:bg-destructive/90':
variant === 'destructive',
'border border-input bg-background hover:bg-accent':
variant === 'outline',
'hover:bg-accent hover:text-accent-foreground': variant === 'ghost',
'h-8 px-3 text-sm': size === 'sm',
'h-10 px-4': size === 'md',
'h-12 px-6 text-lg': size === 'lg',
'opacity-50 cursor-not-allowed': disabled,
},
className
)}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
)
}
// pages/auth/Login.tsx
import { useState } from 'preact/hooks'
import { route } from 'preact-router'
import { login, loading } from '../../stores/auth'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const handleSubmit = async (e: Event) => {
e.preventDefault()
setError('')
try {
await login({ email, password })
const params = new URLSearchParams(location.search)
route(params.get('redirect') || '/dashboard')
} catch (e) {
setError('Invalid email or password')
}
}
return (
<form onSubmit={handleSubmit}>
{error && <div class="text-destructive">{error}</div>}
<input
type="email"
value={email}
onInput={(e) => setEmail(e.currentTarget.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onInput={(e) => setPassword(e.currentTarget.value)}
placeholder="Password"
required
/>
<button type="submit" disabled={loading.value}>
{loading.value ? 'Logging in...' : 'Login'}
</button>
</form>
)
}
// App.tsx
import { lazy, Suspense } from 'preact/compat'
const Dashboard = lazy(() => import('./pages/dashboard/Dashboard'))
const Users = lazy(() => import('./pages/dashboard/Users'))
export function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Router>
<Route path="/dashboard" component={Dashboard} />
<Route path="/users" component={Users} />
</Router>
</Suspense>
)
}
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['preact', 'preact/hooks'],
router: ['preact-router'],
query: ['@tanstack/react-query'],
},
},
},
},
})
// Use computed to avoid redundant calculations
import { signal, computed } from '@preact/signals'
const items = signal([1, 2, 3, 4, 5])
const filter = signal('all')
// Computed properties are automatically cached
const filteredItems = computed(() => {
if (filter.value === 'all') return items.value
return items.value.filter(item => item > 2)
})
// Usage in component
function ItemList() {
return (
<ul>
{filteredItems.value.map(item => (
<li key={item}>{item}</li>
))}
</ul>
)
}
A: Preact provides React compatibility through preact/compat, most React libraries can be used directly:
// vite.config.ts
export default defineConfig({
resolve: {
alias: {
react: 'preact/compat',
'react-dom': 'preact/compat',
'react/jsx-runtime': 'preact/jsx-runtime',
},
},
})
A: Signals can be used directly in components without useState:
import { signal } from '@preact/signals'
const count = signal(0)
function Counter() {
// Use signal.value directly
return (
<button onClick={() => count.value++}>
Count: {count.value}
</button>
)
}
A: Use code splitting and lazy loading:
import { lazy, Suspense } from 'preact/compat'
const HeavyComponent = lazy(() => import('./HeavyComponent'))
function App() {
return (
<Suspense fallback={<Loading />}>
<HeavyComponent />
</Suspense>
)
}
| Feature | Preact | Next.js | Vue |
|---|---|---|---|
| SSR/SSG | ❌ (SPA) | ✅ | ✅ (Nuxt) |
| State Management | Signals | Zustand | Pinia |
| Routing | preact-router | App Router | Vue Router |
| Build Tool | Vite | Next.js | Vite |
| Bundle Size | ~3KB | ~85KB | ~33KB |
| React Compatibility | ✅ | - | ❌ |
| Learning Curve | Low | Medium | Medium |
HaloLight Qwik version is built on Qwik City, featuring Qwik resumability architecture + TypeScript for zero hydration and ultimate performance.
Live Preview: https://halolight-qwik.h7ml.cn
GitHub: https://github.com/halolight/halolight-qwik
| Technology | Version | Description |
|---|---|---|
| Qwik | 2.x | Resumable framework |
| Qwik City | 2.x | Full-stack framework |
| TypeScript | 5.x | Type safety |
| Tailwind CSS | 4.x | Atomic CSS |
| Qwik UI | latest | UI component library |
| Modular Forms | latest | Form handling |
| Zod | 3.x | Data validation |
| ECharts | 5.x | Chart visualization |
| Mock.js | 1.x | Data mocking |
halolight-qwik/
├── src/
│ ├── routes/ # File-based routing
│ │ ├── index.tsx # Home
│ │ ├── layout.tsx # Root layout
│ │ ├── (auth)/ # Auth route group
│ │ │ ├── layout.tsx
│ │ │ ├── login/
│ │ │ │ └── index.tsx
│ │ │ ├── register/
│ │ │ ├── forgot-password/
│ │ │ └── reset-password/
│ │ ├── (dashboard)/ # Dashboard route group
│ │ │ ├── layout.tsx
│ │ │ ├── dashboard/
│ │ │ │ └── index.tsx
│ │ │ ├── users/
│ │ │ │ ├── index.tsx
│ │ │ │ ├── create/
│ │ │ │ └── [id]/
│ │ │ ├── roles/
│ │ │ ├── permissions/
│ │ │ ├── settings/
│ │ │ └── profile/
│ │ └── api/ # API endpoints
│ │ └── auth/
│ │ └── login/
│ │ └── index.ts
│ ├── components/ # Component library
│ │ ├── ui/ # Qwik UI components
│ │ ├── layout/ # Layout components
│ │ │ ├── admin-layout/
│ │ │ ├── auth-layout/
│ │ │ ├── sidebar/
│ │ │ └── header/
│ │ ├── dashboard/ # Dashboard components
│ │ │ ├── dashboard-grid/
│ │ │ ├── widget-wrapper/
│ │ │ └── stats-widget/
│ │ └── shared/ # Shared components
│ │ └── permission-guard/
│ ├── stores/ # State management
│ │ ├── auth.ts
│ │ ├── ui-settings.ts
│ │ └── dashboard.ts
│ ├── lib/ # Utilities
│ │ ├── api.ts
│ │ ├── permission.ts
│ │ └── cn.ts
│ ├── mock/ # Mock data
│ └── types/ # Type definitions
├── public/ # Static assets
├── vite.config.ts # Vite config
├── tailwind.config.ts # Tailwind config
└── package.json
git clone https://github.com/halolight/halolight-qwik.git
cd halolight-qwik
pnpm install
cp .env.example .env
# .env example
VITE_API_URL=/api
VITE_USE_MOCK=true
[email protected]
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=false
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight
pnpm dev
Visit http://localhost:5173
pnpm build
pnpm serve
// stores/auth.ts
import {
createContextId,
useContext,
useStore,
useComputed$,
$,
type Signal,
} from '@builder.io/qwik'
interface User {
id: number
name: string
email: string
permissions: string[]
}
interface AuthState {
user: User | null
token: string | null
loading: boolean
}
export const AuthContext = createContextId<AuthState>('auth')
export function useAuth() {
const state = useContext(AuthContext)
const isAuthenticated = useComputed$(() => !!state.token && !!state.user)
const permissions = useComputed$(() => state.user?.permissions ?? [])
const login = $(async (credentials: { email: string; password: string }) => {
state.loading = true
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
headers: { 'Content-Type': 'application/json' },
})
const data = await response.json()
state.user = data.user
state.token = data.token
} finally {
state.loading = false
}
})
const logout = $(() => {
state.user = null
state.token = null
})
const hasPermission = $((permission: string) => {
const perms = state.user?.permissions ?? []
return perms.some(p =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
})
return {
state,
isAuthenticated,
permissions,
login,
logout,
hasPermission,
}
}
// routes/(dashboard)/users/index.tsx
import { component$ } from '@builder.io/qwik'
import { routeLoader$ } from '@builder.io/qwik-city'
export const useUsers = routeLoader$(async ({ query, cookie, status }) => {
const token = cookie.get('token')?.value
const page = Number(query.get('page')) || 1
// Permission check
const user = await validateToken(token)
if (!hasPermission(user, 'users:list')) {
status(403)
return { error: 'No permission to access' }
}
const response = await fetch(`/api/users?page=${page}`, {
headers: { Authorization: `Bearer ${token}` },
})
return response.json()
})
export default component$(() => {
const users = useUsers()
return (
<div>
<h1>User List</h1>
{users.value.error ? (
<div class="text-destructive">{users.value.error}</div>
) : (
<ul>
{users.value.data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
)
})
// routes/(dashboard)/layout.tsx
import { component$, Slot } from '@builder.io/qwik'
import { routeLoader$ } from '@builder.io/qwik-city'
import { useAuth } from '~/stores/auth'
import { AdminLayout } from '~/components/layout/admin-layout'
export const useAuthGuard = routeLoader$(async ({ cookie, redirect, url }) => {
const token = cookie.get('token')?.value
if (!token) {
throw redirect(302, `/login?redirect=${url.pathname}`)
}
// Validate token and return user info
return {
user: await validateToken(token),
}
})
export default component$(() => {
const data = useAuthGuard()
return (
<AdminLayout user={data.value.user}>
<Slot />
</AdminLayout>
)
})
// components/shared/permission-guard/index.tsx
import { component$, Slot, useComputed$ } from '@builder.io/qwik'
import { useAuth } from '~/stores/auth'
interface Props {
permission: string
}
export const PermissionGuard = component$<Props>(({ permission }) => {
const { hasPermission } = useAuth()
const allowed = useComputed$(async () => {
return await hasPermission(permission)
})
return (
<>
{allowed.value ? (
<Slot />
) : (
<Slot name="fallback" />
)}
</>
)
})
// Usage
<PermissionGuard permission="users:delete">
<Button variant="destructive" q:slot="">Delete</Button>
<span q:slot="fallback" class="text-muted-foreground">No Permission</span>
</PermissionGuard>
// routes/(auth)/login/index.tsx
import { component$ } from '@builder.io/qwik'
import { routeAction$, zod$, z, Form } from '@builder.io/qwik-city'
export const useLogin = routeAction$(
async (data, { cookie, redirect, fail }) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
return fail(401, { message: 'Invalid email or password' })
}
const result = await response.json()
cookie.set('token', result.token, {
path: '/',
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7,
})
throw redirect(302, '/dashboard')
} catch (e) {
return fail(500, { message: 'Server error' })
}
},
zod$({
email: z.string().email('Please enter a valid email'),
password: z.string().min(6, 'Password must be at least 6 characters'),
})
)
export default component$(() => {
const action = useLogin()
return (
<Form action={action}>
{action.value?.failed && (
<div class="text-destructive">{action.value.message}</div>
)}
<input type="email" name="email" placeholder="Email" />
{action.value?.fieldErrors?.email && (
<span class="text-destructive">{action.value.fieldErrors.email}</span>
)}
<input type="password" name="password" placeholder="Password" />
{action.value?.fieldErrors?.password && (
<span class="text-destructive">{action.value.fieldErrors.password}</span>
)}
<button type="submit" disabled={action.isRunning}>
{action.isRunning ? 'Logging in...' : 'Login'}
</button>
</Form>
)
})
// routes/api/auth/login/index.ts
import type { RequestHandler } from '@builder.io/qwik-city'
export const onPost: RequestHandler = async ({ json, parseBody }) => {
const body = await parseBody()
const { email, password } = body as { email: string; password: string }
// Validation logic
if (!email || !password) {
json(400, { success: false, message: 'Email and password are required' })
return
}
// Authentication logic...
json(200, {
success: true,
user: { id: 1, name: 'Admin', email },
token: 'mock_token',
})
}
// components/dashboard/dashboard-grid/index.tsx
import { component$, useSignal, useStore, $ } from '@builder.io/qwik'
interface Widget {
id: string
type: string
x: number
y: number
w: number
h: number
}
export const DashboardGrid = component$(() => {
const widgets = useStore<Widget[]>([
{ id: '1', type: 'stats', x: 0, y: 0, w: 3, h: 2 },
{ id: '2', type: 'chart', x: 3, y: 0, w: 6, h: 4 },
])
const handleLayoutChange = $((newLayout: Widget[]) => {
widgets.splice(0, widgets.length, ...newLayout)
})
return (
<div class="dashboard-grid">
{widgets.map((widget) => (
<div
key={widget.id}
class="widget"
style={{
gridColumn: `${widget.x + 1} / span ${widget.w}`,
gridRow: `${widget.y + 1} / span ${widget.h}`,
}}
>
{/* Widget content */}
</div>
))}
</div>
)
})
Supports 11 preset skins, switchable through quick settings panel:
| Skin | Primary Color | CSS Variable |
|---|---|---|
| Default | Purple | --primary: 51.1% 0.262 276.97 |
| Blue | Blue | --primary: 54.8% 0.243 264.05 |
| Emerald | Emerald | --primary: 64.6% 0.178 142.49 |
| Rose | Rose | --primary: 61.8% 0.238 12.57 |
| Orange | Orange | --primary: 68.3% 0.199 36.35 |
| Yellow | Yellow | --primary: 88.1% 0.197 95.45 |
| Violet | Violet | --primary: 57.8% 0.24 305.4 |
| Cyan | Cyan | --primary: 73.8% 0.139 196.85 |
| Pink | Pink | --primary: 72.2% 0.218 345.82 |
| Lime | Lime | --primary: 79.2% 0.183 123.7 |
| Amber | Amber | --primary: 82.5% 0.157 62.24 |
/* Global variable definitions */
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 98% 0 0;
--secondary: 96.1% 0 0;
--secondary-foreground: 9.8% 0 0;
--muted: 95.1% 0.01 286.38;
--muted-foreground: 45.1% 0.009 285.88;
--accent: 95.1% 0.01 286.38;
--accent-foreground: 9.8% 0 0;
--destructive: 54.3% 0.227 25.78;
--destructive-foreground: 98% 0 0;
--border: 89.8% 0.006 286.32;
--input: 89.8% 0.006 286.32;
--ring: 51.1% 0.262 276.97;
--radius: 0.5rem;
}
.dark {
--background: 0% 0 0;
--foreground: 98% 0 0;
--primary: 59.6% 0.262 276.97;
--primary-foreground: 14.9% 0.017 285.75;
/* ... */
}
| Path | Page | Permission |
|---|---|---|
/ |
Home | Public |
/login |
Login | Public |
/register |
Register | Public |
/forgot-password |
Forgot Password | Public |
/reset-password |
Reset Password | Public |
/dashboard |
Dashboard | dashboard:view |
/users |
User List | users:list |
/users/create |
Create User | users:create |
/users/[id] |
User Details | users:view |
/roles |
Role Management | roles:list |
/permissions |
Permission Management | permissions:list |
/settings |
System Settings | settings:view |
/profile |
Profile | Authenticated |
pnpm dev # Start development server
pnpm build # Production build
pnpm serve # Preview production build
pnpm lint # Code linting
pnpm lint:fix # Auto-fix linting issues
pnpm type-check # Type checking
pnpm test # Run tests
pnpm test:e2e # E2E tests
# Use Vercel Edge adapter
pnpm add -D @builder.io/qwik-city/adapters/vercel-edge
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/server ./server
COPY --from=builder /app/package.json .
RUN npm install --production
EXPOSE 3000
CMD ["node", "server/entry.express.js"]
Node.js Server
pnpm build
node server/entry.express.js
Cloudflare Pages
# Use Cloudflare Pages adapter
pnpm add -D @builder.io/qwik-city/adapters/cloudflare-pages
| Role | Password | |
|---|---|---|
| Admin | [email protected] | 123456 |
| User | [email protected] | 123456 |
pnpm test # Run tests
pnpm test:e2e # E2E tests
pnpm test:coverage # Coverage report
// src/components/permission-guard/permission-guard.spec.tsx
import { describe, it, expect } from 'vitest'
import { createDOM } from '@builder.io/qwik/testing'
import { PermissionGuard } from './permission-guard'
describe('PermissionGuard', () => {
it('should render content when has permission', async () => {
const { screen, render } = await createDOM()
await render(
<PermissionGuard permission="users:view">
<div>Protected Content</div>
</PermissionGuard>
)
expect(screen.innerHTML).toContain('Protected Content')
})
it('should render fallback when no permission', async () => {
const { screen, render } = await createDOM()
await render(
<PermissionGuard permission="admin:*">
<div>Protected Content</div>
<div q:slot="fallback">No Permission</div>
</PermissionGuard>
)
expect(screen.innerHTML).toContain('No Permission')
})
})
// vite.config.ts
import { defineConfig } from 'vite'
import { qwikVite } from '@builder.io/qwik/optimizer'
import { qwikCity } from '@builder.io/qwik-city/vite'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig(() => {
return {
plugins: [qwikCity(), qwikVite(), tsconfigPaths()],
preview: {
headers: {
'Cache-Control': 'public, max-age=600',
},
},
}
})
Project is configured with complete GitHub Actions CI workflow:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
Qwik's core innovation is "resumability" rather than "hydration":
// Traditional frameworks (React/Vue): Need to re-execute all code to rebuild state
// Qwik: Directly resume state from HTML without re-execution
// Server serializes state
export default component$(() => {
const count = useSignal(0)
// Qwik serializes state into HTML
return <div>Count: {count.value}</div>
})
// Client directly resumes state from HTML without executing component code
// Code is loaded and executed only on interaction
Qwik implements the most aggressive code splitting:
// Each event handler is an independent lazy loading unit
export default component$(() => {
const count = useSignal(0)
// This function won't be downloaded until clicked
const handleClick = $(() => {
count.value++
})
return (
<button onClick$={handleClick}>
Count: {count.value}
</button>
)
})
// routes/(dashboard)/layout.tsx
import { component$ } from '@builder.io/qwik'
import { routeLoader$, Link } from '@builder.io/qwik-city'
// Preload data
export const usePreloadData = routeLoader$(async () => {
return {
navigation: await fetchNavigation(),
}
})
export default component$(() => {
const data = usePreloadData()
return (
<nav>
{data.value.navigation.map((item) => (
// Link automatically preloads target page
<Link href={item.path} prefetch>
{item.title}
</Link>
))}
</nav>
)
})
import { component$ } from '@builder.io/qwik'
import { Image } from '@unpic/qwik'
export default component$(() => {
return (
<Image
src="https://example.com/image.jpg"
layout="constrained"
width={800}
height={600}
alt="Optimized image"
/>
)
})
// Component-level lazy loading
import { component$ } from '@builder.io/qwik'
export default component$(() => {
return (
<div>
{/* Use resource$ for component lazy loading */}
<Resource
value={heavyComponentResource}
onPending={() => <div>Loading...</div>}
onResolved={(HeavyComponent) => <HeavyComponent />}
/>
</div>
)
})
// routes/layout.tsx
import { component$, useVisibleTask$ } from '@builder.io/qwik'
export default component$(() => {
useVisibleTask$(() => {
// Preload critical fonts
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'font'
link.href = '/fonts/main.woff2'
link.type = 'font/woff2'
link.crossOrigin = 'anonymous'
document.head.appendChild(link)
})
return <Slot />
})
A: Use useSignal and useStore to create reactive state:
import { component$, useSignal, useStore } from '@builder.io/qwik'
export default component$(() => {
// Use useSignal for simple values
const count = useSignal(0)
// Use useStore for complex objects
const state = useStore({
user: null,
loading: false,
})
return (
<div>
<p>Count: {count.value}</p>
<button onClick$={() => count.value++}>Increment</button>
</div>
)
})
A: Use useVisibleTask$ to execute code on the client:
import { component$, useSignal, useVisibleTask$ } from '@builder.io/qwik'
export default component$(() => {
const chartRef = useSignal<Element>()
useVisibleTask$(({ cleanup }) => {
// Initialize third-party library on the client
import('chart.js').then(({ Chart }) => {
const chart = new Chart(chartRef.value, {
// Configuration...
})
cleanup(() => chart.destroy())
})
})
return <canvas ref={chartRef} />
})
A: Qwik optimizes automatically, but you can further:
<Link href="/dashboard" prefetch>Dashboard</Link>
useVisibleTask$(({ track }) => {
// Load only when component is visible
track(() => isVisible.value)
if (isVisible.value) {
loadAnalytics()
}
})
A: Use routeAction$ for server-side processing:
import { component$ } from '@builder.io/qwik'
import { routeAction$, Form, zod$, z } from '@builder.io/qwik-city'
export const useAddUser = routeAction$(
async (data) => {
// Server-side processing
const user = await createUser(data)
return { success: true, user }
},
zod$({
name: z.string().min(2),
email: z.string().email(),
})
)
export default component$(() => {
const action = useAddUser()
return (
<Form action={action}>
<input name="name" />
<input name="email" type="email" />
<button type="submit">
{action.isRunning ? 'Submitting...' : 'Submit'}
</button>
</Form>
)
})
| Feature | Qwik Version | Next.js | Vue |
|---|---|---|---|
| SSR/SSG | ✅ Built-in | ✅ | ✅ (Nuxt) |
| State Management | Context + Signals | Zustand | Pinia |
| Data Fetching | routeLoader$ | TanStack Query | TanStack Query |
| Form Validation | Modular Forms + Zod | React Hook Form + Zod | VeeValidate + Zod |
| Routing | File-based Routing | App Router | Vue Router |
| Build Tool | Vite | Next.js | Vite |
| Hydration | Resumable (Zero Hydration) | Traditional Hydration | Traditional Hydration |
| Initial JS | ~1KB | ~85KB | ~33KB |
| Server-side | Built-in Full-stack | API Routes | Separate Backend |
| Component Library | Qwik UI | shadcn/ui | shadcn-vue |
HaloLight Railway deployment edition, an optimized one-click deployment solution tailored for the Railway platform.
Live Preview: https://halolight-railway.h7ml.cn
GitHub: https://github.com/halolight/halolight-railway
After clicking the button:
# Install Railway CLI
npm install -g @railway/cli
# Sign in to Railway
railway login
# Clone the project
git clone https://github.com/halolight/halolight-railway.git
cd halolight-railway
# Initialize Railway project
railway init
# Link to an existing project (optional)
railway link
# Deploy
railway up
{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "NIXPACKS",
"buildCommand": "pnpm install && pnpm build"
},
"deploy": {
"startCommand": "pnpm start",
"healthcheckPath": "/api/health",
"healthcheckTimeout": 300,
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 10,
"numReplicas": 1
}
}
[phases.setup]
nixPkgs = ["nodejs_20", "pnpm"]
[phases.install]
cmds = ["pnpm install --frozen-lockfile"]
[phases.build]
cmds = ["pnpm build"]
[start]
cmd = "pnpm start"
| Variable | Description | Example |
|---|---|---|
NODE_ENV |
Runtime environment | production |
PORT |
Server port | 3000 (Railway sets automatically) |
NEXT_PUBLIC_API_URL |
API base URL | /api |
NEXT_PUBLIC_MOCK |
Enable mock data | false |
NEXT_PUBLIC_APP_TITLE |
App title | Admin Pro |
Railway lets you reference other services in env vars:
# Reference the auto-generated domain
NEXT_PUBLIC_API_URL=https://${{RAILWAY_PUBLIC_DOMAIN}}/api
# Reference PostgreSQL service
DATABASE_URL=${{Postgres.DATABASE_URL}}
# Reference Redis service
REDIS_URL=${{Redis.REDIS_URL}}
# Reference project variables
JWT_SECRET=${{shared.JWT_SECRET}}
# Via CLI
railway add --database postgres
# Or in the console
# 1. Click "New Service"
# 2. Select "Database" → "PostgreSQL"
# 3. DATABASE_URL is generated automatically
Generated environment variables:
DATABASE_URL - Full connection stringPGHOST - HostPGPORT - PortPGUSER - UsernamePGPASSWORD - PasswordPGDATABASE - Database name# Via CLI
railway add --database redis
# Or in the console
# 1. Click "New Service"
# 2. Select "Database" → "Redis"
# 3. REDIS_URL is generated automatically
Generated environment variables:
REDIS_URL - Full connection stringREDISHOST - HostREDISPORT - PortREDISPASSWORD - PasswordType: CNAME
Name: your-subdomain
Value: <your-app>.up.railway.app
Railway automatically configures HTTPS for all domains:
# Sign in
railway login
# View status
railway status
# Deploy
railway up
# View logs
railway logs
# Open console
railway open
# Run remote command
railway run <command>
# Connect to database
railway connect postgres
# Environment variables
railway variables
railway variables set KEY=value
# View logs via CLI
railway logs -f
# Or in the console
# Service → Deployments → click a deployment → View Logs
Railway console provides:
// railway.json
{
"deploy": {
"numReplicas": 3
}
}
Railway Pro supports metric-based autoscaling:
| Plan | Price | Features |
|---|---|---|
| Hobby | $5/mo | 500 execution hours, 1GB RAM |
| Pro | $20/mo+ | Unlimited hours, more resources |
| Enterprise | Contact sales | Dedicated support, SLA guarantees |
A: Check these items:
pnpm-lock.yaml is committedstart command is correctA: In the Deployments page:
railway rollbackA: Railway services communicate over the internal network:
# Use internal DNS
DATABASE_URL=postgres://user:[email protected]:5432/db
| Feature | Railway | Vercel | Fly.io |
|---|---|---|---|
| One-Click Deploy | ✅ | ✅ | ⚠️ CLI required |
| Managed Database | ✅ Built-in | ❌ External needed | ✅ Built-in |
| Free Tier/Credit | $5/mo credit | 100GB | 3 shared VMs |
| Auto Scaling | ✅ Pro | ✅ | ✅ |
| Private Network | ✅ | ⚠️ Limited | ✅ |
HaloLight React version is built on React 19 + Vite 6, a pure Client-Side Rendering (CSR) Single Page Application (SPA).
Live Preview: https://halolight-react.h7ml.cn/
GitHub: https://github.com/halolight/halolight-react
| Technology | Version | Description |
|---|---|---|
| React | 19.x | UI framework |
| Vite | 6.x | Build tool |
| TypeScript | 5.x | Type safety |
| React Router | 6.x | Client-side routing |
| Zustand | 5.x | State management |
| TanStack Query | 5.x | Server state |
| React Hook Form | 7.x | Form handling |
| Zod | 4.x | Data validation |
| Tailwind CSS | 4.x | Atomic CSS |
| shadcn/ui | latest | UI component library |
| react-grid-layout | 1.5.x | Drag-and-drop layout |
| Recharts | 3.x | Chart visualization |
| Framer Motion | 12.x | Animation effects |
| Mock.js | 1.x | Data mocking |
halolight-react/
├── src/
│ ├── pages/ # Page components
│ │ ├── auth/ # Auth pages
│ │ │ ├── login/
│ │ │ ├── register/
│ │ │ ├── forgot-password/
│ │ │ └── reset-password/
│ │ ├── dashboard/ # Dashboard
│ │ └── legal/ # Legal pages
│ ├── components/
│ │ ├── ui/ # shadcn/ui components (20+)
│ │ ├── layout/ # Layout components
│ │ │ ├── admin-layout.tsx
│ │ │ ├── auth-layout.tsx
│ │ │ ├── sidebar.tsx
│ │ │ ├── header.tsx
│ │ │ └── footer.tsx
│ │ ├── dashboard/ # Dashboard components
│ │ │ ├── configurable-dashboard.tsx
│ │ │ ├── widget-wrapper.tsx
│ │ │ ├── stats-widget.tsx
│ │ │ ├── chart-widget.tsx
│ │ │ └── ...
│ │ └── shared/ # Shared components
│ ├── hooks/ # Custom Hooks
│ │ ├── use-users.ts
│ │ ├── use-auth.ts
│ │ ├── use-theme.ts
│ │ └── ...
│ ├── stores/ # Zustand Stores
│ │ ├── auth.ts
│ │ ├── ui-settings.ts
│ │ ├── dashboard-layout.ts
│ │ └── tabs.ts
│ ├── lib/
│ │ ├── api/ # API services
│ │ ├── auth/ # Auth logic
│ │ ├── validations/ # Zod schemas
│ │ └── utils.ts # Utility functions
│ ├── routes/ # Route configuration
│ │ └── index.tsx
│ ├── config/ # Configuration files
│ │ ├── routes.ts
│ │ └── tdk.ts
│ ├── types/ # Type definitions
│ ├── mock/ # Mock data
│ ├── providers/ # Context Providers
│ ├── App.tsx
│ └── main.tsx
├── public/ # Static assets
├── vite.config.ts
├── tsconfig.json
└── package.json
git clone https://github.com/halolight/halolight-react.git
cd halolight-react
pnpm install
cp .env.example .env.development
# .env.development example
VITE_API_URL=/api
VITE_MOCK=true
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight
[email protected]
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=true
pnpm dev
Visit http://localhost:5173
pnpm build
pnpm preview
| Role | Password | |
|---|---|---|
| Admin | [email protected] | 123456 |
| User | [email protected] | 123456 |
// stores/auth.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
login: (credentials: LoginCredentials) => Promise<void>
logout: () => void
hasPermission: (permission: string) => boolean
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (credentials) => {
const response = await authApi.login(credentials)
set({
user: response.user,
token: response.token,
isAuthenticated: true,
})
},
logout: () => {
set({ user: null, token: null, isAuthenticated: false })
},
hasPermission: (permission) => {
const { user } = get()
if (!user) return false
return user.permissions.some(p =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
},
}),
{
name: 'auth-storage',
partialize: (state) => ({ token: state.token, user: state.user }),
}
)
)
// hooks/use-users.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { usersApi } from '@/lib/api'
export function useUsers(params?: UserQueryParams) {
return useQuery({
queryKey: ['users', params],
queryFn: () => usersApi.getList(params),
})
}
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: usersApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}
// Usage in component
function UsersPage() {
const { data: users, isLoading, error } = useUsers()
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return <div>{/* Render user list */}</div>
}
// hooks/use-permission.ts
import { useAuthStore } from '@/stores/auth'
export function usePermission(permission: string): boolean {
const hasPermission = useAuthStore((state) => state.hasPermission)
return hasPermission(permission)
}
export function usePermissions(permissions: string[]): boolean {
const hasPermission = useAuthStore((state) => state.hasPermission)
return permissions.every(p => hasPermission(p))
}
// Usage
function DeleteButton() {
const canDelete = usePermission('users:delete')
if (!canDelete) return null
return <Button variant="destructive">Delete</Button>
}
// components/permission-guard.tsx
import { usePermission } from '@/hooks/use-permission'
interface PermissionGuardProps {
permission: string
children: React.ReactNode
fallback?: React.ReactNode
}
export function PermissionGuard({
permission,
children,
fallback = null,
}: PermissionGuardProps) {
const hasPermission = usePermission(permission)
if (!hasPermission) return fallback
return <>{children}</>
}
<!-- Usage -->
<PermissionGuard permission="users:delete" fallback={<span>No permission</span>}>
<DeleteButton />
</PermissionGuard>
// components/dashboard/configurable-dashboard.tsx
import GridLayout from 'react-grid-layout'
import { useDashboardStore } from '@/stores/dashboard-layout'
export function ConfigurableDashboard() {
const { layout, setLayout, isEditing } = useDashboardStore()
return (
<GridLayout
layout={layout}
onLayoutChange={setLayout}
cols={12}
rowHeight={80}
isDraggable={isEditing}
isResizable={isEditing}
margin={[16, 16]}
>
{layout.map((item) => (
<div key={item.i}>
<WidgetWrapper widget={getWidget(item.i)} />
</div>
))}
</GridLayout>
)
}
Supports 11 preset skins, switchable via quick settings panel:
| Skin | Main Color | CSS Variable |
|---|---|---|
| Default | Purple | --primary: 51.1% 0.262 276.97 |
| Blue | Blue | --primary: 54.8% 0.243 264.05 |
| Emerald | Emerald | --primary: 64.6% 0.178 142.49 |
| Orange | Orange | --primary: 65.7% 0.198 45.13 |
| Rose | Rose | --primary: 58.9% 0.238 11.26 |
| Cyan | Cyan | --primary: 75.6% 0.146 191.68 |
| Yellow | Yellow | --primary: 85.1% 0.184 98.08 |
| Violet | Violet | --primary: 55.3% 0.264 293.49 |
| Slate | Slate | --primary: 47.9% 0.017 256.71 |
| Zinc | Zinc | --primary: 48.3% 0 0 |
| Neutral | Neutral | --primary: 48.5% 0 0 |
/* Example variable definitions */
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 100% 0 0;
--secondary: 96.1% 0.004 286.41;
--secondary-foreground: 14.9% 0.017 285.75;
--muted: 96.1% 0.004 286.41;
--muted-foreground: 45.8% 0.009 285.77;
--accent: 96.1% 0.004 286.41;
--accent-foreground: 14.9% 0.017 285.75;
--destructive: 59.3% 0.246 27.33;
--destructive-foreground: 100% 0 0;
--border: 89.8% 0.006 286.32;
--input: 89.8% 0.006 286.32;
--ring: 51.1% 0.262 276.97;
--radius: 0.5rem;
}
.dark {
--background: 0% 0 0;
--foreground: 98.3% 0 0;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 100% 0 0;
/* ... */
}
| Path | Page | Permission |
|---|---|---|
/ |
Redirect to /dashboard |
- |
/login |
Login | Public |
/register |
Register | Public |
/forgot-password |
Forgot password | Public |
/reset-password |
Reset password | Public |
/dashboard |
Dashboard | dashboard:view |
/users |
User list | users:list |
/users/create |
Create user | users:create |
/users/:id |
User details | users:view |
/users/:id/edit |
Edit user | users:update |
/roles |
Role management | roles:list |
/permissions |
Permission management | permissions:list |
/settings |
System settings | settings:view |
/profile |
User profile | Authenticated |
cp .env.example .env.development
# .env.development example
VITE_API_URL=/api
VITE_MOCK=true
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight
[email protected]
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=true
| Variable Name | Description | Default Value |
|---|---|---|
| VITE_API_URL | API base path | /api |
| VITE_MOCK | Enable Mock data | true |
| VITE_APP_TITLE | Application title | Admin Pro |
| VITE_BRAND_NAME | Brand name | Halolight |
| VITE_DEMO_EMAIL | Demo account email | [email protected] |
| VITE_DEMO_PASSWORD | Demo account password | 123456 |
| VITE_SHOW_DEMO_HINT | Show demo hint | true |
// Access environment variables in code
const apiUrl = import.meta.env.VITE_API_URL
const isMock = import.meta.env.VITE_MOCK === 'true'
pnpm dev # Start development server
pnpm build # Production build
pnpm preview # Preview production build
pnpm lint # Code linting
pnpm lint:fix # Auto fix
pnpm type-check # Type checking
pnpm test # Run tests
pnpm test:coverage # Test coverage
pnpm test # Run tests (watch mode)
pnpm test:run # Single run
pnpm test:coverage # Coverage report
pnpm test:ui # Vitest UI interface
// __tests__/components/Button.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from '@/components/ui/button'
describe('Button', () => {
it('renders button with text', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button')).toHaveTextContent('Click me')
})
it('handles click events', async () => {
const handleClick = vi.fn()
render(<Button onClick={handleClick}>Click me</Button>)
await userEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('disables button when disabled prop is true', () => {
render(<Button disabled>Click me</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
})
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'ui-vendor': ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
'chart-vendor': ['recharts'],
},
},
},
},
})
vercel
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
docker build -t halolight-react .
docker run -p 3000:80 halolight-react
Complete GitHub Actions CI workflow configuration:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm type-check
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
Built-in PWA support including:
// public/manifest.json
{
"name": "Admin Pro",
"short_name": "Admin",
"start_url": "/",
"display": "standalone",
"theme_color": "#ffffff",
"background_color": "#ffffff",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
// routes/index.tsx
import { createBrowserRouter, Navigate } from 'react-router-dom'
import { DashboardLayout } from '@/layouts/dashboard-layout'
import { AuthLayout } from '@/layouts/auth-layout'
export const router = createBrowserRouter([
{
path: '/',
element: <Navigate to="/dashboard" replace />,
},
{
path: '/login',
element: <AuthLayout />,
children: [
{ index: true, element: <LoginPage /> },
],
},
{
path: '/',
element: <DashboardLayout />,
children: [
{ path: 'dashboard', element: <HomePage /> },
{ path: 'users', element: <UsersPage /> },
{ path: 'settings', element: <SettingsPage /> },
// More routes...
],
},
])
// components/auth-guard.tsx
import { Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/stores/auth'
interface AuthGuardProps {
children: React.ReactNode
permission?: string
}
export function AuthGuard({ children, permission }: AuthGuardProps) {
const location = useLocation()
const { isAuthenticated, hasPermission } = useAuthStore()
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
if (permission && !hasPermission(permission)) {
return <Navigate to="/403" replace />
}
return <>{children}</>
}
// Lazy load images
import { useState } from 'react'
function LazyImage({ src, alt }: { src: string; alt: string }) {
const [loaded, setLoaded] = useState(false)
return (
<div className="relative">
{!loaded && <div className="skeleton" />}
<img
src={src}
alt={alt}
loading="lazy"
onLoad={() => setLoaded(true)}
className={loaded ? 'opacity-100' : 'opacity-0'}
/>
</div>
)
}
// Route-level code splitting
import { lazy, Suspense } from 'react'
const Dashboard = lazy(() => import('@/pages/dashboard'))
const Users = lazy(() => import('@/pages/users'))
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/users" element={<Users />} />
</Routes>
</Suspense>
)
}
// Preload component on hover
import { lazy } from 'react'
const UserDetails = lazy(() => import('@/pages/user-details'))
function UserList() {
const preloadUserDetails = () => {
// Trigger preload
import('@/pages/user-details')
}
return (
<Link
to="/users/1"
onMouseEnter={preloadUserDetails}
>
View Details
</Link>
)
}
import { memo } from 'react'
// Prevent unnecessary re-renders
const ExpensiveComponent = memo(({ data }: { data: any }) => {
return <div>{/* Complex rendering logic */}</div>
})
A: Add route configuration in src/routes/index.tsx:
{
path: '/new-page',
element: <NewPage />,
}
A: Modify CSS variables or use theme switching feature:
:root {
--primary: 51.1% 0.262 276.97; /* Modify primary color */
}
A: Set VITE_MOCK to false and configure VITE_API_URL:
VITE_MOCK=false
VITE_API_URL=https://api.example.com
A: Add permission string to user's permissions array and use usePermission Hook:
const canEdit = usePermission('users:edit')
| Feature | React Version | Next.js | Vue |
|---|---|---|---|
| SSR/SSG | ❌ | ✅ | ✅ (Nuxt) |
| State Management | Zustand | Zustand | Pinia |
| Routing | React Router | App Router | Vue Router |
| Build Tool | Vite | Next.js | Vite |
HaloLight Remix version is built on React Router 7 (the original Remix team has merged into React Router), featuring TypeScript + Web standards-first full-stack development experience.
Live Preview: https://halolight-remix.h7ml.cn/
GitHub: https://github.com/halolight/halolight-remix
+types/)| Technology | Version | Description |
|---|---|---|
| React Router | 7.x | Full-stack routing framework (formerly Remix) |
| React | 19.x | UI framework |
| TypeScript | 5.9 | Type safety |
| Vite | 7.x | Build tool |
| Tailwind CSS | 4.x | Atomic CSS + OKLch |
| Radix UI | latest | Accessible UI primitives |
| Zustand | 5.x | Lightweight state management |
| Recharts | 3.x | Chart visualization |
| Vitest | 4.x | Unit testing |
| Cloudflare Pages | - | Edge deployment |
+types/)halolight-remix/
├── app/
│ ├── routes/ # File-based routing
│ │ ├── _index.tsx # Homepage (dashboard)
│ │ ├── login.tsx # Login
│ │ ├── register.tsx # Register
│ │ ├── forgot-password.tsx # Forgot password
│ │ ├── reset-password.tsx # Reset password
│ │ ├── users.tsx # User management
│ │ ├── users.$id.tsx # User details (dynamic route)
│ │ ├── settings.tsx # System settings
│ │ ├── profile.tsx # Profile
│ │ ├── security.tsx # Security settings
│ │ ├── analytics.tsx # Data analytics
│ │ ├── notifications.tsx # Notification center
│ │ ├── documents.tsx # Document management
│ │ ├── calendar.tsx # Calendar
│ │ ├── api.users.ts # API endpoint
│ │ ├── api.auth.login.ts # Login API
│ │ ├── api.auth.logout.ts # Logout API
│ │ └── +types/ # Auto-generated types
│ ├── components/ # Component library
│ │ ├── ui/ # Base UI components
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── dropdown-menu.tsx
│ │ │ ├── input.tsx
│ │ │ ├── select.tsx
│ │ │ ├── table.tsx
│ │ │ ├── toast.tsx
│ │ │ └── ...
│ │ ├── layout/ # Layout components
│ │ │ ├── header.tsx
│ │ │ ├── sidebar.tsx
│ │ │ ├── footer.tsx
│ │ │ ├── tab-bar.tsx
│ │ │ └── quick-settings.tsx
│ │ ├── auth/ # Auth components
│ │ │ └── auth-shell.tsx
│ │ ├── dashboard/ # Dashboard components
│ │ │ ├── stats-card.tsx
│ │ │ └── chart-widget.tsx
│ │ ├── admin-layout.tsx # Admin layout
│ │ └── theme-provider.tsx # Theme provider
│ ├── hooks/ # React Hooks
│ │ ├── use-chart-palette.ts
│ │ ├── use-toast.ts
│ │ └── use-media-query.ts
│ ├── lib/ # Utilities
│ │ ├── utils.ts # cn() className utility
│ │ ├── meta.ts # TDK meta info
│ │ ├── session.server.ts # Session management
│ │ ├── auth.server.ts # Auth logic
│ │ └── project-info.ts # Project info
│ ├── stores/ # Zustand state
│ │ ├── tabs-store.ts # Tabs state
│ │ └── ui-settings-store.ts # UI settings state
│ ├── types/ # TypeScript types
│ │ ├── user.ts
│ │ └── api.ts
│ ├── root.tsx # Root component
│ ├── routes.ts # Route config
│ └── app.css # Global styles
├── tests/ # Test files
│ ├── setup.ts
│ ├── lib/
│ ├── stores/
│ └── components/
├── public/ # Static assets
├── .github/workflows/ci.yml # CI config
├── wrangler.json # Cloudflare config
├── vitest.config.ts # Vitest config
├── eslint.config.js # ESLint config
├── vite.config.ts # Vite config
└── package.json
git clone https://github.com/halolight/halolight-remix.git
cd halolight-remix
pnpm install
cp .env.example .env
# .env example
SESSION_SECRET=your-super-secret-session-key
API_BASE_URL=https://api.halolight.h7ml.cn
MOCK_ENABLED=true
DEMO_EMAIL=[email protected]
DEMO_PASSWORD=123456
SHOW_DEMO_HINT=true
APP_TITLE=Admin Pro
BRAND_NAME=Halolight
pnpm dev
Visit http://localhost:5173
pnpm build
pnpm start
| Role | Password | |
|---|---|---|
| Admin | [email protected] | 123456 |
| User | [email protected] | 123456 |
React Router 7 uses file-system routing, where filenames determine URL paths:
app/routes/
├── _index.tsx → / (index route)
├── about.tsx → /about (static route)
├── users.tsx → /users (static route)
├── users.$id.tsx → /users/:id (dynamic route)
├── users.$id_.edit.tsx → /users/:id/edit (nested route)
├── _layout.tsx → layout route (no URL segment)
├── _layout.dashboard.tsx → /dashboard (with layout)
├── $.tsx → /* (splat route)
├── api.users.ts → /api/users (resource route)
└── [...slug].tsx → /* optional catch-all
| Filename | Description |
|---|---|
_index.tsx |
Index route, matches parent route exact path |
_layout.tsx |
Pathless layout, child routes share layout |
$param.tsx |
Dynamic route parameter |
$.tsx |
Splat route, catches all sub-paths |
api.*.ts |
Resource route (loader/action only, no UI) |
+types/ |
Auto-generated type definitions |
Loader executes on the server for page data fetching:
// app/routes/users.tsx
import type { Route } from "./+types/users";
// Server-side data loading
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page")) || 1;
const limit = Number(url.searchParams.get("limit")) || 10;
const search = url.searchParams.get("search") || "";
// Check authentication
const session = await getSession(request.headers.get("Cookie"));
if (!session.has("userId")) {
throw redirect("/login");
}
// Fetch data
const response = await fetch(
`${process.env.API_BASE_URL}/users?page=${page}&limit=${limit}&search=${search}`,
{
headers: {
Authorization: `Bearer ${session.get("token")}`,
},
}
);
if (!response.ok) {
throw new Response("Failed to fetch user list", { status: response.status });
}
const { data, total } = await response.json();
return {
users: data,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
// Page component receives loaderData
export default function UsersPage({ loaderData }: Route.ComponentProps) {
const { users, pagination } = loaderData;
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">User Management</h1>
<div className="rounded-md border">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-4 text-left">Name</th>
<th className="p-4 text-left">Email</th>
<th className="p-4 text-left">Role</th>
<th className="p-4 text-left">Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b">
<td className="p-4">{user.name}</td>
<td className="p-4">{user.email}</td>
<td className="p-4">{user.role}</td>
<td className="p-4">
<Link to={`/users/${user.id}`}>View</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination {...pagination} />
</div>
);
}
Action handles form submissions with progressive enhancement:
// app/routes/login.tsx
import type { Route } from "./+types/login";
import { Form, useActionData, useNavigation, redirect } from "react-router";
import { commitSession, getSession } from "~/lib/session.server";
// Server-side form handling
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const redirectTo = formData.get("redirectTo") as string || "/";
// Validation
const errors: Record<string, string> = {};
if (!email) {
errors.email = "Please enter email";
} else if (!email.includes("@")) {
errors.email = "Please enter a valid email address";
}
if (!password) {
errors.password = "Please enter password";
} else if (password.length < 6) {
errors.password = "Password must be at least 6 characters";
}
if (Object.keys(errors).length > 0) {
return { errors, values: { email } };
}
// Call login API
const response = await fetch(`${process.env.API_BASE_URL}/auth/login`, {
method: "POST",
body: JSON.stringify({ email, password }),
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
const data = await response.json();
return { errors: { form: data.message || "Invalid email or password" } };
}
const { user, token } = await response.json();
// Create session
const session = await getSession(request.headers.get("Cookie"));
session.set("userId", user.id);
session.set("token", token);
session.set("user", user);
// Redirect and set Cookie
return redirect(redirectTo, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
// Meta info
export function meta(): Route.MetaDescriptors {
return [
{ title: "Login - Admin Pro" },
{ name: "description", content: "Login to Admin Pro management system" },
];
}
// Page component
export default function LoginPage() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-8 p-8">
<div className="text-center">
<h1 className="text-2xl font-bold">Welcome Back</h1>
<p className="text-muted-foreground">Login to your account</p>
</div>
{/* Form error message */}
{actionData?.errors?.form && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{actionData.errors.form}
</div>
)}
{/* Progressive enhancement form - works without JS */}
<Form method="post" className="space-y-4">
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
defaultValue={actionData?.values?.email}
className="w-full rounded-md border px-3 py-2"
placeholder="[email protected]"
/>
{actionData?.errors?.email && (
<p className="text-sm text-destructive">{actionData.errors.email}</p>
)}
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="w-full rounded-md border px-3 py-2"
placeholder="••••••••"
/>
{actionData?.errors?.password && (
<p className="text-sm text-destructive">{actionData.errors.password}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
>
{isSubmitting ? "Logging in..." : "Login"}
</button>
</Form>
<p className="text-center text-sm text-muted-foreground">
Don't have an account?{" "}
<Link to="/register" className="text-primary hover:underline">
Register now
</Link>
</p>
</div>
</div>
);
}
// app/routes/users.tsx
import type { Route } from "./+types/users";
import { generateMeta } from "~/lib/meta";
export function meta(): Route.MetaDescriptors {
return generateMeta("/users");
}
// app/lib/meta.ts
export const pageMetas: Record<string, PageMeta> = {
"/users": {
title: "User Management",
description: "Manage system user accounts, including creation, editing, and permission configuration",
keywords: ["user management", "account management", "permission configuration"],
},
// ...
};
export function generateMeta(path: string, overrides?: Partial<PageMeta>) {
const meta = pageMetas[path] || { title: "Page", description: "" };
// Return complete meta tag array
}
// app/stores/tabs-store.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface TabsState {
tabs: Tab[];
activeTabId: string | null;
addTab: (tab: Omit<Tab, "id">) => string;
removeTab: (id: string) => void;
setActiveTab: (id: string) => void;
clearTabs: () => void;
}
export const useTabsStore = create<TabsState>()(
persist(
(set, get) => ({
tabs: [homeTab],
activeTabId: "home",
addTab: (tab) => { /* ... */ },
removeTab: (id) => { /* ... */ },
// ...
}),
{ name: "tabs-storage" }
)
);
// app/stores/ui-settings-store.ts
export type SkinPreset =
| "default" | "blue" | "emerald" | "amber" | "violet"
| "rose" | "teal" | "slate" | "ocean" | "sunset" | "aurora";
export const useUiSettingsStore = create<UiSettingsState>()(
persist(
(set) => ({
skin: "default",
showFooter: true,
showTabBar: true,
setSkin: (skin) => set({ skin }),
setShowFooter: (visible) => set({ showFooter: visible }),
// ...
}),
{ name: "ui-settings-storage" }
)
);
Supports 11 preset skins, switch via Quick Settings panel:
| Skin | Primary Color |
|---|---|
| Default | Purple |
| Blue | Blue |
| Emerald | Emerald |
| Amber | Amber |
| Violet | Violet |
| Rose | Rose |
| Teal | Teal |
| Slate | Slate |
| Ocean | Ocean Blue |
| Sunset | Sunset Orange |
| Aurora | Aurora |
/* app/app.css */
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 100% 0 0;
/* ... */
}
[data-skin="ocean"] {
--primary: 54.3% 0.195 240.03;
}
.dark {
--background: 14.9% 0.017 285.75;
--foreground: 98.5% 0 0;
/* ... */
}
| Path | Page | Permission |
|---|---|---|
/ |
Dashboard | dashboard:view |
/login |
Login | Public |
/register |
Register | Public |
/forgot-password |
Forgot Password | Public |
/reset-password |
Reset Password | Public |
/users |
User Management | users:view |
/settings |
System Settings | settings:view |
/profile |
Profile | settings:view |
/security |
Security Settings | settings:view |
/analytics |
Data Analytics | analytics:view |
/notifications |
Notification Center | notifications:view |
/documents |
Document Management | documents:view |
/calendar |
Calendar | calendar:view |
# .env
SESSION_SECRET=your-super-secret-session-key
API_BASE_URL=https://api.halolight.h7ml.cn
MOCK_ENABLED=true
DEMO_EMAIL=[email protected]
DEMO_PASSWORD=123456
SHOW_DEMO_HINT=true
APP_TITLE=Admin Pro
BRAND_NAME=Halolight
| Variable Name | Description | Default Value |
|---|---|---|
SESSION_SECRET |
Session secret key (required) | (required) |
API_BASE_URL |
API base URL | /api |
MOCK_ENABLED |
Enable Mock data | false |
DEMO_EMAIL |
Demo account email | - |
DEMO_PASSWORD |
Demo account password | - |
SHOW_DEMO_HINT |
Show demo hint | false |
APP_TITLE |
Application title | Admin Pro |
BRAND_NAME |
Brand name | Halolight |
// app/routes/users.tsx
export async function loader({ request }: Route.LoaderArgs) {
const apiUrl = process.env.API_BASE_URL;
const response = await fetch(`${apiUrl}/users`);
return response.json();
}
# Development
pnpm dev # Start dev server
pnpm dev --host # Allow LAN access
# Build
pnpm build # Production build
pnpm start # Start production server
# Code Quality
pnpm typecheck # TypeScript type check
pnpm lint # ESLint check
pnpm lint:fix # ESLint auto-fix
pnpm format # Prettier format
# Testing
pnpm test # Watch mode
pnpm test:run # Single run
pnpm test:coverage # Coverage report
pnpm test:ui # Vitest UI interface
# Deployment
pnpm preview # Cloudflare local preview
pnpm deploy # Deploy to Cloudflare Pages
pnpm test:coverage # Coverage report
### Test Example
```tsx
// tests/stores/tabs-store.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { useTabsStore } from "~/stores/tabs-store";
describe("useTabsStore", () => {
beforeEach(() => {
useTabsStore.getState().clearTabs();
});
it("should add new tab", () => {
const { addTab } = useTabsStore.getState();
addTab({ title: "User Management", path: "/users" });
const { tabs } = useTabsStore.getState();
expect(tabs).toHaveLength(2);
});
});
// vite.config.ts
import { defineConfig } from "vite";
import { reactRouter } from "@react-router/dev/vite";
export default defineConfig({
plugins: [reactRouter()],
});
// wrangler.json
{
"name": "halolight-remix",
"compatibility_date": "2024-12-01",
"compatibility_flags": ["nodejs_compat"],
"pages_build_output_dir": "./build/client"
}
// eslint.config.js
import js from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["build", ".react-router"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
}
);
# Install Wrangler CLI
npm install -g wrangler
# Login
wrangler login
# Deploy
pnpm deploy
// wrangler.json
{
"name": "halolight-remix",
"compatibility_date": "2024-12-01",
"compatibility_flags": ["nodejs_compat"],
"pages_build_output_dir": "./build/client"
}
# .github/workflows/deploy.yml
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: halolight-remix
directory: build/client
pnpm build
pnpm start
# Dockerfile
FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY --from=builder /app/build ./build
COPY --from=builder /app/package.json .
COPY --from=builder /app/pnpm-lock.yaml .
RUN pnpm install --prod --frozen-lockfile
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["pnpm", "start"]
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- SESSION_SECRET=${SESSION_SECRET}
- API_BASE_URL=${API_BASE_URL}
restart: unless-stopped
# Install Vercel CLI
npm install -g vercel
# Deploy
vercel
The project is configured with complete GitHub Actions CI workflow:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
Using Cookie for session management:
// app/lib/session.server.ts
import { createCookieSessionStorage, redirect } from "react-router";
// Create session storage
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__session",
httpOnly: true,
maxAge: 60 * 60 * 24 * 7, // 7 days
path: "/",
sameSite: "lax",
secrets: [process.env.SESSION_SECRET!],
secure: process.env.NODE_ENV === "production",
},
});
export const { getSession, commitSession, destroySession } = sessionStorage;
// Get current user
export async function getUser(request: Request) {
const session = await getSession(request.headers.get("Cookie"));
const user = session.get("user");
return user || null;
}
// Require login
export async function requireUser(request: Request) {
const user = await getUser(request);
if (!user) {
const url = new URL(request.url);
throw redirect(`/login?redirectTo=${encodeURIComponent(url.pathname)}`);
}
return user;
}
// Logout
export async function logout(request: Request) {
const session = await getSession(request.headers.get("Cookie"));
return redirect("/login", {
headers: {
"Set-Cookie": await destroySession(session),
},
});
}
Global and route-level error handling:
// app/root.tsx
import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useRouteError } from "react-router";
export function ErrorBoundary() {
const error = useRouteError();
// Route errors (like 404, 401)
if (isRouteErrorResponse(error)) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Error {error.status}</title>
<Meta />
<Links />
</head>
<body>
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-9xl font-bold text-muted-foreground">
{error.status}
</h1>
<p className="mt-4 text-xl">{error.statusText}</p>
<p className="mt-2 text-muted-foreground">{error.data}</p>
<a href="/" className="mt-8 inline-block text-primary hover:underline">
Back to Home
</a>
</div>
</div>
<Scripts />
</body>
</html>
);
}
// Unknown errors
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Error Occurred</title>
<Meta />
<Links />
</head>
<body>
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-destructive">Error Occurred</h1>
<p className="mt-2 text-muted-foreground">
{error instanceof Error ? error.message : "Unknown error"}
</p>
<a href="/" className="mt-8 inline-block text-primary hover:underline">
Back to Home
</a>
</div>
</div>
<Scripts />
</body>
</html>
);
}
// app/routes/users.$id.tsx - Route-level error boundary
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error) && error.status === 404) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<h2 className="text-xl font-semibold">User Not Found</h2>
<p className="text-muted-foreground">Please check if the user ID is correct</p>
<Link to="/users" className="mt-4 inline-block text-primary">
Back to User List
</Link>
</div>
</div>
);
}
throw error; // Throw other errors upward
}
Resource routes have no UI components, only export loader/action:
// app/routes/api.users.ts
import type { Route } from "./+types/api.users";
import { getSession } from "~/lib/session.server";
// GET /api/users
export async function loader({ request }: Route.LoaderArgs) {
const session = await getSession(request.headers.get("Cookie"));
if (!session.has("userId")) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const page = Number(url.searchParams.get("page")) || 1;
const limit = Number(url.searchParams.get("limit")) || 10;
const response = await fetch(
`${process.env.API_BASE_URL}/users?page=${page}&limit=${limit}`,
{
headers: {
Authorization: `Bearer ${session.get("token")}`,
},
}
);
const data = await response.json();
return Response.json(data);
}
// POST /api/users
export async function action({ request }: Route.ActionArgs) {
const session = await getSession(request.headers.get("Cookie"));
if (!session.has("userId")) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const response = await fetch(`${process.env.API_BASE_URL}/users`, {
method: "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.get("token")}`,
},
});
const data = await response.json();
return Response.json(data, { status: response.status });
}
// app/routes/api.users.$id.ts
import type { Route } from "./+types/api.users.$id";
// GET /api/users/:id
export async function loader({ params, request }: Route.LoaderArgs) {
const { id } = params;
const session = await getSession(request.headers.get("Cookie"));
const response = await fetch(`${process.env.API_BASE_URL}/users/${id}`, {
headers: {
Authorization: `Bearer ${session.get("token")}`,
},
});
if (!response.ok) {
throw new Response("User not found", { status: 404 });
}
return Response.json(await response.json());
}
// PUT /api/users/:id
export async function action({ params, request }: Route.ActionArgs) {
const { id } = params;
const session = await getSession(request.headers.get("Cookie"));
const body = await request.json();
const response = await fetch(`${process.env.API_BASE_URL}/users/${id}`, {
method: request.method,
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.get("token")}`,
},
});
return Response.json(await response.json(), { status: response.status });
}
// app/routes/users.tsx
import { useFetcher } from "react-router";
export default function UsersPage() {
const fetcher = useFetcher();
const handleDelete = (userId: string) => {
if (confirm("Are you sure you want to delete this user?")) {
fetcher.submit(
{ userId },
{ method: "delete", action: "/api/users" }
);
}
};
return (
<div>
{users.map((user) => (
<div key={user.id}>
<span>{user.name}</span>
<button
onClick={() => handleDelete(user.id)}
disabled={fetcher.state === "submitting"}
>
{fetcher.state === "submitting" ? "Deleting..." : "Delete"}
</button>
</div>
))}
</div>
);
}
// app/routes/notifications.tsx
import { useFetcher } from "react-router";
function NotificationItem({ notification }) {
const fetcher = useFetcher();
// Optimistic UI: immediately show read status
const isRead = fetcher.formData
? fetcher.formData.get("read") === "true"
: notification.read;
return (
<div className={isRead ? "opacity-50" : ""}>
<p>{notification.message}</p>
{!isRead && (
<fetcher.Form method="post" action="/api/notifications/mark-read">
<input type="hidden" name="id" value={notification.id} />
<input type="hidden" name="read" value="true" />
<button type="submit">Mark as Read</button>
</fetcher.Form>
)}
</div>
);
}
// app/routes/analytics.tsx
import type { Route } from "./+types/analytics";
import { Await, defer } from "react-router";
import { Suspense } from "react";
export async function loader({ request }: Route.LoaderArgs) {
// Fast data returns immediately
const summary = await getSummary();
// Slow data loads deferred
const chartDataPromise = getChartData();
const reportPromise = generateReport();
return defer({
summary,
chartData: chartDataPromise,
report: reportPromise,
});
}
export default function AnalyticsPage({ loaderData }: Route.ComponentProps) {
const { summary, chartData, report } = loaderData;
return (
<div className="space-y-6">
{/* Show immediately */}
<SummaryCard data={summary} />
{/* Deferred chart loading */}
<Suspense fallback={<ChartSkeleton />}>
<Await resolve={chartData}>
{(data) => <Chart data={data} />}
</Await>
</Suspense>
{/* Deferred report loading */}
<Suspense fallback={<ReportSkeleton />}>
<Await resolve={report}>
{(data) => <Report data={data} />}
</Await>
</Suspense>
</div>
);
}
// app/routes/dashboard.tsx
export async function loader({ request }: Route.LoaderArgs) {
// Parallel requests to multiple data sources
const [stats, recentUsers, notifications, activities] = await Promise.all([
getStats(),
getRecentUsers(),
getNotifications(),
getActivities(),
]);
return { stats, recentUsers, notifications, activities };
}
// app/lib/middleware.ts
import { redirect } from "react-router";
import { getSession } from "./session.server";
type LoaderFunction = (args: LoaderArgs) => Promise<any>;
// Authentication middleware
export function withAuth(loader: LoaderFunction): LoaderFunction {
return async (args) => {
const session = await getSession(args.request.headers.get("Cookie"));
if (!session.has("userId")) {
const url = new URL(args.request.url);
throw redirect(`/login?redirectTo=${encodeURIComponent(url.pathname)}`);
}
// Inject user info
const user = session.get("user");
return loader({ ...args, user });
};
}
// Role check middleware
export function withRole(role: string, loader: LoaderFunction): LoaderFunction {
return withAuth(async (args) => {
const { user } = args as any;
if (user.role !== role) {
throw new Response("Insufficient permissions", { status: 403 });
}
return loader(args);
});
}
// Usage example
// app/routes/admin.tsx
export const loader = withRole("admin", async ({ request }) => {
// Only admin role can access
return getAdminData();
});
// Use React.lazy for dynamic imports
import { lazy, Suspense } from "react";
const Chart = lazy(() => import("~/components/dashboard/chart"));
export default function Dashboard() {
return (
<Suspense fallback={<ChartSkeleton />}>
<Chart data={data} />
</Suspense>
);
}
// Link prefetching
import { Link, prefetchRouteModule } from "react-router";
function NavLink({ to, children }) {
return (
<Link
to={to}
onMouseEnter={() => prefetchRouteModule(to)}
onFocus={() => prefetchRouteModule(to)}
>
{children}
</Link>
);
}
// app/routes/api.static-data.ts
export async function loader() {
const data = await getStaticData();
return Response.json(data, {
headers: {
"Cache-Control": "public, max-age=3600, s-maxage=86400",
},
});
}
A: Combine server-side and client-side validation:
// app/routes/register.tsx
import { z } from "zod";
const registerSchema = z.object({
email: z.string().email("Please enter a valid email"),
password: z.string().min(6, "Password must be at least 6 characters"),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const data = Object.fromEntries(formData);
// Server-side validation
const result = registerSchema.safeParse(data);
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
// Create user...
}
A: Use FormData to handle files:
// app/routes/upload.tsx
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file || file.size === 0) {
return { error: "Please select a file" };
}
// Upload to storage service
const buffer = await file.arrayBuffer();
const url = await uploadToStorage(buffer, file.name, file.type);
return { url };
}
export default function UploadPage() {
const actionData = useActionData<typeof action>();
return (
<Form method="post" encType="multipart/form-data">
<input type="file" name="file" required />
<button type="submit">Upload</button>
{actionData?.url && <p>Upload successful: {actionData.url}</p>}
{actionData?.error && <p className="text-destructive">{actionData.error}</p>}
</Form>
);
}
A: Use Cookie or URL prefix:
// app/lib/i18n.ts
export const locales = ["zh-CN", "en-US"] as const;
export type Locale = typeof locales[number];
export function getLocale(request: Request): Locale {
const url = new URL(request.url);
const cookie = request.headers.get("Cookie");
// 1. Check URL parameter
const urlLocale = url.searchParams.get("locale");
if (urlLocale && locales.includes(urlLocale as Locale)) {
return urlLocale as Locale;
}
// 2. Check Cookie
const cookieLocale = getCookie(cookie, "locale");
if (cookieLocale && locales.includes(cookieLocale as Locale)) {
return cookieLocale as Locale;
}
// 3. Check Accept-Language
const acceptLanguage = request.headers.get("Accept-Language");
if (acceptLanguage?.includes("zh")) {
return "zh-CN";
}
return "en-US";
}
A: Use SSE (Server-Sent Events):
// app/routes/api.events.ts
export async function loader({ request }: Route.LoaderArgs) {
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const sendEvent = (data: any) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
};
// Send events periodically
const interval = setInterval(() => {
sendEvent({ type: "ping", timestamp: Date.now() });
}, 5000);
// Cleanup
request.signal.addEventListener("abort", () => {
clearInterval(interval);
controller.close();
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
// Client-side usage
useEffect(() => {
const eventSource = new EventSource("/api/events");
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
// Handle event
};
return () => eventSource.close();
}, []);
// Use React.lazy for dynamic imports
import { lazy, Suspense } from "react";
const Chart = lazy(() => import("~/components/dashboard/chart"));
export default function Dashboard() {
return (
<Suspense fallback={<ChartSkeleton />}>
<Chart data={data} />
</Suspense>
);
}
// Link prefetching
import { Link, prefetchRouteModule } from "react-router";
function NavLink({ to, children }) {
return (
<Link
to={to}
onMouseEnter={() => prefetchRouteModule(to)}
onFocus={() => prefetchRouteModule(to)}
>
{children}
</Link>
);
}
// app/routes/api.static-data.ts
export async function loader() {
const data = await getStaticData();
return Response.json(data, {
headers: {
"Cache-Control": "public, max-age=3600, s-maxage=86400",
},
});
}
| Variable Name | Description | Default Value |
|---|---|---|
SESSION_SECRET |
Session secret key | (required) |
API_BASE_URL |
API base URL | /api |
MOCK_ENABLED |
Enable Mock data | false |
DEMO_EMAIL |
Demo account email | - |
DEMO_PASSWORD |
Demo account password | - |
SHOW_DEMO_HINT |
Show demo hint | false |
APP_TITLE |
Application title | Admin Pro |
BRAND_NAME |
Brand name | Halolight |
# Development
pnpm dev # Start dev server
pnpm dev --host # Allow LAN access
# Build
pnpm build # Production build
pnpm start # Start production server
# Code Quality
pnpm typecheck # TypeScript type check
pnpm lint # ESLint check
pnpm lint:fix # ESLint auto-fix
pnpm format # Prettier format
# Testing
pnpm test # Watch mode
pnpm test:run # Single run
pnpm test:coverage # Coverage report
pnpm test:ui # Vitest UI interface
# Deployment
pnpm preview # Cloudflare local preview
pnpm deploy # Deploy to Cloudflare Pages
| Feature | Remix Version | Vue Version | Next.js Version |
|---|---|---|---|
| State Management | Zustand | Pinia | Zustand |
| Data Fetching | Loader/Action | TanStack Query | TanStack Query |
| Form Handling | Progressive Enhancement Form | VeeValidate | React Hook Form |
| Server-side | Built-in SSR | Nuxt | App Router |
| Component Library | Radix UI | shadcn-vue | shadcn/ui |
| Routing | File-based routing | Vue Router | App Router |
| Theme | OKLch CSS Variables | OKLch CSS Variables | OKLch CSS Variables |
| Testing | Vitest | Vitest | Vitest |
| Build Tool | Vite | Vite | Turbopack |
HaloLight Solid.js version is built on SolidStart 1.0, featuring Solid.js fine-grained reactivity + TypeScript for high-performance admin dashboard. No virtual DOM, compile-time optimization, minimal bundle size.
Live Preview: https://halolight-solidjs.h7ml.cn/
GitHub: https://github.com/halolight/halolight-solidjs
"use server" seamless server-side logic calls| Technology | Version | Description |
|---|---|---|
| SolidStart | 1.x | Solid full-stack framework |
| Solid.js | 1.9+ | Fine-grained reactive framework |
| TypeScript | 5.x | Type safety |
| Tailwind CSS | 4.x | Atomic CSS + OKLch |
| Kobalte | 0.13+ | Accessible UI primitives |
| solid-primitives | latest | Reactive utilities library |
| Zod | 3.x | Data validation |
| @solid-primitives/storage | latest | Persistent storage |
| solid-charts | latest | Chart visualization |
| Vitest | 4.x | Unit testing |
| Mock.js | 1.x | Data mocking |
halolight-solidjs/
├── src/
│ ├── routes/ # File-based routing
│ │ ├── index.tsx # Home (Dashboard)
│ │ ├── (auth)/ # Auth route group (no layout path)
│ │ │ ├── login.tsx # Login
│ │ │ ├── register.tsx # Register
│ │ │ ├── forgot-password.tsx # Forgot password
│ │ │ └── reset-password.tsx # Reset password
│ │ ├── (dashboard)/ # Dashboard route group (with AdminLayout)
│ │ │ ├── dashboard.tsx # Dashboard home
│ │ │ ├── analytics.tsx # Data analytics
│ │ │ ├── users/ # User management
│ │ │ │ ├── index.tsx # User list
│ │ │ │ ├── create.tsx # Create user
│ │ │ │ └── [id].tsx # User details (dynamic route)
│ │ │ ├── roles.tsx # Role management
│ │ │ ├── permissions.tsx # Permission management
│ │ │ ├── messages.tsx # Message center
│ │ │ ├── notifications.tsx # Notifications
│ │ │ ├── documents.tsx # Document management
│ │ │ ├── calendar.tsx # Calendar
│ │ │ ├── settings.tsx # System settings
│ │ │ └── profile.tsx # User profile
│ │ ├── privacy.tsx # Privacy policy
│ │ ├── terms.tsx # Terms of service
│ │ └── api/ # API routes
│ │ ├── auth/
│ │ │ ├── login.ts # POST /api/auth/login
│ │ │ ├── register.ts # POST /api/auth/register
│ │ │ └── logout.ts # POST /api/auth/logout
│ │ └── users/
│ │ ├── index.ts # GET/POST /api/users
│ │ └── [id].ts # GET/PUT/DELETE /api/users/:id
│ ├── components/ # Component library
│ │ ├── ui/ # Kobalte wrapped components
│ │ │ ├── Button.tsx
│ │ │ ├── Card.tsx
│ │ │ ├── Dialog.tsx
│ │ │ ├── DropdownMenu.tsx
│ │ │ ├── Input.tsx
│ │ │ ├── Select.tsx
│ │ │ ├── Table.tsx
│ │ │ ├── Toast.tsx
│ │ │ └── ...
│ │ ├── layout/ # Layout components
│ │ │ ├── AdminLayout.tsx # Admin main layout
│ │ │ ├── AuthLayout.tsx # Auth page layout
│ │ │ ├── Sidebar.tsx # Sidebar
│ │ │ ├── Header.tsx # Top navigation
│ │ │ ├── Footer.tsx # Footer
│ │ │ ├── TabBar.tsx # Tab bar
│ │ │ └── QuickSettings.tsx # Quick settings panel
│ │ ├── dashboard/ # Dashboard components
│ │ │ ├── DashboardGrid.tsx # Draggable grid
│ │ │ ├── WidgetWrapper.tsx # Widget wrapper
│ │ │ ├── StatsWidget.tsx # Stats card
│ │ │ └── ChartWidget.tsx # Chart widget
│ │ ├── auth/ # Auth components
│ │ │ └── AuthShell.tsx # Auth shell
│ │ └── shared/ # Shared components
│ │ ├── PermissionGuard.tsx # Permission guard
│ │ └── ErrorBoundary.tsx # Error boundary
│ ├── stores/ # State management (Signals + Store)
│ │ ├── auth.ts # Auth state
│ │ ├── ui-settings.ts # UI settings state
│ │ ├── tabs.ts # Tab state
│ │ └── dashboard.ts # Dashboard layout state
│ ├── lib/ # Utilities
│ │ ├── api.ts # API client
│ │ ├── permission.ts # Permission utils
│ │ ├── meta.ts # TDK meta info
│ │ └── cn.ts # Class name utils
│ ├── server/ # Server code
│ │ ├── auth.ts # Auth logic
│ │ ├── session.ts # Session management
│ │ └── middleware.ts # Middleware
│ ├── hooks/ # Custom hooks
│ │ ├── createUsers.ts # User data
│ │ └── createToast.ts # Toast notifications
│ └── types/ # TypeScript types
│ ├── user.ts
│ └── api.ts
├── tests/ # Test files
│ ├── setup.ts
│ ├── stores/
│ └── components/
├── public/ # Static assets
├── .github/workflows/ci.yml # CI configuration
├── app.config.ts # SolidStart configuration
├── tailwind.config.ts # Tailwind configuration
├── vitest.config.ts # Vitest configuration
└── package.json
git clone https://github.com/halolight/halolight-solidjs.git
cd halolight-solidjs
pnpm install
cp .env.example .env
# .env example
VITE_API_URL=/api
VITE_USE_MOCK=true
VITE_DEMO_EMAIL=[email protected]
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=true
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight
pnpm dev
Visit http://localhost:3000
pnpm build
pnpm start
| Role | Password | |
|---|---|---|
| Admin | [email protected] | 123456 |
| User | [email protected] | 123456 |
Solid.js core is Signals, providing the most fine-grained reactive updates:
import { createSignal, createEffect, createMemo } from 'solid-js';
// Create signal - reactive state
const [count, setCount] = createSignal(0);
// Create derived value - auto track dependencies
const doubled = createMemo(() => count() * 2);
// Create side effect - auto respond to changes
createEffect(() => {
console.log('count changed:', count());
});
// Update state
setCount(1); // Set new value
setCount(c => c + 1); // Functional update
For complex nested data, use Store:
import { createStore, produce } from 'solid-js/store';
interface User {
id: number;
name: string;
profile: {
avatar: string;
bio: string;
};
}
const [user, setUser] = createStore<User>({
id: 1,
name: 'Admin',
profile: {
avatar: '/avatar.png',
bio: '',
},
});
// Access - auto track
console.log(user.name);
console.log(user.profile.avatar);
// Update - path-based
setUser('name', 'New Name');
setUser('profile', 'bio', 'This is my bio');
// Update - functional (Immer style)
setUser(
produce((draft) => {
draft.name = 'New Name';
draft.profile.bio = 'This is my bio';
})
);
// stores/auth.ts
import { createSignal, createMemo } from 'solid-js'
import { createStore } from 'solid-js/store'
import { makePersisted } from '@solid-primitives/storage'
interface User {
id: number
name: string
email: string
permissions: string[]
}
interface AuthState {
user: User | null
token: string | null
}
const [state, setState] = makePersisted(
createStore<AuthState>({
user: null,
token: null,
}),
{ name: 'auth' }
)
const [loading, setLoading] = createSignal(false)
export const authStore = {
get user() { return state.user },
get token() { return state.token },
get loading() { return loading() },
isAuthenticated: createMemo(() => !!state.token && !!state.user),
permissions: createMemo(() => state.user?.permissions ?? []),
async login(credentials: { email: string; password: string }) {
setLoading(true)
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
headers: { 'Content-Type': 'application/json' },
})
const data = await response.json()
setState({
user: data.user,
token: data.token,
})
} finally {
setLoading(false)
}
},
logout() {
setState({ user: null, token: null })
},
hasPermission(permission: string): boolean {
const perms = state.user?.permissions ?? []
return perms.some(p =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
},
}
// stores/ui-settings.ts
import { createStore } from 'solid-js/store';
import { makePersisted } from '@solid-primitives/storage';
export type SkinPreset =
| 'default'
| 'blue'
| 'emerald'
| 'amber'
| 'violet'
| 'rose'
| 'teal'
| 'slate'
| 'ocean'
| 'sunset'
| 'aurora';
export type ThemeMode = 'light' | 'dark' | 'system';
interface UiSettingsState {
skin: SkinPreset;
theme: ThemeMode;
showFooter: boolean;
showTabBar: boolean;
sidebarCollapsed: boolean;
}
const [state, setState] = makePersisted(
createStore<UiSettingsState>({
skin: 'default',
theme: 'system',
showFooter: true,
showTabBar: true,
sidebarCollapsed: false,
}),
{ name: 'ui-settings-storage' }
);
export const uiSettingsStore = {
get skin() {
return state.skin;
},
get theme() {
return state.theme;
},
get showFooter() {
return state.showFooter;
},
get showTabBar() {
return state.showTabBar;
},
get sidebarCollapsed() {
return state.sidebarCollapsed;
},
setSkin(skin: SkinPreset) {
document.documentElement.setAttribute('data-skin', skin);
setState('skin', skin);
},
setTheme(theme: ThemeMode) {
if (theme === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', isDark);
} else {
document.documentElement.classList.toggle('dark', theme === 'dark');
}
setState('theme', theme);
},
setShowFooter(visible: boolean) {
setState('showFooter', visible);
},
setShowTabBar(visible: boolean) {
setState('showTabBar', visible);
},
toggleSidebar() {
setState('sidebarCollapsed', (c) => !c);
},
};
// stores/tabs.ts
import { createStore, produce } from 'solid-js/store';
import { makePersisted } from '@solid-primitives/storage';
interface Tab {
id: string;
title: string;
path: string;
closable: boolean;
}
const homeTab: Tab = {
id: 'home',
title: 'Home',
path: '/',
closable: false,
};
interface TabsState {
tabs: Tab[];
activeTabId: string;
}
const [state, setState] = makePersisted(
createStore<TabsState>({
tabs: [homeTab],
activeTabId: 'home',
}),
{ name: 'tabs-storage' }
);
export const tabsStore = {
get tabs() {
return state.tabs;
},
get activeTabId() {
return state.activeTabId;
},
get activeTab() {
return state.tabs.find((t) => t.id === state.activeTabId);
},
addTab(tab: Omit<Tab, 'id' | 'closable'>): string {
// Check if already exists
const existing = state.tabs.find((t) => t.path === tab.path);
if (existing) {
setState('activeTabId', existing.id);
return existing.id;
}
const id = crypto.randomUUID();
const newTab: Tab = { ...tab, id, closable: true };
setState(
produce((draft) => {
draft.tabs.push(newTab);
draft.activeTabId = id;
})
);
return id;
},
removeTab(id: string) {
const tab = state.tabs.find((t) => t.id === id);
if (!tab?.closable) return;
const index = state.tabs.findIndex((t) => t.id === id);
const newTabs = state.tabs.filter((t) => t.id !== id);
let newActiveId = state.activeTabId;
if (state.activeTabId === id) {
// Switch to adjacent tab
newActiveId = newTabs[Math.min(index, newTabs.length - 1)]?.id || 'home';
}
setState({
tabs: newTabs,
activeTabId: newActiveId,
});
},
setActiveTab(id: string) {
setState('activeTabId', id);
},
closeOthers(id: string) {
setState(
produce((draft) => {
draft.tabs = draft.tabs.filter((t) => t.id === id || !t.closable);
draft.activeTabId = id;
})
);
},
closeRight(id: string) {
const index = state.tabs.findIndex((t) => t.id === id);
setState('tabs', (tabs) => tabs.filter((t, i) => i <= index || !t.closable));
},
clearTabs() {
setState({
tabs: [homeTab],
activeTabId: 'home',
});
},
};
// src/middleware.ts
import { createMiddleware } from '@solidjs/start/middleware';
export default createMiddleware({
onRequest: [
// Logging middleware
async (event) => {
const start = Date.now();
const response = await event.next();
const duration = Date.now() - start;
console.log(`${event.request.method} ${event.request.url} - ${duration}ms`);
return response;
},
// Auth middleware
async (event) => {
const url = new URL(event.request.url);
// Public paths
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password', '/api/auth'];
const isPublic = publicPaths.some((path) => url.pathname.startsWith(path));
if (isPublic) {
return;
}
// Protect dashboard routes
if (url.pathname.startsWith('/dashboard') || url.pathname.startsWith('/api/')) {
const cookies = event.request.headers.get('cookie') || '';
const token = cookies.match(/token=([^;]+)/)?.[1];
if (!token) {
// API routes return 401
if (url.pathname.startsWith('/api/')) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
// Page routes redirect
return new Response(null, {
status: 302,
headers: { Location: `/login?redirect=${encodeURIComponent(url.pathname)}` },
});
}
// Verify token and inject user info
try {
const user = await verifyToken(token);
event.locals.user = user;
} catch {
// Invalid token, clear cookie and redirect
return new Response(null, {
status: 302,
headers: {
Location: '/login',
'Set-Cookie': 'token=; Max-Age=0; Path=/',
},
});
}
}
},
],
});
async function verifyToken(token: string) {
// In production, verify JWT
return { id: 1, name: 'Admin', permissions: ['*'] };
}
SolidStart supports "use server" marked server functions:
// server/auth.ts
'use server';
import { z } from 'zod';
import { useSession } from 'vinxi/http';
const loginSchema = z.object({
email: z.string().email('Please enter a valid email'),
password: z.string().min(6, 'Password must be at least 6 characters'),
});
const registerSchema = loginSchema.extend({
name: z.string().min(2, 'Name must be at least 2 characters'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
export async function login(credentials: z.infer<typeof loginSchema>) {
const validated = loginSchema.parse(credentials);
// Mock validation
if (validated.email !== '[email protected]' || validated.password !== '123456') {
throw new Error('Invalid email or password');
}
const user = {
id: 1,
name: 'Admin',
email: validated.email,
role: 'admin',
permissions: ['*'],
};
const token = `mock_token_${Date.now()}`;
// Set session
const session = await useSession({
password: process.env.SESSION_SECRET!,
});
await session.update({ userId: user.id, token });
return {
success: true,
user,
token,
};
}
export async function register(data: z.infer<typeof registerSchema>) {
const validated = registerSchema.parse(data);
// Check if email already exists
const existing = await db.users.findByEmail(validated.email);
if (existing) {
throw new Error('Email already registered');
}
// Create user
const user = await db.users.create({
email: validated.email,
name: validated.name,
password: await hashPassword(validated.password),
});
return { success: true, user };
}
export async function getCurrentUser() {
const session = await useSession({
password: process.env.SESSION_SECRET!,
});
if (!session.data.userId) {
return null;
}
const user = await db.users.findById(session.data.userId);
return user;
}
export async function logout() {
const session = await useSession({
password: process.env.SESSION_SECRET!,
});
await session.clear();
return { success: true };
}
// routes/api/users/index.ts
import type { APIEvent } from '@solidjs/start/server';
import { json } from '@solidjs/router';
// GET /api/users
export async function GET(event: APIEvent) {
const url = new URL(event.request.url);
const page = Number(url.searchParams.get('page')) || 1;
const limit = Number(url.searchParams.get('limit')) || 10;
const search = url.searchParams.get('search') || '';
// Mock data
const users = generateMockUsers(page, limit, search);
const total = 100;
return json({
success: true,
data: users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
}
// POST /api/users
export async function POST(event: APIEvent) {
const body = await event.request.json();
const { email, name, role } = body;
// Validation
if (!email || !name) {
return json({ success: false, message: 'Email and name are required' }, { status: 400 });
}
// Create user
const user = {
id: Date.now(),
email,
name,
role: role || 'user',
createdAt: new Date().toISOString(),
};
return json({
success: true,
data: user,
message: 'User created successfully',
});
}
// routes/api/users/[id].ts
import type { APIEvent } from '@solidjs/start/server';
import { json } from '@solidjs/router';
// GET /api/users/:id
export async function GET(event: APIEvent) {
const id = event.params.id;
const user = await db.users.findById(id);
if (!user) {
return json({ success: false, message: 'User not found' }, { status: 404 });
}
return json({ success: true, data: user });
}
// PUT /api/users/:id
export async function PUT(event: APIEvent) {
const id = event.params.id;
const body = await event.request.json();
const user = await db.users.update(id, body);
return json({
success: true,
data: user,
message: 'User updated successfully',
});
}
// DELETE /api/users/:id
export async function DELETE(event: APIEvent) {
const id = event.params.id;
await db.users.delete(id);
return json({
success: true,
message: 'User deleted successfully',
});
}
// src/middleware.ts import { createMiddleware } from '@solidjs/start/middleware'
export default createMiddleware({ onRequest: [ async (event) => { const url = new URL(event.request.url)
// Protect dashboard routes
if (url.pathname.startsWith('/dashboard')) {
const token = event.request.headers.get('cookie')?.match(/token=([^;]+)/)?.[1]
if (!token) {
return new Response(null, {
status: 302,
headers: { Location: `/login?redirect=${url.pathname}` },
})
}
}
},
], })
### Server Functions (RPC)
```tsx
// server/auth.ts
'use server'
import { z } from 'zod'
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
})
export async function login(credentials: z.infer<typeof loginSchema>) {
const validated = loginSchema.parse(credentials)
// Validation logic...
return {
success: true,
user: { id: 1, name: 'Admin', email: validated.email },
token: 'mock_token',
}
}
export async function getCurrentUser(token: string) {
// Validate token and return user
return {
id: 1,
name: 'Admin',
permissions: ['*'],
}
}
// components/shared/PermissionGuard.tsx
import { Show, type ParentComponent, type JSX } from 'solid-js'
import { authStore } from '~/stores/auth'
interface Props {
permission: string
fallback?: JSX.Element
}
export const PermissionGuard: ParentComponent<Props> = (props) => {
const hasPermission = () => authStore.hasPermission(props.permission)
return (
<Show when={hasPermission()} fallback={props.fallback}>
{props.children}
</Show>
)
}
// Usage
<PermissionGuard
permission="users:delete"
fallback={<span class="text-muted-foreground">No Permission</span>}
>
<Button variant="destructive">Delete</Button>
</PermissionGuard>
// routes/(dashboard)/users/index.tsx
import { createAsync, cache } from '@solidjs/router'
const getUsers = cache(async (params: { page: number }) => {
'use server'
const users = await db.users.findMany({
skip: (params.page - 1) * 10,
take: 10,
})
return users
}, 'users')
export const route = {
load: ({ location }) => {
const page = Number(location.query.page) || 1
void getUsers({ page })
},
}
export default function UsersPage() {
const users = createAsync(() => getUsers({ page: 1 }))
return (
<div>
<h1>User List</h1>
<Show when={users()}>
{(data) => (
<For each={data()}>
{(user) => <UserCard user={user} />}
</For>
)}
</Show>
</div>
)
}
// routes/(auth)/login.tsx
import { createSignal } from 'solid-js'
import { useNavigate, useSearchParams } from '@solidjs/router'
import { authStore } from '~/stores/auth'
export default function LoginPage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [email, setEmail] = createSignal('')
const [password, setPassword] = createSignal('')
const [error, setError] = createSignal('')
const handleSubmit = async (e: Event) => {
e.preventDefault()
setError('')
try {
await authStore.login({
email: email(),
password: password(),
})
navigate(searchParams.redirect || '/dashboard')
} catch (e) {
setError('Invalid email or password')
}
}
return (
<form onSubmit={handleSubmit}>
<Show when={error()}>
<div class="text-destructive">{error()}</div>
</Show>
<input
type="email"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
placeholder="Email"
/>
<input
type="password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
placeholder="Password"
/>
<button type="submit" disabled={authStore.loading}>
{authStore.loading ? 'Logging in...' : 'Login'}
</button>
</form>
)
}
// components/shared/PermissionGuard.tsx
import { Show, type ParentComponent, type JSX, createMemo } from 'solid-js';
import { authStore } from '~/stores/auth';
interface Props {
permission?: string;
permissions?: string[];
mode?: 'any' | 'all';
fallback?: JSX.Element;
}
export const PermissionGuard: ParentComponent<Props> = (props) => {
const hasPermission = createMemo(() => {
// Single permission check
if (props.permission) {
return authStore.hasPermission(props.permission);
}
// Multiple permissions check
if (props.permissions) {
return props.mode === 'all'
? authStore.hasAllPermissions(props.permissions)
: authStore.hasAnyPermission(props.permissions);
}
return true;
});
return (
<Show when={hasPermission()} fallback={props.fallback}>
{props.children}
</Show>
);
};
// Usage example
<PermissionGuard
permission="users:delete"
fallback={<span class="text-muted-foreground">No Permission</span>}
>
<Button variant="destructive" onClick={handleDelete}>
Delete User
</Button>
</PermissionGuard>
// Multiple permissions check
<PermissionGuard
permissions={['users:edit', 'users:delete']}
mode="any"
>
<DropdownMenu>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Delete</DropdownMenuItem>
</DropdownMenu>
</PermissionGuard>
Using createAsync and cache for data fetching:
// routes/(dashboard)/users/index.tsx
import { createAsync, cache, useSearchParams } from '@solidjs/router';
import { Show, For, Suspense } from 'solid-js';
import { AdminLayout } from '~/components/layout/AdminLayout';
import { Table, Pagination, Button, Input } from '~/components/ui';
// Define cache function
const getUsers = cache(async (params: { page: number; limit: number; search?: string }) => {
'use server';
const response = await fetch(
`${process.env.API_BASE_URL}/users?page=${params.page}&limit=${params.limit}&search=${params.search || ''}`
);
if (!response.ok) {
throw new Error('Failed to fetch users');
}
return response.json();
}, 'users');
// Preload
export const route = {
load: ({ location }) => {
const page = Number(new URLSearchParams(location.search).get('page')) || 1;
void getUsers({ page, limit: 10 });
},
};
export default function UsersPage() {
const [searchParams, setSearchParams] = useSearchParams();
const page = () => Number(searchParams.page) || 1;
const search = () => searchParams.search || '';
const users = createAsync(() =>
getUsers({ page: page(), limit: 10, search: search() })
);
const handleSearch = (value: string) => {
setSearchParams({ search: value, page: '1' });
};
const handlePageChange = (newPage: number) => {
setSearchParams({ page: String(newPage) });
};
return (
<AdminLayout title="User Management">
<div class="space-y-6">
{/* Search bar */}
<div class="flex items-center justify-between">
<Input
type="search"
placeholder="Search users..."
value={search()}
onInput={(e) => handleSearch(e.currentTarget.value)}
class="max-w-sm"
/>
<Button>
<PlusIcon class="mr-2 h-4 w-4" />
Add User
</Button>
</div>
{/* Table */}
<Suspense fallback={<TableSkeleton />}>
<Show when={users()}>
{(data) => (
<>
<Table>
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head>Email</Table.Head>
<Table.Head>Role</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
<For each={data().data}>
{(user) => (
<Table.Row>
<Table.Cell>{user.name}</Table.Cell>
<Table.Cell>{user.email}</Table.Cell>
<Table.Cell>
<Badge>{user.role}</Badge>
</Table.Cell>
<Table.Cell>
<StatusBadge status={user.status} />
</Table.Cell>
<Table.Cell class="text-right">
<UserActions user={user} />
</Table.Cell>
</Table.Row>
)}
</For>
</Table.Body>
</Table>
<Pagination
page={page()}
totalPages={data().pagination.totalPages}
onPageChange={handlePageChange}
/>
</>
)}
</Show>
</Suspense>
</div>
</AdminLayout>
);
}
// routes/(auth)/login.tsx
import { createSignal, Show } from 'solid-js';
import { useNavigate, useSearchParams, A } from '@solidjs/router';
import { authStore } from '~/stores/auth';
import { AuthLayout } from '~/components/layout/AuthLayout';
import { Input, Button, Card } from '~/components/ui';
export default function LoginPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [email, setEmail] = createSignal('');
const [password, setPassword] = createSignal('');
const [errors, setErrors] = createSignal<Record<string, string>>({});
const validate = () => {
const newErrors: Record<string, string> = {};
if (!email()) {
newErrors.email = 'Please enter email';
} else if (!email().includes('@')) {
newErrors.email = 'Please enter a valid email address';
}
if (!password()) {
newErrors.password = 'Please enter password';
} else if (password().length < 6) {
newErrors.password = 'Password must be at least 6 characters';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: Event) => {
e.preventDefault();
if (!validate()) return;
try {
await authStore.login({
email: email(),
password: password(),
});
// Redirect to original page or dashboard
const redirect = searchParams.redirect || '/dashboard';
navigate(redirect);
} catch (e) {
setErrors({ form: e instanceof Error ? e.message : 'Login failed' });
}
};
// Fill demo account
const fillDemo = () => {
const demoEmail = import.meta.env.VITE_DEMO_EMAIL;
const demoPassword = import.meta.env.VITE_DEMO_PASSWORD;
if (demoEmail) setEmail(demoEmail);
if (demoPassword) setPassword(demoPassword);
};
return (
<AuthLayout title="Login">
<Card class="w-full max-w-md">
<Card.Header class="text-center">
<Card.Title class="text-2xl">Welcome Back</Card.Title>
<Card.Description>Login to your account</Card.Description>
</Card.Header>
<Card.Content>
{/* Error message */}
<Show when={errors().form}>
<div class="mb-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{errors().form}
</div>
</Show>
{/* Demo hint */}
<Show when={import.meta.env.VITE_SHOW_DEMO_HINT === 'true'}>
<div class="mb-4 rounded-md bg-muted p-3 text-sm">
<p>Demo Account:</p>
<p class="font-mono text-xs">
Email: {import.meta.env.VITE_DEMO_EMAIL}
</p>
<p class="font-mono text-xs">
Password: {import.meta.env.VITE_DEMO_PASSWORD}
</p>
<Button variant="link" size="sm" onClick={fillDemo} class="mt-1 h-auto p-0">
Click to fill
</Button>
</div>
</Show>
<form onSubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<label for="email" class="text-sm font-medium">
Email
</label>
<Input
id="email"
type="email"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
placeholder="[email protected]"
autocomplete="email"
/>
<Show when={errors().email}>
<p class="text-sm text-destructive">{errors().email}</p>
</Show>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label for="password" class="text-sm font-medium">
Password
</label>
<A href="/forgot-password" class="text-sm text-primary hover:underline">
Forgot password?
</A>
</div>
<Input
id="password"
type="password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
placeholder="••••••••"
autocomplete="current-password"
/>
<Show when={errors().password}>
<p class="text-sm text-destructive">{errors().password}</p>
</Show>
</div>
<Button type="submit" class="w-full" disabled={authStore.loading}>
{authStore.loading ? 'Logging in...' : 'Login'}
</Button>
</form>
</Card.Content>
<Card.Footer class="justify-center">
<p class="text-sm text-muted-foreground">
Don't have an account?{' '}
<A href="/register" class="text-primary hover:underline">
Sign up now
</A>
</p>
</Card.Footer>
</Card>
</AuthLayout>
);
}
// components/shared/ErrorBoundary.tsx
import { ErrorBoundary as SolidErrorBoundary, type ParentComponent } from 'solid-js';
import { A, useNavigate } from '@solidjs/router';
import { Button, Card } from '~/components/ui';
interface Props {
fallback?: (error: Error, reset: () => void) => JSX.Element;
}
export const ErrorBoundary: ParentComponent<Props> = (props) => {
return (
<SolidErrorBoundary
fallback={(error, reset) => {
if (props.fallback) {
return props.fallback(error, reset);
}
return <DefaultErrorFallback error={error} reset={reset} />;
}}
>
{props.children}
</SolidErrorBoundary>
);
};
function DefaultErrorFallback(props: { error: Error; reset: () => void }) {
const navigate = useNavigate();
return (
<div class="flex min-h-[400px] items-center justify-center p-4">
<Card class="w-full max-w-md">
<Card.Header class="text-center">
<Card.Title class="text-destructive">An Error Occurred</Card.Title>
<Card.Description>{props.error.message}</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex justify-center gap-2">
<Button variant="outline" onClick={props.reset}>
Retry
</Button>
<Button onClick={() => navigate('/')}>
Back to Home
</Button>
</div>
</Card.Content>
</Card>
</div>
);
}
// routes/[...404].tsx - 404 page
import { A } from '@solidjs/router';
import { Button } from '~/components/ui';
export default function NotFoundPage() {
return (
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<h1 class="text-9xl font-bold text-muted-foreground">404</h1>
<p class="mt-4 text-2xl text-foreground">Page Not Found</p>
<p class="mt-2 text-muted-foreground">
The page you're looking for doesn't exist or has been removed
</p>
<Button as={A} href="/" class="mt-8">
Back to Home
</Button>
</div>
</div>
);
}
// lib/meta.ts
interface PageMeta {
title: string;
description: string;
keywords?: string[];
}
export const pageMetas: Record<string, PageMeta> = {
'/': {
title: 'Dashboard',
description: 'Admin Pro management system dashboard with data overview and analytics',
keywords: ['dashboard', 'analytics', 'management'],
},
'/users': {
title: 'User Management',
description: 'Manage system user accounts including creation, editing, and permission configuration',
keywords: ['user management', 'account management', 'permissions'],
},
'/analytics': {
title: 'Analytics',
description: 'Business data statistics and visualization charts',
keywords: ['analytics', 'charts', 'statistics'],
},
'/settings': {
title: 'System Settings',
description: 'System configuration and personalization settings',
keywords: ['settings', 'configuration', 'personalization'],
},
};
export function generateMeta(path: string, overrides?: Partial<PageMeta>) {
const meta = { ...pageMetas[path], ...overrides } || {
title: 'Page',
description: 'Admin Pro Management System',
};
const brandName = import.meta.env.VITE_BRAND_NAME || 'Halolight';
const fullTitle = `${meta.title} - ${brandName}`;
return {
title: fullTitle,
description: meta.description,
keywords: meta.keywords?.join(', ') || '',
};
}
// Usage in pages
import { Title, Meta } from '@solidjs/meta';
import { generateMeta } from '~/lib/meta';
export default function UsersPage() {
const meta = generateMeta('/users');
return (
<>
<Title>{meta.title}</Title>
<Meta name="description" content={meta.description} />
<Meta name="keywords" content={meta.keywords} />
{/* Page content */}
</>
);
}
Supports 11 preset skins, switchable via Quick Settings panel:
| Skin | Primary Color | CSS Variable |
|---|---|---|
| Default | Purple | --primary: 51.1% 0.262 276.97 |
| Blue | Blue | --primary: 54.8% 0.243 264.05 |
| Emerald | Emerald | --primary: 64.6% 0.178 142.49 |
| Amber | Amber | --primary: 76.9% 0.188 84.94 |
| Violet | Violet | --primary: 54.1% 0.243 293.54 |
| Rose | Rose | --primary: 64.5% 0.246 16.44 |
| Teal | Teal | --primary: 60.0% 0.118 184.71 |
| Slate | Slate | --primary: 45.9% 0.022 264.53 |
| Ocean | Ocean | --primary: 54.3% 0.195 240.03 |
| Sunset | Sunset | --primary: 70.5% 0.213 47.60 |
| Aurora | Aurora | --primary: 62.8% 0.265 303.9 |
/* src/styles/globals.css */
@import "tailwindcss";
:root {
/* Background colors */
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
/* Card */
--card: 100% 0 0;
--card-foreground: 14.9% 0.017 285.75;
/* Popover */
--popover: 100% 0 0;
--popover-foreground: 14.9% 0.017 285.75;
/* Primary */
--primary: 51.1% 0.262 276.97;
--primary-foreground: 100% 0 0;
/* Secondary */
--secondary: 96.7% 0.001 286.38;
--secondary-foreground: 21% 0.006 285.75;
/* Muted */
--muted: 96.7% 0.001 286.38;
--muted-foreground: 55.2% 0.014 285.94;
/* Accent */
--accent: 96.7% 0.001 286.38;
--accent-foreground: 21% 0.006 285.75;
/* Destructive */
--destructive: 57.7% 0.245 27.32;
--destructive-foreground: 100% 0 0;
/* Border/Input */
--border: 91.2% 0.004 286.32;
--input: 91.2% 0.004 286.32;
--ring: 51.1% 0.262 276.97;
/* Radius */
--radius: 0.5rem;
}
/* Skin presets */
[data-skin="blue"] {
--primary: 54.8% 0.243 264.05;
--ring: 54.8% 0.243 264.05;
}
[data-skin="ocean"] {
--primary: 54.3% 0.195 240.03;
--ring: 54.3% 0.195 240.03;
}
[data-skin="emerald"] {
--primary: 64.6% 0.178 142.49;
--ring: 64.6% 0.178 142.49;
}
/* Dark mode */
.dark {
--background: 14.9% 0.017 285.75;
--foreground: 98.5% 0 0;
--card: 14.9% 0.017 285.75;
--card-foreground: 98.5% 0 0;
--popover: 14.9% 0.017 285.75;
--popover-foreground: 98.5% 0 0;
--secondary: 26.8% 0.019 286.07;
--secondary-foreground: 98.5% 0 0;
--muted: 26.8% 0.019 286.07;
--muted-foreground: 71.2% 0.013 286.07;
--accent: 26.8% 0.019 286.07;
--accent-foreground: 98.5% 0 0;
--border: 26.8% 0.019 286.07;
--input: 26.8% 0.019 286.07;
}
/* Tailwind theme mapping */
@theme {
--color-background: oklch(var(--background));
--color-foreground: oklch(var(--foreground));
--color-primary: oklch(var(--primary));
--color-primary-foreground: oklch(var(--primary-foreground));
/* ... */
}
| Path | Page | Layout | Permission |
|---|---|---|---|
/ |
Home | - | Public |
/login |
Login | AuthLayout | Public |
/register |
Register | AuthLayout | Public |
/forgot-password |
Forgot Password | AuthLayout | Public |
/reset-password |
Reset Password | AuthLayout | Public |
/dashboard |
Dashboard | AdminLayout | dashboard:view |
/analytics |
Analytics | AdminLayout | analytics:view |
/users |
User List | AdminLayout | users:list |
/users/create |
Create User | AdminLayout | users:create |
/users/[id] |
User Details | AdminLayout | users:view |
/roles |
Role Management | AdminLayout | roles:list |
/permissions |
Permission Management | AdminLayout | permissions:list |
/messages |
Message Center | AdminLayout | messages:view |
/notifications |
Notifications | AdminLayout | Authenticated |
/documents |
Document Management | AdminLayout | documents:list |
/calendar |
Calendar | AdminLayout | calendar:view |
/settings |
System Settings | AdminLayout | settings:view |
/profile |
User Profile | AdminLayout | Authenticated |
/privacy |
Privacy Policy | - | Public |
/terms |
Terms of Service | - | Public |
| Variable | Description | Default |
|---|---|---|
VITE_API_URL |
API base URL | /api |
VITE_USE_MOCK |
Enable mock data | false |
VITE_DEMO_EMAIL |
Demo account email | - |
VITE_DEMO_PASSWORD |
Demo account password | - |
VITE_SHOW_DEMO_HINT |
Show demo hint | false |
VITE_APP_TITLE |
Application title | Admin Pro |
VITE_BRAND_NAME |
Brand name | Halolight |
SESSION_SECRET |
Session secret (server-side) | (required) |
# Development
pnpm dev # Start development server
pnpm dev --host # Allow LAN access
# Build
pnpm build # Production build
pnpm start # Start production server
# Code Quality
pnpm typecheck # TypeScript type check
pnpm lint # ESLint check
pnpm lint:fix # ESLint auto fix
pnpm format # Prettier format
# Testing
pnpm test # Watch mode
pnpm test:run # Single run
pnpm test:coverage # Coverage report
pnpm test:ui # Vitest UI
# Others
pnpm clean # Clean build artifacts
pnpm deps # Check dependency updates
pnpm test:run # Single run
pnpm test # Watch mode
pnpm test:coverage # Coverage report
// tests/stores/auth.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { authStore } from '~/stores/auth';
describe('authStore', () => {
beforeEach(() => {
authStore.logout();
});
it('initial state should be unauthenticated', () => {
expect(authStore.isAuthenticated()).toBe(false);
expect(authStore.user).toBeNull();
expect(authStore.token).toBeNull();
});
it('should update state after successful login', async () => {
// Mock fetch
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
user: { id: 1, name: 'Admin', permissions: ['*'] },
token: 'mock_token',
}),
});
await authStore.login({
email: '[email protected]',
password: '123456',
});
expect(authStore.isAuthenticated()).toBe(true);
expect(authStore.user?.name).toBe('Admin');
expect(authStore.token).toBe('mock_token');
});
it('permission checks should work correctly', async () => {
// Set user permissions
authStore.login({
email: '[email protected]',
password: '123456',
});
// Mock successful login
expect(authStore.hasPermission('*')).toBe(true);
expect(authStore.hasPermission('users:list')).toBe(true);
expect(authStore.hasPermission('unknown:action')).toBe(true); // * permission
});
it('should clear state after logout', () => {
authStore.logout();
expect(authStore.isAuthenticated()).toBe(false);
expect(authStore.user).toBeNull();
expect(authStore.token).toBeNull();
});
});
// tests/stores/tabs.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { tabsStore } from '~/stores/tabs';
describe('tabsStore', () => {
beforeEach(() => {
tabsStore.clearTabs();
});
it('initial state should only have home tab', () => {
expect(tabsStore.tabs.length).toBe(1);
expect(tabsStore.tabs[0].id).toBe('home');
expect(tabsStore.activeTabId).toBe('home');
});
it('should add new tab', () => {
const id = tabsStore.addTab({ title: 'User Management', path: '/users' });
expect(tabsStore.tabs.length).toBe(2);
expect(tabsStore.tabs[1].title).toBe('User Management');
expect(tabsStore.activeTabId).toBe(id);
});
it('should deduplicate existing routes', () => {
const id1 = tabsStore.addTab({ title: 'User Management', path: '/users' });
const id2 = tabsStore.addTab({ title: 'User Management', path: '/users' });
expect(id1).toBe(id2);
expect(tabsStore.tabs.length).toBe(2);
});
it('should close tab and switch to adjacent tab', () => {
tabsStore.addTab({ title: 'User Management', path: '/users' });
const id = tabsStore.addTab({ title: 'Settings', path: '/settings' });
tabsStore.removeTab(id);
expect(tabsStore.tabs.length).toBe(2);
expect(tabsStore.activeTabId).not.toBe(id);
});
it('home tab should not be closable', () => {
tabsStore.removeTab('home');
expect(tabsStore.tabs.length).toBe(1);
expect(tabsStore.tabs[0].id).toBe('home');
});
});
// app.config.ts
import { defineConfig } from '@solidjs/start/config';
export default defineConfig({
server: {
preset: 'node-server', // Default Node.js server
},
vite: {
plugins: [],
css: {
postcss: './postcss.config.js',
},
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['solid-js', '@solidjs/router'],
ui: ['@kobalte/core'],
},
},
},
},
},
middleware: './src/middleware.ts',
});
// Development environment
export default defineConfig({
server: { preset: 'node-server' },
});
// Vercel
export default defineConfig({
server: { preset: 'vercel' },
});
// Cloudflare Pages
export default defineConfig({
server: { preset: 'cloudflare-pages' },
});
// Netlify
export default defineConfig({
server: { preset: 'netlify' },
});
// AWS Lambda
export default defineConfig({
server: { preset: 'aws-lambda' },
});
// Bun
export default defineConfig({
server: { preset: 'bun' },
});
pnpm build
node .output/server/index.mjs
FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.output ./.output
COPY --from=builder /app/package.json ./
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- SESSION_SECRET=${SESSION_SECRET}
- VITE_API_URL=${VITE_API_URL}
restart: unless-stopped
// app.config.ts
import { defineConfig } from '@solidjs/start/config';
export default defineConfig({
server: {
preset: 'vercel',
},
});
# Deploy
npx vercel
// app.config.ts
import { defineConfig } from '@solidjs/start/config';
export default defineConfig({
server: {
preset: 'cloudflare-pages',
},
});
# Install Wrangler
npm install -g wrangler
# Login
wrangler login
# Deploy
wrangler pages deploy .output/public
// app.config.ts
import { defineConfig } from '@solidjs/start/config';
export default defineConfig({
server: {
preset: 'netlify',
},
});
# netlify.toml
[build]
command = "pnpm build"
publish = ".output/public"
functions = ".output/server"
[functions]
node_bundler = "esbuild"
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
Project has complete GitHub Actions CI workflow:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
import { createResource, Suspense, Show } from 'solid-js';
// Define data fetching function
const fetchUser = async (id: string) => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('User not found');
return response.json();
};
function UserProfile(props: { userId: string }) {
// createResource automatically manages loading/error states
const [user, { refetch, mutate }] = createResource(
() => props.userId,
fetchUser
);
return (
<Suspense fallback={<div>Loading...</div>}>
<Show when={user()} fallback={<div>User not found</div>}>
{(userData) => (
<div>
<h1>{userData().name}</h1>
<p>{userData().email}</p>
<button onClick={refetch}>Refresh</button>
</div>
)}
</Show>
</Suspense>
);
}
// routes/dashboard.tsx
import { Suspense } from 'solid-js';
import { createAsync, cache } from '@solidjs/router';
// Fast data
const getQuickStats = cache(async () => {
'use server';
return await db.stats.getQuick();
}, 'quick-stats');
// Slow data
const getDetailedAnalytics = cache(async () => {
'use server';
return await db.analytics.getDetailed(); // Time-consuming operation
}, 'detailed-analytics');
export default function Dashboard() {
const quickStats = createAsync(() => getQuickStats());
const analytics = createAsync(() => getDetailedAnalytics());
return (
<div class="space-y-6">
{/* Fast rendered content */}
<Show when={quickStats()}>
{(stats) => <QuickStats data={stats()} />}
</Show>
{/* Stream rendered slow content */}
<Suspense fallback={<AnalyticsSkeleton />}>
<Show when={analytics()}>
{(data) => <DetailedAnalytics data={data()} />}
</Show>
</Suspense>
</div>
);
}
import { createSignal, For } from 'solid-js';
import { createStore, produce } from 'solid-js/store';
function TodoList() {
const [todos, setTodos] = createStore<Todo[]>([]);
const [newTodo, setNewTodo] = createSignal('');
const addTodo = async () => {
const text = newTodo();
if (!text.trim()) return;
// Optimistic update - show immediately
const tempId = `temp-${Date.now()}`;
const optimisticTodo: Todo = {
id: tempId,
text,
completed: false,
pending: true,
};
setTodos(produce((draft) => draft.push(optimisticTodo)));
setNewTodo('');
try {
// Actual request
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text }),
});
const realTodo = await response.json();
// Replace with real data
setTodos(
(todo) => todo.id === tempId,
{ id: realTodo.id, pending: false }
);
} catch {
// Rollback
setTodos((todos) => todos.filter((t) => t.id !== tempId));
}
};
return (
<div>
<input
value={newTodo()}
onInput={(e) => setNewTodo(e.currentTarget.value)}
onKeyPress={(e) => e.key === 'Enter' && addTodo()}
/>
<For each={todos}>
{(todo) => (
<div class={todo.pending ? 'opacity-50' : ''}>
{todo.text}
{todo.pending && <span>Saving...</span>}
</div>
)}
</For>
</div>
);
}
// context/theme.tsx
import { createContext, useContext, type ParentComponent } from 'solid-js';
import { createStore } from 'solid-js/store';
interface ThemeContextValue {
theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void;
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextValue>();
export const ThemeProvider: ParentComponent = (props) => {
const [state, setState] = createStore({ theme: 'light' as const });
const value: ThemeContextValue = {
get theme() {
return state.theme;
},
setTheme(theme) {
setState('theme', theme);
document.documentElement.classList.toggle('dark', theme === 'dark');
},
toggle() {
value.setTheme(state.theme === 'light' ? 'dark' : 'light');
},
};
return (
<ThemeContext.Provider value={value}>
{props.children}
</ThemeContext.Provider>
);
};
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
import { Portal, Show } from 'solid-js/web';
import { createSignal } from 'solid-js';
function Modal(props: { isOpen: boolean; onClose: () => void; children: JSX.Element }) {
return (
<Show when={props.isOpen}>
<Portal mount={document.body}>
<div class="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
class="absolute inset-0 bg-black/50"
onClick={props.onClose}
/>
{/* Content */}
<div class="relative z-10 rounded-lg bg-background p-6 shadow-lg">
{props.children}
</div>
</div>
</Portal>
</Show>
);
}
// Usage
function App() {
const [isOpen, setIsOpen] = createSignal(false);
return (
<>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
<Modal isOpen={isOpen()} onClose={() => setIsOpen(false)}>
<h2>Title</h2>
<p>Content</p>
<button onClick={() => setIsOpen(false)}>Close</button>
</Modal>
</>
);
}
Solid.js core advantage is fine-grained updates, no manual optimization needed:
// Component doesn't re-execute when parent updates
function Parent() {
const [count, setCount] = createSignal(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>
Count: {count()}
</button>
{/* Child component only created once */}
<Child />
</div>
);
}
function Child() {
console.log('Child rendered'); // Only executes once
return <div>I'm a child</div>;
}
import { lazy, Suspense } from 'solid-js';
// Lazy load heavy components
const Chart = lazy(() => import('./components/Chart'));
const DataTable = lazy(() => import('./components/DataTable'));
function Dashboard() {
return (
<div>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<DataTable />
</Suspense>
</div>
);
}
import { For, Index } from 'solid-js';
// For - suitable for object arrays, tracked by reference
<For each={users()}>
{(user, index) => (
<div>{index()}: {user.name}</div>
)}
</For>
// Index - suitable for primitive arrays, tracked by index
<Index each={numbers()}>
{(num, index) => (
<div>{index}: {num()}</div>
)}
</Index>
// Route preload
export const route = {
load: ({ params }) => {
// Preload data
void getUser({ id: params.id });
void getUserPosts({ userId: params.id });
},
};
// Link preload
<A href="/users" preload>
User Management
</A>
A: Core differences:
// React - component re-executes on every state change
function ReactComponent() {
const [count, setCount] = useState(0);
console.log('render'); // Logs on every update
return <div>{count}</div>;
}
// Solid - component executes once, only where signal is accessed updates
function SolidComponent() {
const [count, setCount] = createSignal(0);
console.log('setup'); // Only logs once
return <div>{count()}</div>; // Only this updates
}
A: Use createResource or createAsync:
// createResource - more granular control
const [data, { refetch, mutate }] = createResource(source, fetcher);
// createAsync - SolidStart route integration
const data = createAsync(() => getData());
A: Three approaches:
// stores/counter.ts
export const [count, setCount] = createSignal(0);
const CounterContext = createContext();
const [state, setState] = makePersisted(createStore({}), { name: 'key' });
A: Use controlled components or @modular-forms/solid:
// Controlled component
const [email, setEmail] = createSignal('');
<input value={email()} onInput={(e) => setEmail(e.currentTarget.value)} />
// Or use form library
import { createForm } from '@modular-forms/solid';
const [form, { Form, Field }] = createForm<LoginForm>();
A: Use middleware or route load function:
// middleware.ts
export default createMiddleware({
onRequest: [authMiddleware],
});
// Or in route
export const route = {
load: ({ location }) => {
if (!isAuthenticated()) {
throw redirect('/login');
}
},
};
| Feature | Solid.js Version | Vue Version | Next.js Version | Remix Version |
|---|---|---|---|---|
| State Management | Signals + Store | Pinia | Zustand | Zustand |
| Data Fetching | createAsync | TanStack Query | TanStack Query | Loader/Action |
| Form Validation | Custom + Zod | VeeValidate + Zod | React Hook Form + Zod | Progressive Enhancement |
| Server-side | SolidStart Built-in | Separate Backend / Nuxt | API Routes | Built-in |
| Component Library | Kobalte | shadcn-vue | shadcn/ui | Radix UI |
| Routing | File-based Routing | Vue Router | App Router | File-based Routing |
| Reactivity | Fine-grained Signals | Proxy-based | Hooks | Hooks |
| Bundle Size | ~7KB | ~33KB | ~85KB | ~70KB |
| Runtime Performance | Extremely High | High | Medium | Medium |
HaloLight SvelteKit version is built on SvelteKit 2, featuring Svelte 5 Runes + TypeScript with compile-time optimization and ultimate performance.
Live Preview: https://halolight-svelte.h7ml.cn
GitHub: https://github.com/halolight/halolight-svelte
| Technology | Version | Description |
|---|---|---|
| SvelteKit | 2.x | Svelte full-stack framework |
| Svelte | 5.x | Compile-time framework (Runes) |
| TypeScript | 5.9 | Type safety |
| Tailwind CSS | 4.x | Atomic CSS |
| shadcn-svelte | latest | UI component library |
| Superforms | 2.x | Form handling |
| TanStack Query | 5.x | Server state |
| Mock.js | 1.x | Data mocking |
halolight-svelte/
├── src/
│ ├── routes/ # File-based routing
│ │ ├── (auth)/ # Auth pages
│ │ └── (dashboard)/ # Dashboard pages
│ ├── lib/
│ │ ├── components/ # Components
│ │ │ ├── ui/ # Base UI components
│ │ │ ├── layout/ # Layout components
│ │ │ └── dashboard/ # Dashboard components
│ │ ├── stores/ # State management (Runes)
│ │ ├── utils/ # Utilities
│ │ ├── mock/ # Mock data
│ │ └── types/ # Type definitions
│ ├── hooks.server.ts # Server hooks
│ └── app.css # Global styles
├── static/ # Static assets
├── svelte.config.js # Svelte config
└── package.json
git clone https://github.com/halolight/halolight-svelte.git
cd halolight-svelte
pnpm install
cp .env.example .env
# .env
VITE_API_URL=/api
VITE_MOCK=true
[email protected]
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=false
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight
pnpm dev
Visit http://localhost:5173
pnpm build
pnpm preview
// lib/stores/auth.ts
import { browser } from '$app/environment';
interface User {
id: number;
name: string;
email: string;
permissions: string[];
}
class AuthStore {
user = $state<User | null>(null);
token = $state<string | null>(null);
isAuthenticated = $derived(!!this.token && !!this.user);
permissions = $derived(this.user?.permissions ?? []);
constructor() {
if (browser) {
const saved = localStorage.getItem('auth');
if (saved) {
const { user, token } = JSON.parse(saved);
this.user = user;
this.token = token;
}
}
}
async login(credentials: { email: string; password: string }) {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
headers: { 'Content-Type': 'application/json' },
});
const data = await response.json();
this.user = data.user;
this.token = data.token;
this.persist();
}
logout() {
this.user = null;
this.token = null;
localStorage.removeItem('auth');
}
hasPermission(permission: string): boolean {
return this.permissions.some(
(p) =>
p === '*' || p === permission || (p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
);
}
private persist() {
if (browser) {
localStorage.setItem(
'auth',
JSON.stringify({
user: this.user,
token: this.token,
})
);
}
}
}
export const authStore = new AuthStore();
// routes/(dashboard)/+layout.ts
import type { LayoutLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: LayoutLoad = async ({ parent, url }) => {
const { user } = await parent();
if (!user) {
throw redirect(302, `/auth/login?redirect=${url.pathname}`);
}
return { user };
};
<!-- routes/(dashboard)/dashboard/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<h1>Welcome, {data.user.name}!</h1>
<!-- lib/components/PermissionGuard.svelte -->
<script lang="ts">
import { authStore } from '$lib/stores/auth';
interface Props {
permission: string;
children: import('svelte').Snippet;
fallback?: import('svelte').Snippet;
}
let { permission, children, fallback }: Props = $props();
const hasPermission = $derived(authStore.hasPermission(permission));
</script>
{#if hasPermission}
{@render children()}
{:else if fallback}
{@render fallback()}
{/if}
<!-- Usage example -->
<PermissionGuard permission="users:delete">
{#snippet children()}
<Button variant="destructive">Delete</Button>
{/snippet}
{#snippet fallback()}
<span class="text-muted-foreground">No Permission</span>
{/snippet}
</PermissionGuard>
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
import GridLayout from '$lib/components/dashboard/GridLayout.svelte';
// Reactive Set for managing widgets
let activeWidgets = new SvelteSet(['stats', 'chart', 'recent']);
const layout = $state([
{ i: 'stats', x: 0, y: 0, w: 4, h: 2 },
{ i: 'chart', x: 4, y: 0, w: 8, h: 4 },
{ i: 'recent', x: 0, y: 2, w: 4, h: 2 },
]);
function onLayoutChange(newLayout: typeof layout) {
layout.splice(0, layout.length, ...newLayout);
localStorage.setItem('dashboard-layout', JSON.stringify(newLayout));
}
</script>
<GridLayout {layout} on:change={onLayoutChange}>
{#each [...activeWidgets] as widget}
<div data-grid-item={widget}>
<Widget type={widget} />
</div>
{/each}
</GridLayout>
Support 11 preset skins, switch via quick settings panel:
| Skin | Primary Color | CSS Variable |
|---|---|---|
| Default | Purple | --primary: 51.1% 0.262 276.97 |
| Blue | Blue | --primary: 54.8% 0.243 264.05 |
| Emerald | Emerald | --primary: 64.6% 0.178 142.49 |
| Orange | Orange | --primary: 68.9% 0.181 40.84 |
| Rose | Rose | --primary: 60.7% 0.234 11.63 |
| Teal | Teal | --primary: 62.8% 0.149 186.07 |
| Yellow | Yellow | --primary: 82.3% 0.165 92.14 |
| Violet | Violet | --primary: 58.9% 0.264 292.85 |
| Cyan | Cyan | --primary: 73.2% 0.152 196.85 |
| Pink | Pink | --primary: 70.5% 0.226 340.54 |
| Indigo | Indigo | --primary: 52.4% 0.218 270.32 |
/* app.css */
@layer base {
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 98% 0.007 285.89;
--secondary: 96.1% 0.006 286.32;
--secondary-foreground: 14.9% 0.017 285.75;
--muted: 96.1% 0.006 286.32;
--muted-foreground: 45.5% 0.026 285.82;
--accent: 96.1% 0.006 286.32;
--accent-foreground: 14.9% 0.017 285.75;
--destructive: 61.1% 0.246 29.23;
--destructive-foreground: 98% 0.007 285.89;
--border: 92.1% 0.011 286.32;
--input: 92.1% 0.011 286.32;
--ring: 51.1% 0.262 276.97;
--radius: 0.5rem;
}
.dark {
--background: 22.4% 0.015 285.88;
--foreground: 98% 0.007 285.89;
--primary: 61.1% 0.262 276.97;
--primary-foreground: 98% 0.007 285.89;
/* ... */
}
}
<script lang="ts">
function toggleTheme() {
if (!document.startViewTransition) {
document.documentElement.classList.toggle('dark');
return;
}
document.startViewTransition(() => {
document.documentElement.classList.toggle('dark');
});
}
</script>
<button onclick={toggleTheme}>Toggle Theme</button>
<style>
:global(::view-transition-old(root)),
:global(::view-transition-new(root)) {
animation-duration: 0.3s;
}
</style>
| Path | Page | Permission |
|---|---|---|
/ |
Home (redirect) | Public |
/auth/login |
Login | Public |
/auth/register |
Register | Public |
/auth/forgot-password |
Forgot Password | Public |
/auth/reset-password |
Reset Password | Public |
/dashboard |
Dashboard | dashboard:view |
/dashboard/users |
User Management | users:view |
/dashboard/analytics |
Analytics | analytics:view |
/dashboard/calendar |
Calendar | calendar:view |
/dashboard/documents |
Documents | documents:view |
/dashboard/files |
Files | files:view |
/dashboard/messages |
Messages | messages:view |
/dashboard/notifications |
Notifications | notifications:view |
/dashboard/settings |
Settings | settings:view |
/dashboard/profile |
Profile | settings:view |
pnpm dev # Start dev server
pnpm build # Production build
pnpm preview # Preview production build
pnpm lint # Lint code
pnpm lint:fix # Auto-fix lint issues
pnpm format # Format code
pnpm check # Type check (svelte-check)
pnpm test # Run tests
pnpm test:coverage # Test coverage
pnpm ci # Full CI check
Project is configured with Cloudflare Pages adapter by default:
// svelte.config.js
import adapter from '@sveltejs/adapter-cloudflare';
export default {
kit: {
adapter: adapter(),
},
};
pnpm build
# Cloudflare Pages will automatically deploy main branch
docker build -t halolight-svelte .
docker run -p 3000:3000 halolight-svelte
| Role | Password | |
|---|---|---|
| Admin | [email protected] | 123456 |
| User | [email protected] | 123456 |
pnpm test # Run tests (watch mode)
pnpm test:run # Single run
pnpm test:coverage # Coverage report
pnpm test:ui # Vitest UI
// tests/auth.test.ts
import { describe, it, expect } from 'vitest';
import { authStore } from '$lib/stores/auth';
describe('AuthStore', () => {
it('should initialize with null user', () => {
expect(authStore.user).toBeNull();
expect(authStore.isAuthenticated).toBe(false);
});
it('should authenticate user', async () => {
await authStore.login({
email: '[email protected]',
password: '123456',
});
expect(authStore.isAuthenticated).toBe(true);
expect(authStore.user?.email).toBe('[email protected]');
});
it('should check permissions', () => {
expect(authStore.hasPermission('users:view')).toBe(true);
expect(authStore.hasPermission('invalid')).toBe(false);
});
});
// svelte.config.js
import adapter from '@sveltejs/adapter-cloudflare';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
alias: {
$components: 'src/lib/components',
$stores: 'src/lib/stores',
$utils: 'src/lib/utils',
},
},
};
// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
environment: 'jsdom',
},
});
Complete GitHub Actions CI workflow configured:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm format:check
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm check
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
<script lang="ts">
import { SvelteSet, SvelteMap } from 'svelte/reactivity';
// Reactive Set
let selectedIds = new SvelteSet<string>();
function toggleSelection(id: string) {
if (selectedIds.has(id)) {
selectedIds.delete(id);
} else {
selectedIds.add(id);
}
}
// Reactive Map
let itemStatus = new SvelteMap<string, 'pending' | 'done'>();
function markDone(id: string) {
itemStatus.set(id, 'done');
}
</script>
<p>Selected: {selectedIds.size}</p>
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get('token');
if (token) {
// Validate token and set user info
event.locals.user = await validateToken(token);
}
// Route protection
if (event.url.pathname.startsWith('/dashboard')) {
if (!event.locals.user) {
return new Response(null, {
status: 302,
headers: { Location: '/auth/login' },
});
}
}
return resolve(event);
};
<script lang="ts">
const HeavyComponent = $lazy(() => import('$lib/components/Heavy.svelte'));
</script>
{#await HeavyComponent}
<div>Loading...</div>
{:then component}
<svelte:component this={component} />
{/await}
<script lang="ts">
import { preloadData } from '$app/navigation';
function handleMouseEnter() {
preloadData('/dashboard/analytics');
}
</script>
<a href="/dashboard/analytics" onmouseenter={handleMouseEnter}>
Analytics
</a>
<script lang="ts">
import { onMount } from 'svelte';
let visible = $state(false);
onMount(() => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
visible = true;
observer.disconnect();
}
});
observer.observe(element);
});
</script>
{#if visible}
<img src="/large-image.jpg" alt="Optimized image" />
{:else}
<div class="placeholder" />
{/if}
A: SvelteKit recommends using built-in Load functions for data loading, but you can also combine TanStack Query:
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
const query = createQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
});
</script>
{#if $query.isLoading}
<p>Loading...</p>
{:else if $query.error}
<p>Error: {$query.error.message}</p>
{:else if $query.data}
<ul>
{#each $query.data as user}
<li>{user.name}</li>
{/each}
</ul>
{/if}
A: Recommended using Superforms + Zod:
// routes/users/create/+page.server.ts
import { superValidate } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(2),
email: z.string().email(),
});
export const actions = {
default: async ({ request }) => {
const form = await superValidate(request, zod(schema));
if (!form.valid) {
return fail(400, { form });
}
// Process form data
return { form };
},
};
A: Switch to Vercel adapter:
pnpm add -D @sveltejs/adapter-vercel
// svelte.config.js
import adapter from '@sveltejs/adapter-vercel';
export default {
kit: {
adapter: adapter(),
},
};
| Feature | SvelteKit | Next.js | Vue |
|---|---|---|---|
| SSR/SSG | ✅ | ✅ | ✅ (Nuxt) |
| State Management | Svelte 5 Runes | Zustand | Pinia |
| Routing | File-based | App Router | Vue Router |
| Build Tool | Vite | Turbopack | Vite |
| Runtime | No Virtual DOM | Virtual DOM | Virtual DOM |
| Forms | Superforms | React Hook Form | VeeValidate |
| Component Library | shadcn-svelte | shadcn/ui | shadcn-vue |
HaloLight UI is a cross-framework Web Components library built with Stencil, featuring built-in Tailwind themes and OKLch color system.
GitHub: https://github.com/halolight/halolight-ui
npm: @halolight/ui
| Technology | Version | Description |
|---|---|---|
| Stencil | 4.22.x | Web Components compiler |
| TypeScript | 5.x | Type system |
| Tailwind CSS | 4.0 | Utility-first CSS (build time) |
| Jest | 29.x | Unit testing |
| Puppeteer | 23.x | E2E testing |
| Component | Tag | Description |
|---|---|---|
| Button | <hl-button> |
Button with multiple variants and sizes |
| Input | <hl-input> |
Input with validation and error states |
| Select | <hl-select> |
Dropdown selector |
| Modal | <hl-modal> |
Modal dialog |
| Card | <hl-card> |
Card container |
| Table | <hl-table> |
Data table with sorting and selection |
| Form | <hl-form> |
Form container with validation |
halolight-ui/
├── src/
│ ├── components/ # Component source
│ │ ├── hl-button/ # Button component
│ │ │ ├── hl-button.tsx # Component logic
│ │ │ ├── hl-button.css # Component styles
│ │ │ ├── readme.md # Component docs
│ │ │ └── test/ # Component tests
│ │ ├── hl-input/ # Input
│ │ ├── hl-select/ # Select
│ │ ├── hl-modal/ # Modal
│ │ ├── hl-table/ # Table
│ │ ├── hl-card/ # Card
│ │ └── hl-form/ # Form
│ ├── global/ # Global styles
│ │ └── global.css # OKLch theme variables
│ ├── utils/ # Utilities
│ ├── themes/ # Theme configuration
│ ├── components.d.ts # Auto-generated type definitions
│ └── index.ts # Export entry
├── dist/ # Build output
├── loader/ # Runtime loader
├── www/ # Development preview site
├── stencil.config.ts # Stencil configuration
├── tailwind.config.js # Tailwind configuration
├── tsconfig.json
└── package.json
npm install @halolight/ui
# or
pnpm add @halolight/ui
Call once in any framework:
import { defineCustomElements } from '@halolight/ui/loader';
defineCustomElements();
<!DOCTYPE html>
<html>
<head>
<script type="module">
import { defineCustomElements } from 'https://unpkg.com/@halolight/ui/loader/index.js';
defineCustomElements();
</script>
</head>
<body>
<hl-button variant="primary">Click Me</hl-button>
</body>
</html>
import { defineCustomElements } from '@halolight/ui/loader';
// Call once at app entry
defineCustomElements();
function App() {
return (
<div>
<hl-button variant="primary">Click Me</hl-button>
</div>
);
}
TypeScript Support:
// vite-env.d.ts
/// <reference types="@halolight/ui/dist/types/components" />
import { JSX as HaloLightJSX } from '@halolight/ui/dist/types/components';
declare global {
namespace JSX {
interface IntrinsicElements extends HaloLightJSX.IntrinsicElements {}
}
}
<template>
<hl-button variant="primary" @hl-click="handleClick">
Click Me
</hl-button>
</template>
<script setup lang="ts">
import { defineCustomElements } from '@halolight/ui/loader';
defineCustomElements();
const handleClick = () => {
console.log('Button clicked!');
};
</script>
// app.module.ts
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { defineCustomElements } from '@halolight/ui/loader';
defineCustomElements();
@NgModule({
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AppModule {}
<!-- app.component.html -->
<hl-button variant="primary" (hlClick)="handleClick()">
Click Me
</hl-button>
| Property | Type | Default | Description |
|---|---|---|---|
variant |
'primary' | 'secondary' | 'outline' | 'ghost' |
'primary' |
Button variant |
size |
'sm' | 'md' | 'lg' |
'md' |
Button size |
disabled |
boolean |
false |
Disabled state |
loading |
boolean |
false |
Loading state |
Events: hlClick
| Property | Type | Default | Description |
|---|---|---|---|
type |
'text' | 'password' | 'email' | 'number' |
'text' |
Input type |
placeholder |
string |
'' |
Placeholder text |
disabled |
boolean |
false |
Disabled state |
error |
string |
'' |
Error message |
Events: hlChange, hlInput, hlFocus, hlBlur
Add dark class to parent element to enable dark theme:
<div class="dark">
<hl-button variant="primary">Dark Mode Button</hl-button>
</div>
// Dynamic toggle
document.body.classList.toggle('dark');
Define theme variables using OKLch color space:
:root {
/* Primary color - using OKLch */
--hl-color-primary: oklch(0.65 0.15 250);
--hl-color-primary-hover: oklch(0.55 0.18 250);
--hl-color-primary-light: oklch(0.75 0.12 250);
/* Semantic colors */
--hl-color-success: oklch(0.7 0.18 145);
--hl-color-danger: oklch(0.63 0.26 25);
--hl-color-warning: oklch(0.78 0.16 75);
--hl-color-info: oklch(0.73 0.15 195);
/* Neutral colors */
--hl-bg-base: oklch(1 0 0);
--hl-text-primary: oklch(0.2 0 0);
--hl-border-color: oklch(0.9 0 0);
/* Border radius and spacing */
--hl-border-radius: 0.5rem;
--hl-spacing-md: 1rem;
}
/* Dark mode */
.dark {
--hl-color-primary: oklch(0.7 0.15 250);
--hl-bg-base: oklch(0.15 0 0);
--hl-text-primary: oklch(0.98 0 0);
}
OKLch is a perceptually uniform color space:
/* oklch(lightness chroma hue / alpha) */
oklch(0.65 0.15 250) /* Blue */
oklch(0.7 0.18 145) /* Green */
oklch(0.63 0.26 25 / 0.8) /* Semi-transparent red */
# Install dependencies
npm install
# Start development server
npm start
# Production build
npm run build
# Run tests
npm test
# Generate new component
npm run generate
npm run generate
# Enter component name (without hl- prefix)
import { Component, Prop, Event, EventEmitter, h, Host } from '@stencil/core';
@Component({
tag: 'hl-example',
styleUrl: 'hl-example.css',
shadow: true,
})
export class HlExample {
@Prop() size: 'sm' | 'md' | 'lg' = 'md';
@Event() hlChange: EventEmitter<string>;
render() {
return (
<Host>
<div class={`hl-example hl-example--${this.size}`}>
<slot></slot>
</div>
</Host>
);
}
}
hl-{component-name}.hl-button--primaryhl{EventName} (camelCase)OKLch color space support:
For older browsers, use PostCSS plugins for fallback conversion.
HaloLight Vercel deployment version, optimized for Vercel platform with the best Next.js deployment experience.
# Clone repository
git clone https://github.com/halolight/halolight-vercel.git
cd halolight-vercel
# Install dependencies
pnpm install
# Local development
pnpm dev
# Build
pnpm build
{
"buildCommand": "pnpm build",
"outputDirectory": ".next",
"framework": "nextjs",
"regions": ["hkg1", "sin1"],
"functions": {
"api/**/*.ts": {
"memory": 1024,
"maxDuration": 10
}
}
}
Set in Vercel dashboard:
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_USE_MOCK=false
DATABASE_URL=postgresql://...
// app/api/edge/route.ts
import { NextRequest } from 'next/server'
export const runtime = 'edge'
export async function GET(request: NextRequest) {
return new Response(JSON.stringify({ message: 'Hello from Edge!' }), {
headers: { 'content-type': 'application/json' },
})
}
HaloLight Vue version is built on Vue 3.5 + Vite 7, using Composition API + TypeScript.
Live Preview: https://halolight-vue.h7ml.cn/
GitHub: https://github.com/halolight/halolight-vue
| Technology | Version | Description |
|---|---|---|
| Vue | 3.5.x | Progressive framework |
| Vite | 7.x (Rolldown) | Build tool |
| TypeScript | 5.x | Type safety |
| Vue Router | 4.x | Routing |
| Pinia | 2.x | State management |
| TanStack Query | 5.x | Server state |
| VeeValidate | 4.x | Form validation |
| Zod | 3.x | Data validation |
| Tailwind CSS | 4.x | Atomic CSS |
| shadcn-vue | latest | UI component library |
| grid-layout-plus | 1.x | Drag-and-drop layout |
| ECharts | 5.x | Chart visualization |
| Mock.js | 1.x | Data mocking |
halolight-vue/
├── src/
│ ├── views/ # Page views
│ │ ├── (auth)/ # Auth pages
│ │ └── (dashboard)/ # Dashboard pages
│ ├── components/ # Components
│ │ ├── ui/ # Base UI components
│ │ ├── layout/ # Layout components
│ │ └── dashboard/ # Dashboard components
│ ├── composables/ # Composable functions
│ ├── stores/ # Pinia state management
│ ├── lib/ # Utility library
│ ├── mocks/ # Mock data
│ └── types/ # Type definitions
├── public/ # Static assets
├── vite.config.ts
└── package.json
git clone https://github.com/halolight/halolight-vue.git
cd halolight-vue
pnpm install
cp .env.example .env.local
# .env.local
VITE_API_URL=/api
VITE_USE_MOCK=true
[email protected]
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=false
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight
pnpm dev
Visit http://localhost:5173
pnpm build
pnpm preview
| Role | Password | |
|---|---|---|
| Admin | [email protected] | 123456 |
| User | [email protected] | 123456 |
// stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref<User | null>(null)
const token = ref<string | null>(null)
// Getters
const isAuthenticated = computed(() => !!token.value && !!user.value)
const permissions = computed(() => user.value?.permissions || [])
// Actions
async function login(credentials: LoginCredentials) {
const response = await authService.login(credentials)
user.value = response.user
token.value = response.token
}
function logout() {
user.value = null
token.value = null
}
function hasPermission(permission: string): boolean {
return permissions.value.some(p =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
}
return {
user,
token,
isAuthenticated,
permissions,
login,
logout,
hasPermission,
}
}, {
persist: {
paths: ['token', 'user'],
},
})
// composables/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
import { userService } from '@/services/users'
export function useUsers(params?: Ref<UserQueryParams>) {
return useQuery({
queryKey: ['users', params],
queryFn: () => userService.getList(unref(params)),
})
}
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: userService.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}
// composables/usePermission.ts
import { useAuthStore } from '@/stores/auth'
export function usePermission() {
const authStore = useAuthStore()
function hasPermission(permission: string): boolean {
return authStore.hasPermission(permission)
}
function hasAnyPermission(permissions: string[]): boolean {
return permissions.some(p => hasPermission(p))
}
function hasAllPermissions(permissions: string[]): boolean {
return permissions.every(p => hasPermission(p))
}
return {
hasPermission,
hasAnyPermission,
hasAllPermissions,
}
}
// directives/permission.ts
import { useAuthStore } from '@/stores/auth'
export const vPermission = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const authStore = useAuthStore()
if (!authStore.hasPermission(binding.value)) {
el.parentNode?.removeChild(el)
}
},
}
// Register directive
app.directive('permission', vPermission)
<!-- Use permission directive -->
<button v-permission="'users:delete'">Delete</button>
<!-- Use permission component -->
<PermissionGuard permission="users:delete">
<DeleteButton />
<template #fallback>
<span>No permission</span>
</template>
</PermissionGuard>
<!-- components/dashboard/DashboardGrid.vue -->
<script setup lang="ts">
import { GridLayout, GridItem } from 'grid-layout-plus'
import { useDashboardStore } from '@/stores/dashboard'
const dashboardStore = useDashboardStore()
const { layout, isEditing } = storeToRefs(dashboardStore)
</script>
<template>
<GridLayout
v-model:layout="layout"
:col-num="12"
:row-height="80"
:is-draggable="isEditing"
:is-resizable="isEditing"
:margin="[16, 16]"
>
<GridItem
v-for="item in layout"
:key="item.i"
:x="item.x"
:y="item.y"
:w="item.w"
:h="item.h"
>
<WidgetWrapper :widget="getWidget(item.i)" />
</GridItem>
</GridLayout>
</template>
Supports 11 preset skins, switchable via quick settings panel:
| Skin | Primary Color | CSS Variable |
|---|---|---|
| Default | Purple | --primary: 51.1% 0.262 276.97 |
| Blue | Blue | --primary: 54.8% 0.243 264.05 |
| Emerald | Emerald | --primary: 64.6% 0.178 142.49 |
| Orange | Orange | --primary: 69.7% 0.196 49.27 |
| Rose | Rose | --primary: 63.4% 0.243 357.61 |
| Amber | Amber | --primary: 79.1% 0.177 77.54 |
| Cyan | Cyan | --primary: 74.4% 0.167 197.13 |
| Violet | Violet | --primary: 57.2% 0.267 285.75 |
| Lime | Lime | --primary: 78.8% 0.184 127.38 |
| Pink | Pink | --primary: 70.9% 0.254 347.58 |
| Teal | Teal | --primary: 67.8% 0.157 181.02 |
/* Example variable definitions */
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 100% 0 0;
--secondary: 97.3% 0.006 285.75;
--secondary-foreground: 17.9% 0.018 285.75;
--muted: 97.3% 0.006 285.75;
--muted-foreground: 49.5% 0.023 285.75;
--accent: 97.3% 0.006 285.75;
--accent-foreground: 17.9% 0.018 285.75;
--destructive: 59.9% 0.24 29.23;
--destructive-foreground: 98.3% 0.002 285.75;
--border: 91.9% 0.010 285.75;
--input: 91.9% 0.010 285.75;
--ring: 51.1% 0.262 276.97;
--radius: 0.5rem;
}
// composables/useTheme.ts
import { ref, computed, watch } from 'vue'
export function useTheme() {
const theme = ref<'light' | 'dark' | 'system'>('system')
const skin = ref<SkinPreset>('default')
const actualTheme = computed(() => {
if (theme.value === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
return theme.value
})
async function toggleTheme(event?: MouseEvent) {
const newTheme = actualTheme.value === 'dark' ? 'light' : 'dark'
// View Transitions API
if (!document.startViewTransition) {
theme.value = newTheme
return
}
await document.startViewTransition(() => {
theme.value = newTheme
}).ready
// Circular reveal animation
if (event) {
const { clientX, clientY } = event
const radius = Math.hypot(
Math.max(clientX, window.innerWidth - clientX),
Math.max(clientY, window.innerHeight - clientY)
)
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${clientX}px ${clientY}px)`,
`circle(${radius}px at ${clientX}px ${clientY}px)`,
],
},
{
duration: 500,
easing: 'ease-in-out',
pseudoElement: '::view-transition-new(root)',
}
)
}
}
watch([theme, skin], () => {
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(actualTheme.value)
document.documentElement.setAttribute('data-skin', skin.value)
}, { immediate: true })
return { theme, skin, actualTheme, toggleTheme }
}
| Path | Page | Permission |
|---|---|---|
/ |
Redirect to /dashboard |
- |
/login |
Login | Public |
/register |
Register | Public |
/forgot-password |
Forgot password | Public |
/reset-password |
Reset password | Public |
/dashboard |
Dashboard | dashboard:view |
/users |
User management | users:view |
/analytics |
Analytics | analytics:view |
/calendar |
Calendar | calendar:view |
/documents |
Documents | documents:view |
/files |
File storage | files:view |
/messages |
Messages | messages:view |
/notifications |
Notifications | notifications:view |
/settings |
System settings | settings:view |
/profile |
User profile | settings:view |
# .env.local
VITE_API_URL=/api
VITE_USE_MOCK=true
[email protected]
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=false
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight
| Variable Name | Description | Default Value |
|---|---|---|
VITE_API_URL |
API base path | /api |
VITE_USE_MOCK |
Whether to use Mock data | true |
VITE_DEMO_EMAIL |
Demo account email | [email protected] |
VITE_DEMO_PASSWORD |
Demo account password | 123456 |
VITE_SHOW_DEMO_HINT |
Whether to show demo hint | false |
VITE_APP_TITLE |
Application title | Admin Pro |
VITE_BRAND_NAME |
Brand name | Halolight |
// Use in code
const apiUrl = import.meta.env.VITE_API_URL
const useMock = import.meta.env.VITE_USE_MOCK === 'true'
const appTitle = import.meta.env.VITE_APP_TITLE
pnpm dev # Start development server
pnpm build # Production build
pnpm preview # Preview production build
pnpm lint # Code linting
pnpm lint:fix # Auto-fix
pnpm type-check # Type checking
pnpm test # Run tests
pnpm test:coverage # Test coverage
pnpm test # Run tests (watch mode)
pnpm test:run # Run once
pnpm test:coverage # Coverage report
pnpm test:ui # Vitest UI
// tests/components/Button.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from '@/components/ui/Button.vue'
describe('Button', () => {
it('renders properly', () => {
const wrapper = mount(Button, {
props: { variant: 'default' },
slots: { default: 'Click me' }
})
expect(wrapper.text()).toContain('Click me')
})
it('emits click event', async () => {
const wrapper = mount(Button)
await wrapper.trigger('click')
expect(wrapper.emitted()).toHaveProperty('click')
})
})
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
open: true,
},
build: {
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'ui-vendor': ['@tanstack/vue-query'],
},
},
},
},
})
docker build -t halolight-vue .
docker run -p 3000:3000 halolight-vue
The project is configured with a complete GitHub Actions CI workflow:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm type-check
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
<script setup lang="ts">
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, BarChart, PieChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { useTheme } from '@/composables/useTheme'
use([CanvasRenderer, LineChart, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent])
const { actualTheme } = useTheme()
const option = computed(() => ({
backgroundColor: 'transparent',
textStyle: {
color: actualTheme.value === 'dark' ? '#e5e5e5' : '#333',
},
xAxis: {
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
},
yAxis: {
type: 'value'
},
series: [{
data: [820, 932, 901, 934, 1290, 1330, 1320],
type: 'line'
}]
}))
</script>
<template>
<VChart :option="option" autoresize />
</template>
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [...routes]
})
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
// Pages requiring authentication
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({ name: 'login', query: { redirect: to.fullPath } })
return
}
// Permission check
if (to.meta.permission && !authStore.hasPermission(to.meta.permission)) {
next({ name: '403' })
return
}
next()
})
export default router
<script setup lang="ts">
const imageSrc = computed(() => {
const { width } = useWindowSize()
if (width.value < 768) return '/images/mobile.webp'
if (width.value < 1024) return '/images/tablet.webp'
return '/images/desktop.webp'
})
</script>
<template>
<img
:src="imageSrc"
loading="lazy"
decoding="async"
alt="Responsive image"
>
</template>
// router/routes.ts
const routes = [
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { requiresAuth: true }
},
{
path: '/users',
component: () => import('@/views/Users.vue'),
meta: { requiresAuth: true, permission: 'users:view' }
},
]
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
onMounted(() => {
// Preload commonly used routes
router.resolve({ name: 'users' })
router.resolve({ name: 'settings' })
})
</script>
A: Use the useTheme composable:
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
const { theme, toggleTheme, skin } = useTheme()
// Toggle light/dark theme
function handleToggle(event: MouseEvent) {
toggleTheme(event)
}
// Change skin
function changeSkin(newSkin: SkinPreset) {
skin.value = newSkin
}
</script>
<template>
<button @click="handleToggle">Toggle Theme</button>
<select v-model="skin">
<option value="default">Default</option>
<option value="blue">Blue</option>
<option value="emerald">Emerald</option>
</select>
</template>
A: Add permission strings in the authentication response:
// types/auth.ts
interface User {
id: string
name: string
email: string
permissions: string[] // ['users:*', 'posts:view', 'posts:create']
}
// Using wildcards
// 'users:*' - All permissions for user module
// '*' - All permissions
// 'users:view' - Specific permission
A: Manage layout through Dashboard Store:
// stores/dashboard.ts
import { defineStore } from 'pinia'
export const useDashboardStore = defineStore('dashboard', () => {
const layout = ref([
{ i: 'widget-1', x: 0, y: 0, w: 6, h: 4 },
{ i: 'widget-2', x: 6, y: 0, w: 6, h: 4 },
])
function saveLayout(newLayout: Layout[]) {
layout.value = newLayout
// Save to server
}
return { layout, saveLayout }
})
| Feature | Vue | Next.js | Angular |
|---|---|---|---|
| SSR/SSG | ❌ (Requires Nuxt) | ✅ | ✅ (Requires Angular Universal) |
| State Management | Pinia | Zustand | RxJS/Signals |
| Routing | Vue Router | App Router | Angular Router |
| Build Tool | Vite | Next.js | Angular CLI |
| Learning Curve | Medium | Medium | High |
| Ecosystem | Rich | Rich | Enterprise |
HaloLight Web3 provides unified access to EVM + Solana + IPFS, with Core/React/Vue packages.
GitHub: https://github.com/halolight/halolight-web3
npm:
@halolight/web3-core - Core functionality (framework-agnostic)@halolight/web3-react - React components and hooks@halolight/web3-vue - Vue 3 components and composables| Technology | Version | Description |
|---|---|---|
| wagmi | ^2.12.x | EVM wallet and contract calls |
| viem | ^2.21.x | EVM RPC & ABI utilities |
| @solana/web3.js | ^1.95.x | Solana JavaScript SDK |
| @solana/wallet-adapter | latest | Solana wallet adapters |
| @web3-storage/w3up-client | ^16.0.x | IPFS/web3.storage client |
| siwe | ^2.3.x | Sign-In with Ethereum |
| TypeScript | ^5.7.x | Type system |
| Turborepo | ^2.3.x | Monorepo build tool |
halolight-web3/
├── packages/
│ ├── core/ # Core package (@halolight/web3-core)
│ │ ├── src/
│ │ │ ├── evm/ # EVM/Ethereum functionality
│ │ │ │ ├── wallet.ts # Wagmi configuration
│ │ │ │ ├── chains.ts # Chain configuration
│ │ │ │ ├── siwe.ts # Sign-In with Ethereum
│ │ │ │ └── contracts.ts # Smart contract interaction
│ │ │ ├── solana/ # Solana functionality
│ │ │ │ ├── wallet.ts # Wallet adapters
│ │ │ │ └── auth.ts # Signature authentication
│ │ │ ├── storage/ # Storage functionality
│ │ │ │ └── ipfs.ts # IPFS upload
│ │ │ ├── types/ # TypeScript types
│ │ │ └── utils/ # Utilities
│ │ └── package.json
│ │
│ ├── react/ # React package (@halolight/web3-react)
│ │ ├── src/
│ │ │ ├── providers/
│ │ │ │ └── Web3Provider.tsx
│ │ │ ├── components/
│ │ │ │ ├── WalletButton.tsx
│ │ │ │ ├── TokenBalance.tsx
│ │ │ │ ├── NftGallery.tsx
│ │ │ │ └── ContractCall.tsx
│ │ │ └── hooks/
│ │ └── package.json
│ │
│ └── vue/ # Vue package (@halolight/web3-vue)
│ ├── src/
│ │ ├── composables/
│ │ │ └── useWallet.ts
│ │ └── components/
│ │ ├── WalletButton.vue
│ │ └── TokenBalance.vue
│ └── package.json
│
├── .env.example
├── turbo.json
├── pnpm-workspace.yaml
└── package.json
# Using pnpm (recommended)
pnpm add @halolight/web3-core @halolight/web3-react
# or for Vue
pnpm add @halolight/web3-core @halolight/web3-vue
# Using npm
npm install @halolight/web3-core @halolight/web3-react
# Using yarn
yarn add @halolight/web3-core @halolight/web3-react
Copy .env.example to .env and configure:
# EVM - RPC nodes
NEXT_PUBLIC_ALCHEMY_API_KEY=your_alchemy_key
NEXT_PUBLIC_INFURA_API_KEY=your_infura_key
# EVM - WalletConnect
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=your_project_id
# Solana
NEXT_PUBLIC_SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
# IPFS/web3.storage
NEXT_PUBLIC_WEB3_STORAGE_TOKEN=your_web3_storage_token
import { Web3Provider, WalletButton, TokenBalance } from '@halolight/web3-react';
function App() {
return (
<Web3Provider
evmNetwork="mainnet"
solanaCluster="mainnet-beta"
enableEvm={true}
enableSolana={true}
>
<div>
<WalletButton chain="evm" />
<WalletButton chain="solana" />
<TokenBalance
chain="evm"
tokenAddress="0x..." // USDC on Ethereum
showSymbol
/>
</div>
</Web3Provider>
);
}
<script setup lang="ts">
import { WalletButton, TokenBalance, useEvmWallet } from '@halolight/web3-vue';
import { WagmiPlugin } from '@wagmi/vue';
import { createWagmiConfig } from '@halolight/web3-core';
const config = createWagmiConfig('mainnet');
</script>
<template>
<div>
<WalletButton />
<TokenBalance
token-address="0x..."
:show-symbol="true"
/>
</div>
</template>
import {
createWagmiConfig,
getTokenBalance,
uploadToIpfs,
authenticateWithSiwe,
} from '@halolight/web3-core';
// EVM: Get token balance
const balance = await getTokenBalance(client, tokenAddress, walletAddress);
// IPFS: Upload file
const result = await uploadToIpfs(file);
console.log(result.cid, result.gateway);
// SIWE: Authenticate user
const auth = await authenticateWithSiwe({
domain: 'example.com',
address: walletAddress,
chainId: 1,
signMessage: async (msg) => wallet.signMessage(msg),
});
Unified EVM + Solana Provider:
<Web3Provider
evmNetwork="mainnet" // or "testnet" | "development"
solanaCluster="mainnet-beta" // or "devnet" | "testnet"
enableEvm={true} // Enable EVM support
enableSolana={true} // Enable Solana support
>
{children}
</Web3Provider>
import { WalletButton, DefaultWalletButton } from '@halolight/web3-react';
// EVM wallet
<WalletButton
chain="evm"
connectText="Connect Ethereum"
className="custom-class"
/>
// Solana wallet
<WalletButton
chain="solana"
className="custom-class"
/>
// With default styling
<DefaultWalletButton chain="evm" />
import { TokenBalance } from '@halolight/web3-react';
<TokenBalance
chain="evm"
tokenAddress="0x..." // ERC-20 contract address
showSymbol
decimals={4}
loadingComponent={<Spinner />}
errorComponent={(error) => <div>Error: {error}</div>}
/>
import { NftGallery } from '@halolight/web3-react';
<NftGallery
contractAddress="0x..." // ERC-721 contract
maxDisplay={50}
columns={3}
renderNft={(nft) => (
<div>
<img src={nft.image} alt={nft.name} />
<h3>{nft.name}</h3>
</div>
)}
/>
import { ContractCallButton, useContractCall } from '@halolight/web3-react';
// Using component
<ContractCallButton
contract={{
address: '0x...',
abi: MyABI,
functionName: 'mint',
args: [tokenId],
}}
type="write"
buttonText="Mint NFT"
onSuccess={(data) => console.log('Minted!', data)}
/>
// Using hook
const { call, loading, error, txHash } = useContractCall(
{
address: '0x...',
abi: MyABI,
functionName: 'balanceOf',
args: [address],
},
'read'
);
import { useEvmWallet } from '@halolight/web3-vue';
const { address, isConnected, connect, disconnect } = useEvmWallet();
import { useTokenBalance } from '@halolight/web3-vue';
const { balance, loading, error, refresh } = useTokenBalance('0x...');
import { useNativeBalance } from '@halolight/web3-vue';
const { balance, formatted, loading } = useNativeBalance();
import {
createWagmiConfig,
formatAddress,
isValidAddress,
} from '@halolight/web3-core';
// Create wagmi configuration
const config = createWagmiConfig('mainnet');
// Format address
const short = formatAddress('0x1234...5678'); // "0x1234...5678"
// Validate address
const valid = isValidAddress('0x...'); // true/false
import {
readContract,
writeContract,
getTokenBalance,
transferToken,
ERC20_ABI,
} from '@halolight/web3-core';
// Read contract
const name = await readContract(publicClient, {
address: '0x...',
abi: ERC20_ABI,
functionName: 'name',
});
// Write contract
const hash = await writeContract(walletClient, publicClient, {
address: '0x...',
abi: ERC20_ABI,
functionName: 'transfer',
args: [toAddress, amount],
});
import {
createSiweMessage,
formatSiweMessage,
verifySiweMessage,
authenticateWithSiwe,
} from '@halolight/web3-core';
// Complete authentication flow
const result = await authenticateWithSiwe({
domain: 'example.com',
address: walletAddress,
chainId: 1,
signMessage: async (message) => {
return await wallet.signMessage(message);
},
});
if (result.success) {
console.log('Authenticated:', result.address);
}
import {
createSolanaConnection,
getSolBalance,
transferSol,
} from '@halolight/web3-core';
// Create connection
const connection = createSolanaConnection('mainnet-beta');
// Get SOL balance
const balance = await getSolBalance(connection, walletAddress);
// Transfer SOL
const signature = await transferSol(
connection,
wallet,
toAddress,
1.0 // 1 SOL
);
import {
uploadToIpfs,
uploadJsonToIpfs,
fetchJsonFromIpfs,
ipfsToHttp,
} from '@halolight/web3-core';
// Upload file
const result = await uploadToIpfs(file);
console.log(result.cid); // "QmXxx..."
console.log(result.gateway); // "https://w3s.link/ipfs/QmXxx..."
// Upload JSON
const metadata = await uploadJsonToIpfs({
name: 'My NFT',
description: 'Cool NFT',
image: 'ipfs://QmYyy...',
});
// Fetch JSON
const data = await fetchJsonFromIpfs('QmXxx...');
// Convert IPFS URL to HTTP
const url = ipfsToHttp('ipfs://QmXxx...'); // "https://w3s.link/ipfs/QmXxx..."
# Install dependencies
pnpm install
# Build all packages
pnpm build
# Development mode (watch)
pnpm dev
# Run tests
pnpm test
# Type check
pnpm type-check
# Lint
pnpm lint
# Clean build artifacts
pnpm clean
# In packages/core directory
pnpm build
pnpm dev
pnpm test
# Or from root
pnpm --filter @halolight/web3-core build
pnpm --filter @halolight/web3-react dev
HaloLight Action 是基于 Next.js 14 App Router 和 Supabase 构建的现代化签到定时任务平台,支持多平台自动签到、任务调度、执行记录追踪和推送通知。
在线预览:https://halolight-action.h7ml.cn
GitHub:https://github.com/halolight/halolight-action
| 技术 | 版本 | 说明 |
|---|---|---|
| Next.js | 14.2 | App Router 架构 |
| TypeScript | 5.7 | 类型安全 |
| Supabase | latest | PostgreSQL + Auth + RLS |
| Tailwind CSS | 3.4 | 原子化 CSS |
| shadcn/ui | latest | Radix UI 组件库 |
| TanStack Query | 5.x | 服务端状态管理 |
| Zustand | 5.x | 客户端状态管理 |
| Framer Motion | latest | 流畅动画 |
| Vitest | latest | 单元测试 |
halolight-action/
├── app/ # Next.js 14 App Router
│ ├── (auth)/ # 认证页面
│ │ ├── login/
│ │ ├── register/
│ │ ├── forgot-password/
│ │ └── reset-password/
│ ├── (dashboard)/ # 仪表盘页面 (15个功能页面)
│ │ ├── dashboard/ # 仪表盘首页
│ │ ├── signin-tasks/ # 签到任务管理
│ │ ├── signin-records/ # 签到记录查看
│ │ ├── push-logs/ # 推送日志
│ │ ├── scheduled-tasks/# 定时任务
│ │ ├── notifications/ # 通知中心
│ │ ├── data-dictionary/# 数据字典
│ │ ├── users/ # 用户管理
│ │ └── settings/ # 设置
│ │ ├── profile/
│ │ ├── appearance/
│ │ ├── push-channels/
│ │ └── api-proxy/
│ └── api/ # API 路由
│ ├── cron/ # 定时任务执行
│ ├── signin/ # 签到执行
│ └── push/test/ # 推送测试
├── components/ # React 组件
│ ├── ui/ # shadcn/ui 基础组件
│ ├── layout/ # 布局组件
│ └── auth/ # 认证组件
├── hooks/ # React Query Hooks
├── lib/ # 工具库
│ ├── supabase/ # Supabase 客户端
│ ├── dal/ # 数据访问层
│ ├── cron/ # Cron 执行器
│ └── push/ # 推送服务封装
├── providers/ # Context Providers
├── stores/ # Zustand 状态管理
├── types/ # TypeScript 类型定义
└── supabase/migrations/ # 数据库迁移脚本
# 克隆仓库
git clone https://github.com/halolight/halolight-action.git
cd halolight-action
# 安装依赖
pnpm install
cat > .env.local <<'EOF'
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
NEXT_PUBLIC_APP_NAME=HaloLight Action
NEXT_PUBLIC_APP_URL=http://localhost:3000
EOF
supabase/migrations/init.sql.env.localpnpm generate:types 生成类型定义pnpm dev
# 访问 http://localhost:3000
[email protected]Admin@123| 表名 | 说明 | 关键功能 |
|---|---|---|
| signin_tasks | 签到任务配置 | Cron 调度、优先级、凭证管理 |
| signin_records | 签到执行记录 | 历史追踪、成功率统计 |
| push_channels | 推送渠道配置 | 12种推送服务、测试与默认渠道 |
| push_logs | 推送执行日志 | 推送历史、错误追踪 |
| data_dictionary | 数据字典配置 | 系统配置、多类型支持 |
| notifications | 系统通知 | 用户消息、通知中心 |
| cron_jobs | 定时任务配置 | HTTP 请求调度 |
| users | 用户信息 | 认证授权、角色管理 |
| user_tokens | API 令牌 | 访问控制、令牌管理 |
# 开发
pnpm dev # 启动开发服务器
pnpm build # 生产构建
pnpm start # 启动生产服务器
# 质量检查
pnpm lint # ESLint 检查
pnpm type-check # TypeScript 类型检查
pnpm format # Prettier 格式化
pnpm ci # 完整 CI 检查
# 测试
pnpm test # 运行测试 (watch)
pnpm test:run # 运行测试 (单次)
pnpm test:coverage # 测试覆盖率报告
# Supabase
pnpm generate:types # 从 Supabase 生成类型
一键部署:
# 构建
pnpm build
# 启动生产服务器
pnpm start
在 Vercel 项目设置中添加:
0 8 * * * (每天早上 8 点)/api/cron不要提交密钥:
.env.local 提交到 Git使用正确的密钥:
anon public key (安全)service_role key (危险)凭证存储:
RLS 策略:
| 项目 | 后端 | 特点 |
|---|---|---|
| halolight | Mock.js | 纯前端演示,无需后端服务 |
| halolight-action | Supabase | 签到定时任务平台,真实后端 |
| halolight-vue | Mock.js | Vue 3.5 实现 |
| halolight-angular | Mock.js | Angular 实现 |
HaloLight Admin 是一个功能强大的超级管理面板,用于管理多个 HaloLight 实例和租户。
# 克隆仓库
git clone https://github.com/halolight/halolight-admin.git
cd halolight-admin
# 安装依赖
pnpm install
# 运行开发服务器
pnpm dev
HaloLight AI 服务基于 Hono + LangChain.js 构建,提供 RAG 检索增强、动作执行和多模型自动降级。
在线预览:内部 API 服务 (无独立 Demo)
GitHub:https://github.com/halolight/halolight-ai
| 技术 | 版本 | 说明 |
|---|---|---|
| Hono | latest | 轻量 HTTP 框架 |
| LangChain.js | latest | LLM 编排 + RAG 管道 |
| pgvector | latest | PostgreSQL 向量检索扩展 |
| Drizzle ORM | latest | TypeScript ORM & 迁移 |
| PostgreSQL | 14+ | 持久化与向量存储 |
| Node.js | 22+ | 运行时 |
| Zod | latest | 数据验证 |
halolight-ai/
├── src/
│ ├── index.ts # 应用入口
│ ├── routes/ # API 路由
│ │ ├── chat.ts # 对话接口
│ │ ├── actions.ts # 动作执行
│ │ ├── history.ts # 历史记录
│ │ └── knowledge.ts # 知识库管理
│ ├── services/
│ │ ├── llm/ # LLM 服务层
│ │ │ ├── openai.ts
│ │ │ ├── anthropic.ts
│ │ │ └── factory.ts # 模型工厂 (自动降级)
│ │ ├── rag/ # RAG 管道
│ │ │ ├── embeddings.ts # 文档分块与嵌入
│ │ │ ├── retriever.ts # 向量检索
│ │ │ └── pipeline.ts # RAG 管道
│ │ ├── actions/ # 动作执行系统
│ │ │ ├── executor.ts # 动作执行器
│ │ │ ├── registry.ts # 动作注册表
│ │ │ └── permissions.ts # 权限校验
│ │ └── memory/
│ │ └── conversation.ts # 对话记忆管理
│ ├── db/
│ │ ├── schema.ts # Drizzle Schema (含 pgvector)
│ │ └── client.ts # 数据库客户端
│ ├── middleware/
│ │ ├── auth.ts # 认证中间件
│ │ └── tenant.ts # 租户隔离
│ └── types/
│ └── index.ts # TypeScript 类型定义
├── drizzle/ # 迁移文件
├── drizzle.config.ts
├── Dockerfile
├── docker-compose.yml
└── package.json
# 克隆仓库
git clone https://github.com/halolight/halolight-ai.git
cd halolight-ai
# 安装依赖
pnpm install
cp .env.example .env
关键配置项:
# 数据库
DATABASE_URL=postgresql://user:password@localhost:5432/halolight_ai
# LLM 提供商 (至少配置一个)
OPENAI_API_KEY=sk-...
# 或
ANTHROPIC_API_KEY=sk-ant-...
# 或
AZURE_OPENAI_API_KEY=...
# RAG 配置
CHUNK_SIZE=1000 # 文档分块大小
CHUNK_OVERLAP=200 # 分块重叠
RETRIEVAL_TOP_K=5 # 检索数量
# 对话配置
MAX_CONVERSATION_HISTORY=20 # 对话历史长度
ENABLE_STREAMING=true # 启用流式响应
ENABLE_AUDIT_LOG=true # 启用审计日志
# 生产环境
JWT_SECRET=your-secret-key
CORS_ORIGINS=https://your-domain.com
# 生成迁移文件
pnpm db:generate
# 运行迁移
pnpm db:migrate
# 或直接推送 schema (开发环境)
pnpm db:push
# 打开 Drizzle Studio
pnpm db:studio
pnpm dev
服务将在 http://localhost:3000 启动。
pnpm build
pnpm start
系统会自动检测可用的 LLM 提供商并按优先级降级:
Azure OpenAI (1) → OpenAI (2) → Anthropic (3) → Ollama (4)
| 步骤 | 说明 | 配置 |
|---|---|---|
| 文档分块 | RecursiveCharacterTextSplitter | 1000 字符, 200 重叠 |
| 向量嵌入 | OpenAI Embeddings | text-embedding-3-small |
| 向量存储 | pgvector | 1536 维 |
| 检索 | 余弦相似度 | Top-K (默认 5) |
| 上下文注入 | 将检索结果注入 LLM 提示词 | - |
启用 SSE 流式输出,降低首字延迟:
POST /api/ai/chat/stream
Content-Type: application/json
{
"message": "你好",
"streaming": true
}
基于角色的访问控制 (RBAC):
| 角色 | 权限级别 |
|---|---|
super_admin |
最高权限 |
admin |
管理权限 |
user |
普通用户 |
guest |
访客 |
敏感操作需要二次确认 (_confirmed: true)。
conversations 和 messages 表所有数据操作都基于 TenantContext:
interface TenantContext {
tenantId: string;
userId: string;
role: UserRole;
}
从请求头提取:
X-Tenant-IDX-User-IDX-User-RoleGET /health
GET /health/ready
GET /api/ai/info
# 发送消息
POST /api/ai/chat
Content-Type: application/json
{
"message": "你好,介绍一下 HaloLight",
"conversationId": "uuid", // 可选
"includeContext": true,
"maxContextDocs": 5
}
# 流式响应
POST /api/ai/chat/stream
# 执行动作
POST /api/ai/actions/execute
Content-Type: application/json
{
"action": "query_users",
"params": {
"role": "admin",
"limit": 10
}
}
# 获取可用动作
GET /api/ai/actions/available
# 获取动作详情
GET /api/ai/actions/:name
GET /api/ai/history?limit=10
GET /api/ai/history/:id
DELETE /api/ai/history/:id
PATCH /api/ai/history/:id
# 导入文档
POST /api/ai/knowledge/ingest
Content-Type: application/json
{
"content": "文档内容...",
"metadata": {
"title": "文档标题",
"category": "技术文档"
},
"source": "manual",
"sourceId": "doc-001"
}
# 批量导入
POST /api/ai/knowledge/batch-ingest
# 列出文档
GET /api/ai/knowledge?limit=50&offset=0
# 删除文档
DELETE /api/ai/knowledge/:id
所有 /api/* 端点需要以下请求头 (开发环境可省略):
X-Tenant-ID: your-tenant-id
X-User-ID: your-user-id
X-User-Role: admin | user | guest
# 构建镜像
docker build -t halolight-ai .
# 运行容器
docker run -p 3000:3000 \
-e DATABASE_URL=postgresql://... \
-e OPENAI_API_KEY=sk-... \
halolight-ai
docker-compose up -d
DATABASE_URL:PostgreSQL 连接字符串NODE_ENV=productionJWT_SECRET:用于认证的密钥CORS_ORIGINS:允许的跨域来源# 检查 PostgreSQL 是否运行
psql $DATABASE_URL -c "SELECT 1"
# 检查 pgvector 扩展
psql $DATABASE_URL -c "CREATE EXTENSION IF NOT EXISTS vector"
# 检查可用提供商
curl http://localhost:3000/api/ai/info
RETRIEVAL_TOP_K 值CHUNK_SIZE 和 CHUNK_OVERLAPHaloLight Angular 版本基于 Angular 21 构建,采用 Signals + 独立组件 + TypeScript。
在线预览:https://halolight-angular.h7ml.cn/
GitHub:https://github.com/halolight/halolight-angular
| 技术 | 版本 | 说明 |
|---|---|---|
| Angular | 21.x | 企业级框架 |
| TypeScript | 5.x | 类型安全 |
| Tailwind CSS | 4.x | 原子化 CSS |
| spartan/ui | latest | UI 组件库(Radix 风格) |
| NgRx Signals | 21.x | 响应式状态管理 |
| TanStack Query | 5.x | 服务端状态 |
| Mock.js | 1.x | 数据模拟 |
halolight-angular/
├── src/
│ ├── app/
│ │ ├── pages/ # 页面组件
│ │ │ ├── admin/ # 管理后台页面
│ │ │ │ ├── dashboard/ # 仪表盘
│ │ │ │ ├── users/ # 用户管理
│ │ │ │ ├── roles/ # 角色管理
│ │ │ │ ├── permissions/ # 权限管理
│ │ │ │ ├── settings/ # 系统设置
│ │ │ │ └── profile/ # 个人中心
│ │ │ └── auth/ # 认证页面
│ │ │ ├── login/
│ │ │ ├── register/
│ │ │ ├── forgot-password/
│ │ │ └── reset-password/
│ │ ├── components/
│ │ │ ├── ui/ # spartan/ui 组件
│ │ │ ├── layout/ # 布局组件
│ │ │ ├── dashboard/ # 仪表盘组件
│ │ │ └── shared/ # 共享组件
│ │ ├── services/ # 服务层
│ │ ├── stores/ # NgRx Signals Stores
│ │ ├── guards/ # 路由守卫
│ │ ├── interceptors/ # HTTP 拦截器
│ │ ├── directives/ # 指令
│ │ ├── pipes/ # 管道
│ │ ├── lib/ # 工具库
│ │ ├── types/ # 类型定义
│ │ ├── mocks/ # Mock 数据
│ │ ├── app.routes.ts # 路由配置
│ │ ├── app.config.ts # 应用配置
│ │ └── app.component.ts # 根组件
│ ├── environments/ # 环境配置
│ └── styles.css # 全局样式
├── public/ # 静态资源
├── angular.json
├── tailwind.config.js
├── tsconfig.json
└── package.json
git clone https://github.com/halolight/halolight-angular.git
cd halolight-angular
pnpm install
cp src/environments/environment.example.ts src/environments/environment.development.ts
// src/environments/environment.development.ts
export const environment = {
production: false,
apiUrl: '/api',
useMock: true,
appTitle: 'Admin Pro',
brandName: 'Halolight',
demoEmail: '[email protected]',
demoPassword: '123456',
showDemoHint: true,
};
pnpm start
pnpm build
ng build --configuration production
| 角色 | 邮箱 | 密码 |
|---|---|---|
| 管理员 | [email protected] | 123456 |
| 普通用户 | [email protected] | 123456 |
// stores/auth.store.ts
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
import { computed, inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
interface AuthState {
user: User | null;
token: string | null;
loading: boolean;
}
const initialState: AuthState = {
user: null,
token: null,
loading: false,
};
export const AuthStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed((store) => ({
isAuthenticated: computed(() => !!store.token() && !!store.user()),
permissions: computed(() => store.user()?.permissions ?? []),
})),
withMethods((store, authService = inject(AuthService)) => ({
async login(credentials: LoginCredentials) {
patchState(store, { loading: true });
try {
const response = await authService.login(credentials);
patchState(store, {
user: response.user,
token: response.token,
loading: false,
});
} catch (error) {
patchState(store, { loading: false });
throw error;
}
},
logout() {
patchState(store, { user: null, token: null });
},
hasPermission(permission: string): boolean {
const permissions = store.permissions();
return permissions.some(p =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
);
},
}))
);
// services/users.service.ts
import { Injectable, inject } from '@angular/core';
import { injectQuery, injectMutation, injectQueryClient } from '@tanstack/angular-query-experimental';
import { ApiService } from './api.service';
@Injectable({ providedIn: 'root' })
export class UsersService {
private api = inject(ApiService);
private queryClient = injectQueryClient();
getUsers(params?: UserQueryParams) {
return injectQuery(() => ({
queryKey: ['users', params],
queryFn: () => this.api.get<UserListResponse>('/users', { params }),
}));
}
createUser() {
return injectMutation(() => ({
mutationFn: (data: CreateUserDto) => this.api.post<User>('/users', data),
onSuccess: () => {
this.queryClient.invalidateQueries({ queryKey: ['users'] });
},
}));
}
}
// directives/permission.directive.ts
import { Directive, Input, TemplateRef, ViewContainerRef, inject, effect } from '@angular/core';
import { AuthStore } from '../stores/auth.store';
@Directive({
selector: '[appPermission]',
standalone: true,
})
export class PermissionDirective {
private templateRef = inject(TemplateRef<unknown>);
private viewContainer = inject(ViewContainerRef);
private authStore = inject(AuthStore);
@Input() set appPermission(permission: string) {
effect(() => {
const hasPermission = this.authStore.hasPermission(permission);
this.viewContainer.clear();
if (hasPermission) {
this.viewContainer.createEmbeddedView(this.templateRef);
}
});
}
}
<!-- 使用指令 -->
<button *appPermission="'users:delete'">删除</button>
// components/permission-guard.component.ts
import { Component, Input, inject, computed } from '@angular/core';
import { AuthStore } from '../../stores/auth.store';
@Component({
selector: 'app-permission-guard',
standalone: true,
template: `
@if (hasPermission()) {
<ng-content />
} @else {
<ng-content select="[fallback]" />
}
`,
})
export class PermissionGuardComponent {
@Input({ required: true }) permission!: string;
private authStore = inject(AuthStore);
hasPermission = computed(() => this.authStore.hasPermission(this.permission));
}
<!-- 使用组件 -->
<app-permission-guard permission="users:delete">
<app-delete-button />
<span fallback>无权限</span>
</app-permission-guard>
// components/dashboard/dashboard-grid.component.ts
import { Component, inject, computed } from '@angular/core';
import { GridsterModule, GridsterConfig, GridsterItem } from 'angular-gridster2';
import { DashboardStore } from '../../stores/dashboard.store';
@Component({
selector: 'app-dashboard-grid',
standalone: true,
imports: [GridsterModule, WidgetWrapperComponent],
template: `
<gridster [options]="options()">
@for (widget of widgets(); track widget.id) {
<gridster-item [item]="widget">
<app-widget-wrapper [widget]="widget" />
</gridster-item>
}
</gridster>
`,
})
export class DashboardGridComponent {
private dashboardStore = inject(DashboardStore);
widgets = this.dashboardStore.widgets;
isEditing = this.dashboardStore.isEditing;
options = computed<GridsterConfig>(() => ({
gridType: 'fit',
displayGrid: this.isEditing() ? 'always' : 'none',
draggable: { enabled: this.isEditing() },
resizable: { enabled: this.isEditing() },
pushItems: true,
minCols: 12,
maxCols: 12,
minRows: 4,
defaultItemCols: 3,
defaultItemRows: 2,
itemChangeCallback: (item) => this.dashboardStore.updateWidget(item),
}));
}
支持 11 种预设皮肤,通过快捷设置面板切换:
| 皮肤 | 主色调 | CSS 变量 |
|---|---|---|
| Default | 紫色 | --primary: 51.1% 0.262 276.97 |
| Blue | 蓝色 | --primary: 54.8% 0.243 264.05 |
| Emerald | 翠绿 | --primary: 64.6% 0.178 142.49 |
| Rose | 玫瑰红 | --primary: 59.3% 0.214 12.76 |
| Orange | 橙色 | --primary: 65.4% 0.194 35.76 |
| Amber | 琥珀 | --primary: 74.2% 0.167 83.25 |
| Yellow | 黄色 | --primary: 84.5% 0.181 99.58 |
| Lime | 柠檬绿 | --primary: 76.5% 0.165 128.35 |
| Teal | 青色 | --primary: 59.8% 0.134 179.61 |
| Cyan | 青蓝 | --primary: 68.3% 0.148 192.18 |
| Sky | 天蓝 | --primary: 68.5% 0.171 227.08 |
/* 示例变量定义 */
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 98% 0 0;
--secondary: 96.1% 0.002 286.08;
--secondary-foreground: 14.9% 0.017 285.75;
--muted: 96.1% 0.002 286.08;
--muted-foreground: 55.4% 0.009 285.82;
--accent: 96.1% 0.002 286.08;
--accent-foreground: 14.9% 0.017 285.75;
--border: 92.2% 0.004 285.86;
--input: 92.2% 0.004 285.86;
--ring: 51.1% 0.262 276.97;
}
.dark {
--background: 14.9% 0.017 285.75;
--foreground: 98% 0 0;
--primary: 56.1% 0.287 277.04;
/* ... */
}
// 切换主题
const uiSettingsStore = inject(UiSettingsStore);
uiSettingsStore.setTheme('dark'); // 'light' | 'dark' | 'system'
// 切换皮肤
uiSettingsStore.setSkin('rose'); // 11 种皮肤预设
| 路径 | 页面 | 权限 |
|---|---|---|
/ |
重定向到 /dashboard |
- |
/login |
登录 | 公开 |
/register |
注册 | 公开 |
/forgot-password |
忘记密码 | 公开 |
/reset-password |
重置密码 | 公开 |
/dashboard |
仪表盘 | dashboard:view |
/users |
用户列表 | users:list |
/users/create |
创建用户 | users:create |
/users/:id |
用户详情 | users:view |
/users/:id/edit |
编辑用户 | users:update |
/roles |
角色管理 | roles:list |
/permissions |
权限管理 | permissions:list |
/settings |
系统设置 | settings:view |
/profile |
个人中心 | 登录即可 |
// src/environments/environment.development.ts
export const environment = {
production: false,
apiUrl: '/api',
useMock: true,
appTitle: 'Admin Pro',
brandName: 'Halolight',
demoEmail: '[email protected]',
demoPassword: '123456',
showDemoHint: true,
};
| 变量名 | 说明 | 默认值 |
|---|---|---|
production |
是否生产环境 | false |
apiUrl |
API 基础路径 | /api |
useMock |
是否使用 Mock 数据 | true |
appTitle |
应用标题 | Admin Pro |
brandName |
品牌名称 | Halolight |
demoEmail |
演示账号邮箱 | [email protected] |
demoPassword |
演示账号密码 | 123456 |
showDemoHint |
是否显示演示提示 | true |
import { inject } from '@angular/core';
import { environment } from '../environments/environment';
// 在组件或服务中使用
export class ApiService {
private apiUrl = environment.apiUrl;
private useMock = environment.useMock;
// ...
}
pnpm start # 启动开发服务器
pnpm build # 生产构建
pnpm lint # 代码检查
pnpm lint:fix # 自动修复
pnpm type-check # 类型检查
pnpm test # 运行测试
pnpm test:coverage # 测试覆盖率
pnpm test # 运行测试(watch 模式)
pnpm test:run # 单次运行
pnpm test:coverage # 覆盖率报告
pnpm test:ui # Vitest UI 界面
// auth.store.spec.ts
import { TestBed } from '@angular/core/testing';
import { AuthStore } from './auth.store';
import { AuthService } from '../services/auth.service';
describe('AuthStore', () => {
let store: InstanceType<typeof AuthStore>;
let authService: jasmine.SpyObj<AuthService>;
beforeEach(() => {
const authServiceSpy = jasmine.createSpyObj('AuthService', ['login', 'logout']);
TestBed.configureTestingModule({
providers: [
AuthStore,
{ provide: AuthService, useValue: authServiceSpy },
],
});
store = TestBed.inject(AuthStore);
authService = TestBed.inject(AuthService) as jasmine.SpyObj<AuthService>;
});
it('should initialize with default state', () => {
expect(store.user()).toBeNull();
expect(store.token()).toBeNull();
expect(store.isAuthenticated()).toBe(false);
});
it('should login successfully', async () => {
const mockResponse = {
user: { id: '1', email: '[email protected]', permissions: ['users:view'] },
token: 'mock-token',
};
authService.login.and.returnValue(Promise.resolve(mockResponse));
await store.login({ email: '[email protected]', password: '123456' });
expect(store.user()).toEqual(mockResponse.user);
expect(store.token()).toBe('mock-token');
expect(store.isAuthenticated()).toBe(true);
});
it('should check permissions correctly', async () => {
const mockResponse = {
user: { id: '1', email: '[email protected]', permissions: ['users:*', 'dashboard:view'] },
token: 'mock-token',
};
authService.login.and.returnValue(Promise.resolve(mockResponse));
await store.login({ email: '[email protected]', password: '123456' });
expect(store.hasPermission('users:view')).toBe(true);
expect(store.hasPermission('users:delete')).toBe(true);
expect(store.hasPermission('dashboard:view')).toBe(true);
expect(store.hasPermission('settings:view')).toBe(false);
});
});
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideQueryClient } from '@tanstack/angular-query-experimental';
import { QueryClient } from '@tanstack/query-core';
import { routes } from './app.routes';
import { authInterceptor } from './interceptors/auth.interceptor';
import { errorInterceptor } from './interceptors/error.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
provideAnimations(),
provideQueryClient(new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes
},
},
})),
],
};
// tailwind.config.js
import { fontFamily } from 'tailwindcss/defaultTheme';
export default {
darkMode: ['class'],
content: ['./src/**/*.{html,ts}'],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
fontFamily: {
sans: ['Inter var', ...fontFamily.sans],
},
colors: {
border: 'oklch(var(--border))',
input: 'oklch(var(--input))',
ring: 'oklch(var(--ring))',
background: 'oklch(var(--background))',
foreground: 'oklch(var(--foreground))',
primary: {
DEFAULT: 'oklch(var(--primary))',
foreground: 'oklch(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'oklch(var(--secondary))',
foreground: 'oklch(var(--secondary-foreground))',
},
// ... 更多颜色定义
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
};
vercel
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:alpine
COPY --from=builder /app/dist/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
docker build -t halolight-angular .
docker run -p 3000:80 halolight-angular
项目配置了完整的 GitHub Actions CI 工作流:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
// guards/auth.guard.ts
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { AuthStore } from '../stores/auth.store';
export const authGuard: CanActivateFn = (route, state) => {
const authStore = inject(AuthStore);
const router = inject(Router);
if (!authStore.isAuthenticated()) {
router.navigate(['/login'], { queryParams: { redirect: state.url } });
return false;
}
return true;
};
// guards/permission.guard.ts
export const permissionGuard: CanActivateFn = (route) => {
const authStore = inject(AuthStore);
const router = inject(Router);
const permission = route.data['permission'] as string;
if (permission && !authStore.hasPermission(permission)) {
router.navigate(['/403']);
return false;
}
return true;
};
// interceptors/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthStore } from '../stores/auth.store';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authStore = inject(AuthStore);
const token = authStore.token();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
}
return next(req);
};
// interceptors/error.interceptor.ts
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
const authStore = inject(AuthStore);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
authStore.logout();
router.navigate(['/login']);
}
return throwError(() => error);
})
);
};
// stores/ui-settings.store.ts
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { computed } from '@angular/core';
interface UiSettingsState {
theme: 'light' | 'dark' | 'system';
skin: string;
sidebarCollapsed: boolean;
}
const initialState: UiSettingsState = {
theme: 'system',
skin: 'default',
sidebarCollapsed: false,
};
export const UiSettingsStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed((store) => ({
effectiveTheme: computed(() => {
if (store.theme() === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return store.theme();
}),
isDarkMode: computed(() => store.effectiveTheme() === 'dark'),
})),
withMethods((store) => ({
setTheme(theme: 'light' | 'dark' | 'system') {
patchState(store, { theme });
document.documentElement.classList.toggle('dark', store.isDarkMode());
},
setSkin(skin: string) {
patchState(store, { skin });
document.documentElement.setAttribute('data-theme', skin);
},
toggleSidebar() {
patchState(store, { sidebarCollapsed: !store.sidebarCollapsed() });
},
}))
);
// 使用 NgOptimizedImage
import { NgOptimizedImage } from '@angular/common';
@Component({
imports: [NgOptimizedImage],
template: `
<img
ngSrc="proxy.php?url=https%3A%2F%2Fhalolight.docs.h7ml.cn%2Fassets%2Fimages%2Fhero.jpg"
width="1200"
height="600"
priority
alt="Hero image"
/>
`,
})
export class HeroComponent {}
// app.routes.ts
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () => import('./pages/admin/dashboard/dashboard.component')
.then(m => m.DashboardComponent),
},
{
path: 'users',
loadChildren: () => import('./pages/admin/users/users.routes')
.then(m => m.USERS_ROUTES),
},
];
// app.config.ts
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withPreloading(PreloadAllModules),
withComponentInputBinding()
),
],
};
import { Component, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-user-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (user of users(); track user.id) {
<app-user-card [user]="user" />
}
`,
})
export class UserListComponent {
users = signal<User[]>([]);
}
A:在 environment.ts 中设置 useMock: true,并在 src/mocks 目录下定义 Mock 数据:
// mocks/users.mock.ts
import Mock from 'mockjs';
Mock.mock('/api/users', 'get', {
'data|10-20': [{
'id|+1': 1,
'name': '@cname',
'email': '@email',
'avatar': '@image(100x100)',
'role': '@pick(["admin", "user", "guest"])',
'status': '@pick(["active", "inactive"])',
'createdAt': '@datetime',
}],
total: '@integer(10, 100)',
});
A:使用 permissionGuard 并在路由配置中指定所需权限:
// app.routes.ts
{
path: 'users',
loadComponent: () => import('./pages/admin/users/users.component'),
data: { permission: 'users:view' },
canActivate: [authGuard, permissionGuard],
}
A:在 styles.css 中覆盖 CSS 变量:
:root {
--primary: 51.1% 0.262 276.97; /* 自定义主色调 */
--primary-foreground: 98% 0 0;
}
.dark {
--primary: 56.1% 0.287 277.04;
--primary-foreground: 98% 0 0;
}
A:spartan/ui 已集成,如需添加其他组件,可通过 Angular CDK 扩展:
import { CdkDrag, CdkDropList } from '@angular/cdk/drag-drop';
@Component({
imports: [CdkDrag, CdkDropList],
template: `
<div cdkDropList (cdkDropListDropped)="drop($event)">
@for (item of items(); track item.id) {
<div cdkDrag>{{ item.name }}</div>
}
</div>
`,
})
export class DraggableListComponent {}
| 特性 | Angular 版本 | Next.js | Vue |
|---|---|---|---|
| SSR/SSG | ✅ Angular SSR | ✅ | ✅ (Nuxt) |
| 状态管理 | NgRx Signals | Zustand | Pinia |
| 路由 | Angular Router | App Router | Vue Router |
| 构建工具 | Angular CLI + esbuild | Next.js | Vite |
| 类型安全 | TypeScript (强制) | TypeScript | TypeScript |
| 企业支持 | Vercel | 社区 |
HaloLight Bun 后端 API 基于 Bun + Hono + Drizzle ORM 构建,提供超高性能后端服务。
API 文档:https://halolight-api-bun.h7ml.cn/docs
GitHub:https://github.com/halolight/halolight-api-bun
| 技术 | 版本 | 说明 |
|---|---|---|
| Bun | 1.1+ | 运行时 |
| Hono | 4.x | Web 框架 |
| Drizzle ORM | 0.36+ | 数据库 ORM |
| PostgreSQL | 15+ | 数据存储 |
| Zod | 3.x | 数据验证 |
| JWT | - | 身份认证 |
| Swagger | - | API 文档 |
# 克隆仓库
git clone https://github.com/halolight/halolight-api-bun.git
cd halolight-api-bun
# 安装依赖
pnpm install
cp .env.example .env
# 数据库
DATABASE_URL=postgresql://user:password@localhost:5432/halolight
# JWT 密钥
JWT_SECRET=your-super-secret-key
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
# 服务配置
PORT=3002
NODE_ENV=development
CORS_ORIGIN=http://localhost:3000
API_PREFIX=/api
bun run db:push
bun run db:seed
# 开发模式
bun run dev
# 生产模式
bun run build
bun run start
halolight-api-bun/
├── src/
│ ├── routes/ # 控制器/路由处理
│ ├── services/ # 业务逻辑层
│ ├── db/ # 数据模型
│ ├── middleware/ # 中间件
│ ├── utils/ # 工具函数
│ └── index.ts # 应用入口
├── test/ # 测试文件
├── Dockerfile # Docker 配置
├── docker-compose.yml
└── package.json
| 方法 | 路径 | 描述 | 权限 |
|---|---|---|---|
| POST | /api/auth/login |
用户登录 | 公开 |
| POST | /api/auth/register |
用户注册 | 公开 |
| POST | /api/auth/refresh |
刷新令牌 | 公开 |
| POST | /api/auth/logout |
退出登录 | 需认证 |
| POST | /api/auth/forgot-password |
忘记密码 | 公开 |
| POST | /api/auth/reset-password |
重置密码 | 公开 |
| 方法 | 路径 | 描述 | 权限 |
|---|---|---|---|
| GET | /api/users |
获取用户列表 | users:view |
| GET | /api/users/:id |
获取用户详情 | users:view |
| POST | /api/users |
创建用户 | users:create |
| PUT | /api/users/:id |
更新用户 | users:update |
| DELETE | /api/users/:id |
删除用户 | users:delete |
| GET | /api/users/me |
获取当前用户 | 需认证 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/documents |
获取文档列表 |
| GET | /api/documents/:id |
获取文档详情 |
| POST | /api/documents |
创建文档 |
| PUT | /api/documents/:id |
更新文档 |
| DELETE | /api/documents/:id |
删除文档 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/files |
获取文件列表 |
| GET | /api/files/:id |
获取文件详情 |
| POST | /api/files/upload |
上传文件 |
| PUT | /api/files/:id |
更新文件信息 |
| DELETE | /api/files/:id |
删除文件 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/messages |
获取消息列表 |
| GET | /api/messages/:id |
获取消息详情 |
| POST | /api/messages |
发送消息 |
| PUT | /api/messages/:id/read |
标记已读 |
| DELETE | /api/messages/:id |
删除消息 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/notifications |
获取通知列表 |
| PUT | /api/notifications/:id/read |
标记已读 |
| PUT | /api/notifications/read-all |
全部已读 |
| DELETE | /api/notifications/:id |
删除通知 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/calendar/events |
获取日程列表 |
| GET | /api/calendar/events/:id |
获取日程详情 |
| POST | /api/calendar/events |
创建日程 |
| PUT | /api/calendar/events/:id |
更新日程 |
| DELETE | /api/calendar/events/:id |
删除日程 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/dashboard/stats |
统计数据 |
| GET | /api/dashboard/visits |
访问趋势 |
| GET | /api/dashboard/sales |
销售数据 |
| GET | /api/dashboard/pie |
饼图数据 |
| GET | /api/dashboard/tasks |
待办任务 |
| GET | /api/dashboard/calendar |
今日日程 |
Access Token: 15 分钟有效期,用于 API 请求
Refresh Token: 7 天有效期,用于刷新 Access Token
Authorization: Bearer <access_token>
// 刷新令牌示例
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
refreshToken: 'your_refresh_token'
})
});
const { accessToken, refreshToken } = await response.json();
| 角色 | 说明 | 权限 |
|---|---|---|
super_admin |
超级管理员 | * (所有权限) |
admin |
管理员 | users:*, documents:*, ... |
user |
普通用户 | documents:view, files:view, ... |
guest |
访客 | dashboard:view |
{resource}:{action}
示例:
- users:view # 查看用户
- users:create # 创建用户
- users:* # 用户所有操作
- * # 所有权限
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "请求参数验证失败",
"details": [
{ "field": "email", "message": "邮箱格式不正确" }
]
}
}
| 状态码 | 错误码 | 说明 |
|---|---|---|
| 400 | VALIDATION_ERROR |
参数验证失败 |
| 401 | UNAUTHORIZED |
未授权 |
| 403 | FORBIDDEN |
无权限 |
| 404 | NOT_FOUND |
资源不存在 |
| 409 | CONFLICT |
资源冲突 |
| 500 | INTERNAL_ERROR |
服务器错误 |
# 开发
bun run dev # 启动开发服务器
bun run build # 生产构建
bun run start # 运行生产版本
# 构建
bun run build # 构建生产版本
# 测试
bun test # 运行单元测试
bun test --coverage # 生成覆盖率报告
# 数据库
bun run db:push # 推送 Schema 到数据库
bun run db:generate # 生成迁移文件
bun run db:migrate # 运行数据库迁移
bun run db:seed # 填充测试数据
bun run db:studio # 打开 Drizzle Studio
# 代码质量
bun run lint # ESLint 检查
bun run lint:fix # ESLint 自动修复
bun run type-check # TypeScript 类型检查
docker build -t halolight-api-bun .
docker run -p 3002:3002 halolight-api-bun
docker-compose up -d
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3002:3002"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: halolight
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
NODE_ENV=production
DATABASE_URL=postgresql://user:pass@host:5432/db
JWT_SECRET=your-production-secret
bun test # 运行所有测试
bun test --coverage # 生成覆盖率报告
// 认证测试示例
import { describe, test, expect } from 'bun:test';
describe('Auth API', () => {
test('should login successfully', async () => {
const response = await fetch('http://localhost:3002/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: '[email protected]',
password: 'admin123'
})
});
const data = await response.json();
expect(data.success).toBe(true);
expect(data.data.accessToken).toBeDefined();
});
});
| 指标 | 数值 | 说明 |
|---|---|---|
| 请求吞吐量 | ~50,000 req/s | 单核,简单路由 |
| 平均响应时间 | <5ms | 本地数据库 |
| 内存占用 | ~30MB | 冷启动 |
| CPU 使用率 | <10% | 空闲状态 |
// 日志配置示例
import { logger } from './utils/logger';
logger.info('User logged in', { userId: user.id });
logger.error('Database error', { error: err.message });
// GET /health
app.get('/health', (c) => {
return c.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime()
});
});
// Prometheus metrics 端点
app.get('/metrics', async (c) => {
return c.text(await register.metrics());
});
A:在 .env 文件中设置 DATABASE_URL:
DATABASE_URL=postgresql://user:password@localhost:5432/halolight
A:使用 Bun.password API:
// 哈希密码
const hash = await Bun.password.hash(password, {
algorithm: 'bcrypt',
cost: 10
});
// 验证密码
const isValid = await Bun.password.verify(password, hash, 'bcrypt');
| 特性 | Bun + Hono | NestJS | FastAPI | Spring Boot |
|---|---|---|---|---|
| 语言 | TypeScript | TypeScript | Python | Java |
| ORM | Drizzle | Prisma | SQLAlchemy | JPA |
| 性能 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 学习曲线 | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
HaloLight Go Fiber 后端 API 基于 Fiber 3.0 构建,提供高性能 Go 后端服务和完整的 JWT 双令牌认证。
API 文档:https://halolight-api-go.h7ml.cn/docs
GitHub:https://github.com/halolight/halolight-api-go
| 技术 | 版本 | 说明 |
|---|---|---|
| Go | 1.22+ | 运行时 |
| Fiber | 3.0 | Web 框架 |
| GORM | 2.0 | 数据库 ORM |
| PostgreSQL | 16 | 数据存储 |
| go-playground/validator | v10 | 数据验证 |
| JWT | golang-jwt/jwt/v5 | 身份认证 |
| Swagger UI | - | API 文档 |
# 克隆仓库
git clone https://github.com/halolight/halolight-api-go.git
cd halolight-api-go
# 安装依赖
go mod download
cp .env.example .env
# 数据库
DATABASE_URL=postgresql://user:pass@localhost:5432/halolight?sslmode=disable
# JWT 密钥
JWT_SECRET=your-super-secret-key
JWT_ACCESS_EXPIRES=7d
JWT_REFRESH_EXPIRES=30d
# 服务配置
PORT=8080
APP_ENV=development
# GORM 自动迁移
go run cmd/server/main.go
# 或使用 Makefile
make migrate
# 开发模式
go run cmd/server/main.go
# 生产模式
make build
./bin/server
halolight-api-go/
├── cmd/
│ └── server/
│ └── main.go # 应用入口
├── internal/
│ ├── handlers/ # 控制器/路由处理
│ │ ├── auth_handler.go # 认证端点
│ │ ├── user_handler.go # 用户管理
│ │ ├── role_handler.go # 角色管理
│ │ ├── permission_handler.go # 权限管理
│ │ ├── team_handler.go # 团队管理
│ │ ├── document_handler.go # 文档管理
│ │ ├── file_handler.go # 文件管理
│ │ ├── folder_handler.go # 文件夹管理
│ │ ├── calendar_handler.go # 日历事件
│ │ ├── notification_handler.go # 通知管理
│ │ ├── message_handler.go # 消息管理
│ │ ├── dashboard_handler.go # 仪表盘统计
│ │ └── home_handler.go # 首页 + 健康检查
│ ├── services/ # 业务逻辑层
│ │ ├── auth_service.go
│ │ ├── user_service.go
│ │ └── ...
│ ├── models/ # 数据模型
│ │ ├── user.go
│ │ ├── role.go
│ │ └── ...
│ ├── middleware/ # 中间件
│ │ ├── auth.go
│ │ └── cors.go
│ └── routes/ # 路由配置
│ └── router.go
├── pkg/
│ ├── config/ # 配置管理
│ ├── database/ # 数据库连接
│ └── utils/ # 工具函数
├── docs/ # 文档
├── .github/workflows/ # GitHub Actions
├── Dockerfile # Docker 配置
├── docker-compose.yml
└── go.mod
| 方法 | 路径 | 描述 | 权限 |
|---|---|---|---|
| POST | /api/auth/login |
用户登录 | 公开 |
| POST | /api/auth/register |
用户注册 | 公开 |
| POST | /api/auth/refresh |
刷新令牌 | 公开 |
| POST | /api/auth/logout |
退出登录 | 需认证 |
| POST | /api/auth/forgot-password |
忘记密码 | 公开 |
| POST | /api/auth/reset-password |
重置密码 | 公开 |
| GET | /api/auth/me |
获取当前用户 | 需认证 |
| 方法 | 路径 | 描述 | 权限 |
|---|---|---|---|
| GET | /api/users |
获取用户列表 | users:view |
| GET | /api/users/:id |
获取用户详情 | users:view |
| POST | /api/users |
创建用户 | users:create |
| PUT | /api/users/:id |
更新用户 | users:update |
| DELETE | /api/users/:id |
删除用户 | users:delete |
| PATCH | /api/users/:id/status |
更新用户状态 | users:update |
| POST | /api/users/batch-delete |
批量删除用户 | users:delete |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/roles |
获取角色列表 |
| GET | /api/roles/:id |
获取角色详情 |
| POST | /api/roles |
创建角色 |
| PUT | /api/roles/:id |
更新角色 |
| POST | /api/roles/:id/permissions |
分配权限 |
| DELETE | /api/roles/:id |
删除角色 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/permissions |
获取权限列表 |
| GET | /api/permissions/:id |
获取权限详情 |
| POST | /api/permissions |
创建权限 |
| DELETE | /api/permissions/:id |
删除权限 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/teams |
获取团队列表 |
| GET | /api/teams/:id |
获取团队详情 |
| POST | /api/teams |
创建团队 |
| PATCH | /api/teams/:id |
更新团队 |
| DELETE | /api/teams/:id |
删除团队 |
| POST | /api/teams/:id/members |
添加成员 |
| DELETE | /api/teams/:id/members/:userId |
移除成员 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/documents |
获取文档列表 |
| GET | /api/documents/:id |
获取文档详情 |
| POST | /api/documents |
创建文档 |
| PUT | /api/documents/:id |
更新文档 |
| PATCH | /api/documents/:id/rename |
重命名文档 |
| POST | /api/documents/:id/move |
移动文档 |
| POST | /api/documents/:id/tags |
更新标签 |
| POST | /api/documents/:id/share |
分享文档 |
| POST | /api/documents/:id/unshare |
取消分享 |
| POST | /api/documents/batch-delete |
批量删除 |
| DELETE | /api/documents/:id |
删除文档 |
| 方法 | 路径 | 描述 |
|---|---|---|
| POST | /api/files/upload |
上传文件 |
| POST | /api/files/folder |
创建文件夹 |
| GET | /api/files |
获取文件列表 |
| GET | /api/files/storage |
获取存储信息 |
| GET | /api/files/:id |
获取文件详情 |
| GET | /api/files/:id/download-url |
获取下载链接 |
| PATCH | /api/files/:id/rename |
重命名文件 |
| POST | /api/files/:id/move |
移动文件 |
| POST | /api/files/:id/copy |
复制文件 |
| PATCH | /api/files/:id/favorite |
收藏/取消收藏 |
| POST | /api/files/:id/share |
分享文件 |
| POST | /api/files/batch-delete |
批量删除 |
| DELETE | /api/files/:id |
删除文件 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/folders |
获取文件夹列表 |
| GET | /api/folders/tree |
获取树形结构 |
| GET | /api/folders/:id |
获取文件夹详情 |
| POST | /api/folders |
创建文件夹 |
| DELETE | /api/folders/:id |
删除文件夹 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/messages/conversations |
获取会话列表 |
| GET | /api/messages/conversations/:id |
获取会话详情 |
| POST | /api/messages |
发送消息 |
| PUT | /api/messages/:id/read |
标记已读 |
| DELETE | /api/messages/:id |
删除消息 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/notifications |
获取通知列表 |
| GET | /api/notifications/unread-count |
获取未读数量 |
| PUT | /api/notifications/:id/read |
标记已读 |
| PUT | /api/notifications/read-all |
全部已读 |
| DELETE | /api/notifications/:id |
删除通知 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/calendar/events |
获取日程列表 |
| GET | /api/calendar/events/:id |
获取日程详情 |
| POST | /api/calendar/events |
创建日程 |
| PUT | /api/calendar/events/:id |
更新日程 |
| PATCH | /api/calendar/events/:id/reschedule |
重新安排 |
| POST | /api/calendar/events/:id/attendees |
添加参会人 |
| DELETE | /api/calendar/events/:id/attendees/:attendeeId |
移除参会人 |
| POST | /api/calendar/events/batch-delete |
批量删除 |
| DELETE | /api/calendar/events/:id |
删除日程 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/dashboard/stats |
统计数据 |
| GET | /api/dashboard/visits |
访问趋势 |
| GET | /api/dashboard/sales |
销售数据 |
| GET | /api/dashboard/products |
产品数据 |
| GET | /api/dashboard/orders |
订单数据 |
| GET | /api/dashboard/activities |
活动数据 |
| GET | /api/dashboard/pie |
饼图数据 |
| GET | /api/dashboard/tasks |
待办任务 |
| GET | /api/dashboard/overview |
总览数据 |
Access Token: 7 天有效期,用于 API 请求
Refresh Token: 30 天有效期,用于刷新 Access Token
Authorization: Bearer <access_token>
// 刷新令牌示例
func RefreshToken(c *fiber.Ctx) error {
type RefreshRequest struct {
RefreshToken string `json:"refreshToken"`
}
var req RefreshRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{
"success": false,
"message": "Invalid request",
})
}
// 验证 refresh token
claims, err := utils.ValidateToken(req.RefreshToken)
if err != nil {
return c.Status(401).JSON(fiber.Map{
"success": false,
"message": "Invalid refresh token",
})
}
// 生成新的 access token
accessToken, err := utils.GenerateAccessToken(claims.UserID)
if err != nil {
return c.Status(500).JSON(fiber.Map{
"success": false,
"message": "Failed to generate token",
})
}
return c.JSON(fiber.Map{
"success": true,
"accessToken": accessToken,
})
}
| 角色 | 说明 | 权限 |
|---|---|---|
super_admin |
超级管理员 | * (所有权限) |
admin |
管理员 | users:*, documents:*, files:*, teams:* |
user |
普通用户 | documents:view, documents:create, files:* |
guest |
访客 | dashboard:view |
{resource}:{action}
示例:
- users:view # 查看用户
- users:create # 创建用户
- users:* # 用户所有操作
- * # 所有权限
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "请求参数验证失败",
"details": [
{ "field": "email", "message": "邮箱格式不正确" }
]
}
}
| 状态码 | 错误码 | 说明 |
|---|---|---|
| 400 | VALIDATION_ERROR |
参数验证失败 |
| 401 | UNAUTHORIZED |
未授权 |
| 403 | FORBIDDEN |
无权限 |
| 404 | NOT_FOUND |
资源不存在 |
| 409 | CONFLICT |
资源冲突 |
| 500 | INTERNAL_ERROR |
服务器错误 |
# 开发
go run cmd/server/main.go
# 构建
go build -o bin/server cmd/server/main.go
# 测试
go test ./...
# 数据库
make migrate
# 代码质量
go vet ./...
golangci-lint run
docker build -t halolight-api-go .
docker run -p 8080:8080 halolight-api-go
docker-compose up -d
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- APP_ENV=production
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: halolight
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
APP_ENV=production
DATABASE_URL=postgresql://user:pass@host:5432/db
JWT_SECRET=your-production-secret
# 单元测试
go test ./...
# 测试覆盖率
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
func TestUserLogin(t *testing.T) {
app := fiber.New()
// 设置路由
app.Post("/api/auth/login", handlers.Login)
// 准备测试数据
reqBody := `{"email":"[email protected]","password":"password123"}`
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
// 执行请求
resp, _ := app.Test(req)
// 验证响应
assert.Equal(t, 200, resp.StatusCode)
}
| 指标 | 数值 | 说明 |
|---|---|---|
| 请求吞吐量 | 10,000+ QPS | 单机,8 核 CPU |
| 平均响应时间 | < 10ms | 简单查询 |
| 内存占用 | ~50MB | 空闲状态 |
| CPU 使用率 | < 10% | 空闲状态 |
// 日志配置
logger := log.New(os.Stdout, "API: ", log.LstdFlags)
app.Use(func(c *fiber.Ctx) error {
start := time.Now()
err := c.Next()
logger.Printf("%s %s %s %v",
c.Method(),
c.Path(),
c.IP(),
time.Since(start),
)
return err
})
// 健康检查端点
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "healthy",
"timestamp": time.Now(),
"database": db.Ping() == nil,
})
})
// Prometheus 指标
import "github.com/prometheus/client_golang/prometheus"
var (
requestCounter = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "api_requests_total",
Help: "Total API requests",
},
[]string{"method", "path", "status"},
)
)
A:JWT 密钥至少 32 字符,建议使用 64 字符以上的随机字符串。
# 生成安全密钥
openssl rand -base64 64
A:检查数据库配置和网络连接。
# 检查 PostgreSQL 状态
docker-compose ps postgres
# 测试连接
psql -h localhost -U postgres -d halolight
| 特性 | Go Fiber | NestJS | FastAPI | Spring Boot |
|---|---|---|---|---|
| 语言 | Go | TypeScript | Python | Java |
| ORM | GORM | Prisma | SQLAlchemy | JPA |
| 性能 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 学习曲线 | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
HaloLight Spring Boot 后端 API 基于 Spring Boot 3.4.1 构建,提供企业级后端服务和完整的 JWT 双令牌认证。
API 文档:https://halolight-api-java.h7ml.cn/api/swagger-ui
GitHub:https://github.com/halolight/halolight-api-java
| 技术 | 版本 | 说明 |
|---|---|---|
| Java | 23 | 运行时 |
| Spring Boot | 3.4.1 | Web 框架 |
| Spring Data JPA | 3.4.1 | 数据库 ORM |
| PostgreSQL | 16 | 数据存储 |
| Bean Validation | jakarta.validation | 数据验证 |
| JWT | JJWT | 身份认证 |
| Springdoc OpenAPI | 2.7.0 | API 文档 |
# 克隆仓库
git clone https://github.com/halolight/halolight-api-java.git
cd halolight-api-java
# 安装依赖
./mvnw clean install
cp .env.example .env
# 数据库
DATABASE_URL=jdbc:postgresql://localhost:5432/halolight_db
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=your-password
# JWT 密钥
JWT_SECRET=your-super-secret-jwt-key-change-in-production-min-32-chars
JWT_EXPIRATION=86400000
JWT_REFRESH_EXPIRATION=604800000
# 服务配置
PORT=8080
SPRING_PROFILES_ACTIVE=production
# 自动创建表结构(首次启动)
./mvnw spring-boot:run
# 运行种子数据(可选)
./mvnw exec:java -Dexec.mainClass="com.halolight.seed.DataSeeder"
# 开发模式
./mvnw spring-boot:run
# 生产模式
./mvnw clean package -DskipTests
java -jar target/halolight-api-java-1.0.0.jar
halolight-api-java/
├── src/main/java/com/halolight/
│ ├── controller/ # 控制器/路由处理
│ │ ├── AuthController.java
│ │ ├── UserController.java
│ │ └── ...
│ ├── service/ # 业务逻辑层
│ │ ├── AuthService.java
│ │ └── ...
│ ├── domain/ # 数据模型
│ │ ├── entity/ # JPA 实体
│ │ └── repository/ # Repository 接口
│ ├── config/ # 中间件/配置
│ │ ├── SecurityConfig.java
│ │ └── ...
│ ├── web/dto/ # 请求验证 DTO
│ ├── security/ # 安全组件
│ └── HalolightApplication.java # 应用入口
├── src/main/resources/ # 资源文件
│ ├── application.yml
│ └── application-*.yml
├── src/test/ # 测试文件
├── Dockerfile # Docker 配置
├── docker-compose.yml
└── pom.xml # Maven 配置
| 方法 | 路径 | 描述 | 权限 |
|---|---|---|---|
| POST | /api/auth/login |
用户登录 | 公开 |
| POST | /api/auth/register |
用户注册 | 公开 |
| POST | /api/auth/refresh |
刷新令牌 | 公开 |
| POST | /api/auth/logout |
退出登录 | 需认证 |
| POST | /api/auth/forgot-password |
忘记密码 | 公开 |
| POST | /api/auth/reset-password |
重置密码 | 公开 |
| GET | /api/auth/me |
获取当前用户 | 需认证 |
| 方法 | 路径 | 描述 | 权限 |
|---|---|---|---|
| GET | /api/users |
获取用户列表 | users:view |
| GET | /api/users/{id} |
获取用户详情 | users:view |
| POST | /api/users |
创建用户 | users:create |
| PUT | /api/users/{id} |
更新用户 | users:update |
| PUT | /api/users/{id}/status |
更新用户状态 | users:update |
| DELETE | /api/users/{id} |
删除用户 | users:delete |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/roles |
获取角色列表 |
| GET | /api/roles/{id} |
获取角色详情 |
| POST | /api/roles |
创建角色 |
| PUT | /api/roles/{id} |
更新角色 |
| POST | /api/roles/{id}/permissions |
分配权限 |
| DELETE | /api/roles/{id} |
删除角色 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/permissions |
获取权限列表 |
| POST | /api/permissions |
创建权限 |
| PUT | /api/permissions/{id} |
更新权限 |
| DELETE | /api/permissions/{id} |
删除权限 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/documents |
获取文档列表 |
| GET | /api/documents/{id} |
获取文档详情 |
| POST | /api/documents |
创建文档 |
| PUT | /api/documents/{id} |
更新文档 |
| PUT | /api/documents/{id}/rename |
重命名文档 |
| POST | /api/documents/{id}/move |
移动文档 |
| POST | /api/documents/{id}/tags |
更新标签 |
| POST | /api/documents/{id}/share |
分享文档 |
| POST | /api/documents/{id}/unshare |
取消分享 |
| DELETE | /api/documents/{id} |
删除文档 |
| 方法 | 路径 | 描述 |
|---|---|---|
| POST | /api/files/upload |
上传文件 |
| GET | /api/files |
获取文件列表 |
| GET | /api/files/storage |
获取存储配额 |
| GET | /api/files/{id} |
获取文件详情 |
| GET | /api/files/{id}/download |
下载文件 |
| PUT | /api/files/{id}/rename |
重命名文件 |
| POST | /api/files/{id}/move |
移动文件 |
| PUT | /api/files/{id}/favorite |
切换收藏 |
| POST | /api/files/{id}/share |
分享文件 |
| DELETE | /api/files/{id} |
删除文件 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/teams |
获取团队列表 |
| GET | /api/teams/{id} |
获取团队详情 |
| POST | /api/teams |
创建团队 |
| PUT | /api/teams/{id} |
更新团队 |
| POST | /api/teams/{id}/members |
添加成员 |
| DELETE | /api/teams/{id}/members/{userId} |
移除成员 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/messages/conversations |
获取会话列表 |
| GET | /api/messages/conversations/{userId} |
获取会话消息 |
| POST | /api/messages |
发送消息 |
| PUT | /api/messages/{id}/read |
标记已读 |
| DELETE | /api/messages/{id} |
删除消息 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/notifications |
获取通知列表 |
| GET | /api/notifications/unread-count |
获取未读数量 |
| PUT | /api/notifications/{id}/read |
标记单条已读 |
| PUT | /api/notifications/read-all |
全部已读 |
| DELETE | /api/notifications/{id} |
删除通知 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/calendar/events |
获取日程列表 |
| GET | /api/calendar/events/{id} |
获取日程详情 |
| POST | /api/calendar/events |
创建日程 |
| PUT | /api/calendar/events/{id} |
更新日程 |
| PUT | /api/calendar/events/{id}/reschedule |
重新安排 |
| POST | /api/calendar/events/{id}/attendees |
添加参会人 |
| DELETE | /api/calendar/events/{id}/attendees/{attendeeId} |
移除参会人 |
| DELETE | /api/calendar/events/{id} |
删除日程 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/dashboard/stats |
统计数据 |
| GET | /api/dashboard/visits |
访问趋势 |
| GET | /api/dashboard/sales |
销售数据 |
| GET | /api/dashboard/pie |
饼图数据 |
| GET | /api/dashboard/tasks |
待办任务 |
Access Token: 24 小时有效期,用于 API 请求
Refresh Token: 7 天有效期,用于刷新 Access Token
Authorization: Bearer <access_token>
// 前端自动刷新示例
@Component
public class JwtTokenInterceptor {
public Response intercept(Chain chain) throws IOException {
Request request = chain.request();
Response response = chain.proceed(request);
// 401 自动刷新
if (response.code() == 401) {
String newToken = refreshToken(refreshToken);
Request newRequest = request.newBuilder()
.header("Authorization", "Bearer " + newToken)
.build();
return chain.proceed(newRequest);
}
return response;
}
}
| 角色 | 说明 | 权限 |
|---|---|---|
super_admin |
超级管理员 | * (所有权限) |
admin |
管理员 | users:*, documents:*, roles:* |
user |
普通用户 | documents:view, files:view |
guest |
访客 | dashboard:view |
{resource}:{action}
示例:
- users:view # 查看用户
- users:create # 创建用户
- users:* # 用户所有操作
- * # 所有权限
@RestController
@RequestMapping("/api/users")
public class UserController {
@PreAuthorize("hasPermission('users:view')")
@GetMapping
public Page<UserDTO> getUsers(Pageable pageable) {
return userService.findAll(pageable);
}
@PreAuthorize("hasPermission('users:create')")
@PostMapping
public UserDTO createUser(@Valid @RequestBody CreateUserRequest request) {
return userService.create(request);
}
}
{
"timestamp": "2025-12-04T12:00:00.000Z",
"status": 400,
"error": "Bad Request",
"message": "请求参数验证失败",
"path": "/api/users",
"details": [
{ "field": "email", "message": "邮箱格式不正确" }
]
}
| 状态码 | 错误码 | 说明 |
|---|---|---|
| 400 | Bad Request |
参数验证失败 |
| 401 | Unauthorized |
未授权 |
| 403 | Forbidden |
无权限 |
| 404 | Not Found |
资源不存在 |
| 409 | Conflict |
资源冲突 |
| 422 | Unprocessable Entity |
业务逻辑错误 |
| 429 | Too Many Requests |
请求频率超限 |
| 500 | Internal Server Error |
服务器错误 |
Spring Data JPA 实体包含 17 个模型:
// 用户实体
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String name;
private String password;
private String avatar;
@Enumerated(EnumType.STRING)
private UserStatus status = UserStatus.ACTIVE;
@ManyToMany
@JoinTable(name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id"))
private Set<Role> roles;
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
// 角色实体
@Entity
@Table(name = "roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String name;
private String description;
@ManyToMany
@JoinTable(name = "role_permissions")
private Set<Permission> permissions;
}
// 权限实体
@Entity
@Table(name = "permissions")
public class Permission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String name; // 格式: "users:create", "users:*", "*"
private String description;
}
完整实体列表:
| 变量名 | 说明 | 默认值 |
|---|---|---|
SPRING_PROFILES_ACTIVE |
运行环境 | development |
PORT |
服务端口 | 8080 |
DATABASE_URL |
数据库连接 | jdbc:postgresql://localhost:5432/halolight_db |
DATABASE_USERNAME |
数据库用户名 | postgres |
DATABASE_PASSWORD |
数据库密码 | - |
JWT_SECRET |
JWT 密钥(至少 32 字符) | - |
JWT_EXPIRATION |
AccessToken 过期时间(毫秒) | 86400000 (24h) |
JWT_REFRESH_EXPIRATION |
RefreshToken 过期时间(毫秒) | 604800000 (7d) |
CORS_ALLOWED_ORIGINS |
CORS 允许源 | http://localhost:3000 |
# application.yml
spring:
datasource:
url: ${DATABASE_URL}
username: ${DATABASE_USERNAME}
password: ${DATABASE_PASSWORD}
jwt:
secret: ${JWT_SECRET}
expiration: ${JWT_EXPIRATION:86400000}
refreshExpiration: ${JWT_REFRESH_EXPIRATION:604800000}
# 开发
./mvnw spring-boot:run # 启动开发服务器
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev # 指定环境
# 构建
./mvnw clean package # 构建 JAR 包
./mvnw clean package -DskipTests # 跳过测试构建
./mvnw clean install # 安装到本地仓库
# 测试
./mvnw test # 运行所有测试
./mvnw test -Dtest=UserServiceTest # 运行指定测试
./mvnw verify # 运行集成测试
./mvnw test jacoco:report # 生成覆盖率报告
# 数据库
./mvnw flyway:migrate # 运行迁移(如使用 Flyway)
./mvnw liquibase:update # 更新 Schema(如使用 Liquibase)
# 代码质量
./mvnw checkstyle:check # 代码风格检查
./mvnw spotbugs:check # 静态分析
docker build -t halolight-api-java .
docker run -p 8080:8080 --env-file .env halolight-api-java
docker-compose up -d
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=production
- DATABASE_URL=jdbc:postgresql://db:5432/halolight
- DATABASE_USERNAME=postgres
- DATABASE_PASSWORD=${DB_PASSWORD}
- JWT_SECRET=${JWT_SECRET}
depends_on:
- db
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: halolight
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
postgres_data:
SPRING_PROFILES_ACTIVE=production
DATABASE_URL=jdbc:postgresql://prod-db.example.com:5432/halolight
DATABASE_USERNAME=halolight_user
DATABASE_PASSWORD=your-production-password
JWT_SECRET=your-production-secret-min-32-chars
CORS_ALLOWED_ORIGINS=https://halolight.h7ml.cn
./mvnw test # 运行单元测试
./mvnw test jacoco:report # 生成覆盖率报告
./mvnw verify # 运行集成测试
@SpringBootTest
@AutoConfigureMockMvc
public class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
public void testLogin() throws Exception {
LoginRequest request = new LoginRequest();
request.setEmail("[email protected]");
request.setPassword("123456");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.accessToken").exists())
.andExpect(jsonPath("$.refreshToken").exists());
}
@Test
@WithMockUser(authorities = {"users:view"})
public void testGetUsers() throws Exception {
mockMvc.perform(get("/api/users")
.param("page", "0")
.param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray());
}
}
| 指标 | 数值 | 说明 |
|---|---|---|
| 请求吞吐量 | ~3000 QPS | 简单查询,4 核 8GB |
| 平均响应时间 | 15-30ms | P50,数据库查询 |
| P95 响应时间 | 50-100ms | 包含复杂查询 |
| 内存占用 | 256-512 MB | 稳定运行状态 |
| CPU 使用率 | 10-30% | 中等负载 |
# 使用 Apache Bench
ab -n 10000 -c 100 -H "Authorization: Bearer TOKEN" \
http://localhost:8080/api/users
# 使用 wrk
wrk -t4 -c100 -d30s -H "Authorization: Bearer TOKEN" \
http://localhost:8080/api/users
// Logback 配置
@Slf4j
@RestController
public class UserController {
@GetMapping("/api/users/{id}")
public UserDTO getUser(@PathVariable Long id) {
log.info("Fetching user with id: {}", id);
try {
return userService.findById(id);
} catch (Exception e) {
log.error("Error fetching user {}: {}", id, e.getMessage(), e);
throw e;
}
}
}
@Component
public class CustomHealthIndicator implements HealthIndicator {
@Override
public Health health() {
// 检查数据库连接
boolean dbUp = checkDatabase();
if (dbUp) {
return Health.up()
.withDetail("database", "Available")
.build();
}
return Health.down()
.withDetail("database", "Unavailable")
.build();
}
}
端点:GET /actuator/health
{
"status": "UP",
"components": {
"db": { "status": "UP" },
"diskSpace": { "status": "UP" }
}
}
# application.yml
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
metrics:
export:
prometheus:
enabled: true
Prometheus 端点:GET /actuator/prometheus
A:在 .env 或 application.yml 中配置:
JWT_EXPIRATION=3600000 # 1 小时(毫秒)
JWT_REFRESH_EXPIRATION=86400000 # 1 天(毫秒)
A:生成证书并配置 Spring Boot:
# application.yml
server:
port: 8443
ssl:
enabled: true
key-store: classpath:keystore.p12
key-store-password: your-password
key-store-type: PKCS12
# 生成自签名证书(开发环境)
keytool -genkeypair -alias halolight -keyalg RSA -keysize 2048 \
-storetype PKCS12 -keystore keystore.p12 -validity 365
A:使用 HikariCP (Spring Boot 默认):
spring:
datasource:
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
A:使用 Spring Data JPA Pageable:
@GetMapping("/api/users")
public Page<UserDTO> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "id,desc") String sort
) {
String[] sortParams = sort.split(",");
Sort.Direction direction = sortParams.length > 1 &&
sortParams[1].equals("desc") ? Sort.Direction.DESC : Sort.Direction.ASC;
Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sortParams[0]));
return userService.findAll(pageable);
}
| 特性 | Spring Boot | NestJS | FastAPI | Go Fiber |
|---|---|---|---|---|
| 语言 | Java | TypeScript | Python | Go |
| ORM | JPA/Hibernate | Prisma | SQLAlchemy | GORM |
| 性能 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 学习曲线 | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 企业级 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
| 生态系统 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 社区支持 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
HaloLight NestJS 后端 API 基于 NestJS 11 构建,提供企业级后端服务和完整的 RBAC 权限系统。
API 文档:https://halolight-api-nestjs.h7ml.cn/docs
GitHub:https://github.com/halolight/halolight-api-nestjs
| 技术 | 版本 | 说明 |
|---|---|---|
| TypeScript | 5.7 | 运行时 |
| NestJS | 11 | Web 框架 |
| Prisma | 5 | 数据库 ORM |
| PostgreSQL | 16 | 数据存储 |
| class-validator | - | 数据验证 |
| JWT | - | 身份认证 |
| Swagger | - | API 文档 |
# 克隆仓库
git clone https://github.com/halolight/halolight-api-nestjs.git
cd halolight-api-nestjs
# 安装依赖
pnpm install
cp .env.example .env
# 数据库
DATABASE_URL=postgresql://user:password@localhost:5432/halolight_db
# JWT 密钥
JWT_SECRET=your-super-secret-key
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
# 服务配置
PORT=3000
NODE_ENV=development
pnpm prisma:generate
pnpm prisma:migrate
pnpm prisma:seed
# 开发模式
pnpm dev
# 生产模式
pnpm build
pnpm start:prod
halolight-api-nestjs/
├── src/
│ ├── common/ # 共享模块
│ ├── configs/ # 配置模块
│ ├── infrastructure/ # 基础设施层
│ ├── modules/ # 业务模块(12 个)
│ │ ├── auth/ # 认证模块
│ │ ├── users/ # 用户管理
│ │ ├── roles/ # 角色管理
│ │ ├── permissions/ # 权限管理
│ │ ├── teams/ # 团队管理
│ │ ├── documents/ # 文档管理
│ │ ├── files/ # 文件管理
│ │ ├── folders/ # 文件夹管理
│ │ ├── calendar/ # 日历管理
│ │ ├── notifications/ # 通知管理
│ │ ├── messages/ # 消息管理
│ │ └── dashboard/ # 仪表盘统计
│ ├── app.controller.ts # 根控制器
│ ├── app.service.ts # 根服务
│ ├── app.module.ts # 根模块
│ └── main.ts # 应用入口
├── prisma/
│ ├── schema.prisma # 数据库模型定义
│ └── migrations/ # 数据库迁移历史
├── test/ # 测试文件
├── Dockerfile # Docker 配置
├── docker-compose.yml
└── package.json
| 方法 | 路径 | 描述 | 权限 |
|---|---|---|---|
| POST | /api/auth/login |
用户登录 | 公开 |
| POST | /api/auth/register |
用户注册 | 公开 |
| POST | /api/auth/refresh |
刷新令牌 | 公开 |
| POST | /api/auth/logout |
退出登录 | 需认证 |
| POST | /api/auth/forgot-password |
忘记密码 | 公开 |
| POST | /api/auth/reset-password |
重置密码 | 公开 |
| 方法 | 路径 | 描述 | 权限 |
|---|---|---|---|
| GET | /api/users |
获取用户列表 | users:view |
| GET | /api/users/:id |
获取用户详情 | users:view |
| POST | /api/users |
创建用户 | users:create |
| PUT | /api/users/:id |
更新用户 | users:update |
| DELETE | /api/users/:id |
删除用户 | users:delete |
| GET | /api/users/me |
获取当前用户 | 需认证 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/documents |
获取文档列表 |
| GET | /api/documents/:id |
获取文档详情 |
| POST | /api/documents |
创建文档 |
| PUT | /api/documents/:id |
更新文档 |
| DELETE | /api/documents/:id |
删除文档 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/files |
获取文件列表 |
| GET | /api/files/:id |
获取文件详情 |
| POST | /api/files/upload |
上传文件 |
| PUT | /api/files/:id |
更新文件信息 |
| DELETE | /api/files/:id |
删除文件 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/messages |
获取消息列表 |
| GET | /api/messages/:id |
获取消息详情 |
| POST | /api/messages |
发送消息 |
| PUT | /api/messages/:id/read |
标记已读 |
| DELETE | /api/messages/:id |
删除消息 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/notifications |
获取通知列表 |
| PUT | /api/notifications/:id/read |
标记已读 |
| PUT | /api/notifications/read-all |
全部已读 |
| DELETE | /api/notifications/:id |
删除通知 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/calendar/events |
获取日程列表 |
| GET | /api/calendar/events/:id |
获取日程详情 |
| POST | /api/calendar/events |
创建日程 |
| PUT | /api/calendar/events/:id |
更新日程 |
| DELETE | /api/calendar/events/:id |
删除日程 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/dashboard/stats |
统计数据 |
| GET | /api/dashboard/visits |
访问趋势 |
| GET | /api/dashboard/sales |
销售数据 |
| GET | /api/dashboard/pie |
饼图数据 |
| GET | /api/dashboard/tasks |
待办任务 |
| GET | /api/dashboard/overview |
系统概览 |
Access Token: 15 分钟有效期,用于 API 请求
Refresh Token: 7 天有效期,用于刷新 Access Token
Authorization: Bearer <access_token>
// 刷新令牌示例
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
});
const { accessToken, refreshToken: newRefreshToken } = await response.json();
| 角色 | 说明 | 权限 |
|---|---|---|
super_admin |
超级管理员 | * (所有权限) |
admin |
管理员 | users:*, documents:*, ... |
user |
普通用户 | documents:view, files:view, ... |
guest |
访客 | dashboard:view |
{resource}:{action}
示例:
- users:view # 查看用户
- users:create # 创建用户
- users:* # 用户所有操作
- * # 所有权限
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "请求参数验证失败",
"details": [
{ "field": "email", "message": "邮箱格式不正确" }
]
}
}
| 状态码 | 错误码 | 说明 |
|---|---|---|
| 400 | VALIDATION_ERROR |
参数验证失败 |
| 401 | UNAUTHORIZED |
未授权 |
| 403 | FORBIDDEN |
无权限 |
| 404 | NOT_FOUND |
资源不存在 |
| 409 | CONFLICT |
资源冲突 |
| 500 | INTERNAL_ERROR |
服务器错误 |
# 开发
pnpm dev # 启动开发服务器
pnpm start:debug # 调试模式
# 构建
pnpm build # 构建生产版本
pnpm start:prod # 运行生产版本
# 测试
pnpm test # 运行单元测试
pnpm test:e2e # 运行 E2E 测试
pnpm test:cov # 生成覆盖率报告
# 数据库
pnpm prisma:generate # 生成 Prisma Client
pnpm prisma:migrate # 运行迁移
pnpm prisma:studio # Prisma Studio GUI
pnpm prisma:seed # 运行种子数据
# 代码质量
pnpm lint # ESLint 检查
pnpm lint:fix # 自动修复
pnpm format # Prettier 格式化
docker build -t halolight-api-nestjs .
docker run -p 3000:3000 halolight-api-nestjs
docker-compose up -d
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: halolight
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
NODE_ENV=production
DATABASE_URL=postgresql://user:pass@host:5432/db
JWT_SECRET=your-production-secret
pnpm test # 单元测试
pnpm test:e2e # E2E 测试
pnpm test:cov # 覆盖率报告
describe('AuthController', () => {
it('should login user', async () => {
const response = await request(app.getHttpServer())
.post('/api/auth/login')
.send({ email: '[email protected]', password: 'password' })
.expect(200);
expect(response.body).toHaveProperty('accessToken');
});
});
| 指标 | 数值 | 说明 |
|---|---|---|
| 请求吞吐量 | 5000+ QPS | 单核 CPU |
| 平均响应时间 | <50ms | 简单查询 |
| 内存占用 | ~150MB | 启动后 |
| CPU 使用率 | <30% | 正常负载 |
// 日志配置
import { WinstonModule } from 'nest-winston';
WinstonModule.forRoot({
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// GET /health
{
"status": "ok",
"info": {
"database": { "status": "up" },
"redis": { "status": "up" }
}
}
// Prometheus 指标端点
// GET /metrics
http_requests_total{method="GET",status="200"} 1234
http_request_duration_seconds{quantile="0.99"} 0.052
A:在 .env 文件中设置 DATABASE_URL
DATABASE_URL="postgresql://user:password@localhost:5432/halolight"
A:使用 @nestjs/platform-express 的 FileInterceptor
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
uploadFile(@UploadedFile() file: Express.Multer.File) {
return { filename: file.originalname };
}
| 特性 | NestJS | FastAPI | Spring Boot | Laravel |
|---|---|---|---|---|
| 语言 | TypeScript | Python | Java | PHP |
| ORM | Prisma | SQLAlchemy | JPA | Eloquent |
| 性能 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
| 学习曲线 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
HaloLight Node.js 后端 API 基于 Express 5 + TypeScript + Prisma 构建,提供企业级 RESTful API 服务。
API 文档:https://halolight-api-node.h7ml.cn/docs
GitHub:https://github.com/halolight/halolight-api-node
| 技术 | 版本 | 说明 |
|---|---|---|
| Node.js | 20+ | JavaScript 运行时 |
| Express | 5.x | Web 框架 |
| Prisma | 6.x | 数据库 ORM |
| PostgreSQL | 16 | 数据存储 |
| Zod | 3.x | 数据验证 |
| JWT | 9.x | 身份认证 |
| Pino | 9.x | 日志系统 |
| Swagger UI | 5.x | API 文档 |
# 克隆仓库
git clone https://github.com/halolight/halolight-api-node.git
cd halolight-api-node
# 安装依赖
pnpm install
cp .env.example .env
# 数据库
DATABASE_URL="postgresql://user:password@localhost:5432/halolight?schema=public"
# JWT 密钥(必须 ≥32 字符)
JWT_SECRET="your-super-secret-jwt-key-minimum-32-characters-long"
JWT_EXPIRES_IN=7d
REFRESH_TOKEN_SECRET="your-refresh-secret-key-minimum-32-characters-long"
REFRESH_TOKEN_EXPIRES_IN=30d
# 服务配置
PORT=3001
NODE_ENV=development
# CORS
CORS_ORIGIN="http://localhost:3000"
# 生成 Prisma Client
pnpm db:generate
# 推送数据库变更
pnpm db:push
# 填充种子数据(可选)
pnpm db:seed
# 开发模式
pnpm dev
# 生产模式
pnpm build
pnpm start
halolight-api-node/
├── src/
│ ├── routes/ # 控制器/路由处理
│ │ ├── auth.ts # 认证路由
│ │ ├── users.ts # 用户管理
│ │ ├── roles.ts # 角色管理
│ │ ├── permissions.ts # 权限管理
│ │ ├── teams.ts # 团队管理
│ │ ├── documents.ts # 文档管理
│ │ ├── files.ts # 文件管理
│ │ ├── folders.ts # 文件夹管理
│ │ ├── calendar.ts # 日历事件
│ │ ├── notifications.ts # 通知管理
│ │ ├── messages.ts # 消息管理
│ │ └── dashboard.ts # 仪表盘统计
│ ├── services/ # 业务逻辑层
│ ├── middleware/ # 中间件
│ │ ├── auth.ts # JWT 认证 + RBAC
│ │ ├── validate.ts # Zod 请求验证
│ │ └── error.ts # 全局错误处理
│ ├── utils/ # 工具函数
│ ├── config/ # 配置文件
│ │ ├── env.ts # 环境变量
│ │ └── swagger.ts # Swagger 配置
│ └── index.ts # 应用入口
├── prisma/ # 数据库迁移/Schema
│ └── schema.prisma # 数据库模型(17+ 模型)
├── tests/ # 测试文件
├── Dockerfile # Docker 配置
├── docker-compose.yml
└── package.json
| 方法 | 路径 | 描述 | 权限 |
|---|---|---|---|
| POST | /api/auth/login |
用户登录 | 公开 |
| POST | /api/auth/register |
用户注册 | 公开 |
| POST | /api/auth/refresh |
刷新令牌 | 公开 |
| POST | /api/auth/logout |
退出登录 | 需认证 |
| POST | /api/auth/forgot-password |
忘记密码 | 公开 |
| POST | /api/auth/reset-password |
重置密码 | 公开 |
| 方法 | 路径 | 描述 | 权限 |
|---|---|---|---|
| GET | /api/users |
获取用户列表 | users:view |
| GET | /api/users/:id |
获取用户详情 | users:view |
| POST | /api/users |
创建用户 | users:create |
| PUT | /api/users/:id |
更新用户 | users:update |
| DELETE | /api/users/:id |
删除用户 | users:delete |
| GET | /api/users/me |
获取当前用户 | 需认证 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/roles |
获取角色列表 |
| GET | /api/roles/:id |
获取角色详情 |
| POST | /api/roles |
创建角色 |
| PUT | /api/roles/:id |
更新角色 |
| DELETE | /api/roles/:id |
删除角色 |
| PUT | /api/roles/:id/permissions |
分配权限 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/permissions |
获取权限列表 |
| GET | /api/permissions/:id |
获取权限详情 |
| POST | /api/permissions |
创建权限 |
| DELETE | /api/permissions/:id |
删除权限 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/teams |
获取团队列表 |
| GET | /api/teams/:id |
获取团队详情 |
| POST | /api/teams |
创建团队 |
| PUT | /api/teams/:id |
更新团队 |
| DELETE | /api/teams/:id |
删除团队 |
| POST | /api/teams/:id/members |
添加成员 |
| DELETE | /api/teams/:id/members/:userId |
移除成员 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/documents |
获取文档列表 |
| GET | /api/documents/:id |
获取文档详情 |
| POST | /api/documents |
创建文档 |
| PUT | /api/documents/:id |
更新文档 |
| DELETE | /api/documents/:id |
删除文档 |
| POST | /api/documents/:id/share |
分享文档 |
| DELETE | /api/documents/:id/share/:shareId |
取消分享 |
| POST | /api/documents/:id/tags |
添加标签 |
| DELETE | /api/documents/:id/tags/:tagId |
移除标签 |
| POST | /api/documents/:id/move |
移动文档 |
| POST | /api/documents/:id/copy |
复制文档 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/files |
获取文件列表 |
| GET | /api/files/:id |
获取文件详情 |
| POST | /api/files/upload |
上传文件 |
| GET | /api/files/:id/download |
下载文件 |
| PUT | /api/files/:id |
更新文件信息 |
| DELETE | /api/files/:id |
删除文件 |
| POST | /api/files/:id/move |
移动文件 |
| POST | /api/files/:id/copy |
复制文件 |
| POST | /api/files/:id/share |
分享文件 |
| GET | /api/files/:id/versions |
获取版本历史 |
| GET | /api/files/storage |
获取存储信息 |
| POST | /api/files/batch-delete |
批量删除 |
| POST | /api/files/batch-move |
批量移动 |
| POST | /api/files/search |
搜索文件 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/folders |
获取文件夹树 |
| POST | /api/folders |
创建文件夹 |
| PUT | /api/folders/:id |
更新文件夹 |
| DELETE | /api/folders/:id |
删除文件夹 |
| POST | /api/folders/:id/move |
移动文件夹 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/calendar/events |
获取日程列表 |
| GET | /api/calendar/events/:id |
获取日程详情 |
| POST | /api/calendar/events |
创建日程 |
| PUT | /api/calendar/events/:id |
更新日程 |
| DELETE | /api/calendar/events/:id |
删除日程 |
| POST | /api/calendar/events/:id/attendees |
添加参会人 |
| DELETE | /api/calendar/events/:id/attendees/:userId |
移除参会人 |
| POST | /api/calendar/events/:id/reminder |
设置提醒 |
| GET | /api/calendar/events/upcoming |
获取即将到来的事件 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/notifications |
获取通知列表 |
| GET | /api/notifications/:id |
获取通知详情 |
| PUT | /api/notifications/:id/read |
标记已读 |
| PUT | /api/notifications/read-all |
全部已读 |
| DELETE | /api/notifications/:id |
删除通知 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/messages/conversations |
获取会话列表 |
| GET | /api/messages/conversations/:id |
获取会话详情 |
| POST | /api/messages |
发送消息 |
| PUT | /api/messages/:id/read |
标记已读 |
| DELETE | /api/messages/:id |
删除消息 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/dashboard/stats |
统计数据 |
| GET | /api/dashboard/visits |
访问趋势 |
| GET | /api/dashboard/sales |
销售数据 |
| GET | /api/dashboard/pie |
饼图数据 |
| GET | /api/dashboard/tasks |
待办任务 |
| GET | /api/dashboard/calendar |
今日日程 |
| GET | /api/dashboard/activities |
最近活动 |
| GET | /api/dashboard/notifications |
未读通知 |
| GET | /api/dashboard/users |
用户统计 |
Access Token: 7 天有效期,用于 API 请求
Refresh Token: 30 天有效期,用于刷新 Access Token
Authorization: Bearer <access_token>
// POST /api/auth/refresh
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: 'your_refresh_token' })
});
const { accessToken, refreshToken } = await response.json();
// 更新本地存储的令牌
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
| 角色 | 说明 | 权限 |
|---|---|---|
super_admin |
超级管理员 | * (所有权限) |
admin |
管理员 | users:*, documents:*, files:*, teams:* |
user |
普通用户 | documents:view, files:view, calendar:* |
guest |
访客 | dashboard:view |
{resource}:{action}
示例:
- users:view # 查看用户
- users:create # 创建用户
- users:* # 用户所有操作
- * # 所有权限
// 在路由中使用权限中间件
import { requireAuth, requirePermission } from './middleware/auth';
// 需要认证
router.get('/api/users/me', requireAuth, getUserProfile);
// 需要特定权限
router.post('/api/users', requireAuth, requirePermission('users:create'), createUser);
// 需要多个权限之一
router.put('/api/users/:id', requireAuth, requirePermission(['users:update', 'users:*']), updateUser);
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "请求参数验证失败",
"details": [
{ "field": "email", "message": "邮箱格式不正确" }
]
}
}
| 状态码 | 错误码 | 说明 |
|---|---|---|
| 400 | VALIDATION_ERROR |
参数验证失败 |
| 401 | UNAUTHORIZED |
未授权 |
| 403 | FORBIDDEN |
无权限 |
| 404 | NOT_FOUND |
资源不存在 |
| 409 | CONFLICT |
资源冲突 |
| 500 | INTERNAL_ERROR |
服务器错误 |
# 开发
pnpm dev # 启动开发服务器(热重载)
pnpm build # TypeScript 编译
pnpm start # 启动生产服务器
# 测试
pnpm test # 运行测试
pnpm test:coverage # 测试覆盖率
# 数据库
pnpm db:generate # 生成 Prisma Client
pnpm db:push # 推送数据库变更
pnpm db:migrate # 运行迁移
pnpm db:studio # Prisma Studio(数据库 GUI)
pnpm db:seed # 填充种子数据
# 代码质量
pnpm lint # ESLint 检查
pnpm lint:fix # 自动修复
pnpm format # Prettier 格式化
docker build -t halolight-api-node .
docker run -p 3001:3001 halolight-api-node
docker-compose up -d
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
- REFRESH_TOKEN_SECRET=${REFRESH_TOKEN_SECRET}
restart: unless-stopped
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: halolight
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
postgres_data:
NODE_ENV=production
PORT=3001
DATABASE_URL=postgresql://user:pass@host:5432/halolight
JWT_SECRET=your-production-secret-minimum-32-characters
REFRESH_TOKEN_SECRET=your-refresh-secret-minimum-32-characters
CORS_ORIGIN=https://yourdomain.com
# 运行所有测试
pnpm test
# 运行测试覆盖率
pnpm test:coverage
# 监听模式
pnpm test:watch
// tests/auth.test.ts
import request from 'supertest';
import app from '../src/index';
describe('Authentication', () => {
it('should login successfully', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
email: '[email protected]',
password: 'password123'
});
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty('accessToken');
expect(response.body.data).toHaveProperty('refreshToken');
});
it('should reject invalid credentials', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
email: '[email protected]',
password: 'wrongpassword'
});
expect(response.status).toBe(401);
expect(response.body.success).toBe(false);
});
});
| 指标 | 数值 | 说明 |
|---|---|---|
| 请求吞吐量 | ~8,000 req/s | 单核,简单查询 |
| 平均响应时间 | <10ms | 本地数据库,无复杂查询 |
| 内存占用 | ~80MB | 启动后基础内存 |
| CPU 使用率 | <5% | 空闲状态 |
// 使用 Pino 日志记录
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'yyyy-mm-dd HH:MM:ss',
ignore: 'pid,hostname'
}
}
});
// 记录请求日志
app.use((req, res, next) => {
logger.info({
method: req.method,
url: req.url,
ip: req.ip
}, 'Incoming request');
next();
});
// GET /health
app.get('/health', async (req, res) => {
const health = {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
database: 'connected'
};
try {
// 检查数据库连接
await prisma.$queryRaw`SELECT 1`;
} catch (error) {
health.status = 'unhealthy';
health.database = 'disconnected';
return res.status(503).json(health);
}
res.json(health);
});
// 集成 Prometheus 指标
import promClient from 'prom-client';
const register = new promClient.Registry();
// 默认指标
promClient.collectDefaultMetrics({ register });
// 自定义指标
const httpRequestDuration = new promClient.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code'],
registers: [register]
});
// 暴露指标端点
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType);
res.send(await register.metrics());
});
A:配置相同的 DATABASE_URL 并确保使用相同的 Prisma Schema。
# 所有服务使用相同的数据库连接
DATABASE_URL="postgresql://user:pass@shared-db:5432/halolight"
# 确保 Schema 一致
pnpm db:push
A:在前端拦截器中检测 401 错误,自动调用刷新接口。
// Axios 拦截器示例
axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
const { data } = await axios.post('/api/auth/refresh', { refreshToken });
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
originalRequest.headers['Authorization'] = `Bearer ${data.accessToken}`;
return axios(originalRequest);
} catch (err) {
// 刷新失败,跳转登录页
window.location.href = '/login';
return Promise.reject(err);
}
}
return Promise.reject(error);
}
);
A:使用 multer 中间件配置文件大小和类型限制。
import multer from 'multer';
const upload = multer({
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
},
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('不支持的文件类型'));
}
}
});
router.post('/api/files/upload', upload.single('file'), uploadFile);
A:使用 Nginx 反向代理或在 Express 中配置 SSL 证书。
// Express 中启用 HTTPS
import https from 'https';
import fs from 'fs';
const options = {
key: fs.readFileSync('path/to/private-key.pem'),
cert: fs.readFileSync('path/to/certificate.pem')
};
https.createServer(options, app).listen(443, () => {
console.log('HTTPS server running on port 443');
});
pnpm db:studio)// .vscode/settings.json
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"typescript.tsdk": "node_modules/typescript/lib"
}
| 特性 | Express | NestJS | Fastify | Koa |
|---|---|---|---|---|
| 语言 | JavaScript/TypeScript | TypeScript | JavaScript/TypeScript | JavaScript/TypeScript |
| 性能 | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 学习曲线 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 生态系统 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 内置功能 | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
| 社区支持 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
HaloLight PHP 企业级后端 API 服务,基于 Laravel 11 + PostgreSQL + Redis 构建,提供完整的 RESTful API。
API 文档:https://halolight-api-php.h7ml.cn/docs
GitHub:https://github.com/halolight/halolight-api-php
| 技术 | 版本 | 说明 |
|---|---|---|
| PHP | 8.2+ | 运行时 |
| Laravel | 11.x | Web 框架 |
| Eloquent | 11.x | 数据库 ORM |
| PostgreSQL | 16 | 数据存储 |
| Redis | 7 | 缓存/队列 |
| Form Request | 11.x | 数据验证 |
| JWT | tymon/jwt-auth | 身份认证 |
| L5-Swagger | 8.x | API 文档 |
# 克隆仓库
git clone https://github.com/halolight/halolight-api-php.git
cd halolight-api-php
# 安装依赖
composer install
cp .env.example .env
# 数据库
DB_CONNECTION=pgsql
DB_HOST=localhost
DB_PORT=5432
DB_DATABASE=halolight
DB_USERNAME=postgres
DB_PASSWORD=your_password
# JWT 密钥
JWT_SECRET=your-super-secret-key-min-32-chars
JWT_TTL=10080 # 7天,单位:分钟
# 服务配置
APP_PORT=8080
APP_ENV=development
APP_DEBUG=true
# 生成应用密钥
php artisan key:generate
# 运行迁移
php artisan migrate
# 填充种子数据
php artisan db:seed
# 开发模式
php artisan serve --port=8080
# 生产模式
php artisan optimize
php artisan serve --port=8080 --env=production
halolight-api-php/
├── app/
│ ├── Http/
│ │ ├── Controllers/ # 控制器/路由处理
│ │ ├── Middleware/ # 中间件
│ │ └── Requests/ # 请求验证
│ ├── Services/ # 业务逻辑层
│ ├── Models/ # 数据模型
│ ├── Enums/ # 枚举类型
│ └── Providers/ # 服务提供者
├── database/
│ ├── migrations/ # 数据库迁移
│ └── seeders/ # 种子数据
├── tests/ # 测试文件
├── Dockerfile # Docker 配置
├── docker-compose.yml
└── composer.json
| 方法 | 路径 | 描述 | 权限 |
|---|---|---|---|
| POST | /api/auth/login |
用户登录 | 公开 |
| POST | /api/auth/register |
用户注册 | 公开 |
| POST | /api/auth/refresh |
刷新令牌 | 公开 |
| POST | /api/auth/logout |
退出登录 | 需认证 |
| POST | /api/auth/forgot-password |
忘记密码 | 公开 |
| POST | /api/auth/reset-password |
重置密码 | 公开 |
| GET | /api/auth/me |
获取当前用户 | 需认证 |
| 方法 | 路径 | 描述 | 权限 |
|---|---|---|---|
| GET | /api/users |
获取用户列表 | users:view |
| GET | /api/users/:id |
获取用户详情 | users:view |
| POST | /api/users |
创建用户 | users:create |
| PUT | /api/users/:id |
更新用户 | users:update |
| DELETE | /api/users/:id |
删除用户 | users:delete |
| GET | /api/users/me |
获取当前用户 | 需认证 |
| PATCH | /api/users/:id/status |
更新用户状态 | users:update |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/roles |
获取角色列表 |
| GET | /api/roles/:id |
获取角色详情 |
| POST | /api/roles |
创建角色 |
| PUT | /api/roles/:id |
更新角色 |
| DELETE | /api/roles/:id |
删除角色 |
| POST | /api/roles/:id/permissions |
分配权限 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/permissions |
获取权限列表 |
| GET | /api/permissions/:id |
获取权限详情 |
| POST | /api/permissions |
创建权限 |
| DELETE | /api/permissions/:id |
删除权限 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/teams |
获取团队列表 |
| GET | /api/teams/:id |
获取团队详情 |
| POST | /api/teams |
创建团队 |
| PUT | /api/teams/:id |
更新团队 |
| DELETE | /api/teams/:id |
删除团队 |
| POST | /api/teams/:id/members |
添加成员 |
| DELETE | /api/teams/:id/members/:userId |
移除成员 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/documents |
获取文档列表 |
| GET | /api/documents/:id |
获取文档详情 |
| POST | /api/documents |
创建文档 |
| PUT | /api/documents/:id |
更新文档 |
| DELETE | /api/documents/:id |
删除文档 |
| POST | /api/documents/:id/share |
分享文档 |
| GET | /api/documents/:id/versions |
获取版本历史 |
| POST | /api/documents/:id/restore |
恢复版本 |
| POST | /api/documents/:id/duplicate |
复制文档 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/files |
获取文件列表 |
| GET | /api/files/:id |
获取文件详情 |
| POST | /api/files/upload |
上传文件 |
| PUT | /api/files/:id |
更新文件信息 |
| DELETE | /api/files/:id |
删除文件 |
| GET | /api/files/:id/download |
下载文件 |
| POST | /api/files/:id/move |
移动文件 |
| POST | /api/files/:id/copy |
复制文件 |
| GET | /api/files/:id/preview |
预览文件 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/folders |
获取文件夹列表 |
| GET | /api/folders/:id |
获取文件夹详情 |
| POST | /api/folders |
创建文件夹 |
| PUT | /api/folders/:id |
更新文件夹 |
| DELETE | /api/folders/:id |
删除文件夹 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/messages |
获取消息列表 |
| GET | /api/messages/:id |
获取消息详情 |
| POST | /api/messages |
发送消息 |
| PUT | /api/messages/:id/read |
标记已读 |
| DELETE | /api/messages/:id |
删除消息 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/notifications |
获取通知列表 |
| GET | /api/notifications/:id |
获取通知详情 |
| PUT | /api/notifications/:id/read |
标记已读 |
| PUT | /api/notifications/read-all |
全部已读 |
| DELETE | /api/notifications/:id |
删除通知 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/calendar/events |
获取日程列表 |
| GET | /api/calendar/events/:id |
获取日程详情 |
| POST | /api/calendar/events |
创建日程 |
| PUT | /api/calendar/events/:id |
更新日程 |
| DELETE | /api/calendar/events/:id |
删除日程 |
| POST | /api/calendar/events/:id/attendees |
添加参会人 |
| DELETE | /api/calendar/events/:id/attendees/:userId |
移除参会人 |
| GET | /api/calendar/availability |
查询可用时间 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/dashboard/stats |
统计数据 |
| GET | /api/dashboard/visits |
访问趋势 |
| GET | /api/dashboard/sales |
销售数据 |
| GET | /api/dashboard/pie |
饼图数据 |
| GET | /api/dashboard/tasks |
待办任务 |
| GET | /api/dashboard/calendar |
今日日程 |
| GET | /api/dashboard/notifications |
最新通知 |
| GET | /api/dashboard/activity |
活动日志 |
| GET | /api/dashboard/overview |
概览数据 |
Access Token: 7 天有效期,用于 API 请求
Refresh Token: 30 天有效期,用于刷新 Access Token
Authorization: Bearer <access_token>
<?php
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class AuthController extends Controller
{
public function refresh(Request $request)
{
$refreshToken = $request->input('refreshToken');
// 验证 Refresh Token
try {
auth()->setToken($refreshToken)->authenticate();
// 生成新的 Access Token
$newAccessToken = auth()->refresh();
return response()->json([
'accessToken' => $newAccessToken,
'refreshToken' => $refreshToken, // 可选:也可以生成新的 Refresh Token
'expiresIn' => auth()->factory()->getTTL() * 60
]);
} catch (\Exception $e) {
return response()->json([
'error' => 'Invalid refresh token'
], 401);
}
}
}
| 角色 | 说明 | 权限 |
|---|---|---|
super_admin |
超级管理员 | * (所有权限) |
admin |
管理员 | users:*, documents:*, files:*, teams:* |
user |
普通用户 | documents:view, files:view, calendar:* |
guest |
访客 | dashboard:view |
{resource}:{action}
示例:
- users:view # 查看用户
- users:create # 创建用户
- users:* # 用户所有操作
- * # 所有权限
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "请求参数验证失败",
"details": [
{ "field": "email", "message": "邮箱格式不正确" }
]
}
}
| 状态码 | 错误码 | 说明 |
|---|---|---|
| 400 | VALIDATION_ERROR |
参数验证失败 |
| 401 | UNAUTHORIZED |
未授权 |
| 403 | FORBIDDEN |
无权限 |
| 404 | NOT_FOUND |
资源不存在 |
| 409 | CONFLICT |
资源冲突 |
| 422 | UNPROCESSABLE_ENTITY |
无法处理的实体 |
| 500 | INTERNAL_ERROR |
服务器错误 |
# 开发
php artisan serve --port=8080 # 启动开发服务器
php artisan tinker # 进入 REPL 环境
# 构建
php artisan optimize # 优化应用
php artisan config:cache # 缓存配置
php artisan route:cache # 缓存路由
# 测试
php artisan test # 运行测试
php artisan test --coverage # 运行测试并生成覆盖率报告
# 数据库
php artisan migrate # 运行迁移
php artisan migrate:fresh --seed # 重置并填充数据
php artisan db:seed # 填充种子数据
# 代码质量
composer lint # Laravel Pint 代码风格检查
composer analyse # PHPStan 静态分析
docker build -t halolight-api-php .
docker run -p 8080:8080 halolight-api-php
docker-compose up -d
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- APP_ENV=production
- APP_DEBUG=false
- DB_CONNECTION=pgsql
- DB_HOST=db
- DB_DATABASE=halolight
- DB_USERNAME=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- REDIS_HOST=redis
- JWT_SECRET=${JWT_SECRET}
depends_on:
- db
- redis
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: halolight
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
postgres_data:
redis_data:
APP_ENV=production
APP_DEBUG=false
APP_URL=https://halolight-api-php.h7ml.cn
DB_CONNECTION=pgsql
DB_HOST=your-db-host
DB_DATABASE=halolight
DB_USERNAME=your-db-user
DB_PASSWORD=your-db-password
REDIS_HOST=your-redis-host
REDIS_PASSWORD=your-redis-password
JWT_SECRET=your-production-secret-min-32-chars
JWT_TTL=10080
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
php artisan test
php artisan test --coverage
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
class AuthTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_login_with_correct_credentials()
{
$user = User::factory()->create([
'email' => '[email protected]',
'password' => bcrypt('password123')
]);
$response = $this->postJson('/api/auth/login', [
'email' => '[email protected]',
'password' => 'password123'
]);
$response->assertStatus(200)
->assertJsonStructure([
'accessToken',
'refreshToken',
'user'
]);
}
public function test_user_cannot_login_with_incorrect_password()
{
$user = User::factory()->create([
'email' => '[email protected]',
'password' => bcrypt('password123')
]);
$response = $this->postJson('/api/auth/login', [
'email' => '[email protected]',
'password' => 'wrong-password'
]);
$response->assertStatus(401)
->assertJson([
'error' => 'Unauthorized'
]);
}
}
| 指标 | 数值 | 说明 |
|---|---|---|
| 请求吞吐量 | ~1,500 QPS | 单核,使用 Swoole/Octane |
| 平均响应时间 | ~15ms | 简单查询,PostgreSQL |
| 内存占用 | ~50MB | 单进程,未开启缓存 |
| CPU 使用率 | ~40% | 高负载,4核心 |
<?php
use Illuminate\Support\Facades\Log;
// 配置日志通道
// config/logging.php
return [
'default' => env('LOG_CHANNEL', 'stack'),
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['single', 'daily'],
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'days' => 14,
],
],
];
// 使用日志
Log::info('User logged in', ['user_id' => $user->id]);
Log::error('Payment failed', ['error' => $exception->getMessage()]);
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
class HealthController extends Controller
{
public function check()
{
$status = [
'status' => 'healthy',
'timestamp' => now()->toIso8601String(),
'services' => []
];
// 检查数据库
try {
DB::connection()->getPdo();
$status['services']['database'] = 'healthy';
} catch (\Exception $e) {
$status['status'] = 'unhealthy';
$status['services']['database'] = 'unhealthy';
}
// 检查 Redis
try {
Redis::ping();
$status['services']['redis'] = 'healthy';
} catch (\Exception $e) {
$status['status'] = 'unhealthy';
$status['services']['redis'] = 'unhealthy';
}
return response()->json($status);
}
}
<?php
namespace App\Http\Middleware;
use Illuminate\Support\Facades\Cache;
class MetricsMiddleware
{
public function handle($request, $next)
{
$start = microtime(true);
$response = $next($request);
$duration = microtime(true) - $start;
// 记录请求指标
Cache::increment('metrics:requests_total');
Cache::increment("metrics:requests_{$response->status()}");
// 记录响应时间(可以使用 Prometheus 等工具)
$this->recordDuration($request->path(), $duration);
return $response;
}
private function recordDuration($path, $duration)
{
// 实现指标记录逻辑
}
}
A:Laravel 提供了便捷的文件上传处理方式:
<?php
namespace App\Http\Controllers\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class FileController extends Controller
{
public function upload(Request $request)
{
$request->validate([
'file' => 'required|file|max:10240', // 10MB
]);
$file = $request->file('file');
$path = $file->store('uploads', 'public');
return response()->json([
'path' => Storage::url($path),
'name' => $file->getClientOriginalName(),
'size' => $file->getSize(),
'mime' => $file->getMimeType()
]);
}
}
A:使用 Laravel 的 DB facade 或 Eloquent 模型:
<?php
use Illuminate\Support\Facades\DB;
use App\Models\Order;
use App\Models\Payment;
// 方式 1: DB facade
DB::transaction(function () {
$order = Order::create([...]);
$payment = Payment::create([
'order_id' => $order->id,
...
]);
});
// 方式 2: 手动控制
DB::beginTransaction();
try {
$order = Order::create([...]);
$payment = Payment::create([...]);
DB::commit();
} catch (\Exception $e) {
DB::rollback();
throw $e;
}
| 特性 | Laravel | NestJS | FastAPI | Spring Boot |
|---|---|---|---|---|
| 语言 | PHP | TypeScript | Python | Java |
| ORM | Eloquent | Prisma | SQLAlchemy | JPA |
| 性能 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 学习曲线 | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
| 生态系统 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 开发速度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
HaloLight FastAPI 后端 API 基于 FastAPI 0.115+ 构建,提供现代化异步 Python 后端服务。
API 文档:https://halolight-api-python.h7ml.cn/api/docs
GitHub:https://github.com/halolight/halolight-api-python
| 技术 | 版本 | 说明 |
|---|---|---|
| Python | 3.11+ | 运行时 |
| FastAPI | 0.115+ | Web 框架 |
| SQLAlchemy | 2.0+ | 数据库 ORM |
| PostgreSQL | 16 | 数据存储 |
| Pydantic | v2 | 数据验证 |
| JWT | python-jose | 身份认证 |
| Swagger UI | - | API 文档 |
# 克隆仓库
git clone https://github.com/halolight/halolight-api-python.git
cd halolight-api-python
# 创建虚拟环境
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 安装依赖
pip install -e .
cp .env.example .env
# 数据库
DATABASE_URL=postgresql://user:password@localhost:5432/halolight_db
# JWT 密钥
JWT_SECRET=your-super-secret-key
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
# 服务配置
PORT=8000
NODE_ENV=development
alembic upgrade head # 运行迁移
python scripts/seed.py # 填充种子数据
# 开发模式
uvicorn app.main:app --reload --port 8000
# 生产模式
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
halolight-api-python/
├── app/
│ ├── api/ # 控制器/路由处理
│ │ ├── auth.py # 认证端点
│ │ ├── users.py # 用户管理
│ │ └── ...
│ ├── services/ # 业务逻辑层
│ ├── models/ # 数据模型
│ ├── schemas/ # 请求验证
│ ├── core/ # 工具函数
│ └── main.py # 应用入口
├── alembic/ # 数据库迁移/Schema
├── tests/ # 测试文件
├── Dockerfile # Docker 配置
├── docker-compose.yml
└── pyproject.toml
| 方法 | 路径 | 描述 | 权限 |
|---|---|---|---|
| POST | /api/auth/login |
用户登录 | 公开 |
| POST | /api/auth/register |
用户注册 | 公开 |
| POST | /api/auth/refresh |
刷新令牌 | 公开 |
| POST | /api/auth/logout |
退出登录 | 需认证 |
| POST | /api/auth/forgot-password |
忘记密码 | 公开 |
| POST | /api/auth/reset-password |
重置密码 | 公开 |
| 方法 | 路径 | 描述 | 权限 |
|---|---|---|---|
| GET | /api/users |
获取用户列表 | users:view |
| GET | /api/users/:id |
获取用户详情 | users:view |
| POST | /api/users |
创建用户 | users:create |
| PUT | /api/users/:id |
更新用户 | users:update |
| DELETE | /api/users/:id |
删除用户 | users:delete |
| GET | /api/users/me |
获取当前用户 | 需认证 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/documents |
获取文档列表 |
| GET | /api/documents/:id |
获取文档详情 |
| POST | /api/documents |
创建文档 |
| PUT | /api/documents/:id |
更新文档 |
| DELETE | /api/documents/:id |
删除文档 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/files |
获取文件列表 |
| GET | /api/files/:id |
获取文件详情 |
| POST | /api/files/upload |
上传文件 |
| PUT | /api/files/:id |
更新文件信息 |
| DELETE | /api/files/:id |
删除文件 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/messages |
获取消息列表 |
| GET | /api/messages/:id |
获取消息详情 |
| POST | /api/messages |
发送消息 |
| PUT | /api/messages/:id/read |
标记已读 |
| DELETE | /api/messages/:id |
删除消息 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/notifications |
获取通知列表 |
| PUT | /api/notifications/:id/read |
标记已读 |
| PUT | /api/notifications/read-all |
全部已读 |
| DELETE | /api/notifications/:id |
删除通知 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/calendar/events |
获取日程列表 |
| GET | /api/calendar/events/:id |
获取日程详情 |
| POST | /api/calendar/events |
创建日程 |
| PUT | /api/calendar/events/:id |
更新日程 |
| DELETE | /api/calendar/events/:id |
删除日程 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/dashboard/stats |
统计数据 |
| GET | /api/dashboard/visits |
访问趋势 |
| GET | /api/dashboard/sales |
销售数据 |
| GET | /api/dashboard/pie |
饼图数据 |
| GET | /api/dashboard/tasks |
待办任务 |
| GET | /api/dashboard/calendar |
今日日程 |
Access Token: 15 分钟有效期,用于 API 请求
Refresh Token: 7 天有效期,用于刷新 Access Token
Authorization: Bearer <access_token>
# 刷新令牌示例
import requests
response = requests.post(
'http://localhost:8000/api/auth/refresh',
json={'refreshToken': refresh_token}
)
new_tokens = response.json()
| 角色 | 说明 | 权限 |
|---|---|---|
super_admin |
超级管理员 | * (所有权限) |
admin |
管理员 | users:*, documents:*, ... |
user |
普通用户 | documents:view, files:view, ... |
guest |
访客 | dashboard:view |
{resource}:{action}
示例:
- users:view # 查看用户
- users:create # 创建用户
- users:* # 用户所有操作
- * # 所有权限
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "请求参数验证失败",
"details": [
{ "field": "email", "message": "邮箱格式不正确" }
]
}
}
| 状态码 | 错误码 | 说明 |
|---|---|---|
| 400 | VALIDATION_ERROR |
参数验证失败 |
| 401 | UNAUTHORIZED |
未授权 |
| 403 | FORBIDDEN |
无权限 |
| 404 | NOT_FOUND |
资源不存在 |
| 409 | CONFLICT |
资源冲突 |
| 500 | INTERNAL_ERROR |
服务器错误 |
# app/models/user.py
from sqlalchemy import Column, Integer, String, DateTime
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String, unique=True, nullable=False)
username = Column(String, unique=True, nullable=False)
hashed_password = Column(String, nullable=False)
role = Column(String, default="user")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, onupdate=func.now())
# app/models/document.py
from sqlalchemy import Column, Integer, String, Text, ForeignKey
from sqlalchemy.orm import relationship
from app.core.database import Base
class Document(Base):
__tablename__ = "documents"
id = Column(Integer, primary_key=True)
title = Column(String, nullable=False)
content = Column(Text)
author_id = Column(Integer, ForeignKey("users.id"))
author = relationship("User", back_populates="documents")
| 变量名 | 说明 | 默认值 |
|---|---|---|
DATABASE_URL |
数据库连接字符串 | sqlite:///./halolight.db |
JWT_SECRET |
JWT 签名密钥 | - |
JWT_ACCESS_EXPIRES |
Access Token 过期时间 | 15m |
JWT_REFRESH_EXPIRES |
Refresh Token 过期时间 | 7d |
PORT |
服务端口 | 8000 |
NODE_ENV |
运行环境 | development |
CORS_ORIGINS |
CORS 允许的源 | ["http://localhost:3000"] |
{
"success": true,
"data": {
"id": 1,
"name": "示例数据"
},
"message": "操作成功"
}
{
"success": true,
"data": {
"items": [...],
"total": 100,
"page": 1,
"pageSize": 10,
"totalPages": 10
}
}
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "错误描述",
"details": []
}
}
docker build -t halolight-api-python .
docker run -p 8000:8000 halolight-api-python
docker-compose up -d
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "8000:8000"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: halolight
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
NODE_ENV=production
DATABASE_URL=postgresql://user:pass@host:5432/db
JWT_SECRET=your-production-secret
pytest
pytest --cov=app tests/
def test_login_success(client):
response = client.post(
"/api/auth/login",
json={"email": "[email protected]", "password": "123456"}
)
assert response.status_code == 200
assert "accessToken" in response.json()
def test_get_users_with_permission(client, admin_token):
response = client.get(
"/api/users",
headers={"Authorization": f"Bearer {admin_token}"}
)
assert response.status_code == 200
assert isinstance(response.json()["data"], list)
| 指标 | 数值 | 说明 |
|---|---|---|
| 请求吞吐量 | 5000+ QPS | 单核 uvicorn |
| 平均响应时间 | < 10ms | 简单查询 |
| 内存占用 | ~100MB | 基础运行 |
| CPU 使用率 | 30-50% | 高负载 |
import logging
logger = logging.getLogger(__name__)
logger.info("User logged in", extra={"user_id": user.id})
@app.get("/health")
async def health_check():
return {"status": "ok", "timestamp": datetime.now()}
# Prometheus metrics endpoint
from prometheus_fastapi_instrumentator import Instrumentator
Instrumentator().instrument(app).expose(app)
# 开发
uvicorn app.main:app --reload --port 8000
# 构建
pip install -e .
# 测试
pytest
pytest --cov=app tests/
# 数据库
alembic upgrade head
alembic revision --autogenerate -m "描述"
# 代码质量
black app tests
ruff check app tests --fix
A:在 core/database.py 中配置 SQLAlchemy 连接池参数
engine = create_engine(
DATABASE_URL,
pool_size=10,
max_overflow=20,
pool_timeout=30
)
A:在 main.py 中配置 CORS 中间件
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
A:使用 FastAPI 的 UploadFile 类型
from fastapi import UploadFile, File
@app.post("/api/upload")
async def upload_file(file: UploadFile = File(...)):
contents = await file.read()
# 处理文件内容
return {"filename": file.filename}
FastAPI 基于 Python 的 asyncio,支持高并发异步操作:
@app.get("/api/async-example")
async def async_endpoint():
result = await async_database_query()
return result
FastAPI 自动生成 OpenAPI (Swagger) 文档,无需额外配置:
/docs/redoc/openapi.jsonfrom fastapi import Depends
def get_current_user(token: str = Depends(oauth2_scheme)):
return verify_token(token)
@app.get("/api/protected")
async def protected_route(user = Depends(get_current_user)):
return {"user": user}
| 特性 | FastAPI | NestJS | Go Fiber | Spring Boot |
|---|---|---|---|---|
| 语言 | Python | TypeScript | Go | Java |
| ORM | SQLAlchemy | Prisma | GORM | JPA |
| 性能 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 学习曲线 | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
HaloLight Astro 版本基于 Astro 5 构建,采用 Islands 架构实现零 JS 首屏和极致性能,支持多框架组件混用。
在线预览:https://halolight-astro.h7ml.cn/
GitHub:https://github.com/halolight/halolight-astro
| 技术 | 版本 | 说明 |
|---|---|---|
| Astro | 5.x | Islands 架构框架 |
| TypeScript | 5.x | 类型安全 |
| Tailwind CSS | 3.x | 原子化 CSS |
| Vite | 内置 | 构建工具 |
| @astrojs/node | 9.x | Node.js 适配器 |
| Vitest | 4.x | 单元测试 |
halolight-astro/
├── src/
│ ├── pages/ # 文件路由
│ │ ├── index.astro # 首页
│ │ ├── privacy.astro # 隐私政策
│ │ ├── terms.astro # 服务条款
│ │ ├── auth/ # 认证页面
│ │ │ ├── login.astro
│ │ │ ├── register.astro
│ │ │ ├── forgot-password.astro
│ │ │ └── reset-password.astro
│ │ ├── dashboard/ # 仪表盘页面
│ │ │ ├── index.astro # 仪表盘首页
│ │ │ ├── analytics.astro # 数据分析
│ │ │ ├── users.astro # 用户管理
│ │ │ ├── accounts.astro # 账户管理
│ │ │ ├── documents.astro # 文档管理
│ │ │ ├── files.astro # 文件管理
│ │ │ ├── messages.astro # 消息中心
│ │ │ ├── notifications.astro
│ │ │ ├── calendar.astro # 日历
│ │ │ ├── profile.astro # 个人中心
│ │ │ └── settings/ # 设置
│ │ └── api/ # API 端点
│ │ └── auth/
│ │ ├── login.ts
│ │ ├── register.ts
│ │ ├── forgot-password.ts
│ │ └── reset-password.ts
│ ├── layouts/ # 布局组件
│ │ ├── Layout.astro # 基础布局
│ │ ├── AuthLayout.astro # 认证布局
│ │ ├── DashboardLayout.astro # 仪表盘布局
│ │ └── LegalLayout.astro # 法律页面布局
│ ├── components/ # UI 组件
│ │ └── dashboard/
│ │ ├── Sidebar.astro # 侧边栏
│ │ └── Header.astro # 顶部导航
│ ├── styles/ # 全局样式
│ │ └── globals.css
│ └── assets/ # 静态资源
├── public/ # 公共资源
├── tests/ # 测试文件
├── astro.config.mjs # Astro 配置
├── tailwind.config.mjs # Tailwind 配置
├── vitest.config.ts # 测试配置
└── package.json
git clone https://github.com/halolight/halolight-astro.git
cd halolight-astro
pnpm install
cp .env.example .env.local
# .env.local 示例
PUBLIC_API_URL=/api
PUBLIC_MOCK=true
[email protected]
PUBLIC_DEMO_PASSWORD=123456
PUBLIC_SHOW_DEMO_HINT=true
PUBLIC_APP_TITLE=Admin Pro
PUBLIC_BRAND_NAME=Halolight
pnpm dev
pnpm build
pnpm preview
| 角色 | 邮箱 | 密码 |
|---|---|---|
| 管理员 | [email protected] | 123456 |
| 普通用户 | [email protected] | 123456 |
Astro 的 Islands 架构允许页面默认为静态 HTML,仅在需要交互的组件上添加 JavaScript:
---
// 静态导入,无 JS
import StaticCard from '../components/StaticCard.astro';
// 交互组件(可来自 React/Vue/Svelte)
import Counter from '../components/Counter.tsx';
---
<!-- 纯静态,零 JS -->
<StaticCard title="统计数据" />
<!-- 页面加载时水合 -->
<Counter client:load />
<!-- 可见时水合(懒加载) -->
<Counter client:visible />
<!-- 浏览器空闲时水合 -->
<Counter client:idle />
客户端指令:
| 指令 | 行为 | 使用场景 |
|---|---|---|
client:load |
页面加载后立即水合 | 首屏关键交互 |
client:idle |
浏览器空闲时水合 | 非关键交互 |
client:visible |
元素可见时水合 | 懒加载组件 |
client:only |
仅客户端渲染 | 依赖浏览器 API |
client:media |
媒体查询匹配时水合 | 响应式组件 |
---
// layouts/DashboardLayout.astro
import Layout from './Layout.astro';
import Sidebar from '../components/dashboard/Sidebar.astro';
import Header from '../components/dashboard/Header.astro';
interface Props {
title: string;
description?: string;
}
const { title, description } = Astro.props;
const currentPath = Astro.url.pathname;
---
<Layout title={title} description={description}>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<Sidebar currentPath={currentPath} />
<div class="lg:pl-64">
<Header title={title} />
<main class="p-4 lg:p-6">
<slot />
</main>
</div>
</div>
</Layout>
Astro 原生支持创建 API 端点:
// src/pages/api/auth/login.ts
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
const { email, password } = body;
// 验证逻辑
if (!email || !password) {
return new Response(
JSON.stringify({ success: false, message: '邮箱和密码不能为空' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// 认证逻辑...
return new Response(
JSON.stringify({
success: true,
message: '登录成功',
user: { id: 1, name: '管理员', role: 'admin' },
token: 'mock_token',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
};
| 文件路径 | URL | 说明 |
|---|---|---|
src/pages/index.astro |
/ |
首页 |
src/pages/auth/login.astro |
/auth/login |
登录 |
src/pages/dashboard/index.astro |
/dashboard |
仪表盘 |
src/pages/dashboard/[id].astro |
/dashboard/:id |
动态路由 |
src/pages/api/auth/login.ts |
/api/auth/login |
API 端点 |
| 路径 | 页面 | 权限 |
|---|---|---|
/ |
首页 | 公开 |
/auth/login |
登录 | 公开 |
/auth/register |
注册 | 公开 |
/auth/forgot-password |
忘记密码 | 公开 |
/auth/reset-password |
重置密码 | 公开 |
/dashboard |
仪表盘 | dashboard:view |
/dashboard/analytics |
数据分析 | analytics:view |
/dashboard/users |
用户管理 | users:view |
/dashboard/accounts |
账户管理 | accounts:view |
/dashboard/documents |
文档管理 | documents:view |
/dashboard/files |
文件管理 | files:view |
/dashboard/messages |
消息中心 | messages:view |
/dashboard/notifications |
通知中心 | notifications:view |
/dashboard/calendar |
日历 | calendar:view |
/dashboard/profile |
个人中心 | settings:view |
/dashboard/settings |
设置 | settings:view |
/privacy |
隐私政策 | 公开 |
/terms |
服务条款 | 公开 |
# .env
PUBLIC_API_URL=/api
PUBLIC_MOCK=true
PUBLIC_DEMO_EMAIL=[email protected]
PUBLIC_DEMO_PASSWORD=123456
PUBLIC_SHOW_DEMO_HINT=true
PUBLIC_APP_TITLE=Admin Pro
PUBLIC_BRAND_NAME=Halolight
| 变量名 | 说明 | 默认值 |
|---|---|---|
PUBLIC_API_URL |
API 基础 URL | /api |
PUBLIC_MOCK |
启用 Mock 数据 | true |
PUBLIC_APP_TITLE |
应用标题 | Admin Pro |
PUBLIC_BRAND_NAME |
品牌名称 | Halolight |
PUBLIC_DEMO_EMAIL |
演示账号邮箱 | - |
PUBLIC_DEMO_PASSWORD |
演示账号密码 | - |
PUBLIC_SHOW_DEMO_HINT |
显示演示提示 | false |
---
// 在 .astro 文件中
const apiUrl = import.meta.env.PUBLIC_API_URL;
const isMock = import.meta.env.PUBLIC_MOCK === 'true';
---
// 在 .ts 文件中
const apiUrl = import.meta.env.PUBLIC_API_URL;
# 开发
pnpm dev # 启动开发服务器 (默认 4321 端口)
pnpm dev --port 3000 # 指定端口
# 构建
pnpm build # 生产构建
pnpm preview # 预览生产构建
# 检查
pnpm astro check # 类型检查
pnpm lint # ESLint 检查
pnpm lint:fix # ESLint 自动修复
# 测试
pnpm test # 运行测试
pnpm test:run # 单次运行
pnpm test:coverage # 覆盖率报告
# Astro CLI
pnpm astro add react # 添加 React 集成
pnpm astro add vue # 添加 Vue 集成
pnpm astro add tailwind # 添加 Tailwind
pnpm astro add mdx # 添加 MDX 支持
# 运行测试
pnpm test
# 生成覆盖率报告
pnpm test --coverage
// tests/components/Counter.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from '../../src/components/Counter';
describe('Counter', () => {
it('renders with initial count', () => {
render(<Counter />);
expect(screen.getByText('0')).toBeInTheDocument();
});
it('increments count on button click', () => {
render(<Counter />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('1')).toBeInTheDocument();
});
});
// astro.config.mjs
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import node from '@astrojs/node';
export default defineConfig({
integrations: [tailwind()],
output: 'server', // SSR 模式
adapter: node({
mode: 'standalone',
}),
server: {
port: 4321,
host: true,
},
});
| 模式 | 说明 | 适用场景 |
|---|---|---|
static |
静态站点生成 (SSG) | 博客、文档站 |
server |
服务端渲染 (SSR) | 动态应用 |
hybrid |
混合模式 | 部分动态 |
# 安装适配器
pnpm add @astrojs/vercel
# astro.config.mjs
import vercel from '@astrojs/vercel/serverless';
export default defineConfig({
output: 'server',
adapter: vercel(),
});
FROM node:20-alpine AS builder
RUN npm install -g pnpm
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]
项目配置了完整的 GitHub Actions CI 工作流:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm astro check
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
Astro 内置的内容管理系统,支持类型安全的 Markdown/MDX 内容。
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blogCollection = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
author: z.string(),
tags: z.array(z.string()).optional(),
image: z.string().optional(),
}),
});
export const collections = {
blog: blogCollection,
};
---
// src/pages/blog/[...slug].astro
import { getCollection } from 'astro:content';
import BlogLayout from '../../layouts/BlogLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<BlogLayout title={post.data.title}>
<article>
<h1>{post.data.title}</h1>
<time>{post.data.pubDate.toLocaleDateString()}</time>
<Content />
</article>
</BlogLayout>
原生 View Transitions API 支持,实现页面间流畅动画。
---
// src/layouts/Layout.astro
import { ViewTransitions } from 'astro:transitions';
---
<html>
<head>
<ViewTransitions />
</head>
<body>
<slot />
</body>
</html>
---
// 自定义过渡动画
---
<div transition:name="hero">
<h1 transition:animate="slide">欢迎</h1>
</div>
<style>
/* 自定义动画 */
@keyframes slide-in {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
::view-transition-old(hero) {
animation: slide-out 0.3s ease-out;
}
::view-transition-new(hero) {
animation: slide-in 0.3s ease-out;
}
</style>
请求拦截和处理。
// src/middleware.ts
import { defineMiddleware, sequence } from 'astro:middleware';
// 认证中间件
const auth = defineMiddleware(async (context, next) => {
const token = context.cookies.get('token')?.value;
// 保护路由
const protectedPaths = ['/dashboard', '/profile', '/settings'];
const isProtected = protectedPaths.some(path =>
context.url.pathname.startsWith(path)
);
if (isProtected && !token) {
return context.redirect('/auth/login');
}
// 将用户信息传递给页面
if (token) {
context.locals.user = await verifyToken(token);
}
return next();
});
// 日志中间件
const logger = defineMiddleware(async (context, next) => {
const start = Date.now();
const response = await next();
const duration = Date.now() - start;
console.log(`${context.request.method} ${context.url.pathname} - ${duration}ms`);
return response;
});
// 组合中间件
export const onRequest = sequence(logger, auth);
---
import { Image } from 'astro:assets';
import myImage from '../assets/hero.png';
---
<!-- 自动优化图片 -->
<Image src={myImage} alt="Hero" width={800} height={600} />
<!-- 远程图片 -->
<Image
src="https://example.com/image.jpg"
alt="Remote"
width={400}
height={300}
inferSize
/>
---
// 使用 client:visible 实现懒加载
import HeavyComponent from '../components/HeavyComponent';
---
<!-- 仅在元素可见时加载 -->
<HeavyComponent client:visible />
---
// 预加载关键资源
---
<head>
<link rel="preload" href="/fonts/inter.woff2" as="font" crossorigin />
<link rel="preconnect" href="https://api.example.com" />
<link rel="dns-prefetch" href="https://cdn.example.com" />
</head>
---
// 动态导入重型组件
const Chart = await import('../components/Chart.tsx');
---
<Chart.default client:visible data={data} />
A:使用 nanostores 或 Zustand:
pnpm add nanostores @nanostores/react
// src/stores/counter.ts
import { atom } from 'nanostores';
export const $counter = atom(0);
export function increment() {
$counter.set($counter.get() + 1);
}
// React 组件
import { useStore } from '@nanostores/react';
import { $counter, increment } from '../stores/counter';
export function Counter() {
const count = useStore($counter);
return <button onClick={increment}>{count}</button>;
}
A:使用 API 端点:
---
// src/pages/contact.astro
---
<form method="POST" action="/api/contact">
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<button type="submit">提交</button>
</form>
// src/pages/api/contact.ts
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
const data = await request.formData();
const email = data.get('email');
const message = data.get('message');
// 处理表单数据
await sendEmail({ email, message });
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
A:使用中间件 + Cookie:
// src/middleware.ts
export const onRequest = defineMiddleware(async (context, next) => {
const token = context.cookies.get('auth-token')?.value;
if (context.url.pathname.startsWith('/dashboard') && !token) {
return context.redirect('/auth/login');
}
if (token) {
try {
const user = await verifyToken(token);
context.locals.user = user;
} catch {
context.cookies.delete('auth-token');
return context.redirect('/auth/login');
}
}
return next();
});
A:优化建议:
client: 指令使用,尽量用 client:visible 或 client:idle@playform/compress 压缩输出pnpm add @playform/compress
// astro.config.mjs
import compress from '@playform/compress';
export default defineConfig({
integrations: [compress()],
});
| 特性 | Astro | Next.js | Vue |
|---|---|---|---|
| 默认 JS 体积 | 0 KB | ~80 KB | ~70 KB |
| Islands 架构 | 原生支持 | 不支持 | 不支持 (Nuxt) |
| 多框架组件 | 支持 | 不支持 | 不支持 |
| SSG/SSR | 支持 | 支持 | 支持 (Nuxt) |
| 学习曲线 | 低 | 中 | 中 |
HaloLight AWS 部署版本,面向企业级 AWS 生态的部署方案,支持 Amplify、S3 + CloudFront、ECS 等多种部署方式。
在线预览:https://halolight-aws.h7ml.cn
GitHub:https://github.com/halolight/halolight-aws
halolight/halolight-aws 仓库main 分支amplify.yml)# 安装 Amplify CLI
npm install -g @aws-amplify/cli
# 配置 AWS 凭证
amplify configure
# 克隆项目
git clone https://github.com/halolight/halolight-aws.git
cd halolight-aws
# 初始化 Amplify 项目
amplify init
# 添加托管
amplify add hosting
# 选择: Hosting with Amplify Console
# 选择: Continuous deployment
# 发布
amplify publish
# 安装 AWS CLI
brew install awscli # macOS
# 或
pip install awscli
# 配置凭证
aws configure
# 构建静态站点
pnpm build
pnpm export # 如果使用静态导出
# 创建 S3 存储桶
aws s3 mb s3://halolight-static --region ap-northeast-1
# 配置静态网站托管
aws s3 website s3://halolight-static \
--index-document index.html \
--error-document 404.html
# 上传文件
aws s3 sync out/ s3://halolight-static --delete
# 创建 CloudFront 分发
aws cloudfront create-distribution \
--origin-domain-name halolight-static.s3.ap-northeast-1.amazonaws.com \
--default-root-object index.html
version: 1
applications:
- frontend:
phases:
preBuild:
commands:
- corepack enable
- corepack prepare pnpm@latest --activate
- pnpm install --frozen-lockfile
build:
commands:
- pnpm build
artifacts:
baseDirectory: .next
files:
- '**/*'
cache:
paths:
- node_modules/**/*
- .next/cache/**/*
buildPath: /
appRoot: .
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
images: {
unoptimized: process.env.AMPLIFY_ENV === "true",
remotePatterns: [
{
protocol: "https",
hostname: "**.amazonaws.com",
},
],
},
// Amplify 需要的配置
experimental: {
serverActions: {
bodySizeLimit: "2mb",
},
},
};
export default nextConfig;
在 Amplify Console → App settings → Environment variables 设置:
| 变量名 | 说明 | 示例 |
|---|---|---|
NEXT_PUBLIC_API_URL |
API 基础 URL | /api |
NEXT_PUBLIC_MOCK |
启用 Mock 数据 | false |
NEXT_PUBLIC_APP_TITLE |
应用标题 | Admin Pro |
AWS_REGION |
AWS 区域 | ap-northeast-1 |
AMPLIFY_ENV |
Amplify 环境标识 | true |
DATABASE_URL |
RDS 数据库连接 | postgresql://... |
DYNAMODB_TABLE |
DynamoDB 表名 | halolight-users |
# Amplify CLI 设置
amplify env add
amplify env checkout <env-name>
# AWS CLI 设置 (SSM Parameter Store)
aws ssm put-parameter \
--name "/halolight/production/DATABASE_URL" \
--value "postgresql://..." \
--type SecureString
# 在 Amplify 中引用
# amplify.yml
build:
commands:
- export DATABASE_URL=$(aws ssm get-parameter --name "/halolight/production/DATABASE_URL" --with-decryption --query Parameter.Value --output text)
- pnpm build
// edge-functions/ssr.ts
import type { CloudFrontRequestHandler } from "aws-lambda";
export const handler: CloudFrontRequestHandler = async (event) => {
const request = event.Records[0].cf.request;
const uri = request.uri;
// 处理 SSR 路由
if (shouldSSR(uri)) {
// 调用 Lambda 进行 SSR
const response = await renderPage(uri);
return {
status: "200",
statusDescription: "OK",
headers: {
"content-type": [{ value: "text/html; charset=utf-8" }],
"cache-control": [{ value: "public, max-age=0, s-maxage=31536000" }],
},
body: response,
};
}
return request;
};
function shouldSSR(uri: string): boolean {
// 定义需要 SSR 的路由
const ssrRoutes = ["/", "/dashboard", "/users"];
return ssrRoutes.some((route) => uri.startsWith(route));
}
// edge-functions/auth.ts
import type { CloudFrontRequestHandler } from "aws-lambda";
import { verify } from "jsonwebtoken";
export const handler: CloudFrontRequestHandler = async (event) => {
const request = event.Records[0].cf.request;
const headers = request.headers;
// 检查保护路由
if (isProtectedRoute(request.uri)) {
const authHeader = headers.authorization?.[0]?.value;
const cookieHeader = headers.cookie?.[0]?.value;
// 从 header 或 cookie 获取 token
const token = extractToken(authHeader, cookieHeader);
if (!token) {
return {
status: "401",
statusDescription: "Unauthorized",
headers: {
"content-type": [{ value: "application/json" }],
},
body: JSON.stringify({ error: "Unauthorized" }),
};
}
try {
// 验证 JWT
verify(token, process.env.JWT_SECRET!);
} catch {
return {
status: "401",
statusDescription: "Invalid Token",
body: JSON.stringify({ error: "Invalid token" }),
};
}
}
return request;
};
function isProtectedRoute(uri: string): boolean {
const protectedPaths = ["/api/users", "/api/admin", "/dashboard"];
return protectedPaths.some((path) => uri.startsWith(path));
}
function extractToken(authHeader?: string, cookieHeader?: string): string | null {
if (authHeader?.startsWith("Bearer ")) {
return authHeader.substring(7);
}
if (cookieHeader) {
const match = cookieHeader.match(/token=([^;]+)/);
return match ? match[1] : null;
}
return null;
}
// edge-functions/geo-redirect.ts
import type { CloudFrontRequestHandler } from "aws-lambda";
export const handler: CloudFrontRequestHandler = async (event) => {
const request = event.Records[0].cf.request;
const headers = request.headers;
// 获取 CloudFront 注入的地理位置信息
const country = headers["cloudfront-viewer-country"]?.[0]?.value;
const city = headers["cloudfront-viewer-city"]?.[0]?.value;
// 基于地理位置重定向
if (country === "CN" && !request.uri.startsWith("/cn/")) {
return {
status: "302",
statusDescription: "Found",
headers: {
location: [{ value: `/cn${request.uri}` }],
},
};
}
// 添加地理位置 header
request.headers["x-user-country"] = [{ value: country || "unknown" }];
request.headers["x-user-city"] = [{ value: city || "unknown" }];
return request;
};
{
"CallerReference": "halolight-distribution",
"Comment": "HaloLight CDN Distribution",
"DefaultCacheBehavior": {
"TargetOriginId": "S3-halolight",
"ViewerProtocolPolicy": "redirect-to-https",
"AllowedMethods": ["GET", "HEAD", "OPTIONS"],
"CachedMethods": ["GET", "HEAD"],
"ForwardedValues": {
"QueryString": true,
"Cookies": {
"Forward": "none"
},
"Headers": ["Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"]
},
"MinTTL": 0,
"DefaultTTL": 86400,
"MaxTTL": 31536000,
"Compress": true,
"LambdaFunctionAssociations": [
{
"EventType": "viewer-request",
"LambdaFunctionARN": "arn:aws:lambda:us-east-1:123456789:function:auth:1"
},
{
"EventType": "origin-request",
"LambdaFunctionARN": "arn:aws:lambda:us-east-1:123456789:function:ssr:1"
}
]
},
"Origins": {
"Items": [
{
"Id": "S3-halolight",
"DomainName": "halolight-static.s3.ap-northeast-1.amazonaws.com",
"S3OriginConfig": {
"OriginAccessIdentity": "origin-access-identity/cloudfront/E1234567890ABC"
}
}
]
},
"Enabled": true,
"PriceClass": "PriceClass_200",
"HttpVersion": "http2and3",
"IsIPV6Enabled": true
}
{
"Name": "HaloLight-CachePolicy",
"DefaultTTL": 86400,
"MaxTTL": 31536000,
"MinTTL": 1,
"ParametersInCacheKeyAndForwardedToOrigin": {
"EnableAcceptEncodingGzip": true,
"EnableAcceptEncodingBrotli": true,
"HeadersConfig": {
"HeaderBehavior": "whitelist",
"Headers": ["Authorization", "Accept-Language"]
},
"CookiesConfig": {
"CookieBehavior": "none"
},
"QueryStringsConfig": {
"QueryStringBehavior": "whitelist",
"QueryStrings": ["page", "limit", "search"]
}
}
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontAccess",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E1234567890ABC"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::halolight-static/*"
}
]
}
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedOrigins": ["https://halolight-aws.h7ml.cn", "https://halolight.h7ml.cn"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AmplifyAccess",
"Effect": "Allow",
"Action": [
"amplify:*"
],
"Resource": "*"
},
{
"Sid": "S3Access",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::halolight-*",
"arn:aws:s3:::halolight-*/*"
]
},
{
"Sid": "CloudFrontAccess",
"Effect": "Allow",
"Action": [
"cloudfront:CreateInvalidation",
"cloudfront:GetDistribution",
"cloudfront:UpdateDistribution"
],
"Resource": "*"
},
{
"Sid": "LambdaAccess",
"Effect": "Allow",
"Action": [
"lambda:CreateFunction",
"lambda:UpdateFunctionCode",
"lambda:GetFunction",
"lambda:EnableReplication*"
],
"Resource": "arn:aws:lambda:*:*:function:halolight-*"
},
{
"Sid": "SSMAccess",
"Effect": "Allow",
"Action": [
"ssm:GetParameter",
"ssm:GetParameters"
],
"Resource": "arn:aws:ssm:*:*:parameter/halolight/*"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadOnlyAccess",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"dynamodb:GetItem",
"dynamodb:Query",
"rds:DescribeDBInstances"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"aws:RequestedRegion": "ap-northeast-1"
}
}
}
]
}
// lib/dynamodb.ts
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, PutCommand, QueryCommand } from "@aws-sdk/lib-dynamodb";
const client = new DynamoDBClient({ region: process.env.AWS_REGION });
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.DYNAMODB_TABLE || "halolight-users";
export async function getUser(userId: string) {
const command = new GetCommand({
TableName: TABLE_NAME,
Key: { pk: `USER#${userId}`, sk: "PROFILE" },
});
const response = await docClient.send(command);
return response.Item;
}
export async function createUser(user: User) {
const command = new PutCommand({
TableName: TABLE_NAME,
Item: {
pk: `USER#${user.id}`,
sk: "PROFILE",
...user,
createdAt: new Date().toISOString(),
},
});
await docClient.send(command);
return user;
}
export async function getUsersByRole(role: string) {
const command = new QueryCommand({
TableName: TABLE_NAME,
IndexName: "GSI1",
KeyConditionExpression: "gsi1pk = :role",
ExpressionAttributeValues: {
":role": `ROLE#${role}`,
},
});
const response = await docClient.send(command);
return response.Items;
}
# cloudformation/dynamodb.yml
AWSTemplateFormatVersion: '2010-09-09'
Description: HaloLight DynamoDB Tables
Resources:
UsersTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: halolight-users
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: pk
AttributeType: S
- AttributeName: sk
AttributeType: S
- AttributeName: gsi1pk
AttributeType: S
- AttributeName: gsi1sk
AttributeType: S
KeySchema:
- AttributeName: pk
KeyType: HASH
- AttributeName: sk
KeyType: RANGE
GlobalSecondaryIndexes:
- IndexName: GSI1
KeySchema:
- AttributeName: gsi1pk
KeyType: HASH
- AttributeName: gsi1sk
KeyType: RANGE
Projection:
ProjectionType: ALL
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: true
Tags:
- Key: Project
Value: HaloLight
// lib/rds.ts
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: {
rejectUnauthorized: false,
},
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
export async function query<T>(text: string, params?: any[]): Promise<T[]> {
const client = await pool.connect();
try {
const result = await client.query(text, params);
return result.rows as T[];
} finally {
client.release();
}
}
export async function getUsers() {
return query<User>("SELECT * FROM users ORDER BY created_at DESC");
}
// lib/cloudwatch.ts
import { CloudWatchClient, PutMetricDataCommand } from "@aws-sdk/client-cloudwatch";
const client = new CloudWatchClient({ region: process.env.AWS_REGION });
export async function putMetric(name: string, value: number, unit: string = "Count") {
const command = new PutMetricDataCommand({
Namespace: "HaloLight",
MetricData: [
{
MetricName: name,
Value: value,
Unit: unit,
Dimensions: [
{
Name: "Environment",
Value: process.env.NODE_ENV || "development",
},
],
},
],
});
await client.send(command);
}
// 使用示例
export async function trackApiRequest(endpoint: string, duration: number) {
await putMetric(`API_${endpoint}_Requests`, 1, "Count");
await putMetric(`API_${endpoint}_Duration`, duration, "Milliseconds");
}
# cloudformation/alarms.yml
AWSTemplateFormatVersion: '2010-09-09'
Description: HaloLight CloudWatch Alarms
Resources:
HighErrorRateAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: HaloLight-HighErrorRate
AlarmDescription: Alarm when error rate exceeds 5%
MetricName: 5XXError
Namespace: AWS/CloudFront
Statistic: Average
Period: 300
EvaluationPeriods: 2
Threshold: 5
ComparisonOperator: GreaterThanThreshold
AlarmActions:
- !Ref AlertSNSTopic
Dimensions:
- Name: DistributionId
Value: !Ref CloudFrontDistribution
AlertSNSTopic:
Type: AWS::SNS::Topic
Properties:
TopicName: halolight-alerts
Subscription:
- Protocol: email
Endpoint: [email protected]
# Amplify CLI
amplify status # 查看状态
amplify push # 推送后端更改
amplify publish # 发布前端和后端
amplify env list # 列出环境
amplify env checkout prod # 切换环境
amplify delete # 删除项目
# AWS CLI - S3
aws s3 ls # 列出存储桶
aws s3 sync ./out s3://bucket # 同步文件
aws s3 rm s3://bucket --recursive # 清空存储桶
# AWS CLI - CloudFront
aws cloudfront create-invalidation \
--distribution-id E1234567890 \
--paths "/*" # 刷新缓存
# AWS CLI - Lambda
aws lambda update-function-code \
--function-name halolight-ssr \
--zip-file fileb://function.zip # 更新函数
# AWS CLI - CloudWatch
aws logs tail /aws/lambda/halolight --follow # 查看日志
# AWS CLI - SSM
aws ssm get-parameter --name "/halolight/prod/DATABASE_URL" --with-decryption
# 创建托管区域
aws route53 create-hosted-zone \
--name halolight-aws.h7ml.cn \
--caller-reference $(date +%s)
# 添加 A 记录 (指向 CloudFront)
aws route53 change-resource-record-sets \
--hosted-zone-id Z1234567890 \
--change-batch '{
"Changes": [{
"Action": "CREATE",
"ResourceRecordSet": {
"Name": "halolight-aws.h7ml.cn",
"Type": "A",
"AliasTarget": {
"HostedZoneId": "Z2FDTNDATAQYW2",
"DNSName": "d1234567890.cloudfront.net",
"EvaluateTargetHealth": false
}
}
}]
}'
# 请求证书 (必须在 us-east-1 区域)
aws acm request-certificate \
--domain-name halolight-aws.h7ml.cn \
--validation-method DNS \
--region us-east-1
# 验证后关联到 CloudFront
aws cloudfront update-distribution \
--id E1234567890 \
--viewer-certificate '{
"ACMCertificateArn": "arn:aws:acm:us-east-1:123456789:certificate/xxx",
"SSLSupportMethod": "sni-only",
"MinimumProtocolVersion": "TLSv1.2_2021"
}'
A:检查以下几点:
amplify.yml 配置是否正确A:刷新缓存:
aws cloudfront create-invalidation \
--distribution-id E1234567890 \
--paths "/*"
A:注意事项:
us-east-1 区域创建A:使用 AWS CLI 或控制台:
# CLI 查看日志
aws logs tail /aws/lambda/halolight-ssr --follow
# 或在控制台
# CloudWatch → Log groups → /aws/lambda/halolight-ssr
| 服务 | 免费额度 | 超出价格 |
|---|---|---|
| Amplify 构建 | 1000 分钟/月 | $0.01/分钟 |
| Amplify 托管 | 15GB 存储,5GB 传输 | $0.023/GB |
| S3 存储 | 5GB | $0.025/GB |
| CloudFront | 1TB 传输/月 | $0.085/GB |
| Lambda@Edge | 1M 请求/月 | $0.60/M 请求 |
| Route 53 | - | $0.50/托管区域 |
| 特性 | AWS Amplify | Vercel | Netlify |
|---|---|---|---|
| 全球边缘 | ✅ CloudFront | ✅ | ✅ |
| SSR 支持 | ✅ Lambda@Edge | ✅ 原生 | ✅ |
| 托管数据库 | ✅ RDS/DynamoDB | ✅ Postgres | ❌ 需外部 |
| 免费带宽 | 5GB | 100GB | 100GB |
| 企业功能 | ✅ IAM/VPC | ⚠️ 有限 | ⚠️ 有限 |
| 学习曲线 | 较陡 | 平缓 | 平缓 |
HaloLight Azure 部署版本,面向企业级 Microsoft 生态的部署方案。
在线预览:https://halolight-azure.h7ml.cn
GitHub:https://github.com/halolight/halolight-azure
# 安装 Azure CLI
# macOS
brew install azure-cli
# Windows
winget install -e --id Microsoft.AzureCLI
# Linux
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
# 登录 Azure
az login
# 创建资源组
az group create \
--name halolight-rg \
--location eastasia
# 创建 Static Web App
az staticwebapp create \
--name halolight \
--resource-group halolight-rg \
--source https://github.com/halolight/halolight-azure \
--branch main \
--app-location "/" \
--output-location ".next" \
--login-with-github
# 查看部署状态
az staticwebapp show \
--name halolight \
--resource-group halolight-rg
{
"$schema": "https://json.schemastore.org/staticwebapp.config.json",
"navigationFallback": {
"rewrite": "/index.html",
"exclude": ["/api/*", "/_next/*", "/static/*", "*.{css,js,json,ico,png,jpg,svg}"]
},
"routes": [
{
"route": "/api/*",
"allowedRoles": ["authenticated"]
},
{
"route": "/admin/*",
"allowedRoles": ["admin"]
},
{
"route": "/login",
"rewrite": "/.auth/login/aad"
},
{
"route": "/logout",
"redirect": "/.auth/logout"
},
{
"route": "/.auth/login/github",
"statusCode": 404
}
],
"responseOverrides": {
"401": {
"redirect": "/.auth/login/aad",
"statusCode": 302
},
"404": {
"rewrite": "/404.html"
}
},
"globalHeaders": {
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"X-XSS-Protection": "1; mode=block",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';"
},
"mimeTypes": {
".json": "application/json",
".woff2": "font/woff2"
},
"platform": {
"apiRuntime": "node:20"
}
}
# .github/workflows/azure-static-web-apps.yml
name: Azure Static Web Apps CI/CD
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened, closed]
branches: [main]
jobs:
build_and_deploy:
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
runs-on: ubuntu-latest
name: Build and Deploy
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
env:
NEXT_PUBLIC_API_URL: /api
NEXT_PUBLIC_MOCK: false
- name: Deploy
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
action: "upload"
app_location: "/"
api_location: "api"
output_location: ".next"
close_pull_request:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
name: Close Pull Request
steps:
- name: Close Pull Request
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
action: "close"
在 Azure Portal → Static Web App → Configuration 设置:
| 变量名 | 说明 | 示例 |
|---|---|---|
NEXT_PUBLIC_API_URL |
API 基础 URL | /api |
NEXT_PUBLIC_MOCK |
启用 Mock 数据 | false |
NEXT_PUBLIC_APP_TITLE |
应用标题 | Admin Pro |
AZURE_AD_CLIENT_ID |
Azure AD 客户端 ID | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
AZURE_AD_TENANT_ID |
Azure AD 租户 ID | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
DATABASE_URL |
数据库连接字符串 | mongodb://... |
APPLICATIONINSIGHTS_CONNECTION_STRING |
Application Insights 连接 | InstrumentationKey=... |
# Azure CLI 设置
az staticwebapp appsettings set \
--name halolight \
--resource-group halolight-rg \
--setting-names \
NEXT_PUBLIC_API_URL=/api \
NEXT_PUBLIC_MOCK=false
# 查看设置
az staticwebapp appsettings list \
--name halolight \
--resource-group halolight-rg
// api/hello/index.ts
import { AzureFunction, Context, HttpRequest } from "@azure/functions";
const httpTrigger: AzureFunction = async function (
context: Context,
req: HttpRequest
): Promise<void> {
const name = req.query.name || req.body?.name || "World";
context.res = {
status: 200,
headers: {
"Content-Type": "application/json",
},
body: {
message: `Hello, ${name}!`,
timestamp: new Date().toISOString(),
},
};
};
export default httpTrigger;
{
"bindings": [
{
"authLevel": "anonymous",
"type": "httpTrigger",
"direction": "in",
"name": "req",
"methods": ["get", "post"],
"route": "hello"
},
{
"type": "http",
"direction": "out",
"name": "res"
}
]
}
// api/scheduled-task/index.ts
import { AzureFunction, Context } from "@azure/functions";
const timerTrigger: AzureFunction = async function (
context: Context,
myTimer: any
): Promise<void> {
const timestamp = new Date().toISOString();
if (myTimer.isPastDue) {
context.log("Timer is running late!");
}
context.log("Timer trigger executed at:", timestamp);
// 执行定时任务
await processScheduledTask();
};
export default timerTrigger;
// api/scheduled-task/function.json
{
"bindings": [
{
"name": "myTimer",
"type": "timerTrigger",
"direction": "in",
"schedule": "0 0 9 * * *"
}
]
}
// api/users/index.ts
import { AzureFunction, Context, HttpRequest } from "@azure/functions";
const httpTrigger: AzureFunction = async function (
context: Context,
req: HttpRequest,
documents: any[]
): Promise<void> {
context.res = {
status: 200,
body: documents,
};
};
export default httpTrigger;
// api/users/function.json
{
"bindings": [
{
"authLevel": "anonymous",
"type": "httpTrigger",
"direction": "in",
"name": "req",
"methods": ["get"]
},
{
"type": "http",
"direction": "out",
"name": "res"
},
{
"type": "cosmosDB",
"direction": "in",
"name": "documents",
"databaseName": "halolight",
"containerName": "users",
"connection": "CosmosDBConnection",
"sqlQuery": "SELECT * FROM c"
}
]
}
// lib/msal-config.ts
import { Configuration, PublicClientApplication } from "@azure/msal-browser";
const msalConfig: Configuration = {
auth: {
clientId: process.env.NEXT_PUBLIC_AZURE_AD_CLIENT_ID!,
authority: `https://login.microsoftonline.com/${process.env.NEXT_PUBLIC_AZURE_AD_TENANT_ID}`,
redirectUri: process.env.NEXT_PUBLIC_REDIRECT_URI || "/",
postLogoutRedirectUri: "/",
},
cache: {
cacheLocation: "sessionStorage",
storeAuthStateInCookie: false,
},
};
export const msalInstance = new PublicClientApplication(msalConfig);
export const loginRequest = {
scopes: ["User.Read", "openid", "profile", "email"],
};
export const graphConfig = {
graphMeEndpoint: "https://graph.microsoft.com/v1.0/me",
};
// providers/msal-provider.tsx
"use client";
import { MsalProvider as MsalProviderBase } from "@azure/msal-react";
import { msalInstance } from "@/lib/msal-config";
export function MsalProvider({ children }: { children: React.ReactNode }) {
return (
<MsalProviderBase instance={msalInstance}>
{children}
</MsalProviderBase>
);
}
// components/auth/azure-login.tsx
"use client";
import { useMsal } from "@azure/msal-react";
import { loginRequest } from "@/lib/msal-config";
import { Button } from "@/components/ui/button";
export function AzureLogin() {
const { instance, accounts } = useMsal();
const handleLogin = () => {
instance.loginRedirect(loginRequest);
};
const handleLogout = () => {
instance.logoutRedirect();
};
if (accounts.length > 0) {
return (
<div className="flex items-center gap-4">
<span>Welcome, {accounts[0].name}</span>
<Button onClick={handleLogout} variant="outline">
Logout
</Button>
</div>
);
}
return (
<Button onClick={handleLogin}>
Sign in with Microsoft
</Button>
);
}
// lib/graph.ts
import { graphConfig } from "./msal-config";
export async function getGraphData(accessToken: string) {
const response = await fetch(graphConfig.graphMeEndpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.json();
}
// lib/app-insights.ts
import { ApplicationInsights } from "@microsoft/applicationinsights-web";
import { ReactPlugin } from "@microsoft/applicationinsights-react-js";
const reactPlugin = new ReactPlugin();
const appInsights = new ApplicationInsights({
config: {
connectionString: process.env.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING,
extensions: [reactPlugin],
extensionConfig: {
[reactPlugin.identifier]: { history: null },
},
enableAutoRouteTracking: true,
enableCorsCorrelation: true,
enableRequestHeaderTracking: true,
enableResponseHeaderTracking: true,
},
});
appInsights.loadAppInsights();
export { appInsights, reactPlugin };
// lib/telemetry.ts
import { appInsights } from "./app-insights";
// 追踪页面浏览
export function trackPageView(name: string, properties?: Record<string, string>) {
appInsights.trackPageView({ name, properties });
}
// 追踪事件
export function trackEvent(name: string, properties?: Record<string, string>) {
appInsights.trackEvent({ name, properties });
}
// 追踪异常
export function trackException(error: Error, severityLevel?: number) {
appInsights.trackException({ exception: error, severityLevel });
}
// 追踪指标
export function trackMetric(name: string, average: number) {
appInsights.trackMetric({ name, average });
}
// lib/cosmos.ts
import { CosmosClient, Database, Container } from "@azure/cosmos";
const client = new CosmosClient(process.env.COSMOS_CONNECTION_STRING!);
let database: Database;
let usersContainer: Container;
export async function initCosmosDB() {
const { database: db } = await client.databases.createIfNotExists({
id: "halolight",
});
database = db;
const { container } = await database.containers.createIfNotExists({
id: "users",
partitionKey: { paths: ["/id"] },
});
usersContainer = container;
return { database, usersContainer };
}
export async function getUsers() {
const { resources } = await usersContainer.items
.query("SELECT * FROM c")
.fetchAll();
return resources;
}
export async function createUser(user: any) {
const { resource } = await usersContainer.items.create(user);
return resource;
}
# 登录
az login
# 资源组管理
az group list
az group create --name halolight-rg --location eastasia
az group delete --name halolight-rg
# Static Web App 管理
az staticwebapp list
az staticwebapp show --name halolight --resource-group halolight-rg
az staticwebapp delete --name halolight --resource-group halolight-rg
# 环境配置
az staticwebapp appsettings set --name halolight --setting-names KEY=value
az staticwebapp appsettings list --name halolight
# 自定义域名
az staticwebapp hostname set --name halolight --hostname halolight-azure.h7ml.cn
az staticwebapp hostname list --name halolight
# Functions 管理
az functionapp list
az functionapp log tail --name halolight-api
# 部署
az staticwebapp environment list --name halolight
az staticwebapp users list --name halolight
# CLI 方式
az staticwebapp hostname set \
--name halolight \
--hostname halolight-azure.h7ml.cn
# 查看域名配置
az staticwebapp hostname list --name halolight
# CNAME 记录
类型: CNAME
名称: halolight-azure
值: <app-name>.azurestaticapps.net
# TXT 记录 (验证所有权)
类型: TXT
名称: _dnsauth.halolight-azure
值: <validation-token>
Azure Static Web Apps 自动配置 HTTPS:
A:检查以下几点:
app_location 和 output_location 配置正确A:检查 staticwebapp.config.json 配置:
api_location 指向正确目录A:检查以下配置:
A:在 staticwebapp.config.json 中配置:
{
"globalHeaders": {
"Access-Control-Allow-Origin": "https://your-domain.com",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
}
}
| 计划 | 价格 | 特性 |
|---|---|---|
| Free | 免费 | 100GB 带宽,2 自定义域名,内置身份验证 |
| Standard | $9/应用/月 | 无限带宽,5 自定义域名,SLA 99.95% |
| Enterprise | 联系销售 | 私有端点,企业 SLA,专属支持 |
| 资源 | 免费额度 | 超出价格 |
|---|---|---|
| 执行次数 | 100 万次/月 | $0.20/百万次 |
| 资源消耗 | 40 万 GB-s/月 | $0.000016/GB-s |
| 特性 | Azure Static Web Apps | Vercel | Netlify |
|---|---|---|---|
| 全球边缘 | ✅ | ✅ | ✅ |
| Serverless Functions | ✅ Azure Functions | ✅ Edge/Serverless | ✅ Functions |
| 企业 SSO | ✅ Azure AD 原生 | ⚠️ 需集成 | ⚠️ 需集成 |
| 托管数据库 | ✅ Cosmos DB/SQL | ❌ 需外部 | ❌ 需外部 |
| 免费额度 | 100GB | 100GB | 100GB |
| 企业合规 | ✅ SOC/ISO/HIPAA | ⚠️ 有限 | ⚠️ 有限 |
基于 tRPC 11 + Express 5 构建的类型安全 API 网关,为前端应用提供统一的端到端类型安全接口层。
API 文档:https://halolight-bff.h7ml.cn
GitHub:https://github.com/halolight/halolight-bff
| 技术 | 版本 | 说明 |
|---|---|---|
| TypeScript | 5.9 | 编程语言 |
| tRPC | 11 | RPC 框架 |
| Zod | - | 数据验证 |
| Express | 5 | Web 服务器 |
| SuperJSON | - | 序列化 |
| JWT | - | 身份认证 |
| Pino | - | 日志系统 |
# 克隆仓库
git clone https://github.com/halolight/halolight-bff.git
cd halolight-bff
# 安装依赖
pnpm install
cp .env.example .env
# 服务器配置
PORT=3002
HOST=0.0.0.0
NODE_ENV=development
# JWT 密钥(生产环境必须修改)
JWT_SECRET=your-super-secret-key-at-least-32-characters-long
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
# CORS 配置
CORS_ORIGIN=*
# 日志级别
LOG_LEVEL=info
# 后端服务注册(至少配置一个)
HALOLIGHT_API_PYTHON_URL=http://localhost:8000
HALOLIGHT_API_BUN_URL=http://localhost:3000
HALOLIGHT_API_JAVA_URL=http://localhost:8080
HALOLIGHT_API_NESTJS_URL=http://localhost:3001
HALOLIGHT_API_NODE_URL=http://localhost:3003
HALOLIGHT_API_GO_URL=http://localhost:8081
无需数据库 (API 网关不直接操作数据库)。
# 开发模式(热重载)
pnpm dev
# 生产模式
pnpm build
pnpm start
halolight-bff/
├── src/
│ ├── index.ts # 应用入口
│ ├── server.ts # Express 服务器 + tRPC 适配器
│ ├── trpc.ts # tRPC 实例和 procedure 定义
│ ├── context.ts # Context 创建(用户、追踪、服务)
│ ├── routers/
│ │ ├── index.ts # 根 router(组合所有模块)
│ │ ├── auth.ts # 认证模块(8 个端点)
│ │ ├── users.ts # 用户管理(8 个端点)
│ │ ├── dashboard.ts # 仪表盘统计(9 个端点)
│ │ ├── permissions.ts # 权限管理(7 个端点)
│ │ ├── roles.ts # 角色管理(8 个端点)
│ │ ├── teams.ts # 团队管理(9 个端点)
│ │ ├── folders.ts # 文件夹管理(8 个端点)
│ │ ├── files.ts # 文件管理(9 个端点)
│ │ ├── documents.ts # 文档管理(10 个端点)
│ │ ├── calendar.ts # 日历事件(10 个端点)
│ │ ├── notifications.ts # 通知(7 个端点)
│ │ └── messages.ts # 消息/聊天(9 个端点)
│ ├── middleware/
│ │ └── auth.ts # JWT 认证/授权中间件
│ ├── services/
│ │ ├── httpClient.ts # HTTP 客户端(后端通信)
│ │ └── serviceRegistry.ts # 后端服务注册表
│ └── schemas/
│ ├── index.ts # Schema 导出
│ └── common.ts # 通用 Zod schemas(分页、排序、响应)
├── .env.example # 环境变量模板
├── .github/workflows/ # CI/CD 配置
├── Dockerfile # Docker 镜像构建
├── docker-compose.yml # Docker Compose 配置
├── package.json # 依赖配置
└── tsconfig.json # TypeScript 配置
HaloLight BFF 提供 12 个核心业务模块,覆盖 100+ tRPC 端点:
| 模块 | 端点数 | 描述 |
|---|---|---|
| auth | 8 | 登录、注册、令牌刷新、登出、密码管理 |
| users | 8 | 用户 CRUD、角色/状态管理、个人资料 |
| dashboard | 9 | 统计数据、访问趋势、销售数据、任务、日程 |
| permissions | 7 | 权限 CRUD、树结构、模块权限、批量操作 |
| roles | 8 | 角色 CRUD、权限分配、用户关联 |
| teams | 9 | 团队 CRUD、成员管理、邀请、权限 |
| folders | 8 | 文件夹 CRUD、树结构、移动、面包屑 |
| files | 9 | 文件 CRUD、上传、下载、移动、复制、共享 |
| documents | 10 | 文档 CRUD、版本控制、协作、分享 |
| calendar | 10 | 事件 CRUD、参与者管理、RSVP、提醒 |
| notifications | 7 | 通知列表、未读数、标记已读、批量删除 |
| messages | 9 | 对话管理、消息 CRUD、发送、已读状态 |
| Procedure | 类型 | 描述 | 权限 |
|---|---|---|---|
auth.login |
mutation | 用户登录 | 公开 |
auth.register |
mutation | 用户注册 | 公开 |
auth.refresh |
mutation | 刷新令牌 | 公开 |
auth.logout |
mutation | 退出登录 | 需认证 |
auth.forgotPassword |
mutation | 忘记密码 | 公开 |
auth.resetPassword |
mutation | 重置密码 | 公开 |
auth.verifyEmail |
mutation | 验证邮箱 | 公开 |
auth.changePassword |
mutation | 修改密码 | 需认证 |
| Procedure | 类型 | 描述 | 权限 |
|---|---|---|---|
users.list |
query | 获取用户列表 | users:view |
users.byId |
query | 获取用户详情 | users:view |
users.me |
query | 获取当前用户 | 需认证 |
users.create |
mutation | 创建用户 | users:create |
users.update |
mutation | 更新用户 | users:update |
users.delete |
mutation | 删除用户 | users:delete |
users.updateRole |
mutation | 更新用户角色 | users:update |
users.updateStatus |
mutation | 更新用户状态 | users:update |
| Procedure | 类型 | 描述 |
|---|---|---|
dashboard.getStats |
query | 统计数据(用户、文档、文件、任务) |
dashboard.getVisits |
query | 访问趋势(7天/30天) |
dashboard.getSales |
query | 销售数据(折线图) |
dashboard.getPieData |
query | 饼图数据(分类占比) |
dashboard.getTasks |
query | 待办任务列表 |
dashboard.getCalendar |
query | 今日日程 |
dashboard.getActivities |
query | 最近活动 |
dashboard.getNotifications |
query | 最新通知 |
dashboard.getProgress |
query | 项目进度 |
| Procedure | 类型 | 描述 |
|---|---|---|
permissions.list |
query | 获取权限列表 |
permissions.tree |
query | 获取权限树 |
permissions.byId |
query | 获取权限详情 |
permissions.create |
mutation | 创建权限 |
permissions.update |
mutation | 更新权限 |
permissions.delete |
mutation | 删除权限 |
permissions.modules |
query | 获取权限模块 |
| Procedure | 类型 | 描述 |
|---|---|---|
roles.list |
query | 获取角色列表 |
roles.byId |
query | 获取角色详情 |
roles.create |
mutation | 创建角色 |
roles.update |
mutation | 更新角色 |
roles.delete |
mutation | 删除角色 |
roles.assignPermissions |
mutation | 分配权限 |
roles.removePermissions |
mutation | 移除权限 |
roles.users |
query | 获取角色下的用户 |
| Procedure | 类型 | 描述 |
|---|---|---|
teams.list |
query | 获取团队列表 |
teams.byId |
query | 获取团队详情 |
teams.create |
mutation | 创建团队 |
teams.update |
mutation | 更新团队 |
teams.delete |
mutation | 删除团队 |
teams.addMember |
mutation | 添加成员 |
teams.removeMember |
mutation | 移除成员 |
teams.updateMemberRole |
mutation | 更新成员角色 |
teams.members |
query | 获取团队成员 |
| Procedure | 类型 | 描述 |
|---|---|---|
folders.list |
query | 获取文件夹列表 |
folders.tree |
query | 获取文件夹树 |
folders.byId |
query | 获取文件夹详情 |
folders.create |
mutation | 创建文件夹 |
folders.update |
mutation | 更新文件夹 |
folders.delete |
mutation | 删除文件夹 |
folders.move |
mutation | 移动文件夹 |
folders.breadcrumb |
query | 获取面包屑路径 |
| Procedure | 类型 | 描述 |
|---|---|---|
files.list |
query | 获取文件列表 |
files.byId |
query | 获取文件详情 |
files.upload |
mutation | 上传文件 |
files.update |
mutation | 更新文件信息 |
files.delete |
mutation | 删除文件 |
files.move |
mutation | 移动文件 |
files.copy |
mutation | 复制文件 |
files.download |
query | 获取下载链接 |
files.share |
mutation | 共享文件 |
| Procedure | 类型 | 描述 |
|---|---|---|
documents.list |
query | 获取文档列表 |
documents.byId |
query | 获取文档详情 |
documents.create |
mutation | 创建文档 |
documents.update |
mutation | 更新文档 |
documents.delete |
mutation | 删除文档 |
documents.versions |
query | 获取版本历史 |
documents.restore |
mutation | 恢复版本 |
documents.share |
mutation | 共享文档 |
documents.unshare |
mutation | 取消共享 |
documents.collaborators |
query | 获取协作者 |
| Procedure | 类型 | 描述 |
|---|---|---|
calendar.events |
query | 获取日程列表 |
calendar.byId |
query | 获取日程详情 |
calendar.create |
mutation | 创建日程 |
calendar.update |
mutation | 更新日程 |
calendar.delete |
mutation | 删除日程 |
calendar.addAttendee |
mutation | 添加参与者 |
calendar.removeAttendee |
mutation | 移除参与者 |
calendar.rsvp |
mutation | RSVP 响应 |
calendar.setReminder |
mutation | 设置提醒 |
calendar.byMonth |
query | 按月获取日程 |
| Procedure | 类型 | 描述 |
|---|---|---|
notifications.list |
query | 获取通知列表 |
notifications.unreadCount |
query | 获取未读数 |
notifications.markRead |
mutation | 标记已读 |
notifications.markAllRead |
mutation | 全部已读 |
notifications.delete |
mutation | 删除通知 |
notifications.deleteAll |
mutation | 删除全部 |
notifications.preferences |
query | 获取通知偏好 |
| Procedure | 类型 | 描述 |
|---|---|---|
messages.conversations |
query | 获取对话列表 |
messages.byConversation |
query | 获取对话消息 |
messages.send |
mutation | 发送消息 |
messages.markRead |
mutation | 标记已读 |
messages.delete |
mutation | 删除消息 |
messages.createConversation |
mutation | 创建对话 |
messages.deleteConversation |
mutation | 删除对话 |
messages.search |
query | 搜索消息 |
messages.unreadCount |
query | 获取未读数 |
tRPC 提供三种 procedure 类型:
// 公开端点 - 无需认证
export const publicProcedure = t.procedure;
// 受保护端点 - 需要有效 JWT
export const protectedProcedure = t.procedure.use(isAuthenticated);
// 管理员端点 - 需要 admin 角色
export const adminProcedure = t.procedure.use(isAdmin);
使用示例:
export const usersRouter = router({
// Query - 查询数据
list: protectedProcedure
.input(z.object({
page: z.number().default(1),
limit: z.number().default(10),
keyword: z.string().optional(),
}))
.query(async ({ input, ctx }) => {
// ctx.user 包含已认证用户信息
const client = ctx.services.getDefault();
const data = await client.get('/api/users', { query: input });
return { code: 200, message: 'success', data };
}),
// Mutation - 修改数据
create: adminProcedure
.input(z.object({
name: z.string().min(2),
email: z.string().email(),
role: z.string(),
}))
.mutation(async ({ input, ctx }) => {
const client = ctx.services.getDefault();
const data = await client.post('/api/users', { body: input });
return { code: 201, message: 'Created', data };
}),
});
每个请求都会创建一个独立的 context:
interface Context {
req: Request; // Express 请求对象
res: Response; // Express 响应对象
user: JWTPayload | null; // 已认证用户(通过 JWT)
traceId: string; // 分布式追踪 ID(UUID)
services: ServiceRegistry; // 后端服务注册表
}
Context 创建流程:
Authorization 头中的 JWT TokentraceId (用于分布式追踪)ServiceRegistry (后端服务集合)interface JWTPayload {
id: string; // 用户 ID
name: string; // 用户名
email: string; // 邮箱
role: {
id: string; // 角色 ID
name: string; // 角色名称(如 admin, user)
label: string; // 角色显示名称
permissions: string[]; // 权限列表(如 ["users:*", "documents:view"])
};
}
Token 使用:
// 客户端发送请求
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
// 服务端自动解析并注入到 ctx.user
const userId = ctx.user.id;
const userPermissions = ctx.user.role.permissions;
支持灵活的通配符权限匹配:
| 权限格式 | 说明 | 示例 |
|---|---|---|
* |
所有权限(超级管理员) | 可执行任何操作 |
{resource}:* |
模块所有操作 | users:* = 用户模块所有权限 |
{resource}:{action} |
特定操作 | users:view = 仅查看用户 |
权限检查示例:
// 在 middleware 中检查权限
export const requirePermission = (permission: string) => {
return t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const hasPermission = ctx.user.role.permissions.some(p =>
p === '*' ||
p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
);
if (!hasPermission) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return next();
});
};
// 使用
export const deleteUser = protectedProcedure
.use(requirePermission('users:delete'))
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
// 只有拥有 users:delete 权限的用户可以执行
});
通过环境变量配置多个后端服务:
# Python FastAPI
HALOLIGHT_API_PYTHON_URL=http://api-python:8000
# Bun Hono
HALOLIGHT_API_BUN_URL=http://api-bun:3000
# Java Spring Boot
HALOLIGHT_API_JAVA_URL=http://api-java:8080
# Go Fiber
HALOLIGHT_API_GO_URL=http://api-go:8081
服务优先级:按配置顺序,第一个可用的服务作为默认服务。
使用示例:
// 使用默认服务(优先级最高的)
const client = ctx.services.getDefault();
const data = await client.get('/api/users');
// 使用特定服务
const pythonClient = ctx.services.get('python');
const stats = await pythonClient.get('/api/dashboard/stats');
// 故障转移:如果默认服务不可用,自动切换到下一个服务
try {
const data = await ctx.services.getDefault().get('/api/users');
} catch (error) {
// ServiceRegistry 自动重试其他服务
}
所有 API 遵循统一的响应结构:
// 标准响应
interface APIResponse<T> {
code: number; // HTTP 状态码(200, 201, 400, 500...)
message: string; // 人类可读消息(success, error, ...)
data: T | null; // 响应数据(成功时)或 null(失败时)
}
// 分页响应
interface PaginatedResponse<T> {
code: number;
message: string;
data: {
list: T[]; // 数据列表
total: number; // 总记录数
page: number; // 当前页码
limit: number; // 每页条数
totalPages?: number; // 总页数(可选)
};
}
示例:
// 成功响应
{
"code": 200,
"message": "success",
"data": {
"id": "1",
"name": "John Doe",
"email": "[email protected]"
}
}
// 分页响应
{
"code": 200,
"message": "success",
"data": {
"list": [{ "id": "1", "name": "User 1" }],
"total": 100,
"page": 1,
"limit": 10,
"totalPages": 10
}
}
// 错误响应(tRPC 自动格式化)
{
"error": {
"code": "UNAUTHORIZED",
"message": "Not authenticated"
}
}
Access Token: 15 分钟有效期,用于 API 请求
Refresh Token: 7 天有效期,用于刷新 Access Token
Authorization: Bearer <access_token>
// 客户端示例
const refreshToken = async () => {
const refreshToken = localStorage.getItem('refreshToken');
const result = await trpc.auth.refresh.mutate({ refreshToken });
localStorage.setItem('accessToken', result.data.accessToken);
localStorage.setItem('refreshToken', result.data.refreshToken);
return result.data.accessToken;
};
// tRPC 客户端配置 - 自动刷新
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3002/trpc',
async headers() {
let token = localStorage.getItem('accessToken');
// 如果 token 过期,自动刷新
if (isTokenExpired(token)) {
token = await refreshToken();
}
return {
authorization: `Bearer ${token}`,
};
},
}),
],
});
import { TRPCError } from '@trpc/server';
// 400 - 请求参数错误
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid input',
});
// 401 - 未认证
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Not authenticated',
});
// 403 - 无权限
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Insufficient permissions',
});
// 404 - 资源不存在
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Resource not found',
});
// 409 - 资源冲突
throw new TRPCError({
code: 'CONFLICT',
message: 'Email already exists',
});
// 500 - 服务器错误
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Something went wrong',
});
{
"error": {
"code": "UNAUTHORIZED",
"message": "Not authenticated",
"data": {
"code": "UNAUTHORIZED",
"httpStatus": 401,
"path": "auth.login"
}
}
}
import { createTRPCReact } from '@trpc/react-query';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import type { AppRouter } from 'halolight-bff';
// 创建 tRPC React hooks
const trpc = createTRPCReact<AppRouter>();
// 创建 tRPC 客户端
const trpcClient = trpc.createClient({
transformer: superjson, // 支持 Date、Map、Set 等复杂类型
links: [
httpBatchLink({
url: 'http://localhost:3002/trpc',
headers() {
return {
authorization: `Bearer ${localStorage.getItem('token')}`,
};
},
}),
],
});
// 创建 React Query 客户端
const queryClient = new QueryClient();
// 根组件
function App() {
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<UserList />
</QueryClientProvider>
</trpc.Provider>
);
}
// 使用 tRPC hooks
function UserList() {
// Query - 自动管理加载状态、缓存、重新获取
const { data, isLoading, error } = trpc.users.list.useQuery({
page: 1,
limit: 10,
});
// Mutation - 自动管理加载状态、错误处理
const createUser = trpc.users.create.useMutation({
onSuccess: () => {
// 自动刷新用户列表
trpc.users.list.invalidate();
},
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<button onClick={() => createUser.mutate({
name: 'New User',
email: '[email protected]',
role: 'user',
})}>
Create User
</button>
{data?.data.list.map(user => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
// app/api/trpc/[trpc]/route.ts - tRPC API 路由
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => ({}),
});
export { handler as GET, handler as POST };
// app/providers.tsx - tRPC Provider
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query';
import superjson from 'superjson';
import type { AppRouter } from '@/server/routers';
const trpc = createTRPCReact<AppRouter>();
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
transformer: superjson,
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
// app/page.tsx - Server Component
import { createCaller } from '@/server/routers';
export default async function Page() {
const caller = createCaller({ req: {}, res: {}, user: null });
const stats = await caller.dashboard.getStats();
return <div>Total Users: {stats.data.totalUsers}</div>;
}
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query';
import superjson from 'superjson';
import type { AppRouter } from 'halolight-bff';
// 创建 tRPC 客户端
const trpc = createTRPCProxyClient<AppRouter>({
transformer: superjson,
links: [
httpBatchLink({
url: 'http://localhost:3002/trpc',
headers() {
return {
authorization: `Bearer ${localStorage.getItem('token')}`,
};
},
}),
],
});
// 在组件中使用
export default {
setup() {
const queryClient = useQueryClient();
// Query
const { data, isLoading } = useQuery({
queryKey: ['users', { page: 1 }],
queryFn: () => trpc.users.list.query({ page: 1, limit: 10 }),
});
// Mutation
const createUser = useMutation({
mutationFn: (user) => trpc.users.create.mutate(user),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
return { data, isLoading, createUser };
},
};
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from 'halolight-bff';
import superjson from 'superjson';
const client = createTRPCProxyClient<AppRouter>({
transformer: superjson,
links: [
httpBatchLink({
url: 'http://localhost:3002/trpc',
headers() {
return {
authorization: `Bearer ${localStorage.getItem('token')}`,
};
},
}),
],
});
// 使用(完整类型推导)
const users = await client.users.list.query({ page: 1 });
console.log(users.data.list); // TS 自动推导类型
const newUser = await client.users.create.mutate({
name: 'John',
email: '[email protected]',
role: 'user',
});
// src/routers/products.ts
import { z } from 'zod';
import { router, protectedProcedure, adminProcedure } from '../trpc';
export const productsRouter = router({
// Query - 获取产品列表
list: protectedProcedure
.input(z.object({
page: z.number().default(1),
limit: z.number().default(10),
category: z.string().optional(),
}))
.query(async ({ input, ctx }) => {
const client = ctx.services.getDefault();
const data = await client.get('/api/products', { query: input });
return { code: 200, message: 'success', data };
}),
// Query - 获取产品详情
byId: protectedProcedure
.input(z.object({
id: z.string(),
}))
.query(async ({ input, ctx }) => {
const client = ctx.services.getDefault();
const data = await client.get(`/api/products/${input.id}`);
return { code: 200, message: 'success', data };
}),
// Mutation - 创建产品(需要管理员权限)
create: adminProcedure
.input(z.object({
name: z.string().min(2),
price: z.number().positive(),
category: z.string(),
}))
.mutation(async ({ input, ctx }) => {
const client = ctx.services.getDefault();
const data = await client.post('/api/products', { body: input });
return { code: 201, message: 'Created', data };
}),
// Mutation - 更新产品
update: adminProcedure
.input(z.object({
id: z.string(),
name: z.string().min(2).optional(),
price: z.number().positive().optional(),
}))
.mutation(async ({ input, ctx }) => {
const { id, ...updateData } = input;
const client = ctx.services.getDefault();
const data = await client.put(`/api/products/${id}`, { body: updateData });
return { code: 200, message: 'Updated', data };
}),
// Mutation - 删除产品
delete: adminProcedure
.input(z.object({
id: z.string(),
}))
.mutation(async ({ input, ctx }) => {
const client = ctx.services.getDefault();
await client.delete(`/api/products/${input.id}`);
return { code: 200, message: 'Deleted', data: null };
}),
});
// src/routers/index.ts
import { router } from '../trpc';
import { authRouter } from './auth';
import { usersRouter } from './users';
import { productsRouter } from './products'; // 导入新 router
export const appRouter = router({
auth: authRouter,
users: usersRouter,
products: productsRouter, // 注册新 router
// ... 其他 routers
});
export type AppRouter = typeof appRouter;
// 类型自动推导,无需手动定义
const products = await trpc.products.list.query({ page: 1 });
const product = await trpc.products.byId.query({ id: '1' });
const newProduct = await trpc.products.create.mutate({
name: 'iPhone 15',
price: 999,
category: 'electronics',
});
// src/middleware/rateLimit.ts
import { TRPCError } from '@trpc/server';
import { t } from '../trpc';
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
export const rateLimit = (maxRequests: number, windowMs: number) => {
return t.middleware(({ ctx, next }) => {
const key = ctx.user?.id || ctx.req.ip;
const now = Date.now();
const record = rateLimitMap.get(key);
if (!record || now > record.resetAt) {
rateLimitMap.set(key, { count: 1, resetAt: now + windowMs });
return next();
}
if (record.count >= maxRequests) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Rate limit exceeded',
});
}
record.count++;
return next();
});
};
// 使用
export const limitedProcedure = protectedProcedure.use(
rateLimit(10, 60000) // 每分钟最多 10 个请求
);
// src/schemas/product.ts
import { z } from 'zod';
export const productSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2).max(100),
price: z.number().positive(),
category: z.enum(['electronics', 'clothing', 'books']),
stock: z.number().int().nonnegative(),
createdAt: z.date(),
updatedAt: z.date(),
});
export const createProductSchema = productSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const updateProductSchema = createProductSchema.partial();
// 在 router 中使用
export const productsRouter = router({
create: adminProcedure
.input(createProductSchema)
.mutation(async ({ input, ctx }) => {
// input 已经过 Zod 验证,类型安全
}),
update: adminProcedure
.input(z.object({
id: z.string(),
data: updateProductSchema,
}))
.mutation(async ({ input, ctx }) => {
// ...
}),
});
# 开发
pnpm dev # 启动开发服务器(热重载)
pnpm dev:watch # 启动开发服务器(文件监听)
# 构建
pnpm build # 构建生产版本
pnpm start # 启动生产服务器
# 测试
pnpm test # 运行测试
pnpm test:watch # 监听模式运行测试
pnpm test:coverage # 生成测试覆盖率
# 代码质量
pnpm lint # 运行 ESLint
pnpm lint:fix # 自动修复 lint 错误
pnpm type-check # TypeScript 类型检查
pnpm format # Prettier 格式化代码
# 构建镜像
docker build -t halolight-bff .
# 运行容器
docker run -p 3002:3002 \
-e JWT_SECRET=your-secret-key \
-e HALOLIGHT_API_PYTHON_URL=http://api-python:8000 \
halolight-bff
# docker-compose.yml
version: '3.8'
services:
bff:
build: .
ports:
- "3002:3002"
environment:
- NODE_ENV=production
- JWT_SECRET=${JWT_SECRET}
- HALOLIGHT_API_PYTHON_URL=http://api-python:8000
- HALOLIGHT_API_BUN_URL=http://api-bun:3000
- HALOLIGHT_API_JAVA_URL=http://api-java:8080
depends_on:
- api-python
- api-bun
- api-java
restart: unless-stopped
api-python:
image: halolight-api-python
ports:
- "8000:8000"
api-bun:
image: halolight-api-bun
ports:
- "3000:3000"
api-java:
image: halolight-api-java
ports:
- "8080:8080"
docker-compose up -d
NODE_ENV=production
PORT=3002
HOST=0.0.0.0
# 强密钥(至少 32 字符)
JWT_SECRET=your-production-secret-key-with-at-least-32-characters
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
# 限制 CORS
CORS_ORIGIN=https://your-frontend.com
# 生产日志
LOG_LEVEL=warn
# 后端服务
HALOLIGHT_API_PYTHON_URL=https://api-python.production.com
HALOLIGHT_API_BUN_URL=https://api-bun.production.com
HALOLIGHT_API_JAVA_URL=https://api-java.production.com
tRPC 自动批处理多个并发请求,减少网络开销:
// 客户端配置
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:3002/trpc',
maxURLLength: 2083, // 最大 URL 长度
}),
],
});
// 这三个请求会自动批处理为一个 HTTP 请求
const [users, stats, notifications] = await Promise.all([
trpc.users.list.query({ page: 1 }),
trpc.dashboard.getStats.query(),
trpc.notifications.unreadCount.query(),
]);
import DataLoader from 'dataloader';
// 创建 DataLoader
const userLoader = new DataLoader(async (ids: string[]) => {
const users = await db.user.findMany({
where: { id: { in: ids } },
});
return ids.map(id => users.find(u => u.id === id));
});
// 在 context 中注入
export const createContext = (opts: CreateExpressContextOptions) => {
return {
...opts,
loaders: {
user: userLoader,
},
};
};
// 在 router 中使用
export const postsRouter = router({
list: protectedProcedure.query(async ({ ctx }) => {
const posts = await db.post.findMany();
// 批量加载作者信息,避免 N+1 查询
const postsWithAuthors = await Promise.all(
posts.map(async (post) => ({
...post,
author: await ctx.loaders.user.load(post.authorId),
}))
);
return postsWithAuthors;
}),
});
// 使用 Redis 缓存
import Redis from 'ioredis';
const redis = new Redis();
export const dashboardRouter = router({
getStats: protectedProcedure.query(async ({ ctx }) => {
const cacheKey = `dashboard:stats:${ctx.user.id}`;
// 尝试从缓存获取
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// 从后端服务获取
const client = ctx.services.getDefault();
const data = await client.get('/api/dashboard/stats');
// 缓存 5 分钟
await redis.setex(cacheKey, 300, JSON.stringify(data));
return data;
}),
});
import rateLimit from 'express-rate-limit';
// 在 Express 中配置全局限流
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 分钟
max: 100, // 最多 100 个请求
message: 'Too many requests from this IP',
});
app.use('/trpc', limiter);
# 生成强密钥(至少 32 字符)
openssl rand -base64 32
# 在 .env 中配置
JWT_SECRET=your-generated-secret-key-with-at-least-32-characters
// 在生产环境强制使用 HTTPS
if (process.env.NODE_ENV === 'production') {
app.use((req, res, next) => {
if (!req.secure) {
return res.redirect(`https://${req.headers.host}${req.url}`);
}
next();
});
}
# 只允许特定源
CORS_ORIGIN=https://your-frontend.com
# 或多个源(逗号分隔)
CORS_ORIGIN=https://app1.com,https://app2.com
// 使用 Zod 严格验证所有输入
export const createUser = protectedProcedure
.input(z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
age: z.number().int().positive().max(150),
role: z.enum(['admin', 'user']),
}))
.mutation(async ({ input, ctx }) => {
// input 已经过严格验证
});
// 使用 Pino redact 配置
const logger = pino({
redact: {
paths: [
'req.headers.authorization',
'req.body.password',
'req.body.token',
'res.headers["set-cookie"]',
],
remove: true, // 完全移除敏感字段
},
});
// 使用 Pino 结构化日志
import pino from 'pino';
import pinoHttp from 'pino-http';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
},
});
// HTTP 请求日志
app.use(pinoHttp({ logger }));
// 在 router 中使用
export const usersRouter = router({
create: adminProcedure.mutation(async ({ input, ctx }) => {
logger.info({ userId: ctx.user.id, input }, 'Creating user');
try {
const data = await createUser(input);
logger.info({ userId: ctx.user.id, data }, 'User created');
return data;
} catch (error) {
logger.error({ userId: ctx.user.id, error }, 'Failed to create user');
throw error;
}
}),
});
// 健康检查端点
app.get('/health', async (req, res) => {
try {
// 检查后端服务连接
const services = await Promise.all([
fetch(`${process.env.HALOLIGHT_API_PYTHON_URL}/health`),
fetch(`${process.env.HALOLIGHT_API_BUN_URL}/health`),
]);
const allHealthy = services.every(s => s.ok);
res.status(allHealthy ? 200 : 503).json({
status: allHealthy ? 'healthy' : 'unhealthy',
timestamp: new Date().toISOString(),
services: {
python: services[0].ok,
bun: services[1].ok,
},
});
} catch (error) {
res.status(503).json({
status: 'unhealthy',
error: error.message,
});
}
});
// Prometheus 指标
import promClient from 'prom-client';
// 创建注册表
const register = new promClient.Registry();
// 收集默认指标
promClient.collectDefaultMetrics({ register });
// 自定义指标
const httpRequestDuration = new promClient.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code'],
registers: [register],
});
// 暴露指标端点
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});
A:修改 .env 中的 PORT 配置,或者终止占用端口的进程:
# 查找占用端口的进程
lsof -i :3002
# 终止进程
kill -9 <PID>
# 或修改端口
echo "PORT=3003" >> .env
A:更新 .env 中的 CORS_ORIGIN 为允许的源地址:
# 开发环境允许所有源
CORS_ORIGIN=*
# 生产环境指定源
CORS_ORIGIN=https://your-frontend.com
A:确保 JWT_SECRET 在所有环境中保持一致:
# 检查 JWT_SECRET 是否一致
echo $JWT_SECRET
# 重新生成 Token
curl -X POST http://localhost:3002/trpc/auth.login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"password"}'
A:检查后端服务是否正常运行,以及 URL 配置是否正确:
# 检查服务健康状态
curl http://localhost:8000/health
curl http://localhost:3000/health
# 检查环境变量
echo $HALOLIGHT_API_PYTHON_URL
echo $HALOLIGHT_API_BUN_URL
# 测试连接
curl http://localhost:3002/health
A:确保正确导出 AppRouter 类型,并在客户端正确引入:
// 服务端 - src/routers/index.ts
export const appRouter = router({
// ... routers
});
export type AppRouter = typeof appRouter; // 必须导出类型
// 客户端 - 确保从正确的路径导入
import type { AppRouter } from 'halolight-bff'; // NPM 包
// 或
import type { AppRouter } from '@/server/routers'; // Monorepo
| 特性 | tRPC BFF | GraphQL | REST API | gRPC |
|---|---|---|---|---|
| 类型安全 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
| 开发体验 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
| 性能 | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 学习曲线 | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ |
| 生态系统 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 文档 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
HaloLight Cloudflare 部署版本,基于 Next.js 15 App Router + React 19 构建,使用 @opennextjs/cloudflare 适配器部署到 Cloudflare Workers/Pages 边缘运行时,实现全球 300+ 节点低延迟访问。
在线预览:https://halolight-cloudflare.h7ml.cn/
GitHub:https://github.com/halolight/halolight-cloudflare
| 特性 | 原版 (Next.js) | Cloudflare 版 |
|---|---|---|
| Next.js | 14.x | 15.5.x |
| React | 18.x | 19.x |
| 运行时 | Node.js (Vercel) | Cloudflare Workers (Edge) |
| 部署平台 | Vercel / Docker | Cloudflare Pages |
| 开发工具 | webpack | Turbopack |
| 部署命令 | pnpm build && pnpm start |
pnpm deploy |
| SSR 位置 | 服务器/Serverless | 全球边缘节点 |
| 冷启动 | 取决于平台 | < 50ms |
| 技术 | 版本 | 说明 |
|---|---|---|
| Next.js | 15.5.x | React 全栈框架 |
| React | 19.x | UI 库 |
| TypeScript | 5.x | 类型安全 |
| Tailwind CSS | 4.x | 原子化 CSS |
| @opennextjs/cloudflare | 1.x | Cloudflare 适配层 |
| Wrangler | 4.x | Cloudflare CLI |
| shadcn/ui | latest | UI 组件库 |
| Zustand | 5.x | 状态管理 |
| TanStack Query | 5.x | 服务端状态 |
| Vitest | 4.x | 单元测试 |
| Mock.js | 1.x | 数据模拟 |
halolight-cloudflare/
├── src/
│ ├── app/ # App Router 页面
│ │ ├── (dashboard)/ # 管理后台路由组
│ │ ├── (auth)/ # 认证路由组
│ │ ├── (legal)/ # 法律条款路由组
│ │ ├── layout.tsx # 根布局
│ │ └── page.tsx # 首页
│ ├── components/ # React 组件
│ │ ├── ui/ # shadcn/ui 组件
│ │ ├── layout/ # 布局组件
│ │ └── dashboard/ # 仪表盘组件
│ ├── hooks/ # React Hooks
│ ├── stores/ # Zustand Stores
│ ├── lib/ # 工具库
│ ├── mock/ # Mock 数据
│ ├── providers/ # Context Providers
│ ├── config/ # 配置文件
│ └── __tests__/ # 单元测试
├── public/ # 静态资源
├── .github/workflows/ # GitHub Actions CI
├── .open-next/ # OpenNext 构建产物(自动生成)
├── coverage/ # 测试覆盖率(自动生成)
├── cloudflare-env.d.ts # Cloudflare 环境类型
├── vitest.config.ts # Vitest 测试配置
├── open-next.config.ts # OpenNext 配置
├── wrangler.jsonc # Wrangler 配置
├── next.config.ts # Next.js 配置
└── package.json
git clone https://github.com/halolight/halolight-cloudflare.git
cd halolight-cloudflare
pnpm install
cp .dev.vars.example .dev.vars
# .dev.vars 示例
NEXT_PUBLIC_API_URL=/api
NEXT_PUBLIC_MOCK=true
NEXT_PUBLIC_APP_TITLE=HaloLight
NEXT_PUBLIC_BRAND_NAME=HaloLight
NEXT_PUBLIC_DEMO_EMAIL=[email protected]
NEXT_PUBLIC_DEMO_PASSWORD=Admin@123
pnpm dev
pnpm preview
模拟 Cloudflare Workers 环境,检测 Edge Runtime 兼容性问题。
wrangler login # 首次需要登录
pnpm deploy # 构建并部署
pnpm dev # 启动开发服务器(Turbopack,Node.js 环境)
pnpm build # Next.js 生产构建
pnpm preview # 本地预览 Cloudflare 环境
pnpm deploy # 部署到 Cloudflare
pnpm upload # 仅上传不部署
pnpm lint # ESLint 检查
pnpm type-check # TypeScript 类型检查
pnpm test # 运行单元测试(watch 模式)
pnpm test:run # 运行单元测试(单次)
pnpm test:coverage # 运行测试并生成覆盖率报告
pnpm cf-typegen # 生成 Cloudflare 环境类型
Cloudflare Workers 是边缘运行时,部分 Node.js API 不可用:
不可用的 API:
fs - 文件系统操作child_process - 子进程net、dgram - 原生网络套接字crypto.createCipher 等旧加密 API部分可用 (通过 nodejs_compat):
Buffer - 二进制数据处理process.env - 环境变量crypto 部分 API - 如 randomUUID()注意
使用 @opennextjs/cloudflare 时,整个应用自动运行在边缘环境,无需手动声明 export const runtime = 'edge'。
| 服务 | 用途 | 说明 |
|---|---|---|
| KV | 键值存储 | 全球分布式缓存 |
| D1 | SQLite 数据库 | 边缘 SQL 数据库 |
| R2 | 对象存储 | S3 兼容存储 |
| Queues | 消息队列 | 异步任务处理 |
| Durable Objects | 有状态对象 | 实时协作 |
| Workers AI | AI 推理 | 边缘 AI 模型 |
import { getRequestContext } from '@opennextjs/cloudflare';
export async function GET() {
const { env } = getRequestContext();
const value = await env.MY_KV.get('key');
return Response.json({ value });
}
// wrangler.jsonc
{
"kv_namespaces": [
{ "binding": "MY_KV", "id": "xxx" }
]
}
// wrangler.jsonc
{
"d1_databases": [
{ "binding": "MY_DB", "database_id": "xxx" }
]
}
| 渲染模式 | 支持状态 | 说明 |
|---|---|---|
| SSR | ✅ 支持 | 每次请求在边缘渲染 |
| SSG | ✅ 支持 | 构建时生成静态页面 |
| ISR | ⚠️ 部分 | 需配置 R2 缓存 |
// open-next.config.ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
});
项目已配置完整的 GitHub Actions CI 工作流:
| Job | 说明 |
|---|---|
| lint | ESLint + TypeScript 类型检查 |
| test | Vitest 单元测试 + Codecov 覆盖率 |
| build | OpenNext Cloudflare 生产构建 |
| security | 依赖安全审计 |
| dependency-review | PR 依赖变更审查 |
# .github/workflows/deploy.yml
name: Deploy to Cloudflare
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install
- run: pnpm deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
用户请求 → Cloudflare CDN → Workers (Edge) → KV/D1/R2/外部 API
↓
全球 300+ 节点
就近响应 < 50ms
| 限制项 | 免费版 | 付费版 |
|---|---|---|
| Worker 脚本大小 | 1MB(压缩后) | 10MB |
| CPU 时间 | 10-50ms | 数秒 |
| 内存 | 128MB | 128MB |
| 子请求数 | 50 | 1000 |
| 请求持续时间 | 30s | 30s |
参考
实际限制请查阅 Cloudflare 官方文档。
Cloudflare Pages 保留历史部署,支持以下回滚方式:
Dashboard 回滚:
重新部署指定提交:
git checkout <commit-hash>
pnpm deploy
Edge Runtime 不支持 Node.js 内置模块。使用 Web API 替代或确保该代码仅在客户端运行。
# 安装 Wrangler CLI
npm install -g wrangler
# 登录 Cloudflare
wrangler login
# 克隆项目
git clone https://github.com/halolight/halolight-cloudflare.git
cd halolight-cloudflare
# 安装依赖
pnpm install
# 部署
pnpm deploy
halolight/halolight-cloudflare 仓库pnpm build.open-next# .github/workflows/deploy.yml
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
env:
NEXT_PUBLIC_API_URL: /api
NEXT_PUBLIC_MOCK: false
- name: Deploy
run: pnpm deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "halolight",
"compatibility_date": "2024-12-01",
"compatibility_flags": ["nodejs_compat"],
"main": ".open-next/worker.js",
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
// KV 存储
"kv_namespaces": [
{
"binding": "CACHE_KV",
"id": "your-kv-namespace-id"
}
],
// D1 数据库
"d1_databases": [
{
"binding": "DB",
"database_name": "halolight-db",
"database_id": "your-d1-database-id"
}
],
// R2 对象存储
"r2_buckets": [
{
"binding": "STORAGE",
"bucket_name": "halolight-assets"
}
],
// Durable Objects
"durable_objects": {
"bindings": [
{
"name": "COUNTER",
"class_name": "Counter"
}
]
},
// AI 绑定
"ai": {
"binding": "AI"
},
// 环境变量
"vars": {
"ENVIRONMENT": "production"
}
}
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
import kvIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/kv-incremental-cache";
export default defineCloudflareConfig({
// ISR 缓存配置
incrementalCache: r2IncrementalCache,
// 或使用 KV 缓存
// incrementalCache: kvIncrementalCache,
});
# .dev.vars
NEXT_PUBLIC_API_URL=/api
NEXT_PUBLIC_MOCK=true
NEXT_PUBLIC_APP_TITLE=HaloLight
NEXT_PUBLIC_BRAND_NAME=HaloLight
NEXT_PUBLIC_DEMO_EMAIL=[email protected]
NEXT_PUBLIC_DEMO_PASSWORD=Admin@123
NEXT_PUBLIC_SHOW_DEMO_HINT=true
在 Cloudflare Dashboard → Workers & Pages → 项目 → Settings → Variables 设置:
| 变量名 | 说明 | 示例 |
|---|---|---|
NEXT_PUBLIC_API_URL |
API 基础 URL | /api |
NEXT_PUBLIC_MOCK |
启用 Mock 数据 | false |
NEXT_PUBLIC_APP_TITLE |
应用标题 | Admin Pro |
NEXT_PUBLIC_BRAND_NAME |
品牌名称 | HaloLight |
JWT_SECRET |
JWT 密钥 | your-secret-key |
DATABASE_URL |
D1 连接 (自动绑定) | - |
# 设置普通变量
wrangler secret put JWT_SECRET
# 批量设置
wrangler deploy --var ENVIRONMENT:production
# 查看变量
wrangler secret list
全球分布式键值存储,适合会话缓存、配置数据等场景。
// lib/kv.ts
import { getRequestContext } from "@opennextjs/cloudflare";
export async function getFromKV(key: string) {
const { env } = getRequestContext();
return await env.CACHE_KV.get(key, { type: "json" });
}
export async function setToKV(key: string, value: any, ttl?: number) {
const { env } = getRequestContext();
await env.CACHE_KV.put(key, JSON.stringify(value), {
expirationTtl: ttl || 3600, // 默认 1 小时
});
}
export async function deleteFromKV(key: string) {
const { env } = getRequestContext();
await env.CACHE_KV.delete(key);
}
// 使用示例:会话管理
export async function getSession(sessionId: string) {
return await getFromKV(`session:${sessionId}`);
}
export async function setSession(sessionId: string, data: SessionData) {
await setToKV(`session:${sessionId}`, data, 86400); // 24 小时
}
边缘 SQLite 数据库,支持 SQL 查询。
// lib/db.ts
import { getRequestContext } from "@opennextjs/cloudflare";
export async function query<T>(sql: string, params?: any[]): Promise<T[]> {
const { env } = getRequestContext();
const stmt = env.DB.prepare(sql);
if (params) {
const result = await stmt.bind(...params).all();
return result.results as T[];
}
const result = await stmt.all();
return result.results as T[];
}
export async function execute(sql: string, params?: any[]) {
const { env } = getRequestContext();
const stmt = env.DB.prepare(sql);
if (params) {
return await stmt.bind(...params).run();
}
return await stmt.run();
}
// 使用示例
export async function getUsers(page = 1, limit = 10) {
const offset = (page - 1) * limit;
return await query<User>(
"SELECT * FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?",
[limit, offset]
);
}
export async function createUser(user: CreateUserInput) {
return await execute(
"INSERT INTO users (email, name, role) VALUES (?, ?, ?)",
[user.email, user.name, user.role || "user"]
);
}
# 创建数据库
wrangler d1 create halolight-db
# 创建迁移
wrangler d1 migrations create halolight-db init
# 编辑迁移文件 migrations/0001_init.sql
-- migrations/0001_init.sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
role TEXT DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_role ON users(role);
# 应用迁移(本地)
wrangler d1 migrations apply halolight-db --local
# 应用迁移(生产)
wrangler d1 migrations apply halolight-db --remote
S3 兼容的对象存储,零出口费。
// lib/r2.ts
import { getRequestContext } from "@opennextjs/cloudflare";
export async function uploadFile(
key: string,
file: ArrayBuffer | ReadableStream,
contentType: string
) {
const { env } = getRequestContext();
await env.STORAGE.put(key, file, {
httpMetadata: {
contentType,
},
});
return `https://your-bucket.r2.cloudflarestorage.com/${key}`;
}
export async function getFile(key: string) {
const { env } = getRequestContext();
return await env.STORAGE.get(key);
}
export async function deleteFile(key: string) {
const { env } = getRequestContext();
await env.STORAGE.delete(key);
}
export async function listFiles(prefix?: string, limit = 100) {
const { env } = getRequestContext();
const options: R2ListOptions = { limit };
if (prefix) {
options.prefix = prefix;
}
const list = await env.STORAGE.list(options);
return list.objects;
}
// API 路由:文件上传
// app/api/upload/route.ts
export async function POST(request: Request) {
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return Response.json({ error: "No file provided" }, { status: 400 });
}
const key = `uploads/${Date.now()}-${file.name}`;
const buffer = await file.arrayBuffer();
const url = await uploadFile(key, buffer, file.type);
return Response.json({ url, key });
}
边缘 AI 推理,支持多种模型。
// lib/ai.ts
import { getRequestContext } from "@opennextjs/cloudflare";
// 文本生成
export async function generateText(prompt: string) {
const { env } = getRequestContext();
const response = await env.AI.run("@cf/meta/llama-2-7b-chat-int8", {
prompt,
max_tokens: 512,
});
return response.response;
}
// 文本嵌入
export async function getEmbedding(text: string) {
const { env } = getRequestContext();
const response = await env.AI.run("@cf/baai/bge-base-en-v1.5", {
text: [text],
});
return response.data[0];
}
// 图片生成
export async function generateImage(prompt: string) {
const { env } = getRequestContext();
const response = await env.AI.run("@cf/stabilityai/stable-diffusion-xl-base-1.0", {
prompt,
});
return response; // ArrayBuffer
}
// 图片分类
export async function classifyImage(imageBuffer: ArrayBuffer) {
const { env } = getRequestContext();
const response = await env.AI.run("@cf/microsoft/resnet-50", {
image: [...new Uint8Array(imageBuffer)],
});
return response;
}
// API 路由:AI 聊天
// app/api/chat/route.ts
export async function POST(request: Request) {
const { message } = await request.json();
const response = await generateText(`User: ${message}\nAssistant:`);
return Response.json({ response });
}
有状态边缘对象,适合实时协作、计数器等场景。
// lib/counter.ts
export class Counter {
state: DurableObjectState;
value: number = 0;
constructor(state: DurableObjectState) {
this.state = state;
this.state.blockConcurrencyWhile(async () => {
const stored = await this.state.storage.get<number>("value");
this.value = stored || 0;
});
}
async fetch(request: Request) {
const url = new URL(request.url);
switch (url.pathname) {
case "/increment":
this.value++;
await this.state.storage.put("value", this.value);
return Response.json({ value: this.value });
case "/decrement":
this.value--;
await this.state.storage.put("value", this.value);
return Response.json({ value: this.value });
case "/value":
return Response.json({ value: this.value });
default:
return new Response("Not found", { status: 404 });
}
}
}
// 使用 Durable Object
// app/api/counter/route.ts
import { getRequestContext } from "@opennextjs/cloudflare";
export async function GET() {
const { env } = getRequestContext();
const id = env.COUNTER.idFromName("global");
const stub = env.COUNTER.get(id);
const response = await stub.fetch("https://counter/value");
return response;
}
export async function POST() {
const { env } = getRequestContext();
const id = env.COUNTER.idFromName("global");
const stub = env.COUNTER.get(id);
const response = await stub.fetch("https://counter/increment");
return response;
}
异步任务处理。
// 发送消息到队列
// app/api/tasks/route.ts
import { getRequestContext } from "@opennextjs/cloudflare";
export async function POST(request: Request) {
const { env } = getRequestContext();
const task = await request.json();
await env.MY_QUEUE.send({
type: "email",
to: task.email,
subject: task.subject,
body: task.body,
});
return Response.json({ success: true, message: "Task queued" });
}
// 队列消费者 (在 wrangler.jsonc 中配置)
export default {
async queue(batch: MessageBatch, env: Env) {
for (const message of batch.messages) {
const task = message.body as EmailTask;
try {
await sendEmail(task);
message.ack();
} catch (error) {
message.retry();
}
}
},
};
# 认证
wrangler login # 浏览器登录
wrangler logout # 登出
wrangler whoami # 查看当前用户
# 开发
pnpm dev # 启动开发服务器 (Node.js)
pnpm preview # 本地预览 (Workers 环境)
wrangler dev # Wrangler 开发模式
# 部署
pnpm deploy # 构建并部署
wrangler deploy # 仅部署
wrangler rollback # 回滚到上一版本
# KV 管理
wrangler kv namespace list # 列出 KV 命名空间
wrangler kv namespace create <name> # 创建 KV 命名空间
wrangler kv key list --namespace-id <id> # 列出键
wrangler kv key get <key> --namespace-id <id> # 获取值
wrangler kv key put <key> <value> --namespace-id <id> # 设置值
# D1 数据库
wrangler d1 list # 列出数据库
wrangler d1 create <name> # 创建数据库
wrangler d1 execute <db> --command "SELECT * FROM users" # 执行 SQL
wrangler d1 migrations list <db> # 列出迁移
wrangler d1 migrations apply <db> # 应用迁移
# R2 存储
wrangler r2 bucket list # 列出存储桶
wrangler r2 bucket create <name> # 创建存储桶
wrangler r2 object list <bucket> # 列出对象
wrangler r2 object get <bucket> <key> # 获取对象
# 日志
wrangler tail # 实时日志
wrangler tail --format pretty # 格式化日志
# 密钥管理
wrangler secret list # 列出密钥
wrangler secret put <name> # 设置密钥
wrangler secret delete <name> # 删除密钥
# 类型生成
pnpm cf-typegen # 生成 Cloudflare 环境类型
# 方式一:Cloudflare Dashboard
# Workers & Pages → 项目 → Custom domains → Add custom domain
# 方式二:API
curl -X POST "https://api.cloudflare.com/client/v4/accounts/{account_id}/pages/projects/{project_name}/domains" \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
--data '{"name":"halolight-cloudflare.h7ml.cn"}'
如果域名已在 Cloudflare:
# 自动配置,无需手动设置
如果域名在其他服务商:
# CNAME 记录
类型: CNAME
名称: halolight-cloudflare
值: <project-name>.pages.dev
# 或使用自定义域名
类型: CNAME
名称: halolight-cloudflare
值: <custom-domain-target>.pages.dev
Cloudflare Pages 自动配置 HTTPS:
# 在 Cloudflare Dashboard → SSL/TLS → Edge Certificates
# 推荐配置:
# - SSL Mode: Full (strict)
# - Minimum TLS Version: TLS 1.2
# - TLS 1.3: Enabled
# - Automatic HTTPS Rewrites: Enabled
A:Edge Runtime 不支持 Node.js 内置模块。解决方案:
nodejs_compat 兼容标志// wrangler.jsonc
{
"compatibility_flags": ["nodejs_compat"]
}
A:优化建议:
@cloudflare/next-on-pages 分析器npx @cloudflare/next-on-pages --info
A:优化方案:
A:D1 是边缘数据库,注意:
// 使用批量操作
const batch = [
db.prepare("INSERT INTO users VALUES (?, ?)").bind(1, "Alice"),
db.prepare("INSERT INTO users VALUES (?, ?)").bind(2, "Bob"),
];
await db.batch(batch);
A:KV 特性:
A:使用以下方法:
wrangler tail 实时查看日志console.log 输出调试信息# 实时日志
wrangler tail --format pretty
# 过滤错误
wrangler tail --status error
A:确保配置 R2 缓存:
// open-next.config.ts
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
});
并在 wrangler.jsonc 中绑定 R2 bucket。
| 计划 | 价格 | 包含额度 |
|---|---|---|
| Free | 免费 | 10 万请求/天 |
| Paid | $5/月起 | 1000 万请求/月 |
| 资源 | 免费额度 | 超出价格 |
|---|---|---|
| Workers 请求 | 10 万/天 | $0.15/百万 |
| CPU 时间 | 10ms/请求 | $0.02/百万 ms |
| KV 读取 | 10 万/天 | $0.50/百万 |
| KV 写入 | 1 千/天 | $5.00/百万 |
| D1 读取 | 500 万/天 | $0.001/百万 |
| D1 写入 | 10 万/天 | $1.00/百万 |
| R2 存储 | 10GB | $0.015/GB/月 |
| R2 A 类操作 | 100 万/月 | $4.50/百万 |
| R2 B 类操作 | 1000 万/月 | $0.36/百万 |
| Workers AI | 按模型计费 | 见官网 |
// 小型项目(免费)
{
"compatibility_flags": ["nodejs_compat"],
// 使用 KV 缓存,不使用 R2
}
// 中型项目($5-20/月)
{
"kv_namespaces": [...],
"d1_databases": [...],
// 添加 D1 数据库
}
// 大型项目($50+/月)
{
"kv_namespaces": [...],
"d1_databases": [...],
"r2_buckets": [...],
"durable_objects": {...},
// 全功能配置
}
| 特性 | Cloudflare | Vercel Edge | AWS Lambda@Edge |
|---|---|---|---|
| 全球节点 | 300+ | 无公开数据 | 13 |
| 冷启动 | < 50ms | < 100ms | 100-500ms |
| 免费请求 | 10 万/天 | 100 万/月 | 100 万/月 |
| 边缘数据库 | ✅ D1 | ❌ | ❌ |
| KV 存储 | ✅ 内置 | ✅ Vercel KV | ❌ 需外部 |
| 对象存储 | ✅ R2 (零出口费) | ✅ Vercel Blob | ✅ S3 (有出口费) |
| AI 推理 | ✅ Workers AI | ❌ | ✅ SageMaker |
| 实时协作 | ✅ Durable Objects | ❌ | ❌ |
| 消息队列 | ✅ Queues | ❌ | ✅ SQS |
| 脚本大小限制 | 10MB | 4MB | 50MB |
| 执行时间限制 | 30s | 25s | 30s |
用户请求 → Cloudflare CDN (300+ 节点)
↓
Cloudflare Workers (Edge)
↓
┌─────────┼─────────┬─────────┐
↓ ↓ ↓ ↓
KV D1 R2 外部 API
(缓存) (数据库) (存储) (后端)
HaloLight 的核心优势在于前后端完全解耦,支持任意组合。本文档帮助你选择最适合的技术栈组合。
graph TD
A[开始选择] --> B{团队规模?}
B -->|小团队 <5人| C{是否需要SEO?}
B -->|中型团队 5-20人| D{技术栈偏好?}
B -->|大型团队 >20人| E[Angular + Spring Boot]
C -->|需要SEO| F[Nuxt + FastAPI]
C -->|不需要| G[Vue + FastAPI]
D -->|Node.js| H[Next.js + NestJS]
D -->|Python| I[Vue + FastAPI]
D -->|Java| J[Angular + Spring Boot]
D -->|Go| K[SvelteKit + Go Fiber]
根据不同维度为主流组合打分 (满分 ⭐⭐⭐⭐⭐):
| 维度 | 评分 | 说明 |
|---|---|---|
| 开发效率 | ⭐⭐⭐⭐⭐ | TypeScript 全栈统一,类型共享 |
| 性能 | ⭐⭐⭐⭐ | SSR + 边缘缓存优化 |
| 学习曲线 | ⭐⭐⭐ | 需要理解 React 和 NestJS 架构 |
| 生态成熟度 | ⭐⭐⭐⭐⭐ | npm 生态极其丰富 |
| 部署难度 | ⭐⭐⭐⭐ | Vercel + Railway/Fly.io 一键部署 |
| 总评 | ⭐⭐⭐⭐ | 多租户 SaaS、企业后台首选 |
| 维度 | 评分 | 说明 |
|---|---|---|
| 开发效率 | ⭐⭐⭐⭐⭐ | Vue 学习曲线平滑,FastAPI 开发快 |
| 性能 | ⭐⭐⭐⭐ | Vue 3 编译优化,Python 异步高效 |
| 学习曲线 | ⭐⭐⭐⭐⭐ | 两者都相对容易上手 |
| 数据处理 | ⭐⭐⭐⭐⭐ | Python 数据科学生态无敌 |
| 部署难度 | ⭐⭐⭐⭐ | 前端 CDN,后端容器化 |
| 总评 | ⭐⭐⭐⭐⭐ | 数据/AI 驱动应用首选 |
| 维度 | 评分 | 说明 |
|---|---|---|
| 开发效率 | ⭐⭐⭐ | 架构规范严谨,初期投入大 |
| 性能 | ⭐⭐⭐⭐ | 企业级优化成熟 |
| 学习曲线 | ⭐⭐ | 两者都有一定复杂度 |
| 企业成熟度 | ⭐⭐⭐⭐⭐ | 大型企业首选技术栈 |
| 长期维护性 | ⭐⭐⭐⭐⭐ | 架构清晰、可维护性强 |
| 总评 | ⭐⭐⭐⭐ | 大型企业、长周期项目首选 |
| 维度 | 评分 | 说明 |
|---|---|---|
| 开发效率 | ⭐⭐⭐⭐ | 代码简洁、开发体验好 |
| 性能 | ⭐⭐⭐⭐⭐ | 两者都是性能标杆 |
| 学习曲线 | ⭐⭐⭐ | Svelte 独特语法,Go 需要学习 |
| 资源占用 | ⭐⭐⭐⭐⭐ | 内存和 CPU 占用都极低 |
| 部署难度 | ⭐⭐⭐⭐⭐ | 容器镜像极小,适合边缘部署 |
| 总评 | ⭐⭐⭐⭐⭐ | 高性能实时应用首选 |
如果你想从 Vue 迁移到 Angular:
迁移成本:主要是组件语法转换 (Vue → Angular),业务逻辑可直接复用。
如果你想从 NestJS 切换到 FastAPI:
迁移成本:重写服务层逻辑 (TS → Python),接口层面完全兼容。
下表展示所有前端与后端的组合,每个单元格代表可选的技术搭配。
| 前端 \ 后端 | NestJS | Node.js | FastAPI | Spring Boot | Go | PHP | Bun | tRPC BFF |
|---|---|---|---|---|---|---|---|---|
| Next.js | ⭐ 最佳 | ✅ | ✅ | ✅ | ✅ | ✅ | ⭐ 最佳 | ⭐ 最佳 |
| Nuxt | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⭐ 最佳 |
| Vue | ✅ | ✅ | ⭐ 最佳 | ✅ | ✅ | ✅ | ✅ | ✅ |
| Angular | ✅ | ✅ | ✅ | ⭐ 最佳 | ⭐ 最佳 | ✅ | ✅ | ✅ |
| SvelteKit | ✅ | ✅ | ✅ | ✅ | ⭐ 最佳 | ✅ | ✅ | ✅ |
| Astro | ✅ | ✅ | ⭐ 最佳 | ✅ | ✅ | ✅ | ✅ | ✅ |
| Solid.js | ✅ | ✅ | ✅ | ✅ | ⭐ 最佳 | ✅ | ⭐ 最佳 | ✅ |
| Qwik | ✅ | ✅ | ✅ | ✅ | ⭐ 最佳 | ✅ | ⭐ 最佳 | ✅ |
| Remix | ⭐ 最佳 | ⭐ 最佳 | ✅ | ✅ | ✅ | ✅ | ⭐ 最佳 | ⭐ 最佳 |
| Preact | ✅ | ✅ | ✅ | ✅ | ⭐ 最佳 | ✅ | ⭐ 最佳 | ✅ |
| Lit | ✅ | ✅ | ✅ | ✅ | ✅ | ⭐ 最佳 | ✅ | ✅ |
| Fresh | ✅ | ✅ | ✅ | ✅ | ⭐ 最佳 | ✅ | ⭐ 最佳 | ✅ |
图例:
| 特性 | Next.js | Vue | Angular | SvelteKit | Solid | Qwik |
|---|---|---|---|---|---|---|
| 学习曲线 | 中 | 低 | 高 | 低 | 中 | 中 |
| TypeScript | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| SSR/SSG | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 性能 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 生态 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
| 包体积 | 中 | 小 | 大 | 极小 | 极小 | 小 |
| 特性 | NestJS | FastAPI | Spring Boot | Go Fiber |
|---|---|---|---|---|
| 开发效率 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
| 性能 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| TypeScript | ⭐⭐⭐⭐⭐ | - | - | - |
| 企业成熟度 | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 数据科学 | ⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐ |
| 资源占用 | 中 | 小 | 大 | 极小 |
选择组合后,按以下步骤启动示例:
# 以 Vue 为例
git clone https://github.com/halolight/halolight-vue.git
cd halolight-vue
pnpm install
pnpm dev
# 以 FastAPI 为例
git clone https://github.com/halolight/halolight-api-python.git
cd halolight-api-python
pip install -e ".[dev]"
uvicorn app.main:app --reload
# 前端项目 .env.local
VITE_API_URL=http://localhost:8000/api
VITE_USE_MOCK=false # 关闭 Mock,使用真实 API
HaloLight Deno 后端 API 基于 Fresh 框架和 Deno KV 构建,采用 Deno 原生运行时,提供高性能的 RESTful API 服务。
API 文档:https://halolight-deno.h7ml.cn/docs
GitHub:https://github.com/halolight/halolight-deno
| 技术 | 版本 | 说明 |
|---|---|---|
| Deno | 2.x | 运行时 (内置 TypeScript) |
| Fresh | 1.x | Deno 原生 Web 框架 |
| Preact | 10.x | 轻量级 UI 库 |
| Deno KV | - | 内置键值存储 (数据库) |
| Hono | 4.x | API 路由框架 (可选) |
| JWT | - | 身份认证 |
| Tailwind CSS | 3.x | 原子化 CSS |
# 克隆仓库
git clone https://github.com/halolight/halolight-deno.git
cd halolight-deno
# 无需安装依赖,Deno 自动管理
cp .env.example .env
# API 配置
API_URL=/api
USE_MOCK=true
# JWT 密钥
JWT_SECRET=your-super-secret-key
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
# Deno KV (可选,默认使用本地)
DENO_KV_PATH=./data/kv.db
# 服务配置
PORT=8000
NODE_ENV=development
# 演示账号
[email protected]
DEMO_PASSWORD=123456
SHOW_DEMO_HINT=false
# 品牌配置
APP_TITLE=Admin Pro
BRAND_NAME=Halolight
# Deno KV 无需迁移,自动创建
# 如需种子数据
deno task seed
# 开发模式
deno task dev
# 生产模式
deno task build
deno task start
halolight-deno/
├── routes/ # 路由处理
│ ├── api/ # API 端点
│ │ ├── auth/ # 认证路由
│ │ ├── users/ # 用户路由
│ │ ├── dashboard/ # 仪表盘路由
│ │ ├── documents/ # 文档路由
│ │ ├── files/ # 文件路由
│ │ ├── messages/ # 消息路由
│ │ ├── notifications/ # 通知路由
│ │ └── calendar/ # 日历路由
│ ├── (auth)/ # 认证页面
│ │ ├── login.tsx
│ │ ├── register.tsx
│ │ └── forgot-password.tsx
│ └── (dashboard)/ # 仪表盘页面
│ ├── index.tsx
│ ├── users.tsx
│ └── settings.tsx
├── utils/ # 工具函数
│ ├── auth.ts # 认证工具
│ ├── kv.ts # Deno KV 封装
│ ├── permissions.ts # 权限检查
│ ├── jwt.ts # JWT 工具
│ └── validation.ts # 数据验证
├── islands/ # 交互式组件 (客户端)
│ ├── AuthProvider.tsx
│ ├── ThemeToggle.tsx
│ └── Dashboard.tsx
├── components/ # UI 组件
│ ├── ui/
│ ├── layout/
│ └── dashboard/
├── static/ # 静态资源
├── fresh.gen.ts # Fresh 生成文件
├── fresh.config.ts # Fresh 配置
├── deno.json # Deno 配置
└── import_map.json # 导入映射
| 方法 | 路径 | 描述 | 权限 |
|---|---|---|---|
| POST | /api/auth/login |
用户登录 | 公开 |
| POST | /api/auth/register |
用户注册 | 公开 |
| POST | /api/auth/refresh |
刷新令牌 | 公开 |
| POST | /api/auth/logout |
退出登录 | 需认证 |
| POST | /api/auth/forgot-password |
忘记密码 | 公开 |
| POST | /api/auth/reset-password |
重置密码 | 公开 |
| 方法 | 路径 | 描述 | 权限 |
|---|---|---|---|
| GET | /api/users |
获取用户列表 | users:view |
| GET | /api/users/:id |
获取用户详情 | users:view |
| POST | /api/users |
创建用户 | users:create |
| PUT | /api/users/:id |
更新用户 | users:update |
| DELETE | /api/users/:id |
删除用户 | users:delete |
| GET | /api/users/me |
获取当前用户 | 需认证 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/documents |
获取文档列表 |
| GET | /api/documents/:id |
获取文档详情 |
| POST | /api/documents |
创建文档 |
| PUT | /api/documents/:id |
更新文档 |
| DELETE | /api/documents/:id |
删除文档 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/files |
获取文件列表 |
| GET | /api/files/:id |
获取文件详情 |
| POST | /api/files/upload |
上传文件 |
| PUT | /api/files/:id |
更新文件信息 |
| DELETE | /api/files/:id |
删除文件 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/messages |
获取消息列表 |
| GET | /api/messages/:id |
获取消息详情 |
| POST | /api/messages |
发送消息 |
| PUT | /api/messages/:id/read |
标记已读 |
| DELETE | /api/messages/:id |
删除消息 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/notifications |
获取通知列表 |
| PUT | /api/notifications/:id/read |
标记已读 |
| PUT | /api/notifications/read-all |
全部已读 |
| DELETE | /api/notifications/:id |
删除通知 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/calendar/events |
获取日程列表 |
| GET | /api/calendar/events/:id |
获取日程详情 |
| POST | /api/calendar/events |
创建日程 |
| PUT | /api/calendar/events/:id |
更新日程 |
| DELETE | /api/calendar/events/:id |
删除日程 |
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/dashboard/stats |
统计数据 |
| GET | /api/dashboard/visits |
访问趋势 |
| GET | /api/dashboard/sales |
销售数据 |
| GET | /api/dashboard/pie |
饼图数据 |
| GET | /api/dashboard/tasks |
待办任务 |
| GET | /api/dashboard/calendar |
今日日程 |
Access Token: 15 分钟有效期,用于 API 请求
Refresh Token: 7 天有效期,用于刷新 Access Token
Authorization: Bearer <access_token>
// utils/jwt.ts
import { create, verify } from "https://deno.land/x/[email protected]/mod.ts";
const key = await crypto.subtle.generateKey(
{ name: "HMAC", hash: "SHA-256" },
true,
["sign", "verify"],
);
export async function createAccessToken(userId: string): Promise<string> {
return await create(
{ alg: "HS256", typ: "JWT" },
{ userId, exp: Date.now() + 15 * 60 * 1000 }, // 15分钟
key,
);
}
export async function refreshToken(refreshToken: string): Promise<string> {
const payload = await verify(refreshToken, key);
return await createAccessToken(payload.userId as string);
}
| 角色 | 说明 | 权限 |
|---|---|---|
super_admin |
超级管理员 | * (所有权限) |
admin |
管理员 | users:*, documents:*, files:*, messages:*, calendar:* |
user |
普通用户 | documents:view, files:view, messages:view, calendar:view |
guest |
访客 | dashboard:view |
{resource}:{action}
示例:
- users:view # 查看用户
- users:create # 创建用户
- users:* # 用户所有操作
- * # 所有权限
// utils/permissions.ts
export function hasPermission(user: User, permission: string): boolean {
const userPermissions = user.permissions || [];
return userPermissions.some((p) => {
if (p === "*") return true;
if (p.endsWith(":*")) {
const resource = p.slice(0, -2);
return permission.startsWith(resource + ":");
}
return p === permission;
});
}
// 路由中间件
export function requirePermission(permission: string) {
return async (req: Request, ctx: any) => {
const user = ctx.state.user;
if (!hasPermission(user, permission)) {
return new Response("Forbidden", { status: 403 });
}
return await ctx.next();
};
}
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "请求参数验证失败",
"details": [
{ "field": "email", "message": "邮箱格式不正确" }
]
}
}
| 状态码 | 错误码 | 说明 |
|---|---|---|
| 400 | VALIDATION_ERROR |
参数验证失败 |
| 401 | UNAUTHORIZED |
未授权 |
| 403 | FORBIDDEN |
无权限 |
| 404 | NOT_FOUND |
资源不存在 |
| 409 | CONFLICT |
资源冲突 |
| 500 | INTERNAL_ERROR |
服务器错误 |
# 开发
deno task dev # 启动开发服务器 (热重载)
# 构建
deno task build # 构建生产版本
# 测试
deno test # 运行测试
deno test --coverage # 测试覆盖率
# 数据库
deno task seed # 初始化种子数据
# 代码质量
deno lint # 代码检查
deno fmt # 代码格式化
deno check **/*.ts # 类型检查
docker build -t halolight-deno .
docker run -p 8000:8000 halolight-deno
docker-compose up -d
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "8000:8000"
environment:
- NODE_ENV=production
- JWT_SECRET=${JWT_SECRET}
- DENO_KV_PATH=/data/kv.db
volumes:
- deno_kv_data:/data
restart: unless-stopped
volumes:
deno_kv_data:
# 安装 deployctl
deno install -Arf jsr:@deno/deployctl
# 部署到 Deno Deploy
deployctl deploy --project=halolight-deno main.ts
NODE_ENV=production
JWT_SECRET=your-production-secret-key-min-32-chars
DENO_KV_PATH=/data/kv.db
PORT=8000
deno test # 运行所有测试
deno test --coverage # 生成覆盖率报告
deno test --watch # 监听模式
// routes/api/auth/_test.ts
import { assertEquals } from "https://deno.land/[email protected]/assert/mod.ts";
Deno.test("POST /api/auth/login - success", async () => {
const response = await fetch("http://localhost:8000/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "[email protected]",
password: "123456",
}),
});
assertEquals(response.status, 200);
const data = await response.json();
assertEquals(data.success, true);
assertEquals(typeof data.data.accessToken, "string");
});
Deno.test("POST /api/auth/login - invalid credentials", async () => {
const response = await fetch("http://localhost:8000/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "[email protected]",
password: "wrong",
}),
});
assertEquals(response.status, 401);
const data = await response.json();
assertEquals(data.success, false);
});
| 指标 | 数值 | 说明 |
|---|---|---|
| 请求吞吐量 | ~45,000 req/s | Deno 2.0, 单核, wrk 测试 |
| 平均响应时间 | <5ms | 本地 Deno KV, 无外部依赖 |
| 内存占用 | ~30MB | 启动后基础内存 |
| CPU 使用率 | <10% | 空闲状态 |
// utils/logger.ts
import { Logger } from "https://deno.land/[email protected]/log/mod.ts";
const logger = new Logger("app");
export function logRequest(req: Request, res: Response, duration: number) {
logger.info(
`${req.method} ${new URL(req.url).pathname} ${res.status} ${duration}ms`,
);
}
export function logError(error: Error, context?: any) {
logger.error(`Error: ${error.message}`, { error, context });
}
// routes/api/health.ts
export const handler = async (req: Request): Promise<Response> => {
try {
// 检查 Deno KV 连接
const kv = await Deno.openKv();
await kv.get(["health"]);
await kv.close();
return Response.json({
status: "healthy",
timestamp: new Date().toISOString(),
uptime: Deno.memoryUsage(),
});
} catch (error) {
return Response.json(
{ status: "unhealthy", error: error.message },
{ status: 503 },
);
}
};
// utils/metrics.ts
const metrics = {
requests: 0,
errors: 0,
responseTime: [] as number[],
};
export function recordRequest(duration: number) {
metrics.requests++;
metrics.responseTime.push(duration);
}
export function recordError() {
metrics.errors++;
}
export function getMetrics() {
const avg = metrics.responseTime.reduce((a, b) => a + b, 0) /
metrics.responseTime.length;
return {
totalRequests: metrics.requests,
totalErrors: metrics.errors,
avgResponseTime: avg || 0,
};
}
A:通过配置 DENO_KV_PATH 环境变量指定数据文件路径。
# .env
DENO_KV_PATH=./data/kv.db
// 使用自定义路径
const kv = await Deno.openKv(Deno.env.get("DENO_KV_PATH"));
A:在 Deno Deploy 上部署时,使用 Deno.openKv() 会自动连接到托管的分布式 KV。
// 生产环境自动使用远程 KV
const kv = await Deno.openKv();
A:使用 Fresh 的 FormData API 处理文件上传。
// routes/api/files/upload.ts
export const handler = async (req: Request): Promise<Response> => {
const formData = await req.formData();
const file = formData.get("file") as File;
if (!file) {
return Response.json({ error: "No file uploaded" }, { status: 400 });
}
// 保存文件到 Deno KV 或云存储
const bytes = await file.arrayBuffer();
const kv = await Deno.openKv();
await kv.set(["files", crypto.randomUUID()], {
name: file.name,
type: file.type,
size: file.size,
data: new Uint8Array(bytes),
});
return Response.json({ success: true });
};
A:Islands 是客户端交互组件,通过 fetch 调用后端 API。
// islands/UserList.tsx
import { useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";
export default function UserList() {
const users = useSignal([]);
useEffect(() => {
fetch("/api/users")
.then((res) => res.json())
.then((data) => users.value = data);
}, []);
return (
<div>
{users.value.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
| 特性 | Deno Fresh | NestJS | FastAPI | Spring Boot |
|---|---|---|---|---|
| 语言 | TypeScript (Deno) | TypeScript | Python | Java |
| ORM | Deno KV | Prisma | SQLAlchemy | JPA |
| 性能 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 学习曲线 | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
| 启动时间 | <100ms | ~2s | ~1s | ~5s |
| 内存占用 | 30MB | 80MB | 50MB | 150MB |
| 部署 | Deno Deploy | Docker/云 | Docker/云 | Docker/云 |
HaloLight Docker 容器化部署方案,支持多阶段构建、Docker Compose 编排和 Kubernetes 部署。
Docker Hub:https://hub.docker.com/r/halolight/halolight
GitHub:https://github.com/halolight/halolight-docker
# 拉取镜像
docker pull halolight/halolight:latest
# 运行容器
docker run -d \
--name halolight \
-p 3000:3000 \
-e NEXT_PUBLIC_API_URL=/api \
-e NEXT_PUBLIC_MOCK=true \
halolight/halolight:latest
# 查看日志
docker logs -f halolight
# 停止容器
docker stop halolight
# 克隆仓库
git clone https://github.com/halolight/halolight-docker.git
cd halolight-docker
# 复制环境变量
cp .env.example .env
# 启动所有服务
docker-compose up -d
# 查看服务状态
docker-compose ps
# 查看日志
docker-compose logs -f
# 停止服务
docker-compose down
# 克隆主仓库
git clone https://github.com/halolight/halolight.git
cd halolight
# 构建镜像
docker build -t halolight:local .
# 运行
docker run -d -p 3000:3000 halolight:local
# ============================================
# 阶段 1: 依赖安装
# ============================================
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# 安装 pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# 复制依赖文件
COPY package.json pnpm-lock.yaml ./
# 安装依赖
RUN pnpm install --frozen-lockfile
# ============================================
# 阶段 2: 构建应用
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
# 复制依赖
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# 设置构建时环境变量
ARG NEXT_PUBLIC_API_URL=/api
ARG NEXT_PUBLIC_MOCK=false
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_MOCK=$NEXT_PUBLIC_MOCK
ENV NEXT_TELEMETRY_DISABLED=1
# 构建
RUN pnpm build
# ============================================
# 阶段 3: 生产运行
# ============================================
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# 创建非 root 用户
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# 复制构建产物
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# 切换到非 root 用户
USER nextjs
# 暴露端口
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
# 启动命令
CMD ["node", "server.js"]
# Dockerfile.dev
FROM node:20-alpine
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
# 复制依赖文件
COPY package.json pnpm-lock.yaml ./
# 安装所有依赖(包括 devDependencies)
RUN pnpm install
# 复制源代码
COPY . .
EXPOSE 3000
# 启动开发服务器
CMD ["pnpm", "dev"]
# docker-compose.yml
version: '3.8'
services:
# ============================================
# 应用服务
# ============================================
app:
build:
context: .
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: /api
NEXT_PUBLIC_MOCK: "false"
image: halolight/halolight:latest
container_name: halolight-app
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://postgres:${DB_PASSWORD}@db:5432/halolight
- REDIS_URL=redis://redis:6379
- JWT_SECRET=${JWT_SECRET}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- halolight-network
deploy:
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.5'
memory: 256M
# ============================================
# PostgreSQL 数据库
# ============================================
db:
image: postgres:16-alpine
container_name: halolight-db
restart: unless-stopped
environment:
POSTGRES_DB: halolight
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DB_PASSWORD}
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d halolight"]
interval: 10s
timeout: 5s
retries: 5
networks:
- halolight-network
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
# ============================================
# Redis 缓存
# ============================================
redis:
image: redis:7-alpine
container_name: halolight-redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- halolight-network
deploy:
resources:
limits:
cpus: '0.25'
memory: 128M
# ============================================
# Nginx 反向代理
# ============================================
nginx:
image: nginx:alpine
container_name: halolight-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- nginx_logs:/var/log/nginx
depends_on:
- app
networks:
- halolight-network
deploy:
resources:
limits:
cpus: '0.25'
memory: 64M
# ============================================
# Adminer 数据库管理 (可选)
# ============================================
adminer:
image: adminer:latest
container_name: halolight-adminer
restart: unless-stopped
ports:
- "8080:8080"
depends_on:
- db
networks:
- halolight-network
profiles:
- tools
networks:
halolight-network:
driver: bridge
volumes:
postgres_data:
redis_data:
nginx_logs:
# docker-compose.dev.yml
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
container_name: halolight-dev
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
- /app/.next
environment:
- NODE_ENV=development
- NEXT_PUBLIC_MOCK=true
command: pnpm dev
db:
image: postgres:16-alpine
container_name: halolight-db-dev
ports:
- "5432:5432"
environment:
POSTGRES_DB: halolight
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- postgres_dev_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
container_name: halolight-redis-dev
ports:
- "6379:6379"
volumes:
postgres_dev_data:
# nginx/nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'rt=$request_time uct="$upstream_connect_time" '
'uht="$upstream_header_time" urt="$upstream_response_time"';
access_log /var/log/nginx/access.log main;
# 性能优化
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip 压缩
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript
application/xml application/xml+rss text/javascript application/x-javascript;
# 上游服务器
upstream app_servers {
least_conn;
server app:3000 weight=1 max_fails=3 fail_timeout=30s;
keepalive 32;
}
# 限流配置
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
include /etc/nginx/conf.d/*.conf;
}
# nginx/conf.d/default.conf
server {
listen 80;
server_name localhost;
# 重定向到 HTTPS (生产环境启用)
# return 301 https://$server_name$request_uri;
location / {
proxy_pass http://app_servers;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# API 限流
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
limit_conn conn_limit 10;
proxy_pass http://app_servers;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 静态资源缓存
location /_next/static/ {
proxy_pass http://app_servers;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# 健康检查
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
# HTTPS 配置 (生产环境)
server {
listen 443 ssl http2;
server_name localhost;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://app_servers;
# ... 其他配置同上
}
}
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: halolight
namespace: halolight
labels:
app: halolight
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: halolight
template:
metadata:
labels:
app: halolight
spec:
containers:
- name: halolight
image: halolight/halolight:latest
imagePullPolicy: Always
ports:
- containerPort: 3000
protocol: TCP
env:
- name: NODE_ENV
value: "production"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: halolight-secrets
key: database-url
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: halolight-secrets
key: jwt-secret
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values:
- halolight
topologyKey: kubernetes.io/hostname
# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
name: halolight
namespace: halolight
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 3000
protocol: TCP
selector:
app: halolight
# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: halolight
namespace: halolight
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
spec:
tls:
- hosts:
- halolight.example.com
secretName: halolight-tls
rules:
- host: halolight.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: halolight
port:
number: 80
# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: halolight
namespace: halolight
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: halolight
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: halolight-config
namespace: halolight
data:
NEXT_PUBLIC_API_URL: "/api"
NEXT_PUBLIC_MOCK: "false"
NEXT_PUBLIC_APP_TITLE: "Admin Pro"
---
# k8s/secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: halolight-secrets
namespace: halolight
type: Opaque
stringData:
database-url: "postgresql://user:password@postgres:5432/halolight"
jwt-secret: "your-super-secret-key"
redis-url: "redis://redis:6379"
# Docker 基础命令
docker build -t halolight . # 构建镜像
docker run -d -p 3000:3000 halolight # 运行容器
docker logs -f <container_id> # 查看日志
docker exec -it <container_id> sh # 进入容器
docker stop <container_id> # 停止容器
docker rm <container_id> # 删除容器
docker rmi halolight # 删除镜像
# Docker Compose 命令
docker-compose up -d # 后台启动
docker-compose down # 停止并删除
docker-compose down -v # 停止并删除(含数据卷)
docker-compose ps # 查看状态
docker-compose logs -f app # 查看指定服务日志
docker-compose exec app sh # 进入服务容器
docker-compose pull # 拉取最新镜像
docker-compose up -d --build # 重新构建并启动
# Kubernetes 命令
kubectl apply -f k8s/ # 应用所有配置
kubectl get pods -n halolight # 查看 Pod
kubectl logs -f <pod_name> -n halolight # 查看日志
kubectl exec -it <pod_name> -n halolight -- sh # 进入 Pod
kubectl rollout restart deployment/halolight -n halolight # 重启
kubectl rollout status deployment/halolight -n halolight # 查看状态
kubectl scale deployment/halolight --replicas=5 -n halolight # 扩容
| 变量名 | 说明 | 示例 |
|---|---|---|
NODE_ENV |
运行环境 | production |
PORT |
服务端口 | 3000 |
NEXT_PUBLIC_API_URL |
API 基础 URL | /api |
NEXT_PUBLIC_MOCK |
启用 Mock 数据 | false |
DATABASE_URL |
PostgreSQL 连接 | postgresql://... |
REDIS_URL |
Redis 连接 | redis://redis:6379 |
JWT_SECRET |
JWT 密钥 | your-secret-key |
DB_PASSWORD |
数据库密码 | your-db-password |
| 构建方式 | 镜像大小 |
|---|---|
| 单阶段构建 | ~1.5GB |
| 多阶段构建 | ~150MB |
| 多阶段 + Alpine | ~120MB |
| 多阶段 + Distroless | ~100MB |
# prometheus/prometheus.yml
scrape_configs:
- job_name: 'halolight'
static_configs:
- targets: ['app:3000']
metrics_path: '/api/metrics'
# docker-compose with Loki
services:
loki:
image: grafana/loki:latest
ports:
- "3100:3100"
command: -config.file=/etc/loki/local-config.yaml
promtail:
image: grafana/promtail:latest
volumes:
- /var/log:/var/log
- ./promtail-config.yml:/etc/promtail/config.yml
command: -config.file=/etc/promtail/config.yml
A:检查以下几点:
docker logs <container_id>A: Docker Compose:
docker-compose pull
docker-compose up -d --no-deps app
Kubernetes:
kubectl set image deployment/halolight halolight=halolight/halolight:v2
A:使用 Docker volumes:
volumes:
- postgres_data:/var/lib/postgresql/data
- redis_data:/data
A:PostgreSQL 备份:
docker exec halolight-db pg_dump -U postgres halolight > backup.sql
恢复:
docker exec -i halolight-db psql -U postgres halolight < backup.sql
| 特性 | Docker | Vercel | Kubernetes |
|---|---|---|---|
| 部署复杂度 | 中等 | 低 | 高 |
| 可移植性 | ✅ 高 | ❌ 平台锁定 | ✅ 高 |
| 扩展性 | 手动/Swarm | 自动 | ✅ HPA |
| 成本 | 自行承担 | 按用量 | 自行承担 |
| 适用场景 | 自托管/私有云 | 快速上线 | 大规模生产 |
HaloLight Fly.io 部署版本,全球边缘部署方案,支持多区域分布式部署。
在线预览:https://halolight-fly.h7ml.cn
GitHub:https://github.com/halolight/halolight-fly
# 安装 Fly CLI
# macOS
brew install flyctl
# Linux
curl -L https://fly.io/install.sh | sh
# Windows
powershell -Command "iwr https://fly.io/install.ps1 -useb | iex"
# 登录 Fly.io
fly auth login
# 克隆项目
git clone https://github.com/halolight/halolight-fly.git
cd halolight-fly
# 初始化应用 (会创建 fly.toml)
fly launch
# 部署
fly deploy
# 克隆项目
git clone https://github.com/halolight/halolight-fly.git
cd halolight-fly
# 登录
fly auth login
# 创建应用
fly apps create halolight
# 使用 Dockerfile 部署
fly deploy --dockerfile Dockerfile
# .github/workflows/fly.yml
name: Fly Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
# 应用名称
app = "halolight"
# 主区域 (香港)
primary_region = "hkg"
# 构建配置
[build]
dockerfile = "Dockerfile"
# 环境变量
[env]
NODE_ENV = "production"
PORT = "3000"
NEXT_PUBLIC_API_URL = "/api"
NEXT_PUBLIC_MOCK = "false"
# HTTP 服务配置
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = true # 空闲时自动停止
auto_start_machines = true # 请求时自动启动
min_machines_running = 1 # 最少保持 1 个实例
processes = ["app"]
# TCP 服务端口映射
[[services]]
protocol = "tcp"
internal_port = 3000
[[services.ports]]
port = 80
handlers = ["http"]
[[services.ports]]
port = 443
handlers = ["tls", "http"]
# 健康检查
[[services.http_checks]]
interval = "10s"
timeout = "2s"
grace_period = "5s"
method = "GET"
path = "/api/health"
protocol = "http"
tls_skip_verify = false
# TCP 检查
[[services.tcp_checks]]
interval = "15s"
timeout = "2s"
grace_period = "1s"
# 虚拟机配置
[[vm]]
cpu_kind = "shared"
cpus = 1
memory_mb = 512
# 进程组 (可选多进程)
[processes]
app = "pnpm start"
# worker = "pnpm run worker"
# 挂载卷
[mounts]
source = "halolight_data"
destination = "/data"
initial_size = "1gb"
# 部署策略
[deploy]
strategy = "rolling"
max_unavailable = 0.33
# 静态资源
[[statics]]
guest_path = "/app/public"
url_prefix = "/static/"
# 构建阶段
FROM node:20-alpine AS builder
RUN npm install -g pnpm
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# 生产阶段
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# 创建非 root 用户
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]
# CLI 设置单个变量
fly secrets set DATABASE_URL="postgresql://user:pass@host:5432/db"
# CLI 设置多个变量
fly secrets set \
JWT_SECRET="your-secret" \
REDIS_URL="redis://..."
# 从 .env 文件导入
fly secrets import < .env.production
# 查看已设置的变量
fly secrets list
# 删除变量
fly secrets unset DATABASE_URL
| 变量名 | 说明 | 示例 |
|---|---|---|
NODE_ENV |
运行环境 | production |
PORT |
服务端口 | 3000 |
NEXT_PUBLIC_API_URL |
API 基础 URL | /api |
NEXT_PUBLIC_MOCK |
启用 Mock 数据 | false |
DATABASE_URL |
PostgreSQL 连接 | postgresql://... |
REDIS_URL |
Redis 连接 | redis://... |
JWT_SECRET |
JWT 密钥 | your-secret-key |
Fly.io 自动注入以下环境变量:
FLY_APP_NAME # 应用名称
FLY_REGION # 当前区域代码 (如 hkg)
FLY_ALLOC_ID # 实例分配 ID
FLY_PUBLIC_IP # 公网 IP
FLY_PRIVATE_IP # 私有网络 IP
PRIMARY_REGION # 主区域
# 在指定区域创建 Volume
fly volumes create halolight_data \
--region hkg \
--size 10 \
--count 1
# 查看 Volumes
fly volumes list
# 扩展大小
fly volumes extend vol_xxx --size 20
# 删除 Volume
fly volumes destroy vol_xxx
# fly.toml
[mounts]
source = "halolight_data"
destination = "/data"
// lib/db.ts
import Database from 'better-sqlite3';
const db = new Database('/data/app.db');
// 初始化表结构
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
name TEXT
)
`);
# 创建 PostgreSQL 集群
fly postgres create --name halolight-db
# 可选配置
fly postgres create \
--name halolight-db \
--region hkg \
--vm-size shared-cpu-1x \
--volume-size 10 \
--initial-cluster-size 1
# 连接到应用
fly postgres attach halolight-db --app halolight
# 这会自动设置 DATABASE_URL 环境变量
# 连接到数据库 (交互式)
fly postgres connect -a halolight-db
# 使用 psql
fly proxy 5432 -a halolight-db
# 然后在另一个终端
psql "postgresql://postgres:xxx@localhost:5432/halolight"
# 创建 Redis (Upstash)
fly redis create
# 或使用 Fly 托管 Redis
fly apps create halolight-redis
fly deploy --config redis.toml
# 获取连接信息
fly redis status halolight-redis
# redis.toml
app = "halolight-redis"
primary_region = "hkg"
[build]
image = "flyio/redis:6.2.6"
[env]
REDIS_PASSWORD = ""
[[mounts]]
source = "redis_data"
destination = "/data"
[[services]]
internal_port = 6379
protocol = "tcp"
[[services.ports]]
port = 6379
# 查看可用区域
fly platform regions
# 添加区域
fly regions add sin nrt syd
# 查看当前区域
fly regions list
# 移除区域
fly regions remove syd
| 代码 | 位置 | 延迟 (亚洲) |
|---|---|---|
hkg |
香港 | ~5ms |
sin |
新加坡 | ~30ms |
nrt |
东京 | ~50ms |
syd |
悉尼 | ~100ms |
lax |
洛杉矶 | ~150ms |
iad |
华盛顿 | ~200ms |
lhr |
伦敦 | ~200ms |
fra |
法兰克福 | ~180ms |
# 设置实例数量
fly scale count 3
# 按区域设置实例数
fly scale count hkg=2 sin=1 nrt=1
# 查看当前实例
fly scale show
# 调整实例规格
fly scale vm shared-cpu-2x
fly scale memory 1024
# 或在 fly.toml 中配置
[[vm]]
cpu_kind = "shared"
cpus = 2
memory_mb = 1024
# 查看私有 IP
fly ips private
# 应用间通信使用 .internal 域名
# 格式: <app-name>.internal
# 例如连接到数据库
DATABASE_URL=postgres://user:[email protected]:5432/db
# 创建 WireGuard 配置
fly wireguard create
# 查看配置
fly wireguard list
# 导入到 WireGuard 客户端后可直接访问
# 内部服务: http://halolight.internal:3000
# 应用管理
fly apps list # 列出所有应用
fly apps create <name> # 创建应用
fly apps destroy <name> # 删除应用
# 部署
fly deploy # 部署
fly deploy --remote-only # 仅远程构建
fly deploy --local-only # 仅本地构建
fly deploy --strategy rolling # 滚动部署
# 状态与日志
fly status # 查看状态
fly logs # 查看日志
fly logs -a halolight # 指定应用日志
# 实例管理
fly machines list # 列出实例
fly machines start <id> # 启动实例
fly machines stop <id> # 停止实例
fly machines destroy <id> # 销毁实例
# SSH 访问
fly ssh console # SSH 到实例
fly ssh issue # 生成 SSH 证书
# 代理
fly proxy 5432 -a halolight-db # 代理端口
# 监控
fly dashboard # 打开控制台
fly metrics # 查看指标
# 发布管理
fly releases # 查看发布历史
fly releases rollback # 回滚到上一版本
Fly.io 自动暴露 Prometheus 指标:
# 访问指标端点
curl https://halolight.fly.dev/_metrics
# 配置 Prometheus 采集
scrape_configs:
- job_name: 'fly'
static_configs:
- targets: ['halolight.fly.dev']
metrics_path: '/_metrics'
# 部署 Grafana
fly apps create halolight-grafana
fly deploy --config grafana.toml
# 配置数据源连接到 Prometheus
// app/api/health/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
const checks = {
status: 'healthy',
timestamp: new Date().toISOString(),
region: process.env.FLY_REGION,
checks: {
database: await checkDatabase(),
redis: await checkRedis(),
},
};
const allHealthy = Object.values(checks.checks).every(c => c === 'ok');
return NextResponse.json(checks, {
status: allHealthy ? 200 : 503,
});
}
# 添加自定义域名
fly certs create halolight-fly.h7ml.cn
# 查看证书状态
fly certs show halolight-fly.h7ml.cn
# 列出所有证书
fly certs list
# A 记录
类型: A
名称: halolight-fly
值: <fly-app-ipv4>
# AAAA 记录 (IPv6)
类型: AAAA
名称: halolight-fly
值: <fly-app-ipv6>
# 或使用 CNAME
类型: CNAME
名称: halolight-fly
值: halolight.fly.dev
# 查看应用 IP
fly ips list
# 分配专用 IPv4 (付费)
fly ips allocate-v4
# 分配 IPv6 (免费)
fly ips allocate-v6
A:检查以下几点:
fly logs --buildA:使用以下命令:
# 查看发布历史
fly releases
# 回滚到上一版本
fly releases rollback
# 回滚到指定版本
fly releases rollback v5
A:优化建议:
min_machines_running = 1auto_start_machines = trueA:使用 SSH 访问:
# SSH 到实例
fly ssh console
# 运行命令
fly ssh console -C "ls -la"
# 查看进程
fly ssh console -C "ps aux"
A:检查以下几点:
.internal 域名进行内部连接| 资源 | 免费额度 | 超出价格 |
|---|---|---|
| 共享 CPU | 3 个实例 | $1.94/月/实例 |
| 内存 | 256MB/实例 | $0.01/GB/小时 |
| 带宽 | 160GB/月 | $0.02/GB |
| IPv4 | - | $2/月 |
| IPv6 | 无限 | 免费 |
| Volumes | 3GB | $0.15/GB/月 |
| PostgreSQL | - | 从 $6.44/月 |
# 开发/测试环境
[[vm]]
cpu_kind = "shared"
cpus = 1
memory_mb = 256
# 生产环境
[[vm]]
cpu_kind = "shared"
cpus = 2
memory_mb = 1024
| 特性 | Fly.io | Railway | Render |
|---|---|---|---|
| 全球区域 | 30+ | 2 | 4 |
| 私有网络 | ✅ WireGuard | ✅ | ✅ |
| 托管数据库 | ✅ PostgreSQL | ✅ | ✅ |
| 自动扩缩容 | ✅ | ✅ Pro | ✅ |
| 免费额度 | 3 实例 | $5/月 | 750 小时 |
| Docker 支持 | ✅ 原生 | ✅ | ✅ |
| 边缘计算 | ✅ | ❌ | ❌ |
HaloLight Fresh 版本基于 Fresh 2 + Deno 构建,采用 Islands 架构 + Preact,实现零配置、极速启动的管理后台。
在线预览:https://halolight-fresh.h7ml.cn
GitHub:https://github.com/halolight/halolight-fresh
| 技术 | 版本 | 说明 |
|---|---|---|
| Fresh | 2.x | Deno 全栈框架 |
| Deno | 2.x | 现代 JavaScript 运行时 |
| Preact | 10.x | 轻量 UI 库 |
| @preact/signals | 2.x | 响应式状态管理 |
| TypeScript | 内置 | 类型安全 |
| Tailwind CSS | 内置 | 原子化 CSS |
| Zod | 3.x | 数据验证 |
| Chart.js | 4.x | 图表可视化 |
halolight-fresh/
├── routes/ # 文件路由
│ ├── _app.tsx # 根布局
│ ├── _layout.tsx # 默认布局
│ ├── _middleware.ts # 全局中间件
│ ├── index.tsx # 首页
│ ├── auth/ # 认证页面
│ │ ├── login.tsx
│ │ ├── register.tsx
│ │ ├── forgot-password.tsx
│ │ └── reset-password.tsx
│ ├── dashboard/ # 仪表盘页面
│ │ ├── _layout.tsx # 仪表盘布局
│ │ ├── _middleware.ts # 认证中间件
│ │ ├── index.tsx
│ │ ├── users/
│ │ │ ├── index.tsx
│ │ │ ├── create.tsx
│ │ │ └── [id].tsx
│ │ ├── roles.tsx
│ │ ├── permissions.tsx
│ │ ├── settings.tsx
│ │ └── profile.tsx
│ └── api/ # API 路由
│ └── auth/
│ ├── login.ts
│ ├── register.ts
│ └── me.ts
├── islands/ # 交互式 Islands
│ ├── LoginForm.tsx
│ ├── UserTable.tsx
│ ├── DashboardGrid.tsx
│ ├── ThemeToggle.tsx
│ └── Sidebar.tsx
├── components/ # 静态组件
│ ├── ui/ # UI 组件
│ │ ├── Button.tsx
│ │ ├── Input.tsx
│ │ ├── Card.tsx
│ │ └── ...
│ ├── layout/ # 布局组件
│ │ ├── AdminLayout.tsx
│ │ ├── AuthLayout.tsx
│ │ └── Header.tsx
│ └── shared/ # 共享组件
│ └── PermissionGuard.tsx
├── lib/ # 工具库
│ ├── auth.ts
│ ├── permission.ts
│ ├── session.ts
│ └── cn.ts
├── signals/ # 状态管理
│ ├── auth.ts
│ ├── ui-settings.ts
│ └── dashboard.ts
├── static/ # 静态资源
├── fresh.config.ts # Fresh 配置
├── deno.json # Deno 配置
└── tailwind.config.ts # Tailwind 配置
# macOS/Linux
curl -fsSL https://deno.land/install.sh | sh
# Windows
irm https://deno.land/install.ps1 | iex
git clone https://github.com/halolight/halolight-fresh.git
cd halolight-fresh
cp .env.example .env
# .env
API_URL=/api
USE_MOCK=true
[email protected]
DEMO_PASSWORD=123456
SHOW_DEMO_HINT=true
APP_TITLE=Admin Pro
BRAND_NAME=Halolight
SESSION_SECRET=your-secret-key
deno task dev
deno task build
deno task start
// signals/auth.ts
import { signal, computed, effect } from '@preact/signals'
import { IS_BROWSER } from '$fresh/runtime.ts'
interface User {
id: number
name: string
email: string
permissions: string[]
}
export const user = signal<User | null>(null)
export const token = signal<string | null>(null)
export const loading = signal(false)
export const isAuthenticated = computed(() => !!token.value && !!user.value)
export const permissions = computed(() => user.value?.permissions ?? [])
// 仅在浏览器端持久化
if (IS_BROWSER) {
const saved = localStorage.getItem('auth')
if (saved) {
const { user: savedUser, token: savedToken } = JSON.parse(saved)
user.value = savedUser
token.value = savedToken
}
effect(() => {
if (user.value && token.value) {
localStorage.setItem('auth', JSON.stringify({
user: user.value,
token: token.value,
}))
}
})
}
export async function login(credentials: { email: string; password: string }) {
loading.value = true
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
headers: { 'Content-Type': 'application/json' },
})
const data = await response.json()
user.value = data.user
token.value = data.token
} finally {
loading.value = false
}
}
export function logout() {
user.value = null
token.value = null
if (IS_BROWSER) {
localStorage.removeItem('auth')
}
}
export function hasPermission(permission: string): boolean {
const perms = permissions.value
return perms.some((p) =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
}
// routes/api/auth/login.ts
import { Handlers } from '$fresh/server.ts'
import { z } from 'zod'
import { setCookie } from '$std/http/cookie.ts'
import { createToken } from '../../../lib/auth.ts'
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
})
export const handler: Handlers = {
async POST(req) {
try {
const body = await req.json()
const { email, password } = loginSchema.parse(body)
// 验证用户(示例)
const user = await authenticateUser(email, password)
if (!user) {
return new Response(
JSON.stringify({ error: '邮箱或密码错误' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
)
}
const token = await createToken({ userId: user.id })
const response = new Response(
JSON.stringify({ user, token }),
{ headers: { 'Content-Type': 'application/json' } }
)
setCookie(response.headers, {
name: 'token',
value: token,
path: '/',
httpOnly: true,
sameSite: 'Lax',
maxAge: 60 * 60 * 24 * 7,
})
return response
} catch (e) {
if (e instanceof z.ZodError) {
return new Response(
JSON.stringify({ error: '参数验证失败', details: e.errors }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
return new Response(
JSON.stringify({ error: '服务器错误' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
},
}
// components/shared/PermissionGuard.tsx
import { ComponentChildren } from 'preact'
interface Props {
permission: string
userPermissions: string[]
children: ComponentChildren
fallback?: ComponentChildren
}
function checkPermission(
userPermissions: string[],
permission: string
): boolean {
return userPermissions.some((p) =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
}
export function PermissionGuard({
permission,
userPermissions,
children,
fallback,
}: Props) {
if (!checkPermission(userPermissions, permission)) {
return fallback ?? null
}
return <>{children}</>
}
// 使用(在服务端渲染)
<PermissionGuard
permission="users:delete"
userPermissions={ctx.state.user.permissions}
fallback={<span class="text-muted-foreground">无权限</span>}
>
<Button variant="destructive">删除</Button>
</PermissionGuard>
// islands/LoginForm.tsx
import { useSignal } from '@preact/signals'
import { login, loading } from '../signals/auth.ts'
import { Button } from '../components/ui/Button.tsx'
import { Input } from '../components/ui/Input.tsx'
interface Props {
redirectTo?: string
}
export default function LoginForm({ redirectTo = '/dashboard' }: Props) {
const email = useSignal('')
const password = useSignal('')
const error = useSignal('')
const handleSubmit = async (e: Event) => {
e.preventDefault()
error.value = ''
try {
await login({
email: email.value,
password: password.value,
})
globalThis.location.href = redirectTo
} catch (e) {
error.value = '邮箱或密码错误'
}
}
return (
<form onSubmit={handleSubmit} class="space-y-4">
{error.value && (
<div class="text-destructive text-sm">{error.value}</div>
)}
<Input
type="email"
label="邮箱"
value={email.value}
onInput={(e) => email.value = e.currentTarget.value}
required
/>
<Input
type="password"
label="密码"
value={password.value}
onInput={(e) => password.value = e.currentTarget.value}
required
/>
<Button type="submit" class="w-full" disabled={loading.value}>
{loading.value ? '登录中...' : '登录'}
</Button>
</form>
)
}
// routes/auth/login.tsx
import { Handlers, PageProps } from '$fresh/server.ts'
import { AuthLayout } from '../../components/layout/AuthLayout.tsx'
import LoginForm from '../../islands/LoginForm.tsx'
export const handler: Handlers = {
GET(req, ctx) {
const url = new URL(req.url)
const redirect = url.searchParams.get('redirect') || '/dashboard'
return ctx.render({ redirect })
},
}
export default function LoginPage({ data }: PageProps<{ redirect: string }>) {
return (
<AuthLayout>
<div class="max-w-md mx-auto">
<h1 class="text-2xl font-bold text-center mb-8">登录</h1>
<LoginForm redirectTo={data.redirect} />
</div>
</AuthLayout>
)
}
支持 11 种预设皮肤,通过快捷设置面板切换:
| 皮肤 | 主色调 | CSS 变量 |
|---|---|---|
| Default | 紫色 | --primary: 51.1% 0.262 276.97 |
| Blue | 蓝色 | --primary: 54.8% 0.243 264.05 |
| Emerald | 翠绿 | --primary: 64.6% 0.178 142.49 |
| Orange | 橙色 | --primary: 69.7% 0.186 37.37 |
| Rose | 玫红 | --primary: 62.8% 0.241 12.48 |
| Teal | 青绿 | --primary: 66.7% 0.151 193.65 |
| Amber | 琥珀 | --primary: 77.5% 0.166 69.76 |
| Cyan | 青蓝 | --primary: 75.1% 0.146 204.66 |
| Pink | 粉色 | --primary: 65.7% 0.255 347.69 |
| Indigo | 靛青 | --primary: 51.9% 0.235 272.75 |
| Lime | 柠檬绿 | --primary: 78.1% 0.167 136.29 |
/* 主题变量定义 */
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 100% 0 0;
--secondary: 96.1% 0.006 285.75;
--secondary-foreground: 14.9% 0.017 285.75;
--muted: 96.1% 0.006 285.75;
--muted-foreground: 44.7% 0.025 285.75;
--accent: 96.1% 0.006 285.75;
--accent-foreground: 14.9% 0.017 285.75;
--destructive: 62.8% 0.241 12.48;
--destructive-foreground: 100% 0 0;
--border: 89.8% 0.011 285.75;
--input: 89.8% 0.011 285.75;
--ring: 51.1% 0.262 276.97;
--radius: 0.5rem;
}
| 路径 | 页面 | 权限 |
|---|---|---|
/ |
首页 | 公开 |
/auth/login |
登录 | 公开 |
/auth/register |
注册 | 公开 |
/auth/forgot-password |
忘记密码 | 公开 |
/auth/reset-password |
重置密码 | 公开 |
/dashboard |
仪表盘 | dashboard:view |
/dashboard/users |
用户列表 | users:list |
/dashboard/users/create |
创建用户 | users:create |
/dashboard/users/[id] |
用户详情 | users:view |
/dashboard/roles |
角色管理 | roles:list |
/dashboard/permissions |
权限管理 | permissions:list |
/dashboard/settings |
系统设置 | settings:view |
/dashboard/profile |
个人中心 | 登录即可 |
deno task dev # 启动开发服务器
deno task build # 生产构建
deno task start # 启动生产服务器
deno task check # 格式和类型检查
deno task fmt # 格式化代码
deno task fmt:check # 检查代码格式
deno task lint # 代码检查
deno task test # 运行测试
deno task test:watch # 测试 watch 模式
deno task test:coverage # 测试覆盖率
deno task ci # 运行完整 CI 检查
# 安装 deployctl
deno install -A --no-check -r -f https://deno.land/x/deploy/deployctl.ts
# 部署
deployctl deploy --project=halolight-fresh main.ts
FROM denoland/deno:2.0.0
WORKDIR /app
COPY . .
RUN deno cache main.ts
EXPOSE 8000
CMD ["run", "-A", "main.ts"]
docker build -t halolight-fresh .
docker run -p 8000:8000 halolight-fresh
deno task start| 角色 | 邮箱 | 密码 |
|---|---|---|
| 管理员 | [email protected] | 123456 |
| 普通用户 | [email protected] | 123456 |
项目使用 Deno 内置测试框架,测试文件位于 tests/ 目录。
tests/
├── setup.ts # 测试环境设置
│ ├── localStorage mock
│ ├── sessionStorage mock
│ ├── matchMedia mock
│ └── 辅助函数(createMockUser, mockAuthenticatedState 等)
└── lib/
├── utils.test.ts # 工具函数测试
├── config.test.ts # 配置测试
└── stores.test.ts # 状态管理测试
# 运行所有测试
deno task test
# 监视模式
deno task test:watch
# 测试覆盖率
deno task test:coverage
# 覆盖率报告输出到 coverage/lcov.info
// tests/lib/config.test.ts
import { assertEquals, assertExists } from "$std/assert/mod.ts";
import "../setup.ts";
import { hasPermission } from "../../lib/config.ts";
import type { Permission } from "../../lib/types.ts";
Deno.test("hasPermission - 权限检查", async (t) => {
const userPermissions: Permission[] = ["dashboard:view", "users:view"];
await t.step("应该返回 true 当用户有权限时", () => {
const result = hasPermission(userPermissions, "dashboard:view");
assertEquals(result, true);
});
await t.step("应该支持通配符权限", () => {
const adminPermissions: Permission[] = ["*"];
const result = hasPermission(adminPermissions, "dashboard:view");
assertEquals(result, true);
});
});
// fresh.config.ts
import { defineConfig } from '$fresh/server.ts'
import tailwind from '$fresh/plugins/tailwind.ts'
export default defineConfig({
plugins: [tailwind()],
})
// deno.json
{
"lock": false,
"tasks": {
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
"dev": "deno run -A --watch=static/,routes/ dev.ts",
"build": "deno run -A dev.ts build",
"start": "deno run -A main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update ."
},
"imports": {
"$fresh/": "https://deno.land/x/[email protected]/",
"$std/": "https://deno.land/[email protected]/",
"preact": "https://esm.sh/[email protected]",
"preact/": "https://esm.sh/[email protected]/",
"@preact/signals": "https://esm.sh/@preact/[email protected]",
"zod": "https://deno.land/x/[email protected]/mod.ts"
},
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}
项目使用 GitHub Actions 进行持续集成,配置文件位于 .github/workflows/ci.yml。
| 任务 | 说明 | 触发条件 |
|---|---|---|
| lint | 格式检查、代码检查、类型检查 | push/PR |
| test | 运行测试并上传覆盖率 | push/PR |
| build | 生产构建验证 | lint/test 通过后 |
| security | Deno 安全审计 | push/PR |
| dependency-review | 依赖安全审查 | PR only |
// deno.json
{
"lint": {
"rules": {
"tags": ["recommended"],
"exclude": [
"no-explicit-any",
"explicit-function-return-type",
"explicit-module-boundary-types",
"jsx-button-has-type",
"no-unused-vars"
]
}
},
"fmt": {
"lineWidth": 100,
"indentWidth": 2,
"singleQuote": false,
"semiColons": true
}
}
// routes/dashboard/_middleware.ts
import { FreshContext } from '$fresh/server.ts'
import { getCookies } from '$std/http/cookie.ts'
import { verifyToken, getUser } from '../../lib/auth.ts'
export async function handler(req: Request, ctx: FreshContext) {
const cookies = getCookies(req.headers)
const token = cookies.token
if (!token) {
const url = new URL(req.url)
return new Response(null, {
status: 302,
headers: { Location: `/auth/login?redirect=${url.pathname}` },
})
}
try {
const payload = await verifyToken(token)
const user = await getUser(payload.userId)
ctx.state.user = user
ctx.state.token = token
} catch {
return new Response(null, {
status: 302,
headers: { Location: '/auth/login' },
})
}
return ctx.next()
}
// routes/dashboard/_layout.tsx
import { PageProps } from '$fresh/server.ts'
import { AdminLayout } from '../../components/layout/AdminLayout.tsx'
import Sidebar from '../../islands/Sidebar.tsx'
export default function DashboardLayout({ Component, state }: PageProps) {
return (
<AdminLayout>
<div class="flex min-h-screen">
<Sidebar user={state.user} />
<main class="flex-1 p-6">
<Component />
</main>
</div>
</AdminLayout>
)
}
Fresh 默认零 JS,仅交互组件需要水合:
// 静态组件(components/)- 零 JS
export function Card({ title, content }) {
return (
<div class="card">
<h2>{title}</h2>
<p>{content}</p>
</div>
)
}
// 交互式 Island(islands/)- 按需水合
export default function Counter() {
const count = useSignal(0)
return (
<button onClick={() => count.value++}>
Count: {count.value}
</button>
)
}
// 利用 Deno Deploy 边缘运行时
export const handler: Handlers = {
async GET(req) {
// 在边缘节点执行,降低延迟
const data = await fetchFromDatabase()
return new Response(JSON.stringify(data))
}
}
// 预加载关键资源
<link rel="preload" href="/api/auth/me" as="fetch" crossOrigin="anonymous" />
A:使用 @preact/signals,它在服务端和客户端都能工作:
// signals/auth.ts
export const user = signal<User | null>(null)
// islands/UserProfile.tsx (客户端)
import { user } from '../signals/auth.ts'
export default function UserProfile() {
return <div>{user.value?.name}</div>
}
// routes/dashboard/index.tsx (服务端)
import { user } from '../signals/auth.ts'
export default function Dashboard({ data }: PageProps) {
return <div>Welcome {data.user.name}</div>
}
A:Fresh 使用 Deno 的环境变量系统:
// 读取环境变量
const apiUrl = Deno.env.get('API_URL') || '/api'
// .env 文件(开发环境)
// 使用 deno task dev 自动加载
A:使用 Deno KV (内置键值数据库):
// lib/db.ts
const kv = await Deno.openKv()
export async function saveUser(user: User) {
await kv.set(['users', user.id], user)
}
export async function getUser(id: number) {
const result = await kv.get(['users', id])
return result.value as User
}
| 特性 | Fresh 版本 | Astro 版本 | Next.js 版本 |
|---|---|---|---|
| 运行时 | Deno | Node.js | Node.js |
| 状态管理 | @preact/signals | - | Zustand |
| 数据获取 | Handlers | Load 函数 | TanStack Query |
| 表单验证 | Zod | Zod | React Hook Form + Zod |
| 服务端 | 内置 | @astrojs/node | API Routes |
| 组件库 | 自定义 | - | shadcn/ui |
| Islands 架构 | ✅ | ✅ | ❌ |
| 零配置 | ✅ | ❌ | ❌ |
| 边缘部署 | Deno Deploy | Cloudflare | Vercel Edge |
| 构建步骤 | 可选 | 必须 | 必须 |
选择你熟悉的前端框架,并按需搭配后端 API,快速启动 HaloLight。
HaloLight 采用前后端完全分离架构,支持 12 个前端 × 8 个后端 = 96 种组合。
| 框架 | 适用场景 | 特点 |
|---|---|---|
| Next.js / Nuxt | 多租户 SaaS、SEO 需求 | SSR + 边缘渲染友好 |
| Vue | 中小团队快速交付 | 轻量高效、学习曲线平滑 |
| Angular | 大中型、长周期项目 | 强类型、架构清晰 |
| SvelteKit / Solid / Qwik | 高交互、实时场景 | 极致性能与响应式体验 |
| Remix / Preact / Lit | 渐进增强、轻量化 | Web Components、小体积 |
| Astro | 内容为主的管理后台 | Islands 架构、零 JS 默认 |
| 后端技术 | 适用场景 | 特点 |
|---|---|---|
| NestJS / Express | Node 生态团队 | 与前端 TS 契合度高 |
| FastAPI | 数据/AI 驱动应用 | Python 生态、快速迭代 |
| Spring Boot | 企业级、金融行业 | 成熟中间件生态 |
| Go Fiber | 高性能、高并发 | 低资源占用 |
| PHP Laravel | 传统 Web 团队 | 生态完善、上手快 |
| Bun + Hono | 极致性能追求 | 新一代运行时 |
| tRPC BFF | 移动/桌面多端 | 类型共享、聚合与降噪 |
推荐组合:Next.js + NestJS
优势:
推荐组合:Vue + FastAPI 或 React + FastAPI
优势:
推荐组合:Angular + Spring Boot
优势:
推荐组合:SvelteKit + Go Fiber
优势:
推荐组合:任意前端 + tRPC BFF + 任意后端
优势:
# 克隆仓库
git clone https://github.com/halolight/halolight.git
cd halolight
# 安装依赖
pnpm install
# 启动开发服务器
pnpm dev
访问 http://localhost:3000 查看效果。
详细文档:Next.js 版本指南
# 克隆仓库
git clone https://github.com/halolight/halolight-vue.git
cd halolight-vue
# 安装依赖
pnpm install
# 启动开发服务器
pnpm dev
访问 http://localhost:5173 查看效果。
详细文档:Vue 版本指南
所有版本使用相同的 Mock 账号:
| 角色 | 邮箱 | 密码 |
|---|---|---|
| 管理员 | [email protected] | 123456 |
项目根目录/
├── src/
│ ├── app/ # 页面路由
│ ├── components/ # 组件
│ │ ├── ui/ # shadcn/ui 组件
│ │ ├── layout/ # 布局组件
│ │ └── dashboard/ # 仪表盘组件
│ ├── hooks/ # 自定义 hooks
│ ├── stores/ # 状态管理
│ ├── services/ # API 服务
│ ├── lib/ # 工具库
│ ├── types/ # 类型定义
│ └── mocks/ # Mock 数据
├── public/ # 静态资源
└── package.json
# 终端 1:启动前端
git clone https://github.com/halolight/halolight.git && cd halolight
pnpm install && pnpm dev
# 终端 2:启动后端
git clone https://github.com/halolight/halolight-api-nestjs.git && cd halolight-api-nestjs
pnpm install && pnpm dev
# 终端 1:启动前端
git clone https://github.com/halolight/halolight-vue.git && cd halolight-vue
pnpm install && pnpm dev
# 终端 2:启动后端
git clone https://github.com/halolight/halolight-api-python.git && cd halolight-api-python
pip install -e ".[dev]" && uvicorn app.main:app --reload
HaloLight 是一套多框架实现的企业级管理后台解决方案。
HaloLight 采用 “一套设计规范,多框架实现” 的理念,为开发者提供统一的 Admin Dashboard 体验。无论你使用 React、Vue、Angular 还是其他现代框架,都能获得一致的功能和设计。
基于 Grid Layout 的自定义 Dashboard 系统,支持:
完整的 RBAC 权限管理系统:
users:*,*)丰富的视觉定制能力:
基于 shadcn/ui 设计系统:
全部框架版本均已实现并部署 (预览地址见各自仓库 README)。当前作为规范基准的参考实现:
| 框架 | 状态 | 预览 | 仓库 |
|---|---|---|---|
| Next.js 14 | ✅ 已部署 | 预览 | GitHub |
| React (Vite) | ✅ 已部署 | 预览 | GitHub |
| Vue 3.5 | ✅ 已部署 | 预览 | GitHub |
| Angular 21 | ✅ 已部署 | 预览 | GitHub |
| Nuxt 4 | ✅ 已部署 | 预览 | GitHub |
| SvelteKit 2 | ✅ 已部署 | 预览 | GitHub |
| Astro 5 | ✅ 已部署 | 预览 | GitHub |
| Solid.js | ✅ 已部署 | 预览 | GitHub |
| Qwik | ✅ 已部署 | 预览 | GitHub |
| Remix | ✅ 已部署 | 预览 | GitHub |
| Preact | ✅ 已部署 | 预览 | GitHub |
| Lit | ✅ 已部署 | 预览 | GitHub |
| Fresh (Deno) | ✅ 已部署 | 预览 | GitHub |
| Deno | ✅ 已部署 | 预览 | GitHub |
💡 灵活组合:前端主线支持 14 个框架,任意前端均可与 7 个后端 API 组合,形成 98+ 种搭配方案。
| 后端技术 | 状态 | 预览 | 仓库 |
|---|---|---|---|
| NestJS 11 | ✅ 已部署 | API Docs | GitHub |
| Python FastAPI | ✅ 已部署 | API Docs | GitHub |
| Java Spring Boot | ✅ 已部署 | API Docs | GitHub |
| Go Fiber | ✅ 已部署 | API Docs | GitHub |
| Node.js Express | ✅ 已部署 | - | GitHub |
| PHP Laravel | ✅ 已部署 | - | GitHub |
| Bun + Hono | ✅ 已部署 | - | GitHub |
| 项目 | 状态 | 说明 | 仓库 |
|---|---|---|---|
| tRPC BFF | ✅ 已部署 | 类型安全网关 | GitHub |
| Next.js Action | ✅ 已部署 | Server Actions 全栈方案 | GitHub |
所有框架版本共享以下技术栈:
HaloLight Lit 版本基于 Lit 3 构建,采用 Web Components 标准 + TypeScript,提供跨框架可复用的 Web Components 组件库。
在线预览:https://halolight-lit.h7ml.cn
GitHub:https://github.com/halolight/halolight-lit
| 技术 | 版本 | 说明 |
|---|---|---|
| Lit | 3.x | Web Components 框架 |
| TypeScript | 5.x | 类型安全 |
| Tailwind CSS | 4.x | 原子化 CSS |
| @lit-labs/router | 0.1.x | 客户端路由 |
| @lit-labs/context | 1.x | 上下文状态 |
| Shoelace | 2.x | Web Components UI 库 |
| Zod | 3.x | 数据验证 |
| ECharts | 5.x | 图表可视化 |
| Vite | 6.x | 构建工具 |
| Mock.js | 1.x | 数据模拟 |
halolight-lit/
├── src/
│ ├── pages/ # 页面组件
│ │ ├── hl-home.ts # 首页
│ │ ├── auth/ # 认证页面
│ │ │ ├── hl-login.ts
│ │ │ ├── hl-register.ts
│ │ │ ├── hl-forgot-password.ts
│ │ │ └── hl-reset-password.ts
│ │ └── dashboard/ # 仪表盘页面
│ │ ├── hl-dashboard.ts
│ │ ├── hl-users.ts
│ │ ├── hl-user-detail.ts
│ │ ├── hl-user-create.ts
│ │ ├── hl-roles.ts
│ │ ├── hl-permissions.ts
│ │ ├── hl-settings.ts
│ │ └── hl-profile.ts
│ ├── components/ # 组件库
│ │ ├── ui/ # UI 组件
│ │ │ ├── hl-button.ts
│ │ │ ├── hl-input.ts
│ │ │ ├── hl-card.ts
│ │ │ └── hl-dialog.ts
│ │ ├── layout/ # 布局组件
│ │ │ ├── hl-admin-layout.ts
│ │ │ ├── hl-auth-layout.ts
│ │ │ ├── hl-sidebar.ts
│ │ │ └── hl-header.ts
│ │ ├── dashboard/ # 仪表盘组件
│ │ │ ├── hl-dashboard-grid.ts
│ │ │ ├── hl-widget-wrapper.ts
│ │ │ └── hl-stats-widget.ts
│ │ └── shared/ # 共享组件
│ │ └── hl-permission-guard.ts
│ ├── stores/ # 状态管理
│ │ ├── auth-context.ts
│ │ ├── ui-settings-context.ts
│ │ └── dashboard-context.ts
│ ├── lib/ # 工具库
│ │ ├── api.ts
│ │ ├── permission.ts
│ │ └── styles.ts
│ ├── mock/ # Mock 数据
│ ├── types/ # 类型定义
│ ├── hl-app.ts # 根组件
│ ├── router.ts # 路由配置
│ └── main.ts # 入口文件
├── public/ # 静态资源
├── vite.config.ts # Vite 配置
├── tailwind.config.ts # Tailwind 配置
└── package.json
git clone https://github.com/halolight/halolight-lit.git
cd halolight-lit
pnpm install
cp .env.example .env
# .env
VITE_API_URL=/api
VITE_USE_MOCK=true
[email protected]
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=false
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight
pnpm dev
pnpm build
pnpm preview
// stores/auth-context.ts
import { createContext } from '@lit-labs/context'
import { html, LitElement } from 'lit'
import { customElement, state } from 'lit/decorators.js'
import { provide } from '@lit-labs/context'
interface User {
id: number
name: string
email: string
permissions: string[]
}
interface AuthState {
user: User | null
token: string | null
loading: boolean
login: (credentials: { email: string; password: string }) => Promise<void>
logout: () => void
hasPermission: (permission: string) => boolean
}
export const authContext = createContext<AuthState>('auth')
@customElement('hl-auth-provider')
export class AuthProvider extends LitElement {
@state() private user: User | null = null
@state() private token: string | null = null
@state() private loading = false
@provide({ context: authContext })
authState: AuthState = {
user: null,
token: null,
loading: false,
login: this.login.bind(this),
logout: this.logout.bind(this),
hasPermission: this.hasPermission.bind(this),
}
connectedCallback() {
super.connectedCallback()
this.loadFromStorage()
}
private loadFromStorage() {
const saved = localStorage.getItem('auth')
if (saved) {
const { user, token } = JSON.parse(saved)
this.user = user
this.token = token
this.updateContext()
}
}
private updateContext() {
this.authState = {
...this.authState,
user: this.user,
token: this.token,
loading: this.loading,
}
}
async login(credentials: { email: string; password: string }) {
this.loading = true
this.updateContext()
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
headers: { 'Content-Type': 'application/json' },
})
const data = await response.json()
this.user = data.user
this.token = data.token
localStorage.setItem('auth', JSON.stringify({
user: this.user,
token: this.token,
}))
} finally {
this.loading = false
this.updateContext()
}
}
logout() {
this.user = null
this.token = null
localStorage.removeItem('auth')
this.updateContext()
}
hasPermission(permission: string): boolean {
const perms = this.user?.permissions ?? []
return perms.some(p =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
}
render() {
return html`<slot></slot>`
}
}
// components/ui/hl-button.ts
import { LitElement, html, css } from 'lit'
import { customElement, property } from 'lit/decorators.js'
import { classMap } from 'lit/directives/class-map.js'
@customElement('hl-button')
export class HlButton extends LitElement {
static styles = css`
:host {
display: inline-block;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.default {
background-color: var(--primary);
color: var(--primary-foreground);
}
.default:hover {
opacity: 0.9;
}
.destructive {
background-color: var(--destructive);
color: var(--destructive-foreground);
}
.outline {
border: 1px solid var(--border);
background: transparent;
}
.sm { height: 2rem; padding: 0 0.75rem; font-size: 0.875rem; }
.md { height: 2.5rem; padding: 0 1rem; }
.lg { height: 3rem; padding: 0 1.5rem; font-size: 1.125rem; }
.disabled {
opacity: 0.5;
cursor: not-allowed;
}
`
@property() variant: 'default' | 'destructive' | 'outline' | 'ghost' = 'default'
@property() size: 'sm' | 'md' | 'lg' = 'md'
@property({ type: Boolean }) disabled = false
render() {
const classes = {
[this.variant]: true,
[this.size]: true,
disabled: this.disabled,
}
return html`
<button class=${classMap(classes)} ?disabled=${this.disabled}>
<slot></slot>
</button>
`
}
}
// router.ts
import { Router } from '@lit-labs/router'
import { html } from 'lit'
// 延迟加载页面组件
const routes = [
{
path: '/',
render: () => html`<hl-home></hl-home>`,
enter: async () => {
await import('./pages/hl-home.js')
return true
},
},
{
path: '/login',
render: () => html`<hl-login></hl-login>`,
enter: async () => {
await import('./pages/auth/hl-login.js')
return true
},
},
{
path: '/dashboard',
render: () => html`<hl-dashboard></hl-dashboard>`,
enter: async ({ router }) => {
// 路由守卫
const authState = document.querySelector('hl-auth-provider')?.authState
if (!authState?.token) {
router.goto('/login?redirect=/dashboard')
return false
}
await import('./pages/dashboard/hl-dashboard.js')
return true
},
},
{
path: '/users',
render: () => html`<hl-users></hl-users>`,
enter: async ({ router }) => {
const authState = document.querySelector('hl-auth-provider')?.authState
if (!authState?.hasPermission('users:list')) {
return false
}
await import('./pages/dashboard/hl-users.js')
return true
},
},
// 更多路由...
]
export function createRouter(host: HTMLElement) {
return new Router(host, routes)
}
// components/shared/hl-permission-guard.ts
import { LitElement, html } from 'lit'
import { customElement, property } from 'lit/decorators.js'
import { consume } from '@lit-labs/context'
import { authContext, type AuthState } from '../../stores/auth-context'
@customElement('hl-permission-guard')
export class HlPermissionGuard extends LitElement {
@property() permission = ''
@consume({ context: authContext, subscribe: true })
authState!: AuthState
render() {
const hasPermission = this.authState?.hasPermission(this.permission)
if (!hasPermission) {
return html`<slot name="fallback"></slot>`
}
return html`<slot></slot>`
}
}
使用示例:
<hl-permission-guard permission="users:delete">
<hl-button variant="destructive">删除</hl-button>
<span slot="fallback" class="text-muted-foreground">无权限</span>
</hl-permission-guard>
// components/dashboard/hl-dashboard-grid.ts
import { LitElement, html, css } from 'lit'
import { customElement, state } from 'lit/decorators.js'
import Sortable from 'sortablejs'
@customElement('hl-dashboard-grid')
export class HlDashboardGrid extends LitElement {
static styles = css`
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.widget {
background: var(--card);
border-radius: 0.5rem;
padding: 1rem;
cursor: move;
}
`
@state() private widgets = [
{ id: 'stats', type: 'stats' },
{ id: 'chart', type: 'chart' },
{ id: 'table', type: 'table' },
]
firstUpdated() {
const grid = this.shadowRoot?.querySelector('.grid')
if (grid) {
new Sortable(grid as HTMLElement, {
animation: 150,
onEnd: (evt) => {
const { oldIndex, newIndex } = evt
if (oldIndex !== undefined && newIndex !== undefined) {
const item = this.widgets.splice(oldIndex, 1)[0]
this.widgets.splice(newIndex, 0, item)
this.requestUpdate()
}
},
})
}
}
render() {
return html`
<div class="grid">
${this.widgets.map(widget => html`
<div class="widget" data-id=${widget.id}>
<hl-widget-wrapper type=${widget.type}></hl-widget-wrapper>
</div>
`)}
</div>
`
}
}
支持 11 种预设皮肤,通过快捷设置面板切换:
| 皮肤 | 主色调 | CSS 变量 |
|---|---|---|
| Default | 紫色 | --primary: 51.1% 0.262 276.97 |
| Blue | 蓝色 | --primary: 54.8% 0.243 264.05 |
| Emerald | 翠绿 | --primary: 64.6% 0.178 142.49 |
| Rose | 玫红 | --primary: 58.5% 0.217 12.53 |
| Orange | 橘色 | --primary: 68.4% 0.197 41.73 |
/* 主题变量定义 */
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 98% 0 0;
--card: 100% 0 0;
--card-foreground: 14.9% 0.017 285.75;
--border: 93.3% 0.011 285.88;
--radius: 0.5rem;
}
.dark {
--background: 14.9% 0.017 285.75;
--foreground: 98% 0 0;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 98% 0 0;
--card: 14.9% 0.017 285.75;
--card-foreground: 98% 0 0;
--border: 25.1% 0.025 285.82;
}
| 路径 | 页面 | 权限 |
|---|---|---|
/ |
首页 | 公开 |
/login |
登录 | 公开 |
/register |
注册 | 公开 |
/forgot-password |
忘记密码 | 公开 |
/reset-password |
重置密码 | 公开 |
/dashboard |
仪表盘 | dashboard:view |
/users |
用户管理 | users:view |
/users/create |
创建用户 | users:create |
/users/:id |
用户详情 | users:view |
/roles |
角色管理 | roles:view |
/permissions |
权限管理 | permissions:view |
/settings |
系统设置 | settings:view |
/profile |
个人资料 | settings:view |
import '@halolight/lit/hl-button'
function App() {
return (
<hl-button variant="default" onClick={() => console.log('clicked')}>
点击
</hl-button>
)
}
<template>
<hl-button variant="default" @click="handleClick">
点击
</hl-button>
</template>
<script setup>
import '@halolight/lit/hl-button'
function handleClick() {
console.log('clicked')
}
</script>
// app.module.ts
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'
import '@halolight/lit/hl-button'
@NgModule({
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<hl-button variant="default" (click)="handleClick()">
点击
</hl-button>
pnpm dev # 启动开发服务器
pnpm build # 生产构建
pnpm preview # 预览生产构建
pnpm lint # 代码检查
pnpm lint:fix # 自动修复
pnpm type-check # 类型检查
pnpm test # 运行测试
pnpm test:coverage # 测试覆盖率
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
| 角色 | 邮箱 | 密码 |
|---|---|---|
| 管理员 | [email protected] | 123456 |
| 普通用户 | [email protected] | 123456 |
pnpm test # 运行测试(watch 模式)
pnpm test:run # 单次运行
pnpm test:coverage # 覆盖率报告
pnpm test:ui # Vitest UI 界面
// __tests__/hl-button.test.ts
import { expect, fixture, html } from '@open-wc/testing'
import '../src/components/ui/hl-button'
describe('hl-button', () => {
it('renders with default variant', async () => {
const el = await fixture(html`<hl-button>Click me</hl-button>`)
const button = el.shadowRoot?.querySelector('button')
expect(button).to.exist
expect(button?.textContent?.trim()).to.equal('Click me')
})
it('applies variant classes', async () => {
const el = await fixture(html`<hl-button variant="destructive">Delete</hl-button>`)
const button = el.shadowRoot?.querySelector('button')
expect(button?.classList.contains('destructive')).to.be.true
})
it('handles disabled state', async () => {
const el = await fixture(html`<hl-button disabled>Disabled</hl-button>`)
const button = el.shadowRoot?.querySelector('button')
expect(button?.disabled).to.be.true
})
})
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
build: {
lib: {
entry: 'src/main.ts',
formats: ['es'],
},
rollupOptions: {
external: /^lit/,
},
},
server: {
port: 5173,
},
})
// tailwind.config.ts
import type { Config } from 'tailwindcss'
export default {
content: ['./index.html', './src/**/*.{ts,js}'],
darkMode: 'class',
theme: {
extend: {
colors: {
border: 'oklch(var(--border))',
background: 'oklch(var(--background))',
foreground: 'oklch(var(--foreground))',
primary: {
DEFAULT: 'oklch(var(--primary))',
foreground: 'oklch(var(--primary-foreground))',
},
},
},
},
} satisfies Config
项目配置了完整的 GitHub Actions CI 工作流:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm type-check
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
// 组件生命周期
@customElement('my-component')
export class MyComponent extends LitElement {
// 首次连接到 DOM
connectedCallback() {
super.connectedCallback()
console.log('Component connected')
}
// 首次更新完成
firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties)
console.log('First render complete')
}
// 每次更新完成
updated(changedProperties: PropertyValues) {
super.updated(changedProperties)
if (changedProperties.has('value')) {
console.log('Value changed:', this.value)
}
}
// 从 DOM 中移除
disconnectedCallback() {
super.disconnectedCallback()
console.log('Component disconnected')
}
}
// lib/directives/tooltip.ts
import { directive, Directive } from 'lit/directive.js'
import { AsyncDirective } from 'lit/async-directive.js'
class TooltipDirective extends AsyncDirective {
render(text: string) {
return text
}
update(part: any, [text]: [string]) {
const element = part.element
element.setAttribute('title', text)
element.style.cursor = 'help'
return this.render(text)
}
}
export const tooltip = directive(TooltipDirective)
// 使用
import { tooltip } from './lib/directives/tooltip'
render() {
return html`
<span ${tooltip('这是提示信息')}>悬停查看提示</span>
`
}
// components/ui/hl-virtual-list.ts
import { LitElement, html } from 'lit'
import { customElement, property, state } from 'lit/decorators.js'
import { repeat } from 'lit/directives/repeat.js'
@customElement('hl-virtual-list')
export class HlVirtualList extends LitElement {
@property({ type: Array }) items: any[] = []
@property({ type: Number }) itemHeight = 50
@state() private visibleStart = 0
@state() private visibleEnd = 20
private handleScroll(e: Event) {
const target = e.target as HTMLElement
const scrollTop = target.scrollTop
this.visibleStart = Math.floor(scrollTop / this.itemHeight)
this.visibleEnd = this.visibleStart + 20
}
render() {
const visibleItems = this.items.slice(this.visibleStart, this.visibleEnd)
return html`
<div class="container" @scroll=${this.handleScroll}>
<div style="height: ${this.items.length * this.itemHeight}px">
<div style="transform: translateY(${this.visibleStart * this.itemHeight}px)">
${repeat(
visibleItems,
item => item.id,
item => html`<div class="item">${item.name}</div>`
)}
</div>
</div>
</div>
`
}
}
// 路由懒加载
{
path: '/dashboard',
enter: async () => {
await import('./pages/dashboard/hl-dashboard.js')
return true
},
}
// 动态导入
async loadWidget(type: string) {
const module = await import(`./widgets/hl-${type}-widget.js`)
return module.default
}
// 预加载关键路由
const preloadRoutes = ['/dashboard', '/users']
preloadRoutes.forEach(async (route) => {
const link = document.createElement('link')
link.rel = 'modulepreload'
link.href = `./pages${route}.js`
document.head.appendChild(link)
})
A:使用 CSS 自定义属性或 @import 导入全局样式:
static styles = css`
@import url('/global.css');
:host {
color: var(--foreground);
background: var(--background);
}
`
A:使用 @input 事件和 @state 装饰器:
@customElement('hl-form')
export class HlForm extends LitElement {
@state() private formData = { name: '', email: '' }
private handleInput(field: string, value: string) {
this.formData = { ...this.formData, [field]: value }
}
render() {
return html`
<input
.value=${this.formData.name}
@input=${(e: Event) =>
this.handleInput('name', (e.target as HTMLInputElement).value)}
/>
`
}
}
A:使用自定义事件或 Context API:
// 发送事件
this.dispatchEvent(new CustomEvent('data-changed', {
detail: { data: this.data },
bubbles: true,
composed: true, // 穿透 Shadow DOM
}))
// 监听事件
@customElement('parent-component')
export class ParentComponent extends LitElement {
render() {
return html`
<child-component @data-changed=${this.handleDataChanged}></child-component>
`
}
private handleDataChanged(e: CustomEvent) {
console.log('Data:', e.detail.data)
}
}
| 特性 | Lit 版本 | Next.js 版本 | Vue 版本 |
|---|---|---|---|
| SSR/SSG | ✅ (实验性) | ✅ | ✅ (Nuxt) |
| 状态管理 | @lit-labs/context | Zustand | Pinia |
| 路由 | @lit-labs/router | App Router | Vue Router |
| 构建工具 | Vite | Next.js | Vite |
| 跨框架复用 | ✅ 原生支持 | ❌ | ❌ |
| Shadow DOM | ✅ | ❌ | ❌ |
| 包大小 | 5KB (gzip) | ~90KB | ~60KB |
HaloLight Netlify 部署版本,针对 Netlify 平台优化的一键部署方案。
在线预览:https://halolight-netlify.h7ml.cn
GitHub:https://github.com/halolight/halolight-netlify
点击按钮后:
# 安装 Netlify CLI
npm install -g netlify-cli
# 登录 Netlify
netlify login
# 克隆项目
git clone https://github.com/halolight/halolight-netlify.git
cd halolight-netlify
# 安装依赖
pnpm install
# 初始化 Netlify 站点
netlify init
# 本地开发 (带 Functions)
netlify dev
# 部署到生产
netlify deploy --prod
[build]
command = "pnpm build"
publish = ".next"
[build.environment]
NODE_VERSION = "20"
PNPM_VERSION = "9"
# Next.js 插件 (自动处理 SSR/ISR)
[[plugins]]
package = "@netlify/plugin-nextjs"
# 生产环境
[context.production]
command = "pnpm build"
[context.production.environment]
NEXT_PUBLIC_MOCK = "false"
# 预览环境 (分支部署)
[context.deploy-preview]
command = "pnpm build"
[context.deploy-preview.environment]
NEXT_PUBLIC_MOCK = "true"
# 分支部署
[context.branch-deploy]
command = "pnpm build"
# 重定向规则
[[redirects]]
from = "/api/*"
to = "/.netlify/functions/:splat"
status = 200
# SPA 回退
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
conditions = {Role = ["admin"]}
# 自定义头
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
X-XSS-Protection = "1; mode=block"
Referrer-Policy = "strict-origin-when-cross-origin"
[[headers]]
for = "/_next/static/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"netlify:dev": "netlify dev",
"netlify:build": "netlify build",
"netlify:deploy": "netlify deploy --prod"
}
}
在 Netlify 控制台 → Site settings → Environment variables 设置:
| 变量名 | 说明 | 示例 |
|---|---|---|
NODE_ENV |
运行环境 | production |
NEXT_PUBLIC_API_URL |
API 基础 URL | /api |
NEXT_PUBLIC_MOCK |
启用 Mock 数据 | false |
NEXT_PUBLIC_APP_TITLE |
应用标题 | Admin Pro |
DATABASE_URL |
数据库连接 | postgresql://... |
Netlify 支持按部署上下文配置变量:
Production - 生产环境变量
Deploy Preview - PR 预览环境变量
Branch Deploy - 分支部署变量
All - 所有环境共享
// netlify/functions/hello.ts
import type { Handler, HandlerEvent, HandlerContext } from "@netlify/functions";
export const handler: Handler = async (
event: HandlerEvent,
context: HandlerContext
) => {
const { httpMethod, body, queryStringParameters } = event;
return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message: "Hello from Netlify Functions!",
method: httpMethod,
query: queryStringParameters,
}),
};
};
// netlify/functions/background-task.ts
import type { BackgroundHandler } from "@netlify/functions";
export const handler: BackgroundHandler = async (event) => {
// 最长运行 15 分钟
console.log("Processing background task...");
// 执行耗时操作
await processLongRunningTask(event.body);
// 后台函数不返回响应
};
// 配置为后台函数
export const config = {
type: "background",
};
// netlify/functions/daily-report.ts
import type { Handler } from "@netlify/functions";
export const handler: Handler = async () => {
console.log("Generating daily report...");
await generateReport();
return {
statusCode: 200,
body: "Report generated",
};
};
// 每天 UTC 9:00 执行
export const config = {
schedule: "0 9 * * *",
};
// netlify/edge-functions/geolocation.ts
import type { Context } from "@netlify/edge-functions";
export default async (request: Request, context: Context) => {
const { country, city } = context.geo;
// 基于地理位置的响应
return new Response(
JSON.stringify({
country,
city,
message: `Hello from ${city}, ${country}!`,
}),
{
headers: { "Content-Type": "application/json" },
}
);
};
export const config = {
path: "/api/geo",
};
// lib/netlify-identity.ts
import netlifyIdentity from "netlify-identity-widget";
// 初始化
netlifyIdentity.init({
container: "#netlify-modal",
locale: "zh",
});
// 登录
export function login() {
netlifyIdentity.open("login");
}
// 注册
export function signup() {
netlifyIdentity.open("signup");
}
// 登出
export function logout() {
netlifyIdentity.logout();
}
// 获取当前用户
export function getCurrentUser() {
return netlifyIdentity.currentUser();
}
// 监听认证状态
netlifyIdentity.on("login", (user) => {
console.log("User logged in:", user);
netlifyIdentity.close();
});
netlifyIdentity.on("logout", () => {
console.log("User logged out");
});
// netlify/functions/protected.ts
import type { Handler } from "@netlify/functions";
export const handler: Handler = async (event) => {
const { user } = event.context.clientContext || {};
if (!user) {
return {
statusCode: 401,
body: JSON.stringify({ error: "Unauthorized" }),
};
}
return {
statusCode: 200,
body: JSON.stringify({
message: `Hello ${user.email}!`,
roles: user.app_metadata?.roles || [],
}),
};
};
<form name="contact" method="POST" data-netlify="true" netlify-honeypot="bot-field">
<input type="hidden" name="form-name" value="contact" />
<p class="hidden">
<label>Don't fill this out: <input name="bot-field" /></label>
</p>
<p>
<label>Email: <input type="email" name="email" required /></label>
</p>
<p>
<label>Message: <textarea name="message" required></textarea></label>
</p>
<p>
<button type="submit">Send</button>
</p>
</form>
// components/ContactForm.tsx
"use client";
import { useState } from "react";
export function ContactForm() {
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setStatus("loading");
const formData = new FormData(e.currentTarget);
try {
const response = await fetch("/", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(formData as any).toString(),
});
if (response.ok) {
setStatus("success");
} else {
setStatus("error");
}
} catch {
setStatus("error");
}
};
return (
<form
name="contact"
method="POST"
data-netlify="true"
onSubmit={handleSubmit}
>
<input type="hidden" name="form-name" value="contact" />
{/* 表单字段 */}
<button type="submit" disabled={status === "loading"}>
{status === "loading" ? "Sending..." : "Send"}
</button>
{status === "success" && <p>Message sent!</p>}
{status === "error" && <p>Error sending message</p>}
</form>
);
}
# 登录
netlify login
# 查看站点状态
netlify status
# 本地开发
netlify dev
# 构建
netlify build
# 部署预览
netlify deploy
# 部署到生产
netlify deploy --prod
# 打开站点
netlify open
# 打开控制台
netlify open:admin
# 查看日志
netlify logs
# 环境变量
netlify env:list
netlify env:set KEY value
netlify env:unset KEY
# 链接到站点
netlify link
# 取消链接
netlify unlink
# CLI 查看日志
netlify logs
# 实时日志流
netlify logs --live
# 查看函数日志
netlify logs:function hello
# netlify.toml
# 性能分析
[[plugins]]
package = "netlify-plugin-lighthouse"
# 缓存优化
[[plugins]]
package = "netlify-plugin-cache"
[plugins.inputs]
paths = [".next/cache", "node_modules/.cache"]
# 提交状态通知
[[plugins]]
package = "netlify-plugin-checklinks"
# A 记录 (根域名)
类型: A
名称: @
值: 75.2.60.5
# CNAME 记录 (子域名)
类型: CNAME
名称: www
值: your-site.netlify.app
Netlify 自动配置 HTTPS:
| 上下文 | 触发条件 | URL 格式 |
|---|---|---|
| Production | main 分支 push | your-site.netlify.app |
| Deploy Preview | PR 创建/更新 | deploy-preview-123--your-site.netlify.app |
| Branch Deploy | 其他分支 push | branch-name--your-site.netlify.app |
# 锁定当前部署 (停止自动部署)
netlify deploy:lock
# 解锁
netlify deploy:unlock
A:检查以下几点:
pnpm-lock.yaml 已提交A:在 Deploys 页面:
netlify rollbackA:优化建议:
A:在 netlify.toml 或 _redirects 文件中配置:
# netlify.toml
[[redirects]]
from = "/old-path"
to = "/new-path"
status = 301
# 代理
[[redirects]]
from = "/api/*"
to = "https://api.example.com/:splat"
status = 200
| 计划 | 价格 | 特性 |
|---|---|---|
| Starter | 免费 | 100GB 带宽,300 分钟构建 |
| Pro | $19/成员/月 | 1TB 带宽,25000 分钟构建 |
| Business | $99/成员/月 | 自定义 SLA,SSO |
| Enterprise | 联系销售 | 专属支持,合规认证 |
| 计划 | 调用次数 | 运行时间 |
|---|---|---|
| Starter | 125K/月 | 100 小时 |
| Pro | 无限 | 1000 小时 |
| 特性 | Netlify | Vercel | Cloudflare |
|---|---|---|---|
| 一键部署 | ✅ | ✅ | ✅ |
| Edge Functions | ✅ | ✅ | ✅ |
| 表单处理 | ✅ 内置 | ❌ 需外部 | ❌ 需外部 |
| Identity | ✅ 内置 | ❌ 需外部 | ✅ Access |
| 免费带宽 | 100GB | 100GB | 无限 |
| 免费构建 | 300 分钟 | 6000 分钟 | 500 次 |
| Split Testing | ✅ | ⚠️ 有限 | ❌ |
HaloLight Next.js 版本基于 Next.js 14 App Router 构建,采用 React 18 + TypeScript。
在线预览:https://halolight.h7ml.cn/
GitHub:https://github.com/halolight/halolight
| 技术 | 版本 | 说明 |
|---|---|---|
| Next.js | 14.x | React 全栈框架 (App Router) |
| React | 18.x | UI 库 |
| TypeScript | 5.x | 类型安全 |
| Tailwind CSS | 4.x | 原子化 CSS |
| shadcn/ui | latest | UI 组件库 (28 组件) |
| Zustand | 5.x | 状态管理 (6 Store) |
| TanStack Query | 5.x | 服务端状态 |
| React Hook Form | 7.x | 表单处理 |
| Zod | 4.x | 数据验证 |
| react-grid-layout | 1.x | 拖拽布局 |
| Recharts | 3.x | 图表可视化 |
| Framer Motion | 12.x | 动画效果 |
| Mock.js | 1.x | 数据模拟 |
| next-pwa | 5.x | PWA 支持 |
halolight/
├── src/
│ ├── app/ # App Router 页面
│ │ ├── (auth)/ # 认证路由组
│ │ │ ├── login/ # 登录
│ │ │ ├── register/ # 注册
│ │ │ ├── forgot-password/ # 忘记密码
│ │ │ ├── reset-password/ # 重置密码
│ │ │ └── layout.tsx # 认证布局
│ │ ├── (dashboard)/ # 仪表盘路由组
│ │ │ ├── page.tsx # 仪表盘首页(可配置)
│ │ │ ├── accounts/ # 账户与权限
│ │ │ ├── analytics/ # 数据分析
│ │ │ ├── calendar/ # 日程管理
│ │ │ ├── docs/ # 帮助文档
│ │ │ ├── documents/ # 文档管理
│ │ │ ├── files/ # 文件存储
│ │ │ ├── messages/ # 消息中心
│ │ │ ├── notifications/ # 通知中心
│ │ │ ├── profile/ # 个人资料
│ │ │ ├── users/ # 用户管理
│ │ │ ├── settings/ # 系统设置
│ │ │ │ └── teams/ # 团队管理
│ │ │ │ └── roles/ # 角色管理
│ │ │ └── layout.tsx # 仪表盘布局
│ │ ├── (legal)/ # 法律条款路由组
│ │ │ ├── privacy/ # 隐私政策
│ │ │ ├── terms/ # 服务条款
│ │ │ └── layout.tsx
│ │ ├── layout.tsx # 根布局
│ │ ├── error.tsx # 错误页面
│ │ └── not-found.tsx # 404 页面
│ ├── components/
│ │ ├── ui/ # shadcn/ui 组件 (28 个)
│ │ ├── layout/ # 布局组件 (11 个)
│ │ │ ├── admin-layout.tsx # 管理后台布局
│ │ │ ├── sidebar.tsx # 可折叠侧边栏
│ │ │ ├── header.tsx # 页头(通知/错误/用户菜单)
│ │ │ ├── footer.tsx # 页脚
│ │ │ ├── tab-bar.tsx # 多标签导航
│ │ │ ├── command-menu.tsx # 命令面板 (⌘K)
│ │ │ ├── quick-settings.tsx # 界面设置面板
│ │ │ ├── theme-toggle.tsx # 主题切换
│ │ │ └── pending-overlay.tsx # 加载遮罩
│ │ ├── dashboard/ # 仪表盘组件
│ │ │ ├── configurable-dashboard.tsx # 可配置仪表盘
│ │ │ ├── charts.tsx # 图表组件
│ │ │ ├── stats-card.tsx # 统计卡片
│ │ │ └── recent-activity.tsx # 最近活动
│ │ └── shared/ # 共享组件
│ ├── hooks/ # React Hooks (15 个)
│ │ ├── use-users.ts # 用户 CRUD
│ │ ├── use-teams.ts # 团队管理
│ │ ├── use-messages.ts # 消息管理
│ │ ├── use-notifications.ts # 通知管理
│ │ ├── use-calendar.ts # 日历数据
│ │ ├── use-documents.ts # 文档管理
│ │ ├── use-files.ts # 文件管理
│ │ ├── use-dashboard.ts # 仪表盘状态
│ │ ├── use-dashboard-data.ts # 仪表盘数据 Hook 集合
│ │ ├── use-chart-palette.ts # 图表配色(主题感知)
│ │ ├── use-action-mutation.ts # Server Action 封装
│ │ ├── use-keep-alive.tsx # 页面状态缓存
│ │ ├── use-tdk.ts # TDK 管理
│ │ └── use-title.ts # 页面标题
│ ├── stores/ # Zustand Stores (6 个)
│ │ ├── auth-store.ts # 认证状态(含多账户)
│ │ ├── ui-settings-store.ts # UI 设置
│ │ ├── dashboard-store.ts # 仪表盘状态
│ │ ├── navigation-store.ts # 导航状态
│ │ ├── tabs-store.ts # 标签页状态
│ │ └── error-store.ts # 错误收集
│ ├── providers/ # React Providers (8 个)
│ │ ├── app-providers.tsx # Provider 聚合
│ │ ├── auth-provider.tsx # 认证 Provider
│ │ ├── theme-provider.tsx # 主题 Provider
│ │ ├── query-provider.tsx # TanStack Query
│ │ ├── error-provider.tsx # 错误处理
│ │ ├── permission-provider.tsx # 权限检查
│ │ ├── websocket-provider.tsx # WebSocket 实时通知
│ │ └── keep-alive-provider.tsx # 页面保活
│ ├── actions/ # Server Actions
│ ├── config/ # 配置文件
│ │ ├── routes.ts # 路由与权限配置
│ │ └── tdk.ts # TDK 配置
│ ├── lib/ # 工具库
│ │ └── api/ # API 客户端
│ ├── mock/ # Mock 数据 (9 模块)
│ └── middleware.ts # 中间件(认证+安全头)
├── public/
│ ├── manifest.json # PWA 清单
│ ├── sw.js # Service Worker
│ ├── icons/ # PWA 图标 (8 尺寸)
│ ├── screenshots/ # PWA 截图
│ └── fonts/ # 自托管字体
├── next.config.mjs # Next.js + PWA 配置
├── tailwind.config.js
├── tsconfig.json
└── package.json
git clone https://github.com/halolight/halolight.git
cd halolight
pnpm install
cp .env.example .env.local
# .env.local 示例
NEXT_PUBLIC_API_URL=/api
NEXT_PUBLIC_MOCK=true # 开启 Mock 数据
[email protected]
NEXT_PUBLIC_DEMO_PASSWORD=123456
NEXT_PUBLIC_SHOW_DEMO_HINT=false
NEXT_PUBLIC_WS_URL= # WebSocket 地址
NEXT_PUBLIC_APP_TITLE=Admin Pro
NEXT_PUBLIC_BRAND_NAME=Halolight
pnpm dev
pnpm build
pnpm start
| 角色 | 邮箱 | 密码 |
|---|---|---|
| 管理员 | [email protected] | 123456 |
| 普通用户 | [email protected] | 123456 |
// stores/auth-store.ts
interface AuthState {
user: AccountWithToken | null
accounts: AccountWithToken[] // 多账户列表
activeAccountId: string | null // 当前账户
login: (data: LoginRequest) => Promise<void>
register: (data: RegisterRequest) => Promise<void>
logout: () => Promise<void>
switchAccount: (accountId: string) => Promise<void> // 快速切换账户
forgotPassword: (email: string) => Promise<void>
resetPassword: (token: string, password: string) => Promise<void>
checkAuth: () => Promise<void>
}
// 使用 Cookie 存储 Token,支持"记住我"(7天/1天)
Cookies.set("token", response.token, {
expires: data.remember ? 7 : 1,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
})
// hooks/use-users.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
export function useUsers() {
const queryClient = useQueryClient()
// 查询用户列表
const { data, isLoading } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
// 创建用户
const createUser = useMutation({
mutationFn: createUserApi,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
return { data, isLoading, createUser }
}
// 路由权限配置
export const ROUTE_PERMISSIONS: Record<string, Permission> = {
"/": "dashboard:view",
"/users": "users:view",
"/analytics": "analytics:view",
// ...
}
// 权限检查
const { hasPermission } = usePermission()
if (hasPermission("users:delete")) {
// 显示删除按钮
}
// 权限守卫组件
<PermissionGuard permission="users:delete" fallback={<Disabled />}>
<DeleteButton />
</PermissionGuard>
// 仪表盘编辑模式
const { isEditing, setIsEditing, addWidget, removeWidget, resetToDefault } = useDashboardStore()
// 响应式布局 (列数自动适配)
// lg: 12列, md: 8列, sm: 4列, xs: 2列, mobile: 1列
支持 9 种小部件类型:
| 小部件类型 | 说明 | 数据来源 |
|---|---|---|
stats |
数据统计卡片(4 指标) | useDashboardStats |
chart-line |
折线图(访问趋势) | useDashboardVisits |
chart-bar |
柱状图(销售统计) | useDashboardSales |
chart-pie |
饼图(流量占比) | useDashboardPie |
recent-users |
最近用户列表 | useDashboardUsers |
notifications |
通知列表 | useDashboardNotifications |
tasks |
待办任务 | useDashboardTasks |
calendar |
今日日程 | useDashboardCalendar |
quick-actions |
快捷操作入口 | 静态配置 |
支持 11 种皮肤预设,带实时预览和平滑过渡动画:
| 预设 | 名称 | 说明 |
|---|---|---|
default |
Shadcn · Neutral | 官方默认中性色 |
blue |
Shadcn · Blue | 蓝色主色 + Charts 冷色调 |
emerald |
Shadcn · Emerald | 清新绿色,适合数据展示 |
amber |
Shadcn · Amber | 琥珀/橙色,温暖明快 |
violet |
Shadcn · Violet | 紫色高饱和,科技感 |
rose |
Shadcn · Rose | 玫红主色,图表撞色 |
teal |
Shadcn · Teal | 青色主色,现代感 |
slate |
Shadcn · Slate | 低饱和灰蓝,工具感 |
ocean |
旧 · 深海蓝 | 蓝绿渐变 |
sunset |
旧 · 暮光橙 | 橙粉撞色 |
aurora |
旧 · 极光绿 | 青绿 + 紫色 |
/* 示例变量定义 */
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 100% 0 0;
--muted: 96.4% 0.004 285.75;
--accent: 96.4% 0.004 285.75;
/* ... */
}
| 路径 | 页面 | 权限 |
|---|---|---|
/ |
重定向到仪表盘 | - |
/login |
登录 | 公开 |
/register |
注册 | 公开 |
/forgot-password |
忘记密码 | 公开 |
/reset-password |
重置密码 | 公开 |
/dashboard |
可配置仪表盘 | dashboard:view |
/accounts |
账号与权限 | settings:view |
/analytics |
数据分析 | analytics:view |
/calendar |
日程管理 | calendar:view |
/documents |
文档管理 | documents:view |
/files |
文件存储 | files:view |
/messages |
消息中心 | messages:view |
/notifications |
通知中心 | notifications:view |
/users |
用户管理 | users:view |
/settings |
系统设置 | settings:view |
/settings/teams |
团队设置 | settings:view |
/settings/teams/roles |
角色管理 | settings:view |
/profile |
个人资料 | settings:view |
/docs |
帮助文档 | documents:view |
/privacy |
隐私政策 | 公开 |
/terms |
服务条款 | 公开 |
cp .env.example .env.local
# .env.local
NEXT_PUBLIC_API_URL=/api
NEXT_PUBLIC_MOCK=true
[email protected]
NEXT_PUBLIC_DEMO_PASSWORD=123456
NEXT_PUBLIC_SHOW_DEMO_HINT=false
NEXT_PUBLIC_WS_URL=
NEXT_PUBLIC_APP_TITLE=Admin Pro
NEXT_PUBLIC_BRAND_NAME=Halolight
| 变量名 | 说明 | 默认值 |
|---|---|---|
NEXT_PUBLIC_API_URL |
API 基础路径 | /api |
NEXT_PUBLIC_MOCK |
是否启用 Mock 数据 | true |
NEXT_PUBLIC_DEMO_EMAIL |
演示账号邮箱 | [email protected] |
NEXT_PUBLIC_DEMO_PASSWORD |
演示账号密码 | 123456 |
NEXT_PUBLIC_SHOW_DEMO_HINT |
是否显示演示提示 | false |
NEXT_PUBLIC_WS_URL |
WebSocket 地址 | - |
NEXT_PUBLIC_APP_TITLE |
应用标题 | Admin Pro |
NEXT_PUBLIC_BRAND_NAME |
品牌名称 | Halolight |
// 在客户端组件中使用
const apiUrl = process.env.NEXT_PUBLIC_API_URL
const isMock = process.env.NEXT_PUBLIC_MOCK === 'true'
pnpm dev # 启动开发服务器
pnpm build # 生产构建
pnpm start # 预览生产构建
pnpm lint # 代码检查
pnpm lint:fix # 自动修复
pnpm type-check # 类型检查
pnpm test # 运行测试
pnpm test:coverage # 测试覆盖率
pnpm test # 运行测试(watch 模式)
pnpm test:run # 单次运行
pnpm test:coverage # 覆盖率报告
pnpm test:ui # Vitest UI 界面
// __tests__/components/button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from '@/components/ui/button'
describe('Button Component', () => {
it('renders correctly', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('handles click events', () => {
const handleClick = vi.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
// next.config.mjs
import withPWA from "next-pwa"
const nextConfig = {
// 包导入优化 - 减少打包体积
experimental: {
optimizePackageImports: [
"@radix-ui/react-*",
"lucide-react",
"framer-motion",
"@tanstack/react-query",
"recharts",
"zustand",
],
},
// 生产环境移除 console
compiler: {
removeConsole: { exclude: ["error", "warn"] },
},
// 关闭 source map
productionBrowserSourceMaps: false,
// 图片优化
images: {
formats: ["image/avif", "image/webp"],
},
}
const pwaConfig = withPWA({
dest: "public",
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === "development",
})
export default pwaConfig(nextConfig)
vercel
FROM node:18-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
EXPOSE 3000
CMD ["pnpm", "start"]
docker build -t halolight-nextjs .
docker run -p 3000:3000 halolight-nextjs
项目配置了完整的 GitHub Actions CI 工作流:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm type-check
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
// stores/tabs-store.ts
interface Tab {
id: string
title: string
path: string
icon?: string
closable?: boolean // 首页不可关闭
}
// 右键菜单功能
- 刷新页面
- 关闭标签
- 关闭其他
- 关闭右侧
- 关闭所有
// hooks/use-keep-alive.tsx
// 自动保存/恢复滚动位置
useScrollRestore()
// 保存表单状态
const [values, saveValues, clearCache] = useFormCache('filter-form', initialValues)
// 保存自定义状态
const [state, setState] = useStateCache('my-key', initialValue)
// components/layout/command-menu.tsx
// 支持键盘快速导航、主题切换、账户切换、退出登录等操作
快捷键:
- ⌘K / Ctrl+K - 打开命令面板
- 搜索页面 - 快速导航到任意页面
- 切换主题 - 明暗模式切换
- 切换账户 - 多账户快速切换
// providers/websocket-provider.tsx
const { status, lastMessage, sendMessage, reconnect } = useWebSocket()
// 监听新通知
useRealtimeNotifications((notification) => {
console.log('新通知:', notification)
})
// 连接状态
status === 'Open' // 已连接
status === 'Connecting' // 连接中
status === 'Closed' // 已断开
// next.config.mjs
const pwaConfig = withPWA({
dest: "public",
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === "development",
runtimeCaching: [
// 字体缓存 (1年)
{ urlPattern: /\.(?:woff|woff2|ttf)$/i, handler: "CacheFirst" },
// 图片缓存 (24小时)
{ urlPattern: /\.(?:jpg|png|svg|webp)$/i, handler: "StaleWhileRevalidate" },
// Next.js 静态资源 (1年)
{ urlPattern: /\/_next\/static\/.+\.(js|css)$/i, handler: "CacheFirst" },
// 页面数据 (1小时)
{ urlPattern: /\/_next\/data\/.+\.json$/i, handler: "NetworkFirst" },
],
})
功能特性:
// 使用 Next.js Image 组件
import Image from 'next/image'
<Image
src="/images/hero.png"
alt="Hero"
width={800}
height={600}
priority // 优先加载
placeholder="blur" // 模糊占位符
/>
// next.config.mjs
images: {
formats: ["image/avif", "image/webp"],
}
// 动态导入组件
import dynamic from 'next/dynamic'
const DashboardChart = dynamic(
() => import('@/components/dashboard/chart'),
{
loading: () => <Skeleton />,
ssr: false // 禁用 SSR
}
)
// 路由预加载
import Link from 'next/link'
<Link href="/dashboard" prefetch>
Dashboard
</Link>
// 数据预加载
queryClient.prefetchQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
// next.config.mjs
experimental: {
optimizePackageImports: [
"@radix-ui/react-*",
"lucide-react",
"framer-motion",
"@tanstack/react-query",
"recharts",
"zustand",
],
}
A:在 .env.local 中设置 NEXT_PUBLIC_MOCK=false,并配置真实的 API 地址。
NEXT_PUBLIC_MOCK=false
NEXT_PUBLIC_API_URL=https://api.example.com
A:在 src/app/(dashboard) 下创建新目录和 page.tsx 文件。
// src/app/(dashboard)/my-page/page.tsx
export default function MyPage() {
return <div>My Page</div>
}
// 添加路由权限
// src/config/routes.ts
export const ROUTE_PERMISSIONS = {
// ...
"/my-page": "my-page:view",
}
A:修改 tailwind.config.js 中的 CSS 变量。
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
primary: {
DEFAULT: 'oklch(var(--primary))',
foreground: 'oklch(var(--primary-foreground))',
},
},
},
},
}
/* app/globals.css */
:root {
--primary: 51.1% 0.262 276.97; /* 修改为你的颜色 */
}
A:在 next.config.mjs 中设置 disable: true。
const pwaConfig = withPWA({
dest: "public",
disable: true, // 禁用 PWA
})
A:配置静态导出模式。
// next.config.mjs
export default {
output: 'export',
images: {
unoptimized: true, // 静态导出需要禁用图片优化
},
}
pnpm build
# 输出到 out/ 目录
| 特性 | Next.js | Vue | Angular |
|---|---|---|---|
| SSR/SSG | ✅ | ✅ (Nuxt) | ✅ (Universal) |
| 状态管理 | Zustand | Pinia | Services + RxJS |
| 路由 | App Router | Vue Router | Angular Router |
| 构建工具 | Next.js | Vite | esbuild + Vite |
| 组件库 | shadcn/ui | shadcn-vue | Angular Material |
| 学习曲线 | 中等 | 较低 | 较高 |
| 性能 | 优秀 | 优秀 | 优秀 |
HaloLight Nuxt 版本基于 Nuxt 3 构建,采用 Vue 3.5 + Composition API + TypeScript,提供开箱即用的全栈开发体验。
在线预览:https://halolight-nuxt.h7ml.cn/
GitHub:https://github.com/halolight/halolight-nuxt
⌘/Ctrl + K 快速导航| 技术 | 版本 | 说明 |
|---|---|---|
| Nuxt | 3.10 | Vue 全栈框架 |
| Vue | 3.5+ | 渐进式框架 |
| TypeScript | 5.7 | 类型安全 |
| Tailwind CSS | 3.x (CDN) | 原子化 CSS |
| Pinia | 0.5 | 状态管理 |
| VueUse | 10.x | 组合式工具库 |
| Mock.js | 1.x | 数据模拟 |
⌘/Ctrl + K 快速导航halolight-nuxt/
├── nuxt.config.ts # Nuxt 配置
├── app.vue # 应用根组件
├── pages/ # 文件路由
│ ├── index.vue # 首页
│ ├── login.vue # 登录
│ ├── register.vue # 注册
│ ├── forgot-password.vue # 忘记密码
│ ├── reset-password.vue # 重置密码
│ ├── terms.vue # 服务条款
│ ├── privacy.vue # 隐私政策
│ ├── dashboard/ # 仪表盘
│ │ └── index.vue
│ ├── users/ # 用户管理
│ │ └── index.vue
│ ├── messages/ # 消息中心
│ │ └── index.vue
│ ├── analytics/ # 数据分析
│ │ └── index.vue
│ ├── profile/ # 个人中心
│ │ └── index.vue
│ └── settings/ # 系统设置
│ └── index.vue
├── components/ # 自动导入组件
│ └── common/ # 通用组件
│ ├── AppHeader.vue
│ ├── AppSidebar.vue
│ ├── AppFooter.vue
│ ├── AppTabs.vue
│ ├── CommandMenu.vue
│ └── ToastContainer.vue
├── composables/ # 组合式函数
│ ├── useTheme.ts
│ ├── useToast.ts
│ └── useCommandMenu.ts
├── layouts/ # 布局
│ ├── default.vue # 管理后台布局
│ └── auth.vue # 认证页面布局
├── middleware/ # 中间件
│ └── auth.global.ts
├── plugins/ # 插件
│ └── pinia-persist.client.ts
├── stores/ # Pinia stores
│ ├── auth.ts
│ ├── ui-settings.ts
│ ├── dashboard.ts
│ ├── layout.ts
│ └── tabs.ts
├── utils/ # 工具函数
│ ├── index.ts
│ └── mock.ts
├── assets/css/ # 样式文件
│ ├── main.css
│ └── tailwind.css
├── tests/ # 测试文件
│ └── unit/
├── .github/ # GitHub Actions
│ └── workflows/
│ ├── ci.yml
│ └── deploy.yml
└── public/ # 静态资源
git clone https://github.com/halolight/halolight-nuxt.git
cd halolight-nuxt
pnpm install
cp .env.example .env.local
# .env.local
NUXT_PUBLIC_API_BASE=/api
NUXT_PUBLIC_MOCK=true
[email protected]
NUXT_PUBLIC_DEMO_PASSWORD=123456
NUXT_PUBLIC_SHOW_DEMO_HINT=true
NUXT_PUBLIC_APP_TITLE=Admin Pro
NUXT_PUBLIC_BRAND_NAME=Halolight
pnpm dev
pnpm build
pnpm preview
| 角色 | 邮箱 | 密码 |
|---|---|---|
| 管理员 | [email protected] | 123456 |
| 普通用户 | [email protected] | 123456 |
// stores/auth.ts
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref('')
const loading = ref(false)
const error = ref('')
const isAuthenticated = computed(() => !!token.value && !!user.value)
async function login(credentials: LoginCredentials) {
loading.value = true
error.value = ''
try {
// 登录逻辑
const result = await mockLogin(credentials)
user.value = result.user
token.value = result.token
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
function logout() {
user.value = null
token.value = ''
navigateTo('/login')
}
return { user, token, loading, error, isAuthenticated, login, logout }
})
<script setup lang="ts">
// 使用 useFetch 自动处理 SSR
const { data: users, pending, error, refresh } = await useFetch('/api/users', {
query: { page: 1, limit: 10 },
})
// 使用 useAsyncData 自定义 key
const { data: stats } = await useAsyncData('dashboard-stats', () =>
$fetch('/api/dashboard/stats')
)
</script>
// middleware/auth.global.ts
export default defineNuxtRouteMiddleware((to) => {
const authStore = useAuthStore()
// 公开页面列表
const publicPages = ['/login', '/register', '/forgot-password', '/reset-password']
if (publicPages.includes(to.path)) {
return
}
if (!authStore.isAuthenticated) {
return navigateTo({
path: '/login',
query: { redirect: to.fullPath },
})
}
})
<script setup lang="ts">
// 仪表盘配置
const dashboardStore = useDashboardStore()
const widgets = computed(() => dashboardStore.widgets)
// 拖拽实现
function handleDragEnd(event) {
dashboardStore.updateLayout(event.newLayout)
}
</script>
| 路径 | 页面 | 权限 |
|---|---|---|
/ |
首页 | 公开 |
/login |
登录 | 公开 |
/register |
注册 | 公开 |
/forgot-password |
忘记密码 | 公开 |
/reset-password |
重置密码 | 公开 |
/terms |
服务条款 | 公开 |
/privacy |
隐私政策 | 公开 |
/dashboard |
仪表盘 | dashboard:view |
/users |
用户管理 | users:view |
/messages |
消息中心 | messages:view |
/analytics |
数据分析 | analytics:view |
/profile |
个人中心 | settings:view |
/settings |
系统设置 | settings:view |
# .env
NUXT_PUBLIC_API_BASE=/api
NUXT_PUBLIC_MOCK=true
NUXT_PUBLIC_DEMO_EMAIL=[email protected]
NUXT_PUBLIC_DEMO_PASSWORD=123456
NUXT_PUBLIC_SHOW_DEMO_HINT=true
NUXT_PUBLIC_APP_TITLE=Admin Pro
NUXT_PUBLIC_BRAND_NAME=Halolight
# 服务端私有变量
NUXT_JWT_SECRET=your-jwt-secret
NUXT_DATABASE_URL=postgresql://localhost:5432/halolight
| 变量名 | 说明 | 默认值 |
|---|---|---|
NUXT_PUBLIC_API_BASE |
API 基础 URL | /api |
NUXT_PUBLIC_MOCK |
启用 Mock 数据 | true |
NUXT_PUBLIC_APP_TITLE |
应用标题 | Admin Pro |
NUXT_PUBLIC_BRAND_NAME |
品牌名称 | Halolight |
NUXT_PUBLIC_DEMO_EMAIL |
演示账号邮箱 | - |
NUXT_PUBLIC_DEMO_PASSWORD |
演示账号密码 | - |
NUXT_JWT_SECRET |
JWT 密钥 (服务端) | - |
NUXT_DATABASE_URL |
数据库连接 (服务端) | - |
<script setup lang="ts">
// 在组件中使用
const config = useRuntimeConfig();
// 公开变量
const apiBase = config.public.apiBase;
// 私有变量(仅服务端)
// const jwtSecret = config.jwtSecret; // 客户端不可访问
</script>
// 在 server/api 中使用
export default defineEventHandler((event) => {
const config = useRuntimeConfig();
const jwtSecret = config.jwtSecret; // 可以访问私有变量
});
// nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: '2025-11-30',
devtools: { enabled: false },
modules: [
'@pinia/nuxt',
'@vueuse/nuxt',
],
css: ['~/assets/css/main.css'],
runtimeConfig: {
public: {
apiBase: '/api',
mock: true,
demoEmail: '[email protected]',
demoPassword: '123456',
showDemoHint: true,
appTitle: 'Admin Pro',
brandName: 'Halolight',
},
},
app: {
head: {
title: 'Admin Pro',
script: [
{ src: 'https://cdn.tailwindcss.com' },
],
},
},
})
npx vercel
或使用 Vercel 按钮一键部署:
FROM node:20-alpine AS builder
RUN npm install -g pnpm
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.output ./.output
ENV HOST=0.0.0.0
ENV PORT=3000
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
nitro.preset: 'cloudflare-pages'nitro.preset: 'netlify'pnpm build && node .output/server/index.mjs项目配置了完整的 GitHub Actions CI 工作流:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
Nuxt 3 内置 Nitro 服务器,支持创建服务端 API。
// server/api/users/index.get.ts
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const { page = 1, limit = 10 } = query;
// 模拟数据库查询
const users = await getUsersFromDB({ page: Number(page), limit: Number(limit) });
return {
success: true,
data: users,
pagination: {
page: Number(page),
limit: Number(limit),
total: 100,
},
};
});
// server/middleware/auth.ts
export default defineEventHandler((event) => {
const url = getRequestURL(event);
// 保护 API 路由
if (url.pathname.startsWith('/api/admin')) {
const token = getHeader(event, 'authorization')?.replace('Bearer ', '');
if (!token) {
throw createError({
statusCode: 401,
message: '未授权访问',
});
}
try {
const user = verifyToken(token);
event.context.user = user;
} catch {
throw createError({
statusCode: 401,
message: 'Token 无效或已过期',
});
}
}
});
// plugins/api.ts
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig();
const api = $fetch.create({
baseURL: config.public.apiBase,
onRequest({ options }) {
const authStore = useAuthStore();
if (authStore.token) {
options.headers = {
...options.headers,
Authorization: `Bearer ${authStore.token}`,
};
}
},
onResponseError({ response }) {
if (response.status === 401) {
const authStore = useAuthStore();
authStore.logout();
navigateTo('/login');
}
},
});
return {
provide: {
api,
},
};
});
// composables/useUsers.ts
export function useUsers() {
const { $api } = useNuxtApp();
const users = ref<User[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
async function fetchUsers(params?: { page?: number; limit?: number }) {
loading.value = true;
error.value = null;
try {
const response = await $api<ApiResponse<User[]>>('/api/users', {
params,
});
users.value = response.data;
return response;
} catch (e: any) {
error.value = e.message;
throw e;
} finally {
loading.value = false;
}
}
return {
users,
loading,
error,
fetchUsers,
};
}
<script setup lang="ts">
// 使用 @nuxt/image
</script>
<template>
<NuxtImg
src="/hero.jpg"
alt="Hero"
width="800"
height="600"
loading="lazy"
format="webp"
/>
<NuxtPicture
src="/hero.jpg"
alt="Hero"
width="800"
height="600"
sizes="sm:100vw md:50vw lg:33vw"
/>
</template>
<script setup lang="ts">
// 延迟加载重型组件
const Chart = defineAsyncComponent(() => import('~/components/Chart.vue'));
</script>
<template>
<ClientOnly>
<Chart :data="chartData" />
<template #fallback>
<div class="h-80 animate-pulse bg-gray-200" />
</template>
</ClientOnly>
</template>
<script setup lang="ts">
// 预加载关键数据
const { data } = await useFetch('/api/critical-data', {
key: 'critical',
lazy: false,
});
</script>
A:修改 nuxt.config.ts:
export default defineNuxtConfig({
ssr: true,
nitro: {
prerender: {
routes: ['/', '/about', '/contact'],
crawlLinks: true,
},
},
});
运行 pnpm generate 生成静态站点。
A:禁用 SSR:
export default defineNuxtConfig({
ssr: false,
});
A:
useFetch 是 composable,自动处理 SSR 数据同步$fetch 是底层方法,不处理 SSR<script setup lang="ts">
// 推荐:自动处理 SSR
const { data } = await useFetch('/api/users');
// 手动调用
const fetchData = async () => {
const data = await $fetch('/api/users');
};
</script>
A:在 nuxt.config.ts 中配置:
export default defineNuxtConfig({
css: ['~/assets/css/main.css'],
});
A:使用 nitro.routeRules:
export default defineNuxtConfig({
nitro: {
routeRules: {
'/api/external/**': {
proxy: 'https://api.example.com/**',
},
},
},
});
A:优化建议:
export default defineNuxtConfig({
// 按需导入组件
components: {
dirs: ['~/components'],
global: false,
},
// 实验性功能
experimental: {
treeshakeClientOnly: true,
},
// Vite 优化
vite: {
build: {
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
},
},
},
},
},
});
| 功能 | Nuxt 版本 | Vue 版本 | Next.js 版本 |
|---|---|---|---|
| 状态管理 | Pinia | Pinia | Zustand |
| 数据获取 | useFetch | Axios | TanStack Query |
| 表单验证 | 原生 | VeeValidate + Zod | React Hook Form + Zod |
| 服务端 | Nitro 内置 | 独立后端 | API Routes |
| 样式 | Tailwind CDN | Tailwind | Tailwind |
| 路由 | 文件路由 | Vue Router | App Router |
| SSR | 内置支持 | 需配置 | 内置支持 |
HaloLight Preact 版本基于 Preact + Vite 构建,采用 Signals + TypeScript,实现轻量高性能的管理后台。
在线预览:https://halolight-preact.h7ml.cn
GitHub:https://github.com/halolight/halolight-preact
| 技术 | 版本 | 说明 |
|---|---|---|
| Preact | 10.x | 轻量 React 替代方案 |
| @preact/signals | 2.x | 响应式状态管理 |
| TypeScript | 5.9 | 类型安全 |
| Tailwind CSS | 4.x | 原子化 CSS |
| shadcn/ui | latest | UI 组件库(兼容层) |
| Vite | 7.2 | 构建工具 |
| preact-router | 4.x | 客户端路由 |
| TanStack Query | 5.x | 服务端状态 |
| Mock.js | 1.x | 数据模拟 |
halolight-preact/
├── src/
│ ├── pages/ # 页面组件
│ │ ├── Home.tsx # 首页
│ │ ├── auth/ # 认证页面
│ │ │ ├── Login.tsx
│ │ │ ├── Register.tsx
│ │ │ ├── ForgotPassword.tsx
│ │ │ └── ResetPassword.tsx
│ │ └── dashboard/ # 仪表盘页面
│ │ ├── Dashboard.tsx
│ │ ├── Users.tsx
│ │ ├── UserDetail.tsx
│ │ ├── UserCreate.tsx
│ │ ├── Roles.tsx
│ │ ├── Permissions.tsx
│ │ ├── Settings.tsx
│ │ └── Profile.tsx
│ ├── components/ # 组件库
│ │ ├── ui/ # UI 组件
│ │ │ ├── Button.tsx
│ │ │ ├── Input.tsx
│ │ │ ├── Card.tsx
│ │ │ └── Dialog.tsx
│ │ ├── layout/ # 布局组件
│ │ │ ├── AdminLayout.tsx
│ │ │ ├── AuthLayout.tsx
│ │ │ ├── Sidebar.tsx
│ │ │ └── Header.tsx
│ │ ├── dashboard/ # 仪表盘组件
│ │ │ ├── DashboardGrid.tsx
│ │ │ ├── WidgetWrapper.tsx
│ │ │ └── StatsWidget.tsx
│ │ └── shared/ # 共享组件
│ │ └── PermissionGuard.tsx
│ ├── stores/ # 状态管理
│ │ ├── auth.ts
│ │ ├── ui-settings.ts
│ │ └── dashboard.ts
│ ├── hooks/ # 自定义 Hooks
│ │ ├── useAuth.ts
│ │ └── usePermission.ts
│ ├── lib/ # 工具库
│ │ ├── api.ts
│ │ ├── permission.ts
│ │ └── cn.ts
│ ├── mock/ # Mock 数据
│ │ ├── index.ts
│ │ └── handlers/
│ ├── types/ # 类型定义
│ ├── App.tsx # 根组件
│ ├── routes.tsx # 路由配置
│ └── main.tsx # 入口文件
├── public/ # 静态资源
├── vite.config.ts # Vite 配置
├── tailwind.config.ts # Tailwind 配置
└── package.json
git clone https://github.com/halolight/halolight-preact.git
cd halolight-preact
pnpm install
cp .env.example .env
# .env
VITE_API_URL=/api
VITE_USE_MOCK=true
[email protected]
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=false
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight
pnpm dev
pnpm build
pnpm preview
// stores/auth.ts
import { signal, computed, effect } from '@preact/signals'
interface User {
id: number
name: string
email: string
permissions: string[]
}
// 响应式状态
export const user = signal<User | null>(null)
export const token = signal<string | null>(null)
export const loading = signal(false)
// 计算属性
export const isAuthenticated = computed(() => !!token.value && !!user.value)
export const permissions = computed(() => user.value?.permissions ?? [])
// 持久化
effect(() => {
if (user.value && token.value) {
localStorage.setItem('auth', JSON.stringify({
user: user.value,
token: token.value,
}))
}
})
// 初始化
const saved = localStorage.getItem('auth')
if (saved) {
const { user: savedUser, token: savedToken } = JSON.parse(saved)
user.value = savedUser
token.value = savedToken
}
// 方法
export async function login(credentials: { email: string; password: string }) {
loading.value = true
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
headers: { 'Content-Type': 'application/json' },
})
const data = await response.json()
user.value = data.user
token.value = data.token
} finally {
loading.value = false
}
}
export function logout() {
user.value = null
token.value = null
localStorage.removeItem('auth')
}
export function hasPermission(permission: string): boolean {
const perms = permissions.value
return perms.some(p =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
}
Signals 特性:
// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { token } from '../stores/auth'
export function useUsers(page = 1) {
return useQuery({
queryKey: ['users', page],
queryFn: async () => {
const response = await fetch(`/api/users?page=${page}`, {
headers: { Authorization: `Bearer ${token.value}` },
})
return response.json()
},
enabled: !!token.value,
})
}
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: CreateUserDto) => {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token.value}`,
},
})
return response.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}
// 使用
import { useUsers } from '../hooks/useUsers'
export function UsersPage() {
const { data, isLoading, error } = useUsers(1)
if (isLoading) return <div>加载中...</div>
if (error) return <div>加载失败</div>
return (
<ul>
{data.data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
// hooks/usePermission.ts
import { hasPermission } from '../stores/auth'
export function usePermission() {
return {
hasPermission,
can: (permission: string) => hasPermission(permission),
}
}
// components/shared/PermissionGuard.tsx
import { ComponentChildren } from 'preact'
import { hasPermission } from '../../stores/auth'
interface Props {
permission: string
children: ComponentChildren
fallback?: ComponentChildren
}
export function PermissionGuard({ permission, children, fallback }: Props) {
if (!hasPermission(permission)) {
return fallback ?? null
}
return children
}
// 使用
<PermissionGuard
permission="users:delete"
fallback={<span class="text-muted-foreground">无权限</span>}
>
<Button variant="destructive">删除</Button>
</PermissionGuard>
// routes.tsx
import Router, { Route } from 'preact-router'
import { isAuthenticated, hasPermission } from './stores/auth'
// 页面组件
import Home from './pages/Home'
import Login from './pages/auth/Login'
import Register from './pages/auth/Register'
import Dashboard from './pages/dashboard/Dashboard'
import Users from './pages/dashboard/Users'
// 路由守卫 HOC
function ProtectedRoute({ component: Component, permission, ...rest }) {
if (!isAuthenticated.value) {
route('/login?redirect=' + rest.path)
return null
}
if (permission && !hasPermission(permission)) {
return <div>无权限访问</div>
}
return <Component {...rest} />
}
export function AppRouter() {
return (
<Router>
<Route path="/" component={Home} />
<Route path="/login" component={Login} />
<Route path="/register" component={Register} />
<ProtectedRoute
path="/dashboard"
component={Dashboard}
permission="dashboard:view"
/>
<ProtectedRoute
path="/users"
component={Users}
permission="users:list"
/>
</Router>
)
}
支持 11 种预设皮肤,通过快捷设置面板切换:
| 皮肤 | 主色调 | CSS 变量 |
|---|---|---|
| Default | 紫色 | --primary: 51.1% 0.262 276.97 |
| Blue | 蓝色 | --primary: 54.8% 0.243 264.05 |
| Emerald | 翠绿 | --primary: 64.6% 0.178 142.49 |
| Amber | 琥珀 | --primary: 78.3% 0.177 74.21 |
| Rose | 玫瑰 | --primary: 62.8% 0.243 12.48 |
| Slate | 石板 | --primary: 51.4% 0.032 257.42 |
| Zinc | 锌灰 | --primary: 50.7% 0.017 285.96 |
| Stone | 石灰 | --primary: 53.4% 0.015 69.82 |
| Neutral | 中性 | --primary: 50.9% 0.016 286.13 |
| Red | 红色 | --primary: 55.5% 0.238 25.33 |
| Orange | 橙色 | --primary: 72.3% 0.187 56.24 |
/* 亮色模式 */
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 100% 0 0;
--secondary: 96.5% 0.006 286.32;
--secondary-foreground: 21.7% 0.026 285.88;
--accent: 96.5% 0.006 286.32;
--accent-foreground: 21.7% 0.026 285.88;
}
/* 暗色模式 */
.dark {
--background: 15.5% 0.018 285.88;
--foreground: 98.3% 0.006 286.32;
--primary: 74.1% 0.196 275.74;
--primary-foreground: 21.7% 0.043 286.07;
--secondary: 20.7% 0.021 286.05;
--secondary-foreground: 98.3% 0.006 286.32;
}
// stores/ui-settings.ts
import { signal, effect } from '@preact/signals'
export const theme = signal<'light' | 'dark'>('light')
export const skin = signal<string>('default')
// 持久化
effect(() => {
localStorage.setItem('theme', theme.value)
document.documentElement.classList.toggle('dark', theme.value === 'dark')
})
effect(() => {
localStorage.setItem('skin', skin.value)
document.documentElement.dataset.skin = skin.value
})
// 初始化
theme.value = (localStorage.getItem('theme') as any) || 'light'
skin.value = localStorage.getItem('skin') || 'default'
export function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
export function setSkin(newSkin: string) {
skin.value = newSkin
}
| 路径 | 页面 | 权限 |
|---|---|---|
/ |
首页 | 公开 |
/login |
登录 | 公开 |
/register |
注册 | 公开 |
/forgot-password |
忘记密码 | 公开 |
/reset-password |
重置密码 | 公开 |
/dashboard |
仪表盘 | dashboard:view |
/users |
用户列表 | users:list |
/users/create |
创建用户 | users:create |
/users/:id |
用户详情 | users:view |
/roles |
角色管理 | roles:list |
/permissions |
权限管理 | permissions:list |
/settings |
系统设置 | settings:view |
/profile |
个人中心 | 登录即可 |
pnpm dev # 启动开发服务器
pnpm build # 生产构建
pnpm preview # 预览生产构建
pnpm lint # 代码检查
pnpm lint:fix # 自动修复
pnpm type-check # 类型检查
pnpm test # 运行测试
pnpm test:coverage # 测试覆盖率
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
docker build -t halolight-preact .
docker run -p 80:80 halolight-preact
| 角色 | 邮箱 | 密码 |
|---|---|---|
| 管理员 | [email protected] | 123456 |
| 普通用户 | [email protected] | 123456 |
pnpm test # 运行测试(watch 模式)
pnpm test:run # 单次运行
pnpm test:coverage # 覆盖率报告
pnpm test:ui # Vitest UI 界面
测试文件与源文件放在一起,使用 .test.ts 或 .test.tsx 后缀:
src/components/ui/
├── Button.tsx
├── Button.test.tsx # Button 组件测试
├── Input.tsx
└── Input.test.tsx # Input 组件测试
// src/components/ui/Button.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/preact'
import { Button } from './Button'
describe('Button', () => {
it('渲染默认按钮', () => {
render(<Button>点击</Button>)
expect(screen.getByRole('button')).toHaveTextContent('点击')
})
it('渲染不同变体', () => {
render(<Button variant="destructive">删除</Button>)
expect(screen.getByRole('button')).toHaveClass('bg-destructive')
})
it('禁用状态', () => {
render(<Button disabled>禁用</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
})
// vite.config.ts
import { defineConfig } from 'vite'
import preact from '@preact/preset-vite'
import path from 'path'
export default defineConfig({
plugins: [preact()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
// React 兼容
react: 'preact/compat',
'react-dom': 'preact/compat',
'react/jsx-runtime': 'preact/jsx-runtime',
},
},
build: {
target: 'esnext',
minify: 'terser',
rollupOptions: {
output: {
manualChunks: {
vendor: ['preact', 'preact/hooks'],
router: ['preact-router'],
query: ['@tanstack/react-query'],
},
},
},
},
})
// tailwind.config.ts
import type { Config } from 'tailwindcss'
export default {
darkMode: ['class'],
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
border: 'oklch(var(--border) / <alpha-value>)',
input: 'oklch(var(--input) / <alpha-value>)',
ring: 'oklch(var(--ring) / <alpha-value>)',
background: 'oklch(var(--background) / <alpha-value>)',
foreground: 'oklch(var(--foreground) / <alpha-value>)',
primary: {
DEFAULT: 'oklch(var(--primary) / <alpha-value>)',
foreground: 'oklch(var(--primary-foreground) / <alpha-value>)',
},
},
},
},
plugins: [require('tailwindcss-animate')],
} satisfies Config
项目配置了完整的 GitHub Actions CI 工作流:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm type-check
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
// components/ui/Button.tsx
import { ComponentChildren } from 'preact'
import { cn } from '../../lib/cn'
interface Props {
variant?: 'default' | 'destructive' | 'outline' | 'ghost'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
class?: string
children: ComponentChildren
onClick?: () => void
}
export function Button({
variant = 'default',
size = 'md',
disabled,
class: className,
children,
onClick,
}: Props) {
return (
<button
class={cn(
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
{
'bg-primary text-primary-foreground hover:bg-primary/90':
variant === 'default',
'bg-destructive text-destructive-foreground hover:bg-destructive/90':
variant === 'destructive',
'border border-input bg-background hover:bg-accent':
variant === 'outline',
'hover:bg-accent hover:text-accent-foreground': variant === 'ghost',
'h-8 px-3 text-sm': size === 'sm',
'h-10 px-4': size === 'md',
'h-12 px-6 text-lg': size === 'lg',
'opacity-50 cursor-not-allowed': disabled,
},
className
)}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
)
}
// pages/auth/Login.tsx
import { useState } from 'preact/hooks'
import { route } from 'preact-router'
import { login, loading } from '../../stores/auth'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const handleSubmit = async (e: Event) => {
e.preventDefault()
setError('')
try {
await login({ email, password })
const params = new URLSearchParams(location.search)
route(params.get('redirect') || '/dashboard')
} catch (e) {
setError('邮箱或密码错误')
}
}
return (
<form onSubmit={handleSubmit}>
{error && <div class="text-destructive">{error}</div>}
<input
type="email"
value={email}
onInput={(e) => setEmail(e.currentTarget.value)}
placeholder="邮箱"
required
/>
<input
type="password"
value={password}
onInput={(e) => setPassword(e.currentTarget.value)}
placeholder="密码"
required
/>
<button type="submit" disabled={loading.value}>
{loading.value ? '登录中...' : '登录'}
</button>
</form>
)
}
// App.tsx
import { lazy, Suspense } from 'preact/compat'
const Dashboard = lazy(() => import('./pages/dashboard/Dashboard'))
const Users = lazy(() => import('./pages/dashboard/Users'))
export function App() {
return (
<Suspense fallback={<div>加载中...</div>}>
<Router>
<Route path="/dashboard" component={Dashboard} />
<Route path="/users" component={Users} />
</Router>
</Suspense>
)
}
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['preact', 'preact/hooks'],
router: ['preact-router'],
query: ['@tanstack/react-query'],
},
},
},
},
})
// 使用 computed 避免重复计算
import { signal, computed } from '@preact/signals'
const items = signal([1, 2, 3, 4, 5])
const filter = signal('all')
// 计算属性自动缓存
const filteredItems = computed(() => {
if (filter.value === 'all') return items.value
return items.value.filter(item => item > 2)
})
// 组件中使用
function ItemList() {
return (
<ul>
{filteredItems.value.map(item => (
<li key={item}>{item}</li>
))}
</ul>
)
}
A:Preact 通过 preact/compat 提供 React 兼容层,大部分 React 库可直接使用:
// vite.config.ts
export default defineConfig({
resolve: {
alias: {
react: 'preact/compat',
'react-dom': 'preact/compat',
'react/jsx-runtime': 'preact/jsx-runtime',
},
},
})
A:Signals 可以直接在组件中使用,无需 useState:
import { signal } from '@preact/signals'
const count = signal(0)
function Counter() {
// 直接使用 signal.value
return (
<button onClick={() => count.value++}>
Count: {count.value}
</button>
)
}
A:使用代码分割和懒加载:
import { lazy, Suspense } from 'preact/compat'
const HeavyComponent = lazy(() => import('./HeavyComponent'))
function App() {
return (
<Suspense fallback={<Loading />}>
<HeavyComponent />
</Suspense>
)
}
| 特性 | Preact | Next.js | Vue |
|---|---|---|---|
| SSR/SSG | ❌ (SPA) | ✅ | ✅ (Nuxt) |
| 状态管理 | Signals | Zustand | Pinia |
| 路由 | preact-router | App Router | Vue Router |
| 构建工具 | Vite | Next.js | Vite |
| Bundle 大小 | ~3KB | ~85KB | ~33KB |
| React 兼容 | ✅ | - | ❌ |
| 学习曲线 | 低 | 中 | 中 |
HaloLight Qwik 版本基于 Qwik City 构建,采用 Qwik 可恢复性架构 + TypeScript,实现零水合的极致性能。
在线预览:https://halolight-qwik.h7ml.cn
GitHub:https://github.com/halolight/halolight-qwik
| 技术 | 版本 | 说明 |
|---|---|---|
| Qwik | 2.x | 可恢复性框架 |
| Qwik City | 2.x | 全栈框架 |
| TypeScript | 5.x | 类型安全 |
| Tailwind CSS | 4.x | 原子化 CSS |
| Qwik UI | latest | UI 组件库 |
| Modular Forms | latest | 表单处理 |
| Zod | 3.x | 数据验证 |
| ECharts | 5.x | 图表可视化 |
| Mock.js | 1.x | 数据模拟 |
halolight-qwik/
├── src/
│ ├── routes/ # 文件路由
│ │ ├── index.tsx # 首页
│ │ ├── layout.tsx # 根布局
│ │ ├── (auth)/ # 认证路由组
│ │ │ ├── layout.tsx
│ │ │ ├── login/
│ │ │ │ └── index.tsx
│ │ │ ├── register/
│ │ │ ├── forgot-password/
│ │ │ └── reset-password/
│ │ ├── (dashboard)/ # 仪表盘路由组
│ │ │ ├── layout.tsx
│ │ │ ├── dashboard/
│ │ │ │ └── index.tsx
│ │ │ ├── users/
│ │ │ │ ├── index.tsx
│ │ │ │ ├── create/
│ │ │ │ └── [id]/
│ │ │ ├── roles/
│ │ │ ├── permissions/
│ │ │ ├── settings/
│ │ │ └── profile/
│ │ └── api/ # API 端点
│ │ └── auth/
│ │ └── login/
│ │ └── index.ts
│ ├── components/ # 组件库
│ │ ├── ui/ # Qwik UI 组件
│ │ ├── layout/ # 布局组件
│ │ │ ├── admin-layout/
│ │ │ ├── auth-layout/
│ │ │ ├── sidebar/
│ │ │ └── header/
│ │ ├── dashboard/ # 仪表盘组件
│ │ │ ├── dashboard-grid/
│ │ │ ├── widget-wrapper/
│ │ │ └── stats-widget/
│ │ └── shared/ # 共享组件
│ │ └── permission-guard/
│ ├── stores/ # 状态管理
│ │ ├── auth.ts
│ │ ├── ui-settings.ts
│ │ └── dashboard.ts
│ ├── lib/ # 工具库
│ │ ├── api.ts
│ │ ├── permission.ts
│ │ └── cn.ts
│ ├── mock/ # Mock 数据
│ └── types/ # 类型定义
├── public/ # 静态资源
├── vite.config.ts # Vite 配置
├── tailwind.config.ts # Tailwind 配置
└── package.json
git clone https://github.com/halolight/halolight-qwik.git
cd halolight-qwik
pnpm install
cp .env.example .env
# .env 示例
VITE_API_URL=/api
VITE_USE_MOCK=true
[email protected]
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=false
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight
pnpm dev
pnpm build
pnpm serve
// stores/auth.ts
import {
createContextId,
useContext,
useStore,
useComputed$,
$,
type Signal,
} from '@builder.io/qwik'
interface User {
id: number
name: string
email: string
permissions: string[]
}
interface AuthState {
user: User | null
token: string | null
loading: boolean
}
export const AuthContext = createContextId<AuthState>('auth')
export function useAuth() {
const state = useContext(AuthContext)
const isAuthenticated = useComputed$(() => !!state.token && !!state.user)
const permissions = useComputed$(() => state.user?.permissions ?? [])
const login = $(async (credentials: { email: string; password: string }) => {
state.loading = true
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
headers: { 'Content-Type': 'application/json' },
})
const data = await response.json()
state.user = data.user
state.token = data.token
} finally {
state.loading = false
}
})
const logout = $(() => {
state.user = null
state.token = null
})
const hasPermission = $((permission: string) => {
const perms = state.user?.permissions ?? []
return perms.some(p =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
})
return {
state,
isAuthenticated,
permissions,
login,
logout,
hasPermission,
}
}
// routes/(dashboard)/users/index.tsx
import { component$ } from '@builder.io/qwik'
import { routeLoader$ } from '@builder.io/qwik-city'
export const useUsers = routeLoader$(async ({ query, cookie, status }) => {
const token = cookie.get('token')?.value
const page = Number(query.get('page')) || 1
// 权限检查
const user = await validateToken(token)
if (!hasPermission(user, 'users:list')) {
status(403)
return { error: '无权限访问' }
}
const response = await fetch(`/api/users?page=${page}`, {
headers: { Authorization: `Bearer ${token}` },
})
return response.json()
})
export default component$(() => {
const users = useUsers()
return (
<div>
<h1>用户列表</h1>
{users.value.error ? (
<div class="text-destructive">{users.value.error}</div>
) : (
<ul>
{users.value.data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
)
})
// routes/(dashboard)/layout.tsx
import { component$, Slot } from '@builder.io/qwik'
import { routeLoader$ } from '@builder.io/qwik-city'
import { useAuth } from '~/stores/auth'
import { AdminLayout } from '~/components/layout/admin-layout'
export const useAuthGuard = routeLoader$(async ({ cookie, redirect, url }) => {
const token = cookie.get('token')?.value
if (!token) {
throw redirect(302, `/login?redirect=${url.pathname}`)
}
// 验证 token 并返回用户信息
return {
user: await validateToken(token),
}
})
export default component$(() => {
const data = useAuthGuard()
return (
<AdminLayout user={data.value.user}>
<Slot />
</AdminLayout>
)
})
// components/shared/permission-guard/index.tsx
import { component$, Slot, useComputed$ } from '@builder.io/qwik'
import { useAuth } from '~/stores/auth'
interface Props {
permission: string
}
export const PermissionGuard = component$<Props>(({ permission }) => {
const { hasPermission } = useAuth()
const allowed = useComputed$(async () => {
return await hasPermission(permission)
})
return (
<>
{allowed.value ? (
<Slot />
) : (
<Slot name="fallback" />
)}
</>
)
})
// 使用
<PermissionGuard permission="users:delete">
<Button variant="destructive" q:slot="">删除</Button>
<span q:slot="fallback" class="text-muted-foreground">无权限</span>
</PermissionGuard>
// routes/(auth)/login/index.tsx
import { component$ } from '@builder.io/qwik'
import { routeAction$, zod$, z, Form } from '@builder.io/qwik-city'
export const useLogin = routeAction$(
async (data, { cookie, redirect, fail }) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
return fail(401, { message: '邮箱或密码错误' })
}
const result = await response.json()
cookie.set('token', result.token, {
path: '/',
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7,
})
throw redirect(302, '/dashboard')
} catch (e) {
return fail(500, { message: '服务器错误' })
}
},
zod$({
email: z.string().email('请输入有效邮箱'),
password: z.string().min(6, '密码至少6位'),
})
)
export default component$(() => {
const action = useLogin()
return (
<Form action={action}>
{action.value?.failed && (
<div class="text-destructive">{action.value.message}</div>
)}
<input type="email" name="email" placeholder="邮箱" />
{action.value?.fieldErrors?.email && (
<span class="text-destructive">{action.value.fieldErrors.email}</span>
)}
<input type="password" name="password" placeholder="密码" />
{action.value?.fieldErrors?.password && (
<span class="text-destructive">{action.value.fieldErrors.password}</span>
)}
<button type="submit" disabled={action.isRunning}>
{action.isRunning ? '登录中...' : '登录'}
</button>
</Form>
)
})
// routes/api/auth/login/index.ts
import type { RequestHandler } from '@builder.io/qwik-city'
export const onPost: RequestHandler = async ({ json, parseBody }) => {
const body = await parseBody()
const { email, password } = body as { email: string; password: string }
// 验证逻辑
if (!email || !password) {
json(400, { success: false, message: '邮箱和密码不能为空' })
return
}
// 认证逻辑...
json(200, {
success: true,
user: { id: 1, name: '管理员', email },
token: 'mock_token',
})
}
// components/dashboard/dashboard-grid/index.tsx
import { component$, useSignal, useStore, $ } from '@builder.io/qwik'
interface Widget {
id: string
type: string
x: number
y: number
w: number
h: number
}
export const DashboardGrid = component$(() => {
const widgets = useStore<Widget[]>([
{ id: '1', type: 'stats', x: 0, y: 0, w: 3, h: 2 },
{ id: '2', type: 'chart', x: 3, y: 0, w: 6, h: 4 },
])
const handleLayoutChange = $((newLayout: Widget[]) => {
widgets.splice(0, widgets.length, ...newLayout)
})
return (
<div class="dashboard-grid">
{widgets.map((widget) => (
<div
key={widget.id}
class="widget"
style={{
gridColumn: `${widget.x + 1} / span ${widget.w}`,
gridRow: `${widget.y + 1} / span ${widget.h}`,
}}
>
{/* Widget 内容 */}
</div>
))}
</div>
)
})
支持 11 种预设皮肤,通过快捷设置面板切换:
| 皮肤 | 主色调 | CSS 变量 |
|---|---|---|
| Default | 紫色 | --primary: 51.1% 0.262 276.97 |
| Blue | 蓝色 | --primary: 54.8% 0.243 264.05 |
| Emerald | 翠绿 | --primary: 64.6% 0.178 142.49 |
| Rose | 玫瑰 | --primary: 61.8% 0.238 12.57 |
| Orange | 橙色 | --primary: 68.3% 0.199 36.35 |
| Yellow | 黄色 | --primary: 88.1% 0.197 95.45 |
| Violet | 紫罗兰 | --primary: 57.8% 0.24 305.4 |
| Cyan | 青色 | --primary: 73.8% 0.139 196.85 |
| Pink | 粉色 | --primary: 72.2% 0.218 345.82 |
| Lime | 青柠 | --primary: 79.2% 0.183 123.7 |
| Amber | 琥珀 | --primary: 82.5% 0.157 62.24 |
/* 全局变量定义 */
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 98% 0 0;
--secondary: 96.1% 0 0;
--secondary-foreground: 9.8% 0 0;
--muted: 95.1% 0.01 286.38;
--muted-foreground: 45.1% 0.009 285.88;
--accent: 95.1% 0.01 286.38;
--accent-foreground: 9.8% 0 0;
--destructive: 54.3% 0.227 25.78;
--destructive-foreground: 98% 0 0;
--border: 89.8% 0.006 286.32;
--input: 89.8% 0.006 286.32;
--ring: 51.1% 0.262 276.97;
--radius: 0.5rem;
}
.dark {
--background: 0% 0 0;
--foreground: 98% 0 0;
--primary: 59.6% 0.262 276.97;
--primary-foreground: 14.9% 0.017 285.75;
/* ... */
}
| 路径 | 页面 | 权限 |
|---|---|---|
/ |
首页 | 公开 |
/login |
登录 | 公开 |
/register |
注册 | 公开 |
/forgot-password |
忘记密码 | 公开 |
/reset-password |
重置密码 | 公开 |
/dashboard |
仪表盘 | dashboard:view |
/users |
用户列表 | users:list |
/users/create |
创建用户 | users:create |
/users/[id] |
用户详情 | users:view |
/roles |
角色管理 | roles:list |
/permissions |
权限管理 | permissions:list |
/settings |
系统设置 | settings:view |
/profile |
个人中心 | 登录即可 |
pnpm dev # 启动开发服务器
pnpm build # 生产构建
pnpm serve # 预览生产构建
pnpm lint # 代码检查
pnpm lint:fix # 自动修复
pnpm type-check # 类型检查
pnpm test # 运行测试
pnpm test:e2e # E2E 测试
# 使用 Vercel Edge 适配器
pnpm add -D @builder.io/qwik-city/adapters/vercel-edge
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/server ./server
COPY --from=builder /app/package.json .
RUN npm install --production
EXPOSE 3000
CMD ["node", "server/entry.express.js"]
Node.js 服务器
pnpm build
node server/entry.express.js
Cloudflare Pages
# 使用 Cloudflare Pages 适配器
pnpm add -D @builder.io/qwik-city/adapters/cloudflare-pages
| 角色 | 邮箱 | 密码 |
|---|---|---|
| 管理员 | [email protected] | 123456 |
| 普通用户 | [email protected] | 123456 |
pnpm test # 运行测试
pnpm test:e2e # E2E 测试
pnpm test:coverage # 覆盖率报告
// src/components/permission-guard/permission-guard.spec.tsx
import { describe, it, expect } from 'vitest'
import { createDOM } from '@builder.io/qwik/testing'
import { PermissionGuard } from './permission-guard'
describe('PermissionGuard', () => {
it('应当在有权限时渲染内容', async () => {
const { screen, render } = await createDOM()
await render(
<PermissionGuard permission="users:view">
<div>Protected Content</div>
</PermissionGuard>
)
expect(screen.innerHTML).toContain('Protected Content')
})
it('应当在无权限时渲染回退内容', async () => {
const { screen, render } = await createDOM()
await render(
<PermissionGuard permission="admin:*">
<div>Protected Content</div>
<div q:slot="fallback">No Permission</div>
</PermissionGuard>
)
expect(screen.innerHTML).toContain('No Permission')
})
})
// vite.config.ts
import { defineConfig } from 'vite'
import { qwikVite } from '@builder.io/qwik/optimizer'
import { qwikCity } from '@builder.io/qwik-city/vite'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig(() => {
return {
plugins: [qwikCity(), qwikVite(), tsconfigPaths()],
preview: {
headers: {
'Cache-Control': 'public, max-age=600',
},
},
}
})
项目配置了完整的 GitHub Actions CI 工作流:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
Qwik 的核心创新是 “可恢复性” 而非 “水合”:
// 传统框架(React/Vue):需要重新执行所有代码来重建状态
// Qwik:直接从 HTML 恢复状态,无需重新执行
// 服务端序列化状态
export default component$(() => {
const count = useSignal(0)
// Qwik 会将状态序列化到 HTML 中
return <div>Count: {count.value}</div>
})
// 客户端直接从 HTML 恢复状态,不需要执行组件代码
// 只有在交互时才按需加载和执行代码
Qwik 实现了最激进的代码分割:
// 每个事件处理器都是独立的懒加载单元
export default component$(() => {
const count = useSignal(0)
// 点击前这个函数不会被下载
const handleClick = $(() => {
count.value++
})
return (
<button onClick$={handleClick}>
Count: {count.value}
</button>
)
})
// routes/(dashboard)/layout.tsx
import { component$ } from '@builder.io/qwik'
import { routeLoader$, Link } from '@builder.io/qwik-city'
// 预加载数据
export const usePreloadData = routeLoader$(async () => {
return {
navigation: await fetchNavigation(),
}
})
export default component$(() => {
const data = usePreloadData()
return (
<nav>
{data.value.navigation.map((item) => (
// Link 会自动预加载目标页面
<Link href={item.path} prefetch>
{item.title}
</Link>
))}
</nav>
)
})
import { component$ } from '@builder.io/qwik'
import { Image } from '@unpic/qwik'
export default component$(() => {
return (
<Image
src="https://example.com/image.jpg"
layout="constrained"
width={800}
height={600}
alt="优化的图片"
/>
)
})
// 组件级别的懒加载
import { component$ } from '@builder.io/qwik'
export default component$(() => {
return (
<div>
{/* 使用 resource$ 实现组件懒加载 */}
<Resource
value={heavyComponentResource}
onPending={() => <div>加载中...</div>}
onResolved={(HeavyComponent) => <HeavyComponent />}
/>
</div>
)
})
// routes/layout.tsx
import { component$, useVisibleTask$ } from '@builder.io/qwik'
export default component$(() => {
useVisibleTask$(() => {
// 预加载关键字体
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'font'
link.href = '/fonts/main.woff2'
link.type = 'font/woff2'
link.crossOrigin = 'anonymous'
document.head.appendChild(link)
})
return <Slot />
})
A:使用 useSignal 和 useStore 创建响应式状态:
import { component$, useSignal, useStore } from '@builder.io/qwik'
export default component$(() => {
// 简单值使用 useSignal
const count = useSignal(0)
// 复杂对象使用 useStore
const state = useStore({
user: null,
loading: false,
})
return (
<div>
<p>Count: {count.value}</p>
<button onClick$={() => count.value++}>Increment</button>
</div>
)
})
A:使用 useVisibleTask$ 在客户端执行代码:
import { component$, useSignal, useVisibleTask$ } from '@builder.io/qwik'
export default component$(() => {
const chartRef = useSignal<Element>()
useVisibleTask$(({ cleanup }) => {
// 在客户端初始化第三方库
import('chart.js').then(({ Chart }) => {
const chart = new Chart(chartRef.value, {
// 配置...
})
cleanup(() => chart.destroy())
})
})
return <canvas ref={chartRef} />
})
A:Qwik 自动优化,但你可以进一步:
<Link href="/dashboard" prefetch>Dashboard</Link>
useVisibleTask$(({ track }) => {
// 只在组件可见时加载
track(() => isVisible.value)
if (isVisible.value) {
loadAnalytics()
}
})
A:使用 routeAction$ 实现服务端处理:
import { component$ } from '@builder.io/qwik'
import { routeAction$, Form, zod$, z } from '@builder.io/qwik-city'
export const useAddUser = routeAction$(
async (data) => {
// 服务端处理
const user = await createUser(data)
return { success: true, user }
},
zod$({
name: z.string().min(2),
email: z.string().email(),
})
)
export default component$(() => {
const action = useAddUser()
return (
<Form action={action}>
<input name="name" />
<input name="email" type="email" />
<button type="submit">
{action.isRunning ? '提交中...' : '提交'}
</button>
</Form>
)
})
| 特性 | Qwik 版本 | Next.js | Vue |
|---|---|---|---|
| SSR/SSG | ✅ 内置 | ✅ | ✅ (Nuxt) |
| 状态管理 | Context + Signals | Zustand | Pinia |
| 数据获取 | routeLoader$ | TanStack Query | TanStack Query |
| 表单验证 | Modular Forms + Zod | React Hook Form + Zod | VeeValidate + Zod |
| 路由 | 文件路由 | App Router | Vue Router |
| 构建工具 | Vite | Next.js | Vite |
| 水合 | 可恢复(零水合) | 传统水合 | 传统水合 |
| 首屏 JS | ~1KB | ~85KB | ~33KB |
| 服务端 | 内置全栈 | API Routes | 独立后端 |
| 组件库 | Qwik UI | shadcn/ui | shadcn-vue |
HaloLight Railway 部署版本,针对 Railway 平台优化的一键部署方案。
在线预览:https://halolight-railway.h7ml.cn
GitHub:https://github.com/halolight/halolight-railway
点击按钮后:
# 安装 Railway CLI
npm install -g @railway/cli
# 登录 Railway
railway login
# 克隆项目
git clone https://github.com/halolight/halolight-railway.git
cd halolight-railway
# 初始化 Railway 项目
railway init
# 关联到已有项目 (可选)
railway link
# 部署
railway up
{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "NIXPACKS",
"buildCommand": "pnpm install && pnpm build"
},
"deploy": {
"startCommand": "pnpm start",
"healthcheckPath": "/api/health",
"healthcheckTimeout": 300,
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 10,
"numReplicas": 1
}
}
[phases.setup]
nixPkgs = ["nodejs_20", "pnpm"]
[phases.install]
cmds = ["pnpm install --frozen-lockfile"]
[phases.build]
cmds = ["pnpm build"]
[start]
cmd = "pnpm start"
| 变量名 | 说明 | 示例 |
|---|---|---|
NODE_ENV |
运行环境 | production |
PORT |
服务端口 | 3000 (Railway 自动设置) |
NEXT_PUBLIC_API_URL |
API 基础 URL | /api |
NEXT_PUBLIC_MOCK |
启用 Mock 数据 | false |
NEXT_PUBLIC_APP_TITLE |
应用标题 | Admin Pro |
Railway 支持在环境变量中引用其他服务:
# 引用自动生成的域名
NEXT_PUBLIC_API_URL=https://${{RAILWAY_PUBLIC_DOMAIN}}/api
# 引用 PostgreSQL 服务
DATABASE_URL=${{Postgres.DATABASE_URL}}
# 引用 Redis 服务
REDIS_URL=${{Redis.REDIS_URL}}
# 引用项目变量
JWT_SECRET=${{shared.JWT_SECRET}}
# CLI 方式
railway add --database postgres
# 或在控制台
# 1. 点击 "New Service"
# 2. 选择 "Database" → "PostgreSQL"
# 3. 自动生成 DATABASE_URL
生成的环境变量:
DATABASE_URL - 完整连接字符串PGHOST - 主机地址PGPORT - 端口PGUSER - 用户名PGPASSWORD - 密码PGDATABASE - 数据库名# CLI 方式
railway add --database redis
# 或在控制台
# 1. 点击 "New Service"
# 2. 选择 "Database" → "Redis"
# 3. 自动生成 REDIS_URL
生成的环境变量:
REDIS_URL - 完整连接字符串REDISHOST - 主机地址REDISPORT - 端口REDISPASSWORD - 密码类型: CNAME
名称: your-subdomain
值: <your-app>.up.railway.app
Railway 自动为所有域名配置 HTTPS:
# 登录
railway login
# 查看状态
railway status
# 部署
railway up
# 查看日志
railway logs
# 打开控制台
railway open
# 运行远程命令
railway run <command>
# 连接数据库
railway connect postgres
# 环境变量
railway variables
railway variables set KEY=value
# CLI 查看日志
railway logs -f
# 或在控制台
# Service → Deployments → 点击部署 → View Logs
Railway 控制台提供:
// railway.json
{
"deploy": {
"numReplicas": 3
}
}
Railway Pro 支持基于指标的自动扩容:
| 计划 | 价格 | 特性 |
|---|---|---|
| Hobby | $5/月 | 500 小时执行时间,1GB 内存 |
| Pro | $20/月起 | 无限执行时间,更多资源 |
| Enterprise | 联系销售 | 专属支持,SLA 保障 |
A:检查以下几点:
pnpm-lock.yaml 已提交start 命令正确A:在 Deployments 页面:
railway rollbackA:Railway 服务间通过内部网络通信:
# 使用内部 DNS
DATABASE_URL=postgres://user:[email protected]:5432/db
| 特性 | Railway | Vercel | Fly.io |
|---|---|---|---|
| 一键部署 | ✅ | ✅ | ⚠️ 需 CLI |
| 托管数据库 | ✅ 内置 | ❌ 需外部 | ✅ 内置 |
| 免费额度 | $5/月信用 | 100GB | 3 个共享 VM |
| 自动扩容 | ✅ Pro | ✅ | ✅ |
| 私有网络 | ✅ | ⚠️ 有限 | ✅ |
HaloLight React 版本基于 React 19 + Vite 6 构建,是一个纯客户端渲染 (CSR) 的单页应用 (SPA)。
在线预览:https://halolight-react.h7ml.cn/
GitHub:https://github.com/halolight/halolight-react
| 技术 | 版本 | 说明 |
|---|---|---|
| React | 19.x | UI 框架 |
| Vite | 6.x | 构建工具 |
| TypeScript | 5.x | 类型安全 |
| React Router | 6.x | 客户端路由 |
| Zustand | 5.x | 状态管理 |
| TanStack Query | 5.x | 服务端状态 |
| React Hook Form | 7.x | 表单处理 |
| Zod | 4.x | 数据验证 |
| Tailwind CSS | 4.x | 原子化 CSS |
| shadcn/ui | latest | UI 组件库 |
| react-grid-layout | 1.5.x | 拖拽布局 |
| Recharts | 3.x | 图表可视化 |
| Framer Motion | 12.x | 动画效果 |
| Mock.js | 1.x | 数据模拟 |
halolight-react/
├── src/
│ ├── pages/ # 页面组件
│ │ ├── auth/ # 认证页面
│ │ │ ├── login/
│ │ │ ├── register/
│ │ │ ├── forgot-password/
│ │ │ └── reset-password/
│ │ ├── dashboard/ # 仪表盘
│ │ └── legal/ # 法律条款
│ ├── components/
│ │ ├── ui/ # shadcn/ui 组件 (20+)
│ │ ├── layout/ # 布局组件
│ │ │ ├── admin-layout.tsx
│ │ │ ├── auth-layout.tsx
│ │ │ ├── sidebar.tsx
│ │ │ ├── header.tsx
│ │ │ └── footer.tsx
│ │ ├── dashboard/ # 仪表盘组件
│ │ │ ├── configurable-dashboard.tsx
│ │ │ ├── widget-wrapper.tsx
│ │ │ ├── stats-widget.tsx
│ │ │ ├── chart-widget.tsx
│ │ │ └── ...
│ │ └── shared/ # 共享组件
│ ├── hooks/ # 自定义 Hooks
│ │ ├── use-users.ts
│ │ ├── use-auth.ts
│ │ ├── use-theme.ts
│ │ └── ...
│ ├── stores/ # Zustand Stores
│ │ ├── auth.ts
│ │ ├── ui-settings.ts
│ │ ├── dashboard-layout.ts
│ │ └── tabs.ts
│ ├── lib/
│ │ ├── api/ # API 服务
│ │ ├── auth/ # 认证逻辑
│ │ ├── validations/ # Zod schemas
│ │ └── utils.ts # 工具函数
│ ├── routes/ # 路由配置
│ │ └── index.tsx
│ ├── config/ # 配置文件
│ │ ├── routes.ts
│ │ └── tdk.ts
│ ├── types/ # 类型定义
│ ├── mock/ # Mock 数据
│ ├── providers/ # Context Providers
│ ├── App.tsx
│ └── main.tsx
├── public/ # 静态资源
├── vite.config.ts
├── tsconfig.json
└── package.json
git clone https://github.com/halolight/halolight-react.git
cd halolight-react
pnpm install
cp .env.example .env.development
# .env.development 示例
VITE_API_URL=/api
VITE_MOCK=true
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight
[email protected]
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=true
pnpm dev
pnpm build
pnpm preview
| 角色 | 邮箱 | 密码 |
|---|---|---|
| 管理员 | [email protected] | 123456 |
| 普通用户 | [email protected] | 123456 |
// stores/auth.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
login: (credentials: LoginCredentials) => Promise<void>
logout: () => void
hasPermission: (permission: string) => boolean
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (credentials) => {
const response = await authApi.login(credentials)
set({
user: response.user,
token: response.token,
isAuthenticated: true,
})
},
logout: () => {
set({ user: null, token: null, isAuthenticated: false })
},
hasPermission: (permission) => {
const { user } = get()
if (!user) return false
return user.permissions.some(p =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
},
}),
{
name: 'auth-storage',
partialize: (state) => ({ token: state.token, user: state.user }),
}
)
)
// hooks/use-users.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { usersApi } from '@/lib/api'
export function useUsers(params?: UserQueryParams) {
return useQuery({
queryKey: ['users', params],
queryFn: () => usersApi.getList(params),
})
}
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: usersApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}
// 在组件中使用
function UsersPage() {
const { data: users, isLoading, error } = useUsers()
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return <div>{/* 渲染用户列表 */}</div>
}
// hooks/use-permission.ts
import { useAuthStore } from '@/stores/auth'
export function usePermission(permission: string): boolean {
const hasPermission = useAuthStore((state) => state.hasPermission)
return hasPermission(permission)
}
export function usePermissions(permissions: string[]): boolean {
const hasPermission = useAuthStore((state) => state.hasPermission)
return permissions.every(p => hasPermission(p))
}
// 使用
function DeleteButton() {
const canDelete = usePermission('users:delete')
if (!canDelete) return null
return <Button variant="destructive">删除</Button>
}
// components/permission-guard.tsx
import { usePermission } from '@/hooks/use-permission'
interface PermissionGuardProps {
permission: string
children: React.ReactNode
fallback?: React.ReactNode
}
export function PermissionGuard({
permission,
children,
fallback = null,
}: PermissionGuardProps) {
const hasPermission = usePermission(permission)
if (!hasPermission) return fallback
return <>{children}</>
}
<!-- 使用 -->
<PermissionGuard permission="users:delete" fallback={<span>无权限</span>}>
<DeleteButton />
</PermissionGuard>
// components/dashboard/configurable-dashboard.tsx
import GridLayout from 'react-grid-layout'
import { useDashboardStore } from '@/stores/dashboard-layout'
export function ConfigurableDashboard() {
const { layout, setLayout, isEditing } = useDashboardStore()
return (
<GridLayout
layout={layout}
onLayoutChange={setLayout}
cols={12}
rowHeight={80}
isDraggable={isEditing}
isResizable={isEditing}
margin={[16, 16]}
>
{layout.map((item) => (
<div key={item.i}>
<WidgetWrapper widget={getWidget(item.i)} />
</div>
))}
</GridLayout>
)
}
支持 11 种预设皮肤,通过快捷设置面板切换:
| 皮肤 | 主色调 | CSS 变量 |
|---|---|---|
| Default | 紫色 | --primary: 51.1% 0.262 276.97 |
| Blue | 蓝色 | --primary: 54.8% 0.243 264.05 |
| Emerald | 翠绿 | --primary: 64.6% 0.178 142.49 |
| Orange | 橙色 | --primary: 65.7% 0.198 45.13 |
| Rose | 玫红 | --primary: 58.9% 0.238 11.26 |
| Cyan | 青色 | --primary: 75.6% 0.146 191.68 |
| Yellow | 黄色 | --primary: 85.1% 0.184 98.08 |
| Violet | 紫罗兰 | --primary: 55.3% 0.264 293.49 |
| Slate | 石板灰 | --primary: 47.9% 0.017 256.71 |
| Zinc | 锌灰 | --primary: 48.3% 0 0 |
| Neutral | 中性灰 | --primary: 48.5% 0 0 |
/* 示例变量定义 */
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 100% 0 0;
--secondary: 96.1% 0.004 286.41;
--secondary-foreground: 14.9% 0.017 285.75;
--muted: 96.1% 0.004 286.41;
--muted-foreground: 45.8% 0.009 285.77;
--accent: 96.1% 0.004 286.41;
--accent-foreground: 14.9% 0.017 285.75;
--destructive: 59.3% 0.246 27.33;
--destructive-foreground: 100% 0 0;
--border: 89.8% 0.006 286.32;
--input: 89.8% 0.006 286.32;
--ring: 51.1% 0.262 276.97;
--radius: 0.5rem;
}
.dark {
--background: 0% 0 0;
--foreground: 98.3% 0 0;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 100% 0 0;
/* ... */
}
| 路径 | 页面 | 权限 |
|---|---|---|
/ |
重定向到 /dashboard |
- |
/login |
登录 | 公开 |
/register |
注册 | 公开 |
/forgot-password |
忘记密码 | 公开 |
/reset-password |
重置密码 | 公开 |
/dashboard |
仪表盘 | dashboard:view |
/users |
用户列表 | users:list |
/users/create |
创建用户 | users:create |
/users/:id |
用户详情 | users:view |
/users/:id/edit |
编辑用户 | users:update |
/roles |
角色管理 | roles:list |
/permissions |
权限管理 | permissions:list |
/settings |
系统设置 | settings:view |
/profile |
个人中心 | 登录即可 |
cp .env.example .env.development
# .env.development 示例
VITE_API_URL=/api
VITE_MOCK=true
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight
[email protected]
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=true
| 变量名 | 说明 | 默认值 |
|---|---|---|
| VITE_API_URL | API 基础路径 | /api |
| VITE_MOCK | 是否启用 Mock 数据 | true |
| VITE_APP_TITLE | 应用标题 | Admin Pro |
| VITE_BRAND_NAME | 品牌名称 | Halolight |
| VITE_DEMO_EMAIL | 演示账号邮箱 | [email protected] |
| VITE_DEMO_PASSWORD | 演示账号密码 | 123456 |
| VITE_SHOW_DEMO_HINT | 是否显示演示提示 | true |
// 在代码中访问环境变量
const apiUrl = import.meta.env.VITE_API_URL
const isMock = import.meta.env.VITE_MOCK === 'true'
pnpm dev # 启动开发服务器
pnpm build # 生产构建
pnpm preview # 预览生产构建
pnpm lint # 代码检查
pnpm lint:fix # 自动修复
pnpm type-check # 类型检查
pnpm test # 运行测试
pnpm test:coverage # 测试覆盖率
pnpm test # 运行测试(watch 模式)
pnpm test:run # 单次运行
pnpm test:coverage # 覆盖率报告
pnpm test:ui # Vitest UI 界面
// __tests__/components/Button.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from '@/components/ui/button'
describe('Button', () => {
it('renders button with text', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button')).toHaveTextContent('Click me')
})
it('handles click events', async () => {
const handleClick = vi.fn()
render(<Button onClick={handleClick}>Click me</Button>)
await userEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('disables button when disabled prop is true', () => {
render(<Button disabled>Click me</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
})
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'ui-vendor': ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
'chart-vendor': ['recharts'],
},
},
},
},
})
vercel
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
docker build -t halolight-react .
docker run -p 3000:80 halolight-react
项目配置了完整的 GitHub Actions CI 工作流:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm type-check
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
项目内置 PWA 支持,包括:
// public/manifest.json
{
"name": "Admin Pro",
"short_name": "Admin",
"start_url": "/",
"display": "standalone",
"theme_color": "#ffffff",
"background_color": "#ffffff",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
// routes/index.tsx
import { createBrowserRouter, Navigate } from 'react-router-dom'
import { DashboardLayout } from '@/layouts/dashboard-layout'
import { AuthLayout } from '@/layouts/auth-layout'
export const router = createBrowserRouter([
{
path: '/',
element: <Navigate to="/dashboard" replace />,
},
{
path: '/login',
element: <AuthLayout />,
children: [
{ index: true, element: <LoginPage /> },
],
},
{
path: '/',
element: <DashboardLayout />,
children: [
{ path: 'dashboard', element: <HomePage /> },
{ path: 'users', element: <UsersPage /> },
{ path: 'settings', element: <SettingsPage /> },
// 更多路由...
],
},
])
// components/auth-guard.tsx
import { Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/stores/auth'
interface AuthGuardProps {
children: React.ReactNode
permission?: string
}
export function AuthGuard({ children, permission }: AuthGuardProps) {
const location = useLocation()
const { isAuthenticated, hasPermission } = useAuthStore()
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
if (permission && !hasPermission(permission)) {
return <Navigate to="/403" replace />
}
return <>{children}</>
}
// 使用 lazy 加载图片
import { useState } from 'react'
function LazyImage({ src, alt }: { src: string; alt: string }) {
const [loaded, setLoaded] = useState(false)
return (
<div className="relative">
{!loaded && <div className="skeleton" />}
<img
src={src}
alt={alt}
loading="lazy"
onLoad={() => setLoaded(true)}
className={loaded ? 'opacity-100' : 'opacity-0'}
/>
</div>
)
}
// 路由级别代码分割
import { lazy, Suspense } from 'react'
const Dashboard = lazy(() => import('@/pages/dashboard'))
const Users = lazy(() => import('@/pages/users'))
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/users" element={<Users />} />
</Routes>
</Suspense>
)
}
// 鼠标悬停时预加载组件
import { lazy } from 'react'
const UserDetails = lazy(() => import('@/pages/user-details'))
function UserList() {
const preloadUserDetails = () => {
// 触发预加载
import('@/pages/user-details')
}
return (
<Link
to="/users/1"
onMouseEnter={preloadUserDetails}
>
查看详情
</Link>
)
}
import { memo } from 'react'
// 防止不必要的重渲染
const ExpensiveComponent = memo(({ data }: { data: any }) => {
return <div>{/* 复杂渲染逻辑 */}</div>
})
A:在 src/routes/index.tsx 中添加路由配置:
{
path: '/new-page',
element: <NewPage />,
}
A:修改 CSS 变量或使用主题切换功能:
:root {
--primary: 51.1% 0.262 276.97; /* 修改主色调 */
}
A:将 VITE_MOCK 设置为 false,并配置 VITE_API_URL:
VITE_MOCK=false
VITE_API_URL=https://api.example.com
A:在用户的 permissions 数组中添加权限字符串,并使用 usePermission Hook:
const canEdit = usePermission('users:edit')
| 特性 | React 版本 | Next.js | Vue |
|---|---|---|---|
| SSR/SSG | ❌ | ✅ | ✅ (Nuxt) |
| 状态管理 | Zustand | Zustand | Pinia |
| 路由 | React Router | App Router | Vue Router |
| 构建工具 | Vite | Next.js | Vite |
HaloLight Remix 版本基于 React Router 7 构建 (原 Remix 团队已合并至 React Router),采用 TypeScript + Web 标准优先的全栈开发体验。
在线预览:https://halolight-remix.h7ml.cn/
GitHub:https://github.com/halolight/halolight-remix
+types/)| 技术 | 版本 | 说明 |
|---|---|---|
| React Router | 7.x | 全栈路由框架 (原 Remix) |
| React | 19.x | UI 框架 |
| TypeScript | 5.9 | 类型安全 |
| Vite | 7.x | 构建工具 |
| Tailwind CSS | 4.x | 原子化 CSS + OKLch |
| Radix UI | latest | 无障碍 UI 原语 |
| Zustand | 5.x | 轻量状态管理 |
| Recharts | 3.x | 图表可视化 |
| Vitest | 4.x | 单元测试 |
| Cloudflare Pages | - | 边缘部署 |
+types/)halolight-remix/
├── app/
│ ├── routes/ # 文件路由
│ │ ├── _index.tsx # 首页 (仪表盘)
│ │ ├── login.tsx # 登录
│ │ ├── register.tsx # 注册
│ │ ├── forgot-password.tsx # 忘记密码
│ │ ├── reset-password.tsx # 重置密码
│ │ ├── users.tsx # 用户管理
│ │ ├── users.$id.tsx # 用户详情 (动态路由)
│ │ ├── settings.tsx # 系统设置
│ │ ├── profile.tsx # 个人中心
│ │ ├── security.tsx # 安全设置
│ │ ├── analytics.tsx # 数据分析
│ │ ├── notifications.tsx # 通知中心
│ │ ├── documents.tsx # 文档管理
│ │ ├── calendar.tsx # 日历
│ │ ├── api.users.ts # API 端点
│ │ ├── api.auth.login.ts # 登录 API
│ │ ├── api.auth.logout.ts # 登出 API
│ │ └── +types/ # 自动生成类型
│ ├── components/ # 组件库
│ │ ├── ui/ # 基础 UI 组件
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── dropdown-menu.tsx
│ │ │ ├── input.tsx
│ │ │ ├── select.tsx
│ │ │ ├── table.tsx
│ │ │ ├── toast.tsx
│ │ │ └── ...
│ │ ├── layout/ # 布局组件
│ │ │ ├── header.tsx
│ │ │ ├── sidebar.tsx
│ │ │ ├── footer.tsx
│ │ │ ├── tab-bar.tsx
│ │ │ └── quick-settings.tsx
│ │ ├── auth/ # 认证组件
│ │ │ └── auth-shell.tsx
│ │ ├── dashboard/ # 仪表盘组件
│ │ │ ├── stats-card.tsx
│ │ │ └── chart-widget.tsx
│ │ ├── admin-layout.tsx # 后台布局
│ │ └── theme-provider.tsx # 主题提供者
│ ├── hooks/ # React Hooks
│ │ ├── use-chart-palette.ts
│ │ ├── use-toast.ts
│ │ └── use-media-query.ts
│ ├── lib/ # 工具库
│ │ ├── utils.ts # cn() 类名工具
│ │ ├── meta.ts # TDK 元信息
│ │ ├── session.server.ts # 会话管理
│ │ ├── auth.server.ts # 认证逻辑
│ │ └── project-info.ts # 项目信息
│ ├── stores/ # Zustand 状态
│ │ ├── tabs-store.ts # 标签页状态
│ │ └── ui-settings-store.ts # UI 设置状态
│ ├── types/ # TypeScript 类型
│ │ ├── user.ts
│ │ └── api.ts
│ ├── root.tsx # 根组件
│ ├── routes.ts # 路由配置
│ └── app.css # 全局样式
├── tests/ # 测试文件
│ ├── setup.ts
│ ├── lib/
│ ├── stores/
│ └── components/
├── public/ # 静态资源
├── .github/workflows/ci.yml # CI 配置
├── wrangler.json # Cloudflare 配置
├── vitest.config.ts # Vitest 配置
├── eslint.config.js # ESLint 配置
├── vite.config.ts # Vite 配置
└── package.json
git clone https://github.com/halolight/halolight-remix.git
cd halolight-remix
pnpm install
cp .env.example .env
# .env 示例
SESSION_SECRET=your-super-secret-session-key
API_BASE_URL=https://api.halolight.h7ml.cn
MOCK_ENABLED=true
DEMO_EMAIL=[email protected]
DEMO_PASSWORD=123456
SHOW_DEMO_HINT=true
APP_TITLE=Admin Pro
BRAND_NAME=Halolight
pnpm dev
pnpm build
pnpm start
| 角色 | 邮箱 | 密码 |
|---|---|---|
| 管理员 | [email protected] | 123456 |
| 普通用户 | [email protected] | 123456 |
React Router 7 使用文件系统路由,文件名决定 URL 路径:
app/routes/
├── _index.tsx → / (索引路由)
├── about.tsx → /about (静态路由)
├── users.tsx → /users (静态路由)
├── users.$id.tsx → /users/:id (动态路由)
├── users.$id_.edit.tsx → /users/:id/edit (嵌套路由)
├── _layout.tsx → 布局路由 (无 URL 段)
├── _layout.dashboard.tsx → /dashboard (带布局)
├── $.tsx → /* (通配符路由)
├── api.users.ts → /api/users (资源路由)
└── [...slug].tsx → /* 可选捕获
| 文件名 | 说明 |
|---|---|
_index.tsx |
索引路由,匹配父路由精确路径 |
_layout.tsx |
无路径布局,子路由共享布局 |
$param.tsx |
动态路由参数 |
$.tsx |
通配符路由,捕获所有子路径 |
api.*.ts |
资源路由(仅 loader/action,无 UI) |
+types/ |
自动生成的类型定义 |
Loader 在服务端执行,用于页面数据获取:
// app/routes/users.tsx
import type { Route } from "./+types/users";
// 服务端数据加载
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page")) || 1;
const limit = Number(url.searchParams.get("limit")) || 10;
const search = url.searchParams.get("search") || "";
// 检查认证
const session = await getSession(request.headers.get("Cookie"));
if (!session.has("userId")) {
throw redirect("/login");
}
// 获取数据
const response = await fetch(
`${process.env.API_BASE_URL}/users?page=${page}&limit=${limit}&search=${search}`,
{
headers: {
Authorization: `Bearer ${session.get("token")}`,
},
}
);
if (!response.ok) {
throw new Response("获取用户列表失败", { status: response.status });
}
const { data, total } = await response.json();
return {
users: data,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
// 页面组件接收 loaderData
export default function UsersPage({ loaderData }: Route.ComponentProps) {
const { users, pagination } = loaderData;
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">用户管理</h1>
<div className="rounded-md border">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-4 text-left">姓名</th>
<th className="p-4 text-left">邮箱</th>
<th className="p-4 text-left">角色</th>
<th className="p-4 text-left">操作</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b">
<td className="p-4">{user.name}</td>
<td className="p-4">{user.email}</td>
<td className="p-4">{user.role}</td>
<td className="p-4">
<Link to={`/users/${user.id}`}>查看</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination {...pagination} />
</div>
);
}
Action 处理表单提交,支持渐进增强:
// app/routes/login.tsx
import type { Route } from "./+types/login";
import { Form, useActionData, useNavigation, redirect } from "react-router";
import { commitSession, getSession } from "~/lib/session.server";
// 服务端表单处理
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const redirectTo = formData.get("redirectTo") as string || "/";
// 验证
const errors: Record<string, string> = {};
if (!email) {
errors.email = "请输入邮箱";
} else if (!email.includes("@")) {
errors.email = "请输入有效的邮箱地址";
}
if (!password) {
errors.password = "请输入密码";
} else if (password.length < 6) {
errors.password = "密码至少 6 位";
}
if (Object.keys(errors).length > 0) {
return { errors, values: { email } };
}
// 调用登录 API
const response = await fetch(`${process.env.API_BASE_URL}/auth/login`, {
method: "POST",
body: JSON.stringify({ email, password }),
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
const data = await response.json();
return { errors: { form: data.message || "邮箱或密码错误" } };
}
const { user, token } = await response.json();
// 创建会话
const session = await getSession(request.headers.get("Cookie"));
session.set("userId", user.id);
session.set("token", token);
session.set("user", user);
// 重定向并设置 Cookie
return redirect(redirectTo, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
// Meta 信息
export function meta(): Route.MetaDescriptors {
return [
{ title: "登录 - Admin Pro" },
{ name: "description", content: "登录到 Admin Pro 管理系统" },
];
}
// 页面组件
export default function LoginPage() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-8 p-8">
<div className="text-center">
<h1 className="text-2xl font-bold">欢迎回来</h1>
<p className="text-muted-foreground">登录到您的账户</p>
</div>
{/* 表单错误提示 */}
{actionData?.errors?.form && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{actionData.errors.form}
</div>
)}
{/* 渐进增强表单 - 无 JS 也能工作 */}
<Form method="post" className="space-y-4">
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
邮箱
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
defaultValue={actionData?.values?.email}
className="w-full rounded-md border px-3 py-2"
placeholder="[email protected]"
/>
{actionData?.errors?.email && (
<p className="text-sm text-destructive">{actionData.errors.email}</p>
)}
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
密码
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="w-full rounded-md border px-3 py-2"
placeholder="••••••••"
/>
{actionData?.errors?.password && (
<p className="text-sm text-destructive">{actionData.errors.password}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
>
{isSubmitting ? "登录中..." : "登录"}
</button>
</Form>
<p className="text-center text-sm text-muted-foreground">
还没有账户?{" "}
<Link to="/register" className="text-primary hover:underline">
立即注册
</Link>
</p>
</div>
</div>
);
}
资源路由没有 UI 组件,仅导出 loader/action:
// app/routes/api.users.ts
import type { Route } from "./+types/api.users";
import { getSession } from "~/lib/session.server";
// GET /api/users
export async function loader({ request }: Route.LoaderArgs) {
const session = await getSession(request.headers.get("Cookie"));
if (!session.has("userId")) {
return Response.json({ error: "未授权" }, { status: 401 });
}
const url = new URL(request.url);
const page = Number(url.searchParams.get("page")) || 1;
const limit = Number(url.searchParams.get("limit")) || 10;
const response = await fetch(
`${process.env.API_BASE_URL}/users?page=${page}&limit=${limit}`,
{
headers: {
Authorization: `Bearer ${session.get("token")}`,
},
}
);
const data = await response.json();
return Response.json(data);
}
// POST /api/users
export async function action({ request }: Route.ActionArgs) {
const session = await getSession(request.headers.get("Cookie"));
if (!session.has("userId")) {
return Response.json({ error: "未授权" }, { status: 401 });
}
const body = await request.json();
const response = await fetch(`${process.env.API_BASE_URL}/users`, {
method: "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.get("token")}`,
},
});
const data = await response.json();
return Response.json(data, { status: response.status });
}
// app/routes/api.users.$id.ts
import type { Route } from "./+types/api.users.$id";
// GET /api/users/:id
export async function loader({ params, request }: Route.LoaderArgs) {
const { id } = params;
const session = await getSession(request.headers.get("Cookie"));
const response = await fetch(`${process.env.API_BASE_URL}/users/${id}`, {
headers: {
Authorization: `Bearer ${session.get("token")}`,
},
});
if (!response.ok) {
throw new Response("用户不存在", { status: 404 });
}
return Response.json(await response.json());
}
// PUT /api/users/:id
export async function action({ params, request }: Route.ActionArgs) {
const { id } = params;
const session = await getSession(request.headers.get("Cookie"));
const body = await request.json();
const response = await fetch(`${process.env.API_BASE_URL}/users/${id}`, {
method: request.method,
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.get("token")}`,
},
});
return Response.json(await response.json(), { status: response.status });
}
使用 Cookie 进行会话管理:
// app/lib/session.server.ts
import { createCookieSessionStorage, redirect } from "react-router";
// 创建会话存储
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__session",
httpOnly: true,
maxAge: 60 * 60 * 24 * 7, // 7 天
path: "/",
sameSite: "lax",
secrets: [process.env.SESSION_SECRET!],
secure: process.env.NODE_ENV === "production",
},
});
export const { getSession, commitSession, destroySession } = sessionStorage;
// 获取当前用户
export async function getUser(request: Request) {
const session = await getSession(request.headers.get("Cookie"));
const user = session.get("user");
return user || null;
}
// 要求登录
export async function requireUser(request: Request) {
const user = await getUser(request);
if (!user) {
const url = new URL(request.url);
throw redirect(`/login?redirectTo=${encodeURIComponent(url.pathname)}`);
}
return user;
}
// 登出
export async function logout(request: Request) {
const session = await getSession(request.headers.get("Cookie"));
return redirect("/login", {
headers: {
"Set-Cookie": await destroySession(session),
},
});
}
全局和路由级错误处理:
// app/root.tsx
import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useRouteError } from "react-router";
export function ErrorBoundary() {
const error = useRouteError();
// 路由错误(如 404、401)
if (isRouteErrorResponse(error)) {
return (
<html lang="zh-CN">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>错误 {error.status}</title>
<Meta />
<Links />
</head>
<body>
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-9xl font-bold text-muted-foreground">
{error.status}
</h1>
<p className="mt-4 text-xl">{error.statusText}</p>
<p className="mt-2 text-muted-foreground">{error.data}</p>
<a href="/" className="mt-8 inline-block text-primary hover:underline">
返回首页
</a>
</div>
</div>
<Scripts />
</body>
</html>
);
}
// 未知错误
return (
<html lang="zh-CN">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>发生错误</title>
<Meta />
<Links />
</head>
<body>
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-destructive">发生错误</h1>
<p className="mt-2 text-muted-foreground">
{error instanceof Error ? error.message : "未知错误"}
</p>
<a href="/" className="mt-8 inline-block text-primary hover:underline">
返回首页
</a>
</div>
</div>
<Scripts />
</body>
</html>
);
}
// app/routes/users.$id.tsx - 路由级错误边界
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error) && error.status === 404) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<h2 className="text-xl font-semibold">用户不存在</h2>
<p className="text-muted-foreground">请检查用户 ID 是否正确</p>
<Link to="/users" className="mt-4 inline-block text-primary">
返回用户列表
</Link>
</div>
</div>
);
}
throw error; // 向上抛出其他错误
}
// app/routes/users.tsx
import type { Route } from "./+types/users";
import { generateMeta } from "~/lib/meta";
export function meta(): Route.MetaDescriptors {
return generateMeta("/users");
}
// app/lib/meta.ts
interface PageMeta {
title: string;
description: string;
keywords?: string[];
}
export const pageMetas: Record<string, PageMeta> = {
"/": {
title: "仪表盘",
description: "Admin Pro 管理系统仪表盘,数据概览与统计分析",
keywords: ["仪表盘", "数据分析", "管理系统"],
},
"/users": {
title: "用户管理",
description: "管理系统用户账户,包括创建、编辑和权限配置",
keywords: ["用户管理", "账户管理", "权限配置"],
},
"/analytics": {
title: "数据分析",
description: "业务数据统计分析,可视化图表展示",
keywords: ["数据分析", "图表", "统计"],
},
"/settings": {
title: "系统设置",
description: "系统配置与个性化设置",
keywords: ["系统设置", "配置", "个性化"],
},
};
export function generateMeta(path: string, overrides?: Partial<PageMeta>): MetaDescriptor[] {
const meta = { ...pageMetas[path], ...overrides } || {
title: "页面",
description: "Admin Pro 管理系统",
};
const brandName = process.env.BRAND_NAME || "Halolight";
const fullTitle = `${meta.title} - ${brandName}`;
return [
{ title: fullTitle },
{ name: "description", content: meta.description },
{ name: "keywords", content: meta.keywords?.join(", ") || "" },
{ property: "og:title", content: fullTitle },
{ property: "og:description", content: meta.description },
{ property: "og:type", content: "website" },
];
}
// app/stores/tabs-store.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface Tab {
id: string;
title: string;
path: string;
closable?: boolean;
}
const homeTab: Tab = {
id: "home",
title: "首页",
path: "/",
closable: false,
};
interface TabsState {
tabs: Tab[];
activeTabId: string | null;
addTab: (tab: Omit<Tab, "id">) => string;
removeTab: (id: string) => void;
setActiveTab: (id: string) => void;
closeOthers: (id: string) => void;
closeRight: (id: string) => void;
clearTabs: () => void;
}
export const useTabsStore = create<TabsState>()(
persist(
(set, get) => ({
tabs: [homeTab],
activeTabId: "home",
addTab: (tab) => {
const { tabs } = get();
// 检查是否已存在
const existing = tabs.find((t) => t.path === tab.path);
if (existing) {
set({ activeTabId: existing.id });
return existing.id;
}
const id = crypto.randomUUID();
const newTab = { ...tab, id, closable: true };
set({
tabs: [...tabs, newTab],
activeTabId: id,
});
return id;
},
removeTab: (id) => {
const { tabs, activeTabId } = get();
const tab = tabs.find((t) => t.id === id);
if (!tab?.closable) return;
const newTabs = tabs.filter((t) => t.id !== id);
let newActiveId = activeTabId;
// 如果关闭的是当前标签,切换到相邻标签
if (activeTabId === id) {
const index = tabs.findIndex((t) => t.id === id);
newActiveId = newTabs[Math.min(index, newTabs.length - 1)]?.id || "home";
}
set({ tabs: newTabs, activeTabId: newActiveId });
},
setActiveTab: (id) => set({ activeTabId: id }),
closeOthers: (id) => {
const { tabs } = get();
const newTabs = tabs.filter((t) => t.id === id || !t.closable);
set({ tabs: newTabs, activeTabId: id });
},
closeRight: (id) => {
const { tabs } = get();
const index = tabs.findIndex((t) => t.id === id);
const newTabs = tabs.filter((t, i) => i <= index || !t.closable);
set({ tabs: newTabs });
},
clearTabs: () => set({ tabs: [homeTab], activeTabId: "home" }),
}),
{ name: "tabs-storage" }
)
);
// app/stores/ui-settings-store.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type SkinPreset =
| "default"
| "blue"
| "emerald"
| "amber"
| "violet"
| "rose"
| "teal"
| "slate"
| "ocean"
| "sunset"
| "aurora";
export type ThemeMode = "light" | "dark" | "system";
interface UiSettingsState {
skin: SkinPreset;
theme: ThemeMode;
showFooter: boolean;
showTabBar: boolean;
sidebarCollapsed: boolean;
setSkin: (skin: SkinPreset) => void;
setTheme: (theme: ThemeMode) => void;
setShowFooter: (visible: boolean) => void;
setShowTabBar: (visible: boolean) => void;
toggleSidebar: () => void;
}
export const useUiSettingsStore = create<UiSettingsState>()(
persist(
(set) => ({
skin: "default",
theme: "system",
showFooter: true,
showTabBar: true,
sidebarCollapsed: false,
setSkin: (skin) => {
document.documentElement.setAttribute("data-skin", skin);
set({ skin });
},
setTheme: (theme) => {
if (theme === "system") {
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.classList.toggle("dark", isDark);
} else {
document.documentElement.classList.toggle("dark", theme === "dark");
}
set({ theme });
},
setShowFooter: (visible) => set({ showFooter: visible }),
setShowTabBar: (visible) => set({ showTabBar: visible }),
toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
}),
{ name: "ui-settings-storage" }
)
);
支持 11 种预设皮肤,通过 Quick Settings 面板切换:
| 皮肤 | 主色调 | CSS 变量 |
|---|---|---|
| Default | 紫色 | --primary: 51.1% 0.262 276.97 |
| Blue | 蓝色 | --primary: 54.8% 0.243 264.05 |
| Emerald | 翠绿色 | --primary: 64.6% 0.178 142.49 |
| Amber | 琥珀色 | --primary: 76.9% 0.188 84.94 |
| Violet | 紫罗兰 | --primary: 54.1% 0.243 293.54 |
| Rose | 玫瑰色 | --primary: 64.5% 0.246 16.44 |
| Teal | 青色 | --primary: 60.0% 0.118 184.71 |
| Slate | 石板灰 | --primary: 45.9% 0.022 264.53 |
| Ocean | 海洋蓝 | --primary: 54.3% 0.195 240.03 |
| Sunset | 日落橙 | --primary: 70.5% 0.213 47.60 |
| Aurora | 极光色 | --primary: 62.8% 0.265 303.9 |
/* app/app.css */
@import "tailwindcss";
:root {
/* 背景色 */
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
/* 卡片 */
--card: 100% 0 0;
--card-foreground: 14.9% 0.017 285.75;
/* 弹出层 */
--popover: 100% 0 0;
--popover-foreground: 14.9% 0.017 285.75;
/* 主色 */
--primary: 51.1% 0.262 276.97;
--primary-foreground: 100% 0 0;
/* 次要色 */
--secondary: 96.7% 0.001 286.38;
--secondary-foreground: 21% 0.006 285.75;
/* 静音色 */
--muted: 96.7% 0.001 286.38;
--muted-foreground: 55.2% 0.014 285.94;
/* 强调色 */
--accent: 96.7% 0.001 286.38;
--accent-foreground: 21% 0.006 285.75;
/* 危险色 */
--destructive: 57.7% 0.245 27.32;
--destructive-foreground: 100% 0 0;
/* 边框/输入框 */
--border: 91.2% 0.004 286.32;
--input: 91.2% 0.004 286.32;
--ring: 51.1% 0.262 276.97;
/* 圆角 */
--radius: 0.5rem;
}
/* 皮肤预设 */
[data-skin="blue"] {
--primary: 54.8% 0.243 264.05;
--ring: 54.8% 0.243 264.05;
}
[data-skin="ocean"] {
--primary: 54.3% 0.195 240.03;
--ring: 54.3% 0.195 240.03;
}
[data-skin="emerald"] {
--primary: 64.6% 0.178 142.49;
--ring: 64.6% 0.178 142.49;
}
[data-skin="sunset"] {
--primary: 70.5% 0.213 47.60;
--ring: 70.5% 0.213 47.60;
}
/* 深色模式 */
.dark {
--background: 14.9% 0.017 285.75;
--foreground: 98.5% 0 0;
--card: 14.9% 0.017 285.75;
--card-foreground: 98.5% 0 0;
--popover: 14.9% 0.017 285.75;
--popover-foreground: 98.5% 0 0;
--secondary: 26.8% 0.019 286.07;
--secondary-foreground: 98.5% 0 0;
--muted: 26.8% 0.019 286.07;
--muted-foreground: 71.2% 0.013 286.07;
--accent: 26.8% 0.019 286.07;
--accent-foreground: 98.5% 0 0;
--border: 26.8% 0.019 286.07;
--input: 26.8% 0.019 286.07;
}
/* Tailwind 主题映射 */
@theme {
--color-background: oklch(var(--background));
--color-foreground: oklch(var(--foreground));
--color-card: oklch(var(--card));
--color-card-foreground: oklch(var(--card-foreground));
--color-popover: oklch(var(--popover));
--color-popover-foreground: oklch(var(--popover-foreground));
--color-primary: oklch(var(--primary));
--color-primary-foreground: oklch(var(--primary-foreground));
--color-secondary: oklch(var(--secondary));
--color-secondary-foreground: oklch(var(--secondary-foreground));
--color-muted: oklch(var(--muted));
--color-muted-foreground: oklch(var(--muted-foreground));
--color-accent: oklch(var(--accent));
--color-accent-foreground: oklch(var(--accent-foreground));
--color-destructive: oklch(var(--destructive));
--color-destructive-foreground: oklch(var(--destructive-foreground));
--color-border: oklch(var(--border));
--color-input: oklch(var(--input));
--color-ring: oklch(var(--ring));
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
| 路径 | 页面 | 权限 |
|---|---|---|
/ |
仪表盘 | dashboard:view |
/login |
登录 | 公开 |
/register |
注册 | 公开 |
/forgot-password |
忘记密码 | 公开 |
/reset-password |
重置密码 | 公开 |
/users |
用户管理 | users:view |
/users/:id |
用户详情 | users:view |
/settings |
系统设置 | settings:view |
/profile |
个人中心 | settings:view |
/security |
安全设置 | settings:view |
/analytics |
数据分析 | analytics:view |
/notifications |
通知中心 | notifications:view |
/documents |
文档管理 | documents:view |
/calendar |
日历 | calendar:view |
# .env
SESSION_SECRET=your-super-secret-session-key
API_BASE_URL=https://api.halolight.h7ml.cn
MOCK_ENABLED=true
DEMO_EMAIL=[email protected]
DEMO_PASSWORD=123456
SHOW_DEMO_HINT=true
APP_TITLE=Admin Pro
BRAND_NAME=Halolight
| 变量名 | 说明 | 默认值 |
|---|---|---|
SESSION_SECRET |
会话密钥(必需) | (必需) |
API_BASE_URL |
API 基础 URL | /api |
MOCK_ENABLED |
启用 Mock 数据 | false |
DEMO_EMAIL |
演示账号邮箱 | - |
DEMO_PASSWORD |
演示账号密码 | - |
SHOW_DEMO_HINT |
显示演示提示 | false |
APP_TITLE |
应用标题 | Admin Pro |
BRAND_NAME |
品牌名称 | Halolight |
// app/routes/users.tsx
export async function loader({ request }: Route.LoaderArgs) {
const apiUrl = process.env.API_BASE_URL;
const response = await fetch(`${apiUrl}/users`);
return response.json();
}
# 开发
pnpm dev # 启动开发服务器
pnpm dev --host # 允许局域网访问
# 构建
pnpm build # 生产构建
pnpm start # 启动生产服务器
# 代码质量
pnpm typecheck # TypeScript 类型检查
pnpm lint # ESLint 检查
pnpm lint:fix # ESLint 自动修复
pnpm format # Prettier 格式化
# 测试
pnpm test # 监视模式
pnpm test:run # 单次运行
pnpm test:coverage # 覆盖率报告
pnpm test:ui # Vitest UI 界面
# 部署
pnpm preview # Cloudflare 本地预览
pnpm deploy # 部署到 Cloudflare Pages
pnpm test:run # 单次运行
pnpm test # 监视模式
pnpm test:coverage # 覆盖率报告
// tests/stores/tabs-store.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { useTabsStore } from "~/stores/tabs-store";
describe("useTabsStore", () => {
beforeEach(() => {
useTabsStore.getState().clearTabs();
});
it("初始状态应该只有首页标签", () => {
const { tabs, activeTabId } = useTabsStore.getState();
expect(tabs).toHaveLength(1);
expect(tabs[0].id).toBe("home");
expect(activeTabId).toBe("home");
});
it("应该添加新标签", () => {
const { addTab } = useTabsStore.getState();
const id = addTab({ title: "用户管理", path: "/users" });
const { tabs, activeTabId } = useTabsStore.getState();
expect(tabs).toHaveLength(2);
expect(tabs[1].title).toBe("用户管理");
expect(activeTabId).toBe(id);
});
it("应该去重已存在的路由", () => {
const { addTab } = useTabsStore.getState();
const id1 = addTab({ title: "用户管理", path: "/users" });
const id2 = addTab({ title: "用户管理", path: "/users" });
expect(id1).toBe(id2);
const { tabs } = useTabsStore.getState();
expect(tabs).toHaveLength(2);
});
it("应该关闭标签并切换到相邻标签", () => {
const { addTab, removeTab } = useTabsStore.getState();
addTab({ title: "用户管理", path: "/users" });
const id = addTab({ title: "设置", path: "/settings" });
removeTab(id);
const { tabs, activeTabId } = useTabsStore.getState();
expect(tabs).toHaveLength(2);
expect(activeTabId).not.toBe(id);
});
it("首页标签不可关闭", () => {
const { removeTab } = useTabsStore.getState();
removeTab("home");
const { tabs } = useTabsStore.getState();
expect(tabs).toHaveLength(1);
expect(tabs[0].id).toBe("home");
});
});
// tests/lib/meta.test.ts
import { describe, it, expect } from "vitest";
import { generateMeta, pageMetas } from "~/lib/meta";
describe("generateMeta", () => {
it("应该生成正确的 meta 标签", () => {
const meta = generateMeta("/users");
expect(meta).toContainEqual(
expect.objectContaining({ name: "description" })
);
expect(meta).toContainEqual(
expect.objectContaining({ property: "og:title" })
);
});
it("应该支持覆盖默认值", () => {
const meta = generateMeta("/users", { title: "自定义标题" });
const titleMeta = meta.find((m) => "title" in m);
expect(titleMeta?.title).toContain("自定义标题");
});
});
// vite.config.ts
import { defineConfig } from "vite";
import { reactRouter } from "@react-router/dev/vite";
export default defineConfig({
plugins: [reactRouter()],
});
// wrangler.json
{
"name": "halolight-remix",
"compatibility_date": "2024-12-01",
"compatibility_flags": ["nodejs_compat"],
"pages_build_output_dir": "./build/client"
}
// eslint.config.js
import js from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["build", ".react-router"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
}
);
# 安装 Wrangler CLI
npm install -g wrangler
# 登录
wrangler login
# 部署
pnpm deploy
// wrangler.json
{
"name": "halolight-remix",
"compatibility_date": "2024-12-01",
"compatibility_flags": ["nodejs_compat"],
"pages_build_output_dir": "./build/client"
}
# .github/workflows/deploy.yml
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: halolight-remix
directory: build/client
pnpm build
pnpm start
# Dockerfile
FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY --from=builder /app/build ./build
COPY --from=builder /app/package.json .
COPY --from=builder /app/pnpm-lock.yaml .
RUN pnpm install --prod --frozen-lockfile
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["pnpm", "start"]
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- SESSION_SECRET=${SESSION_SECRET}
- API_BASE_URL=${API_BASE_URL}
restart: unless-stopped
# 安装 Vercel CLI
npm install -g vercel
# 部署
vercel
项目配置了完整的 GitHub Actions CI 流程:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
// app/routes/users.tsx
import { useFetcher } from "react-router";
export default function UsersPage() {
const fetcher = useFetcher();
const handleDelete = (userId: string) => {
if (confirm("确定删除此用户?")) {
fetcher.submit(
{ userId },
{ method: "delete", action: "/api/users" }
);
}
};
return (
<div>
{users.map((user) => (
<div key={user.id}>
<span>{user.name}</span>
<button
onClick={() => handleDelete(user.id)}
disabled={fetcher.state === "submitting"}
>
{fetcher.state === "submitting" ? "删除中..." : "删除"}
</button>
</div>
))}
</div>
);
}
// app/routes/notifications.tsx
import { useFetcher } from "react-router";
function NotificationItem({ notification }) {
const fetcher = useFetcher();
// 乐观 UI:立即显示已读状态
const isRead = fetcher.formData
? fetcher.formData.get("read") === "true"
: notification.read;
return (
<div className={isRead ? "opacity-50" : ""}>
<p>{notification.message}</p>
{!isRead && (
<fetcher.Form method="post" action="/api/notifications/mark-read">
<input type="hidden" name="id" value={notification.id} />
<input type="hidden" name="read" value="true" />
<button type="submit">标为已读</button>
</fetcher.Form>
)}
</div>
);
}
// app/routes/analytics.tsx
import type { Route } from "./+types/analytics";
import { Await, defer } from "react-router";
import { Suspense } from "react";
export async function loader({ request }: Route.LoaderArgs) {
// 快速数据立即返回
const summary = await getSummary();
// 慢速数据延迟加载
const chartDataPromise = getChartData();
const reportPromise = generateReport();
return defer({
summary,
chartData: chartDataPromise,
report: reportPromise,
});
}
export default function AnalyticsPage({ loaderData }: Route.ComponentProps) {
const { summary, chartData, report } = loaderData;
return (
<div className="space-y-6">
{/* 立即显示 */}
<SummaryCard data={summary} />
{/* 延迟加载的图表 */}
<Suspense fallback={<ChartSkeleton />}>
<Await resolve={chartData}>
{(data) => <Chart data={data} />}
</Await>
</Suspense>
{/* 延迟加载的报告 */}
<Suspense fallback={<ReportSkeleton />}>
<Await resolve={report}>
{(data) => <Report data={data} />}
</Await>
</Suspense>
</div>
);
}
// app/routes/dashboard.tsx
export async function loader({ request }: Route.LoaderArgs) {
// 并行请求多个数据源
const [stats, recentUsers, notifications, activities] = await Promise.all([
getStats(),
getRecentUsers(),
getNotifications(),
getActivities(),
]);
return { stats, recentUsers, notifications, activities };
}
// app/lib/middleware.ts
import { redirect } from "react-router";
import { getSession } from "./session.server";
type LoaderFunction = (args: LoaderArgs) => Promise<any>;
// 认证中间件
export function withAuth(loader: LoaderFunction): LoaderFunction {
return async (args) => {
const session = await getSession(args.request.headers.get("Cookie"));
if (!session.has("userId")) {
const url = new URL(args.request.url);
throw redirect(`/login?redirectTo=${encodeURIComponent(url.pathname)}`);
}
// 注入用户信息
const user = session.get("user");
return loader({ ...args, user });
};
}
// 角色检查中间件
export function withRole(role: string, loader: LoaderFunction): LoaderFunction {
return withAuth(async (args) => {
const { user } = args as any;
if (user.role !== role) {
throw new Response("权限不足", { status: 403 });
}
return loader(args);
});
}
// 使用示例
// app/routes/admin.tsx
export const loader = withRole("admin", async ({ request }) => {
// 只有 admin 角色才能访问
return getAdminData();
});
// 使用 React.lazy 动态导入
import { lazy, Suspense } from "react";
const Chart = lazy(() => import("~/components/dashboard/chart"));
export default function Dashboard() {
return (
<Suspense fallback={<ChartSkeleton />}>
<Chart data={data} />
</Suspense>
);
}
// 链接预加载
import { Link, prefetchRouteModule } from "react-router";
function NavLink({ to, children }) {
return (
<Link
to={to}
onMouseEnter={() => prefetchRouteModule(to)}
onFocus={() => prefetchRouteModule(to)}
>
{children}
</Link>
);
}
// app/routes/api.static-data.ts
export async function loader() {
const data = await getStaticData();
return Response.json(data, {
headers: {
"Cache-Control": "public, max-age=3600, s-maxage=86400",
},
});
}
A:结合服务端和客户端验证:
// app/routes/register.tsx
import { z } from "zod";
const registerSchema = z.object({
email: z.string().email("请输入有效的邮箱"),
password: z.string().min(6, "密码至少 6 位"),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "两次密码不一致",
path: ["confirmPassword"],
});
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const data = Object.fromEntries(formData);
// 服务端验证
const result = registerSchema.safeParse(data);
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
// 创建用户...
}
A:使用 FormData 处理文件:
// app/routes/upload.tsx
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file || file.size === 0) {
return { error: "请选择文件" };
}
// 上传到存储服务
const buffer = await file.arrayBuffer();
const url = await uploadToStorage(buffer, file.name, file.type);
return { url };
}
export default function UploadPage() {
const actionData = useActionData<typeof action>();
return (
<Form method="post" encType="multipart/form-data">
<input type="file" name="file" required />
<button type="submit">上传</button>
{actionData?.url && <p>上传成功: {actionData.url}</p>}
{actionData?.error && <p className="text-destructive">{actionData.error}</p>}
</Form>
);
}
A:使用 Cookie 或 URL 前缀:
// app/lib/i18n.ts
export const locales = ["zh-CN", "en-US"] as const;
export type Locale = typeof locales[number];
export function getLocale(request: Request): Locale {
const url = new URL(request.url);
const cookie = request.headers.get("Cookie");
// 1. 检查 URL 参数
const urlLocale = url.searchParams.get("locale");
if (urlLocale && locales.includes(urlLocale as Locale)) {
return urlLocale as Locale;
}
// 2. 检查 Cookie
const cookieLocale = getCookie(cookie, "locale");
if (cookieLocale && locales.includes(cookieLocale as Locale)) {
return cookieLocale as Locale;
}
// 3. 检查 Accept-Language
const acceptLanguage = request.headers.get("Accept-Language");
if (acceptLanguage?.includes("zh")) {
return "zh-CN";
}
return "en-US";
}
A:使用 SSE (Server-Sent Events):
// app/routes/api.events.ts
export async function loader({ request }: Route.LoaderArgs) {
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const sendEvent = (data: any) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
};
// 定时发送事件
const interval = setInterval(() => {
sendEvent({ type: "ping", timestamp: Date.now() });
}, 5000);
// 清理
request.signal.addEventListener("abort", () => {
clearInterval(interval);
controller.close();
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
// 客户端使用
useEffect(() => {
const eventSource = new EventSource("/api/events");
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
// 处理事件
};
return () => eventSource.close();
}, []);
// 使用 React.lazy 动态导入
import { lazy, Suspense } from "react";
const Chart = lazy(() => import("~/components/dashboard/chart"));
export default function Dashboard() {
return (
<Suspense fallback={<ChartSkeleton />}>
<Chart data={data} />
</Suspense>
);
}
// 链接预加载
import { Link, prefetchRouteModule } from "react-router";
function NavLink({ to, children }) {
return (
<Link
to={to}
onMouseEnter={() => prefetchRouteModule(to)}
onFocus={() => prefetchRouteModule(to)}
>
{children}
</Link>
);
}
// app/routes/api.static-data.ts
export async function loader() {
const data = await getStaticData();
return Response.json(data, {
headers: {
"Cache-Control": "public, max-age=3600, s-maxage=86400",
},
});
}
| 功能 | Remix 版本 | Vue 版本 | Next.js 版本 |
|---|---|---|---|
| 状态管理 | Zustand | Pinia | Zustand |
| 数据获取 | Loader/Action | TanStack Query | TanStack Query |
| 表单处理 | 渐进增强 Form | VeeValidate | React Hook Form |
| 服务端 | 内置 SSR | Nuxt | App Router |
| 组件库 | Radix UI | shadcn-vue | shadcn/ui |
| 路由 | 文件路由 | Vue Router | App Router |
| 主题 | OKLch CSS 变量 | OKLch CSS 变量 | OKLch CSS 变量 |
| 测试 | Vitest | Vitest | Vitest |
| 构建工具 | Vite | Vite | Turbopack |
HaloLight Solid.js 版本基于 SolidStart 1.0 构建,采用 Solid.js 细粒度响应式 + TypeScript,实现高性能管理后台。无虚拟 DOM、编译时优化、极小 Bundle 体积。
在线预览:https://halolight-solidjs.h7ml.cn/
GitHub:https://github.com/halolight/halolight-solidjs
"use server" 无缝调用服务端逻辑| 技术 | 版本 | 说明 |
|---|---|---|
| SolidStart | 1.x | Solid 全栈框架 |
| Solid.js | 1.9+ | 细粒度响应式框架 |
| TypeScript | 5.x | 类型安全 |
| Tailwind CSS | 4.x | 原子化 CSS + OKLch |
| Kobalte | 0.13+ | 无障碍 UI 原语 |
| solid-primitives | latest | 响应式工具库 |
| Zod | 3.x | 数据验证 |
| @solid-primitives/storage | latest | 持久化存储 |
| solid-charts | latest | 图表可视化 |
| Vitest | 4.x | 单元测试 |
| Mock.js | 1.x | 数据模拟 |
halolight-solidjs/
├── src/
│ ├── routes/ # 文件路由
│ │ ├── index.tsx # 首页 (仪表盘)
│ │ ├── (auth)/ # 认证路由组 (无布局路径)
│ │ │ ├── login.tsx # 登录
│ │ │ ├── register.tsx # 注册
│ │ │ ├── forgot-password.tsx # 忘记密码
│ │ │ └── reset-password.tsx # 重置密码
│ │ ├── (dashboard)/ # 仪表盘路由组 (带 AdminLayout)
│ │ │ ├── dashboard.tsx # 仪表盘首页
│ │ │ ├── analytics.tsx # 数据分析
│ │ │ ├── users/ # 用户管理
│ │ │ │ ├── index.tsx # 用户列表
│ │ │ │ ├── create.tsx # 创建用户
│ │ │ │ └── [id].tsx # 用户详情 (动态路由)
│ │ │ ├── roles.tsx # 角色管理
│ │ │ ├── permissions.tsx # 权限管理
│ │ │ ├── messages.tsx # 消息中心
│ │ │ ├── notifications.tsx # 通知列表
│ │ │ ├── documents.tsx # 文档管理
│ │ │ ├── calendar.tsx # 日历
│ │ │ ├── settings.tsx # 系统设置
│ │ │ └── profile.tsx # 个人中心
│ │ ├── privacy.tsx # 隐私政策
│ │ ├── terms.tsx # 服务条款
│ │ └── api/ # API 路由
│ │ ├── auth/
│ │ │ ├── login.ts # POST /api/auth/login
│ │ │ ├── register.ts # POST /api/auth/register
│ │ │ └── logout.ts # POST /api/auth/logout
│ │ └── users/
│ │ ├── index.ts # GET/POST /api/users
│ │ └── [id].ts # GET/PUT/DELETE /api/users/:id
│ ├── components/ # 组件库
│ │ ├── ui/ # Kobalte 封装组件
│ │ │ ├── Button.tsx
│ │ │ ├── Card.tsx
│ │ │ ├── Dialog.tsx
│ │ │ ├── DropdownMenu.tsx
│ │ │ ├── Input.tsx
│ │ │ ├── Select.tsx
│ │ │ ├── Table.tsx
│ │ │ ├── Toast.tsx
│ │ │ └── ...
│ │ ├── layout/ # 布局组件
│ │ │ ├── AdminLayout.tsx # 后台主布局
│ │ │ ├── AuthLayout.tsx # 认证页布局
│ │ │ ├── Sidebar.tsx # 侧边栏
│ │ │ ├── Header.tsx # 顶部导航
│ │ │ ├── Footer.tsx # 页脚
│ │ │ ├── TabBar.tsx # 标签栏
│ │ │ └── QuickSettings.tsx # 快捷设置面板
│ │ ├── dashboard/ # 仪表盘组件
│ │ │ ├── DashboardGrid.tsx # 可拖拽网格
│ │ │ ├── WidgetWrapper.tsx # 部件包装器
│ │ │ ├── StatsWidget.tsx # 统计卡片
│ │ │ └── ChartWidget.tsx # 图表部件
│ │ ├── auth/ # 认证组件
│ │ │ └── AuthShell.tsx # 认证外壳
│ │ └── shared/ # 共享组件
│ │ ├── PermissionGuard.tsx # 权限守卫
│ │ └── ErrorBoundary.tsx # 错误边界
│ ├── stores/ # 状态管理 (Signals + Store)
│ │ ├── auth.ts # 认证状态
│ │ ├── ui-settings.ts # UI 设置状态
│ │ ├── tabs.ts # 标签页状态
│ │ └── dashboard.ts # 仪表盘布局状态
│ ├── lib/ # 工具库
│ │ ├── api.ts # API 客户端
│ │ ├── permission.ts # 权限工具
│ │ ├── meta.ts # TDK 元信息
│ │ └── cn.ts # 类名工具
│ ├── server/ # 服务端代码
│ │ ├── auth.ts # 认证逻辑
│ │ ├── session.ts # 会话管理
│ │ └── middleware.ts # 中间件
│ ├── hooks/ # 自定义 Hooks
│ │ ├── createUsers.ts # 用户数据
│ │ └── createToast.ts # Toast 通知
│ └── types/ # TypeScript 类型
│ ├── user.ts
│ └── api.ts
├── tests/ # 测试文件
│ ├── setup.ts
│ ├── stores/
│ └── components/
├── public/ # 静态资源
├── .github/workflows/ci.yml # CI 配置
├── app.config.ts # SolidStart 配置
├── tailwind.config.ts # Tailwind 配置
├── vitest.config.ts # Vitest 配置
└── package.json
git clone https://github.com/halolight/halolight-solidjs.git
cd halolight-solidjs
pnpm install
cp .env.example .env
# .env 示例
VITE_API_URL=/api
VITE_USE_MOCK=true
VITE_DEMO_EMAIL=[email protected]
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=true
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight
pnpm dev
pnpm build
pnpm start
| 角色 | 邮箱 | 密码 |
|---|---|---|
| 管理员 | [email protected] | 123456 |
| 普通用户 | [email protected] | 123456 |
Solid.js 的核心是 Signals,它提供了最细粒度的响应式更新:
import { createSignal, createEffect, createMemo } from 'solid-js';
// 创建信号 - 响应式状态
const [count, setCount] = createSignal(0);
// 创建派生值 - 自动追踪依赖
const doubled = createMemo(() => count() * 2);
// 创建副作用 - 自动响应变化
createEffect(() => {
console.log('count changed:', count());
});
// 更新状态
setCount(1); // 设置新值
setCount(c => c + 1); // 函数式更新
对于复杂嵌套数据,使用 Store:
import { createStore, produce } from 'solid-js/store';
interface User {
id: number;
name: string;
profile: {
avatar: string;
bio: string;
};
}
const [user, setUser] = createStore<User>({
id: 1,
name: '管理员',
profile: {
avatar: '/avatar.png',
bio: '',
},
});
// 访问 - 自动追踪
console.log(user.name);
console.log(user.profile.avatar);
// 更新 - 路径式
setUser('name', '新名称');
setUser('profile', 'bio', '这是我的简介');
// 更新 - 函数式 (Immer 风格)
setUser(
produce((draft) => {
draft.name = '新名称';
draft.profile.bio = '这是我的简介';
})
);
// stores/auth.ts
import { createSignal, createMemo } from 'solid-js';
import { createStore } from 'solid-js/store';
import { makePersisted } from '@solid-primitives/storage';
interface User {
id: number;
name: string;
email: string;
avatar?: string;
role: string;
permissions: string[];
}
interface AuthState {
user: User | null;
token: string | null;
}
// 创建持久化 store
const [state, setState] = makePersisted(
createStore<AuthState>({
user: null,
token: null,
}),
{ name: 'auth-storage' }
);
// 独立的加载状态
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
export const authStore = {
// Getters - 响应式访问
get user() {
return state.user;
},
get token() {
return state.token;
},
get loading() {
return loading();
},
get error() {
return error();
},
// 派生状态
isAuthenticated: createMemo(() => !!state.token && !!state.user),
permissions: createMemo(() => state.user?.permissions ?? []),
isAdmin: createMemo(() => state.user?.role === 'admin'),
// Actions
async login(credentials: { email: string; password: string }) {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || '登录失败');
}
const data = await response.json();
setState({
user: data.user,
token: data.token,
});
return data;
} catch (e) {
const message = e instanceof Error ? e.message : '登录失败';
setError(message);
throw e;
} finally {
setLoading(false);
}
},
logout() {
setState({ user: null, token: null });
},
updateProfile(updates: Partial<User>) {
setState('user', (user) => (user ? { ...user, ...updates } : null));
},
// 权限检查
hasPermission(permission: string): boolean {
const perms = state.user?.permissions ?? [];
return perms.some(
(p) =>
p === '*' ||
p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
);
},
hasAnyPermission(permissions: string[]): boolean {
return permissions.some((p) => authStore.hasPermission(p));
},
hasAllPermissions(permissions: string[]): boolean {
return permissions.every((p) => authStore.hasPermission(p));
},
};
// stores/ui-settings.ts
import { createStore } from 'solid-js/store';
import { makePersisted } from '@solid-primitives/storage';
export type SkinPreset =
| 'default'
| 'blue'
| 'emerald'
| 'amber'
| 'violet'
| 'rose'
| 'teal'
| 'slate'
| 'ocean'
| 'sunset'
| 'aurora';
export type ThemeMode = 'light' | 'dark' | 'system';
interface UiSettingsState {
skin: SkinPreset;
theme: ThemeMode;
showFooter: boolean;
showTabBar: boolean;
sidebarCollapsed: boolean;
}
const [state, setState] = makePersisted(
createStore<UiSettingsState>({
skin: 'default',
theme: 'system',
showFooter: true,
showTabBar: true,
sidebarCollapsed: false,
}),
{ name: 'ui-settings-storage' }
);
export const uiSettingsStore = {
get skin() {
return state.skin;
},
get theme() {
return state.theme;
},
get showFooter() {
return state.showFooter;
},
get showTabBar() {
return state.showTabBar;
},
get sidebarCollapsed() {
return state.sidebarCollapsed;
},
setSkin(skin: SkinPreset) {
document.documentElement.setAttribute('data-skin', skin);
setState('skin', skin);
},
setTheme(theme: ThemeMode) {
if (theme === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', isDark);
} else {
document.documentElement.classList.toggle('dark', theme === 'dark');
}
setState('theme', theme);
},
setShowFooter(visible: boolean) {
setState('showFooter', visible);
},
setShowTabBar(visible: boolean) {
setState('showTabBar', visible);
},
toggleSidebar() {
setState('sidebarCollapsed', (c) => !c);
},
};
// stores/tabs.ts
import { createStore, produce } from 'solid-js/store';
import { makePersisted } from '@solid-primitives/storage';
interface Tab {
id: string;
title: string;
path: string;
closable: boolean;
}
const homeTab: Tab = {
id: 'home',
title: '首页',
path: '/',
closable: false,
};
interface TabsState {
tabs: Tab[];
activeTabId: string;
}
const [state, setState] = makePersisted(
createStore<TabsState>({
tabs: [homeTab],
activeTabId: 'home',
}),
{ name: 'tabs-storage' }
);
export const tabsStore = {
get tabs() {
return state.tabs;
},
get activeTabId() {
return state.activeTabId;
},
get activeTab() {
return state.tabs.find((t) => t.id === state.activeTabId);
},
addTab(tab: Omit<Tab, 'id' | 'closable'>): string {
// 检查是否已存在
const existing = state.tabs.find((t) => t.path === tab.path);
if (existing) {
setState('activeTabId', existing.id);
return existing.id;
}
const id = crypto.randomUUID();
const newTab: Tab = { ...tab, id, closable: true };
setState(
produce((draft) => {
draft.tabs.push(newTab);
draft.activeTabId = id;
})
);
return id;
},
removeTab(id: string) {
const tab = state.tabs.find((t) => t.id === id);
if (!tab?.closable) return;
const index = state.tabs.findIndex((t) => t.id === id);
const newTabs = state.tabs.filter((t) => t.id !== id);
let newActiveId = state.activeTabId;
if (state.activeTabId === id) {
// 切换到相邻标签
newActiveId = newTabs[Math.min(index, newTabs.length - 1)]?.id || 'home';
}
setState({
tabs: newTabs,
activeTabId: newActiveId,
});
},
setActiveTab(id: string) {
setState('activeTabId', id);
},
closeOthers(id: string) {
setState(
produce((draft) => {
draft.tabs = draft.tabs.filter((t) => t.id === id || !t.closable);
draft.activeTabId = id;
})
);
},
closeRight(id: string) {
const index = state.tabs.findIndex((t) => t.id === id);
setState('tabs', (tabs) => tabs.filter((t, i) => i <= index || !t.closable));
},
clearTabs() {
setState({
tabs: [homeTab],
activeTabId: 'home',
});
},
};
// src/middleware.ts
import { createMiddleware } from '@solidjs/start/middleware';
export default createMiddleware({
onRequest: [
// 日志中间件
async (event) => {
const start = Date.now();
const response = await event.next();
const duration = Date.now() - start;
console.log(`${event.request.method} ${event.request.url} - ${duration}ms`);
return response;
},
// 认证中间件
async (event) => {
const url = new URL(event.request.url);
// 公开路径
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password', '/api/auth'];
const isPublic = publicPaths.some((path) => url.pathname.startsWith(path));
if (isPublic) {
return;
}
// 保护 dashboard 路由
if (url.pathname.startsWith('/dashboard') || url.pathname.startsWith('/api/')) {
const cookies = event.request.headers.get('cookie') || '';
const token = cookies.match(/token=([^;]+)/)?.[1];
if (!token) {
// API 路由返回 401
if (url.pathname.startsWith('/api/')) {
return new Response(JSON.stringify({ error: '未授权' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
// 页面路由重定向
return new Response(null, {
status: 302,
headers: { Location: `/login?redirect=${encodeURIComponent(url.pathname)}` },
});
}
// 验证 token 并注入用户信息
try {
const user = await verifyToken(token);
event.locals.user = user;
} catch {
// Token 无效,清除 cookie 并重定向
return new Response(null, {
status: 302,
headers: {
Location: '/login',
'Set-Cookie': 'token=; Max-Age=0; Path=/',
},
});
}
}
},
],
});
async function verifyToken(token: string) {
// 实际项目中验证 JWT
return { id: 1, name: '管理员', permissions: ['*'] };
}
SolidStart 支持 "use server" 标记的服务端函数:
// server/auth.ts
'use server';
import { z } from 'zod';
import { useSession } from 'vinxi/http';
const loginSchema = z.object({
email: z.string().email('请输入有效的邮箱地址'),
password: z.string().min(6, '密码至少 6 位'),
});
const registerSchema = loginSchema.extend({
name: z.string().min(2, '姓名至少 2 个字符'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: '两次密码不一致',
path: ['confirmPassword'],
});
export async function login(credentials: z.infer<typeof loginSchema>) {
const validated = loginSchema.parse(credentials);
// 模拟验证
if (validated.email !== '[email protected]' || validated.password !== '123456') {
throw new Error('邮箱或密码错误');
}
const user = {
id: 1,
name: '管理员',
email: validated.email,
role: 'admin',
permissions: ['*'],
};
const token = `mock_token_${Date.now()}`;
// 设置 session
const session = await useSession({
password: process.env.SESSION_SECRET!,
});
await session.update({ userId: user.id, token });
return {
success: true,
user,
token,
};
}
export async function register(data: z.infer<typeof registerSchema>) {
const validated = registerSchema.parse(data);
// 检查邮箱是否已存在
const existing = await db.users.findByEmail(validated.email);
if (existing) {
throw new Error('该邮箱已被注册');
}
// 创建用户
const user = await db.users.create({
email: validated.email,
name: validated.name,
password: await hashPassword(validated.password),
});
return { success: true, user };
}
export async function getCurrentUser() {
const session = await useSession({
password: process.env.SESSION_SECRET!,
});
if (!session.data.userId) {
return null;
}
const user = await db.users.findById(session.data.userId);
return user;
}
export async function logout() {
const session = await useSession({
password: process.env.SESSION_SECRET!,
});
await session.clear();
return { success: true };
}
// routes/api/users/index.ts
import type { APIEvent } from '@solidjs/start/server';
import { json } from '@solidjs/router';
// GET /api/users
export async function GET(event: APIEvent) {
const url = new URL(event.request.url);
const page = Number(url.searchParams.get('page')) || 1;
const limit = Number(url.searchParams.get('limit')) || 10;
const search = url.searchParams.get('search') || '';
// 模拟数据
const users = generateMockUsers(page, limit, search);
const total = 100;
return json({
success: true,
data: users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
}
// POST /api/users
export async function POST(event: APIEvent) {
const body = await event.request.json();
const { email, name, role } = body;
// 验证
if (!email || !name) {
return json({ success: false, message: '邮箱和姓名不能为空' }, { status: 400 });
}
// 创建用户
const user = {
id: Date.now(),
email,
name,
role: role || 'user',
createdAt: new Date().toISOString(),
};
return json({
success: true,
data: user,
message: '用户创建成功',
});
}
// routes/api/users/[id].ts
import type { APIEvent } from '@solidjs/start/server';
import { json } from '@solidjs/router';
// GET /api/users/:id
export async function GET(event: APIEvent) {
const id = event.params.id;
const user = await db.users.findById(id);
if (!user) {
return json({ success: false, message: '用户不存在' }, { status: 404 });
}
return json({ success: true, data: user });
}
// PUT /api/users/:id
export async function PUT(event: APIEvent) {
const id = event.params.id;
const body = await event.request.json();
const user = await db.users.update(id, body);
return json({
success: true,
data: user,
message: '用户更新成功',
});
}
// DELETE /api/users/:id
export async function DELETE(event: APIEvent) {
const id = event.params.id;
await db.users.delete(id);
return json({
success: true,
message: '用户删除成功',
});
}
// components/shared/PermissionGuard.tsx
import { Show, type ParentComponent, type JSX, createMemo } from 'solid-js';
import { authStore } from '~/stores/auth';
interface Props {
permission?: string;
permissions?: string[];
mode?: 'any' | 'all';
fallback?: JSX.Element;
}
export const PermissionGuard: ParentComponent<Props> = (props) => {
const hasPermission = createMemo(() => {
// 单权限检查
if (props.permission) {
return authStore.hasPermission(props.permission);
}
// 多权限检查
if (props.permissions) {
return props.mode === 'all'
? authStore.hasAllPermissions(props.permissions)
: authStore.hasAnyPermission(props.permissions);
}
return true;
});
return (
<Show when={hasPermission()} fallback={props.fallback}>
{props.children}
</Show>
);
};
// 使用示例
<PermissionGuard
permission="users:delete"
fallback={<span class="text-muted-foreground">无权限</span>}
>
<Button variant="destructive" onClick={handleDelete}>
删除用户
</Button>
</PermissionGuard>
// 多权限检查
<PermissionGuard
permissions={['users:edit', 'users:delete']}
mode="any"
>
<DropdownMenu>
<DropdownMenuItem>编辑</DropdownMenuItem>
<DropdownMenuItem>删除</DropdownMenuItem>
</DropdownMenu>
</PermissionGuard>
使用 createAsync 和 cache 进行数据获取:
// routes/(dashboard)/users/index.tsx
import { createAsync, cache, useSearchParams } from '@solidjs/router';
import { Show, For, Suspense } from 'solid-js';
import { AdminLayout } from '~/components/layout/AdminLayout';
import { Table, Pagination, Button, Input } from '~/components/ui';
// 定义缓存函数
const getUsers = cache(async (params: { page: number; limit: number; search?: string }) => {
'use server';
const response = await fetch(
`${process.env.API_BASE_URL}/users?page=${params.page}&limit=${params.limit}&search=${params.search || ''}`
);
if (!response.ok) {
throw new Error('获取用户列表失败');
}
return response.json();
}, 'users');
// 预加载
export const route = {
load: ({ location }) => {
const page = Number(new URLSearchParams(location.search).get('page')) || 1;
void getUsers({ page, limit: 10 });
},
};
export default function UsersPage() {
const [searchParams, setSearchParams] = useSearchParams();
const page = () => Number(searchParams.page) || 1;
const search = () => searchParams.search || '';
const users = createAsync(() =>
getUsers({ page: page(), limit: 10, search: search() })
);
const handleSearch = (value: string) => {
setSearchParams({ search: value, page: '1' });
};
const handlePageChange = (newPage: number) => {
setSearchParams({ page: String(newPage) });
};
return (
<AdminLayout title="用户管理">
<div class="space-y-6">
{/* 搜索栏 */}
<div class="flex items-center justify-between">
<Input
type="search"
placeholder="搜索用户..."
value={search()}
onInput={(e) => handleSearch(e.currentTarget.value)}
class="max-w-sm"
/>
<Button>
<PlusIcon class="mr-2 h-4 w-4" />
添加用户
</Button>
</div>
{/* 表格 */}
<Suspense fallback={<TableSkeleton />}>
<Show when={users()}>
{(data) => (
<>
<Table>
<Table.Header>
<Table.Row>
<Table.Head>姓名</Table.Head>
<Table.Head>邮箱</Table.Head>
<Table.Head>角色</Table.Head>
<Table.Head>状态</Table.Head>
<Table.Head class="text-right">操作</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
<For each={data().data}>
{(user) => (
<Table.Row>
<Table.Cell>{user.name}</Table.Cell>
<Table.Cell>{user.email}</Table.Cell>
<Table.Cell>
<Badge>{user.role}</Badge>
</Table.Cell>
<Table.Cell>
<StatusBadge status={user.status} />
</Table.Cell>
<Table.Cell class="text-right">
<UserActions user={user} />
</Table.Cell>
</Table.Row>
)}
</For>
</Table.Body>
</Table>
<Pagination
page={page()}
totalPages={data().pagination.totalPages}
onPageChange={handlePageChange}
/>
</>
)}
</Show>
</Suspense>
</div>
</AdminLayout>
);
}
// routes/(auth)/login.tsx
import { createSignal, Show } from 'solid-js';
import { useNavigate, useSearchParams, A } from '@solidjs/router';
import { authStore } from '~/stores/auth';
import { AuthLayout } from '~/components/layout/AuthLayout';
import { Input, Button, Card } from '~/components/ui';
export default function LoginPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [email, setEmail] = createSignal('');
const [password, setPassword] = createSignal('');
const [errors, setErrors] = createSignal<Record<string, string>>({});
const validate = () => {
const newErrors: Record<string, string> = {};
if (!email()) {
newErrors.email = '请输入邮箱';
} else if (!email().includes('@')) {
newErrors.email = '请输入有效的邮箱地址';
}
if (!password()) {
newErrors.password = '请输入密码';
} else if (password().length < 6) {
newErrors.password = '密码至少 6 位';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: Event) => {
e.preventDefault();
if (!validate()) return;
try {
await authStore.login({
email: email(),
password: password(),
});
// 跳转到原页面或仪表盘
const redirect = searchParams.redirect || '/dashboard';
navigate(redirect);
} catch (e) {
setErrors({ form: e instanceof Error ? e.message : '登录失败' });
}
};
// 填充演示账号
const fillDemo = () => {
const demoEmail = import.meta.env.VITE_DEMO_EMAIL;
const demoPassword = import.meta.env.VITE_DEMO_PASSWORD;
if (demoEmail) setEmail(demoEmail);
if (demoPassword) setPassword(demoPassword);
};
return (
<AuthLayout title="登录">
<Card class="w-full max-w-md">
<Card.Header class="text-center">
<Card.Title class="text-2xl">欢迎回来</Card.Title>
<Card.Description>登录到您的账户</Card.Description>
</Card.Header>
<Card.Content>
{/* 错误提示 */}
<Show when={errors().form}>
<div class="mb-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{errors().form}
</div>
</Show>
{/* 演示提示 */}
<Show when={import.meta.env.VITE_SHOW_DEMO_HINT === 'true'}>
<div class="mb-4 rounded-md bg-muted p-3 text-sm">
<p>演示账号:</p>
<p class="font-mono text-xs">
邮箱:{import.meta.env.VITE_DEMO_EMAIL}
</p>
<p class="font-mono text-xs">
密码:{import.meta.env.VITE_DEMO_PASSWORD}
</p>
<Button variant="link" size="sm" onClick={fillDemo} class="mt-1 h-auto p-0">
点击填充
</Button>
</div>
</Show>
<form onSubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<label for="email" class="text-sm font-medium">
邮箱
</label>
<Input
id="email"
type="email"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
placeholder="[email protected]"
autocomplete="email"
/>
<Show when={errors().email}>
<p class="text-sm text-destructive">{errors().email}</p>
</Show>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label for="password" class="text-sm font-medium">
密码
</label>
<A href="/forgot-password" class="text-sm text-primary hover:underline">
忘记密码?
</A>
</div>
<Input
id="password"
type="password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
placeholder="••••••••"
autocomplete="current-password"
/>
<Show when={errors().password}>
<p class="text-sm text-destructive">{errors().password}</p>
</Show>
</div>
<Button type="submit" class="w-full" disabled={authStore.loading}>
{authStore.loading ? '登录中...' : '登录'}
</Button>
</form>
</Card.Content>
<Card.Footer class="justify-center">
<p class="text-sm text-muted-foreground">
还没有账户?{' '}
<A href="/register" class="text-primary hover:underline">
立即注册
</A>
</p>
</Card.Footer>
</Card>
</AuthLayout>
);
}
// components/shared/ErrorBoundary.tsx
import { ErrorBoundary as SolidErrorBoundary, type ParentComponent } from 'solid-js';
import { A, useNavigate } from '@solidjs/router';
import { Button, Card } from '~/components/ui';
interface Props {
fallback?: (error: Error, reset: () => void) => JSX.Element;
}
export const ErrorBoundary: ParentComponent<Props> = (props) => {
return (
<SolidErrorBoundary
fallback={(error, reset) => {
if (props.fallback) {
return props.fallback(error, reset);
}
return <DefaultErrorFallback error={error} reset={reset} />;
}}
>
{props.children}
</SolidErrorBoundary>
);
};
function DefaultErrorFallback(props: { error: Error; reset: () => void }) {
const navigate = useNavigate();
return (
<div class="flex min-h-[400px] items-center justify-center p-4">
<Card class="w-full max-w-md">
<Card.Header class="text-center">
<Card.Title class="text-destructive">发生错误</Card.Title>
<Card.Description>{props.error.message}</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex justify-center gap-2">
<Button variant="outline" onClick={props.reset}>
重试
</Button>
<Button onClick={() => navigate('/')}>
返回首页
</Button>
</div>
</Card.Content>
</Card>
</div>
);
}
// routes/[...404].tsx - 404 页面
import { A } from '@solidjs/router';
import { Button } from '~/components/ui';
export default function NotFoundPage() {
return (
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<h1 class="text-9xl font-bold text-muted-foreground">404</h1>
<p class="mt-4 text-2xl text-foreground">页面未找到</p>
<p class="mt-2 text-muted-foreground">
您访问的页面不存在或已被移除
</p>
<Button as={A} href="/" class="mt-8">
返回首页
</Button>
</div>
</div>
);
}
// lib/meta.ts
interface PageMeta {
title: string;
description: string;
keywords?: string[];
}
export const pageMetas: Record<string, PageMeta> = {
'/': {
title: '仪表盘',
description: 'Admin Pro 管理系统仪表盘,数据概览与统计分析',
keywords: ['仪表盘', '数据分析', '管理系统'],
},
'/users': {
title: '用户管理',
description: '管理系统用户账户,包括创建、编辑和权限配置',
keywords: ['用户管理', '账户管理', '权限配置'],
},
'/analytics': {
title: '数据分析',
description: '业务数据统计分析,可视化图表展示',
keywords: ['数据分析', '图表', '统计'],
},
'/settings': {
title: '系统设置',
description: '系统配置与个性化设置',
keywords: ['系统设置', '配置', '个性化'],
},
};
export function generateMeta(path: string, overrides?: Partial<PageMeta>) {
const meta = { ...pageMetas[path], ...overrides } || {
title: '页面',
description: 'Admin Pro 管理系统',
};
const brandName = import.meta.env.VITE_BRAND_NAME || 'Halolight';
const fullTitle = `${meta.title} - ${brandName}`;
return {
title: fullTitle,
description: meta.description,
keywords: meta.keywords?.join(', ') || '',
};
}
// 在页面中使用
import { Title, Meta } from '@solidjs/meta';
import { generateMeta } from '~/lib/meta';
export default function UsersPage() {
const meta = generateMeta('/users');
return (
<>
<Title>{meta.title}</Title>
<Meta name="description" content={meta.description} />
<Meta name="keywords" content={meta.keywords} />
{/* 页面内容 */}
</>
);
}
支持 11 种预设皮肤,通过 Quick Settings 面板切换:
| 皮肤 | 主色调 | CSS 变量 |
|---|---|---|
| Default | 紫色 | --primary: 51.1% 0.262 276.97 |
| Blue | 蓝色 | --primary: 54.8% 0.243 264.05 |
| Emerald | 翠绿色 | --primary: 64.6% 0.178 142.49 |
| Amber | 琥珀色 | --primary: 76.9% 0.188 84.94 |
| Violet | 紫罗兰 | --primary: 54.1% 0.243 293.54 |
| Rose | 玫瑰色 | --primary: 64.5% 0.246 16.44 |
| Teal | 青色 | --primary: 60.0% 0.118 184.71 |
| Slate | 石板灰 | --primary: 45.9% 0.022 264.53 |
| Ocean | 海洋蓝 | --primary: 54.3% 0.195 240.03 |
| Sunset | 日落橙 | --primary: 70.5% 0.213 47.60 |
| Aurora | 极光色 | --primary: 62.8% 0.265 303.9 |
/* src/styles/globals.css */
@import "tailwindcss";
:root {
/* 背景色 */
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
/* 卡片 */
--card: 100% 0 0;
--card-foreground: 14.9% 0.017 285.75;
/* 弹出层 */
--popover: 100% 0 0;
--popover-foreground: 14.9% 0.017 285.75;
/* 主色 */
--primary: 51.1% 0.262 276.97;
--primary-foreground: 100% 0 0;
/* 次要色 */
--secondary: 96.7% 0.001 286.38;
--secondary-foreground: 21% 0.006 285.75;
/* 静音色 */
--muted: 96.7% 0.001 286.38;
--muted-foreground: 55.2% 0.014 285.94;
/* 强调色 */
--accent: 96.7% 0.001 286.38;
--accent-foreground: 21% 0.006 285.75;
/* 危险色 */
--destructive: 57.7% 0.245 27.32;
--destructive-foreground: 100% 0 0;
/* 边框/输入框 */
--border: 91.2% 0.004 286.32;
--input: 91.2% 0.004 286.32;
--ring: 51.1% 0.262 276.97;
/* 圆角 */
--radius: 0.5rem;
}
/* 皮肤预设 */
[data-skin="blue"] {
--primary: 54.8% 0.243 264.05;
--ring: 54.8% 0.243 264.05;
}
[data-skin="ocean"] {
--primary: 54.3% 0.195 240.03;
--ring: 54.3% 0.195 240.03;
}
[data-skin="emerald"] {
--primary: 64.6% 0.178 142.49;
--ring: 64.6% 0.178 142.49;
}
/* 深色模式 */
.dark {
--background: 14.9% 0.017 285.75;
--foreground: 98.5% 0 0;
--card: 14.9% 0.017 285.75;
--card-foreground: 98.5% 0 0;
--popover: 14.9% 0.017 285.75;
--popover-foreground: 98.5% 0 0;
--secondary: 26.8% 0.019 286.07;
--secondary-foreground: 98.5% 0 0;
--muted: 26.8% 0.019 286.07;
--muted-foreground: 71.2% 0.013 286.07;
--accent: 26.8% 0.019 286.07;
--accent-foreground: 98.5% 0 0;
--border: 26.8% 0.019 286.07;
--input: 26.8% 0.019 286.07;
}
/* Tailwind 主题映射 */
@theme {
--color-background: oklch(var(--background));
--color-foreground: oklch(var(--foreground));
--color-primary: oklch(var(--primary));
--color-primary-foreground: oklch(var(--primary-foreground));
/* ... */
}
| 路径 | 页面 | 布局 | 权限 |
|---|---|---|---|
/ |
首页 | - | 公开 |
/login |
登录 | AuthLayout | 公开 |
/register |
注册 | AuthLayout | 公开 |
/forgot-password |
忘记密码 | AuthLayout | 公开 |
/reset-password |
重置密码 | AuthLayout | 公开 |
/dashboard |
仪表盘 | AdminLayout | dashboard:view |
/analytics |
数据分析 | AdminLayout | analytics:view |
/users |
用户列表 | AdminLayout | users:list |
/users/create |
创建用户 | AdminLayout | users:create |
/users/[id] |
用户详情 | AdminLayout | users:view |
/roles |
角色管理 | AdminLayout | roles:list |
/permissions |
权限管理 | AdminLayout | permissions:list |
/messages |
消息中心 | AdminLayout | messages:view |
/notifications |
通知列表 | AdminLayout | 登录即可 |
/documents |
文档管理 | AdminLayout | documents:list |
/calendar |
日历 | AdminLayout | calendar:view |
/settings |
系统设置 | AdminLayout | settings:view |
/profile |
个人中心 | AdminLayout | 登录即可 |
/privacy |
隐私政策 | - | 公开 |
/terms |
服务条款 | - | 公开 |
| 变量名 | 说明 | 默认值 |
|---|---|---|
VITE_API_URL |
API 基础 URL | /api |
VITE_USE_MOCK |
启用 Mock 数据 | false |
VITE_DEMO_EMAIL |
演示账号邮箱 | - |
VITE_DEMO_PASSWORD |
演示账号密码 | - |
VITE_SHOW_DEMO_HINT |
显示演示提示 | false |
VITE_APP_TITLE |
应用标题 | Admin Pro |
VITE_BRAND_NAME |
品牌名称 | Halolight |
SESSION_SECRET |
会话密钥 (服务端) | (必需) |
# 开发
pnpm dev # 启动开发服务器
pnpm dev --host # 允许局域网访问
# 构建
pnpm build # 生产构建
pnpm start # 启动生产服务器
# 代码质量
pnpm typecheck # TypeScript 类型检查
pnpm lint # ESLint 检查
pnpm lint:fix # ESLint 自动修复
pnpm format # Prettier 格式化
# 测试
pnpm test # 监视模式
pnpm test:run # 单次运行
pnpm test:coverage # 覆盖率报告
pnpm test:ui # Vitest UI 界面
# 其他
pnpm clean # 清理构建产物
pnpm deps # 检查依赖更新
pnpm test:run # 单次运行
pnpm test # 监视模式
pnpm test:coverage # 覆盖率报告
// tests/stores/auth.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { authStore } from '~/stores/auth';
describe('authStore', () => {
beforeEach(() => {
authStore.logout();
});
it('初始状态应该是未登录', () => {
expect(authStore.isAuthenticated()).toBe(false);
expect(authStore.user).toBeNull();
expect(authStore.token).toBeNull();
});
it('登录成功后应该更新状态', async () => {
// Mock fetch
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
user: { id: 1, name: '管理员', permissions: ['*'] },
token: 'mock_token',
}),
});
await authStore.login({
email: '[email protected]',
password: '123456',
});
expect(authStore.isAuthenticated()).toBe(true);
expect(authStore.user?.name).toBe('管理员');
expect(authStore.token).toBe('mock_token');
});
it('权限检查应该正确工作', async () => {
// 设置用户权限
authStore.login({
email: '[email protected]',
password: '123456',
});
// 模拟登录成功后
expect(authStore.hasPermission('*')).toBe(true);
expect(authStore.hasPermission('users:list')).toBe(true);
expect(authStore.hasPermission('unknown:action')).toBe(true); // * 权限
});
it('登出后应该清除状态', () => {
authStore.logout();
expect(authStore.isAuthenticated()).toBe(false);
expect(authStore.user).toBeNull();
expect(authStore.token).toBeNull();
});
});
// tests/stores/tabs.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { tabsStore } from '~/stores/tabs';
describe('tabsStore', () => {
beforeEach(() => {
tabsStore.clearTabs();
});
it('初始状态应该只有首页标签', () => {
expect(tabsStore.tabs.length).toBe(1);
expect(tabsStore.tabs[0].id).toBe('home');
expect(tabsStore.activeTabId).toBe('home');
});
it('应该添加新标签', () => {
const id = tabsStore.addTab({ title: '用户管理', path: '/users' });
expect(tabsStore.tabs.length).toBe(2);
expect(tabsStore.tabs[1].title).toBe('用户管理');
expect(tabsStore.activeTabId).toBe(id);
});
it('应该去重已存在的路由', () => {
const id1 = tabsStore.addTab({ title: '用户管理', path: '/users' });
const id2 = tabsStore.addTab({ title: '用户管理', path: '/users' });
expect(id1).toBe(id2);
expect(tabsStore.tabs.length).toBe(2);
});
it('应该关闭标签并切换到相邻标签', () => {
tabsStore.addTab({ title: '用户管理', path: '/users' });
const id = tabsStore.addTab({ title: '设置', path: '/settings' });
tabsStore.removeTab(id);
expect(tabsStore.tabs.length).toBe(2);
expect(tabsStore.activeTabId).not.toBe(id);
});
it('首页标签不可关闭', () => {
tabsStore.removeTab('home');
expect(tabsStore.tabs.length).toBe(1);
expect(tabsStore.tabs[0].id).toBe('home');
});
});
// app.config.ts
import { defineConfig } from '@solidjs/start/config';
export default defineConfig({
server: {
preset: 'node-server', // 默认 Node.js 服务器
},
vite: {
plugins: [],
css: {
postcss: './postcss.config.js',
},
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['solid-js', '@solidjs/router'],
ui: ['@kobalte/core'],
},
},
},
},
},
middleware: './src/middleware.ts',
});
// 开发环境
export default defineConfig({
server: { preset: 'node-server' },
});
// Vercel
export default defineConfig({
server: { preset: 'vercel' },
});
// Cloudflare Pages
export default defineConfig({
server: { preset: 'cloudflare-pages' },
});
// Netlify
export default defineConfig({
server: { preset: 'netlify' },
});
// AWS Lambda
export default defineConfig({
server: { preset: 'aws-lambda' },
});
// Bun
export default defineConfig({
server: { preset: 'bun' },
});
pnpm build
node .output/server/index.mjs
FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.output ./.output
COPY --from=builder /app/package.json ./
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- SESSION_SECRET=${SESSION_SECRET}
- VITE_API_URL=${VITE_API_URL}
restart: unless-stopped
// app.config.ts
import { defineConfig } from '@solidjs/start/config';
export default defineConfig({
server: {
preset: 'vercel',
},
});
# 部署
npx vercel
// app.config.ts
import { defineConfig } from '@solidjs/start/config';
export default defineConfig({
server: {
preset: 'cloudflare-pages',
},
});
# 安装 Wrangler
npm install -g wrangler
# 登录
wrangler login
# 部署
wrangler pages deploy .output/public
// app.config.ts
import { defineConfig } from '@solidjs/start/config';
export default defineConfig({
server: {
preset: 'netlify',
},
});
# netlify.toml
[build]
command = "pnpm build"
publish = ".output/public"
functions = ".output/server"
[functions]
node_bundler = "esbuild"
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
项目配置了完整的 GitHub Actions CI 流程:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
import { createResource, Suspense, Show } from 'solid-js';
// 定义数据获取函数
const fetchUser = async (id: string) => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('用户不存在');
return response.json();
};
function UserProfile(props: { userId: string }) {
// createResource 自动管理加载/错误状态
const [user, { refetch, mutate }] = createResource(
() => props.userId,
fetchUser
);
return (
<Suspense fallback={<div>加载中...</div>}>
<Show when={user()} fallback={<div>用户不存在</div>}>
{(userData) => (
<div>
<h1>{userData().name}</h1>
<p>{userData().email}</p>
<button onClick={refetch}>刷新</button>
</div>
)}
</Show>
</Suspense>
);
}
// routes/dashboard.tsx
import { Suspense } from 'solid-js';
import { createAsync, cache } from '@solidjs/router';
// 快速数据
const getQuickStats = cache(async () => {
'use server';
return await db.stats.getQuick();
}, 'quick-stats');
// 慢速数据
const getDetailedAnalytics = cache(async () => {
'use server';
return await db.analytics.getDetailed(); // 耗时操作
}, 'detailed-analytics');
export default function Dashboard() {
const quickStats = createAsync(() => getQuickStats());
const analytics = createAsync(() => getDetailedAnalytics());
return (
<div class="space-y-6">
{/* 快速渲染的内容 */}
<Show when={quickStats()}>
{(stats) => <QuickStats data={stats()} />}
</Show>
{/* 流式渲染的慢内容 */}
<Suspense fallback={<AnalyticsSkeleton />}>
<Show when={analytics()}>
{(data) => <DetailedAnalytics data={data()} />}
</Show>
</Suspense>
</div>
);
}
import { createSignal, For } from 'solid-js';
import { createStore, produce } from 'solid-js/store';
function TodoList() {
const [todos, setTodos] = createStore<Todo[]>([]);
const [newTodo, setNewTodo] = createSignal('');
const addTodo = async () => {
const text = newTodo();
if (!text.trim()) return;
// 乐观更新 - 立即显示
const tempId = `temp-${Date.now()}`;
const optimisticTodo: Todo = {
id: tempId,
text,
completed: false,
pending: true,
};
setTodos(produce((draft) => draft.push(optimisticTodo)));
setNewTodo('');
try {
// 实际请求
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text }),
});
const realTodo = await response.json();
// 替换为真实数据
setTodos(
(todo) => todo.id === tempId,
{ id: realTodo.id, pending: false }
);
} catch {
// 回滚
setTodos((todos) => todos.filter((t) => t.id !== tempId));
}
};
return (
<div>
<input
value={newTodo()}
onInput={(e) => setNewTodo(e.currentTarget.value)}
onKeyPress={(e) => e.key === 'Enter' && addTodo()}
/>
<For each={todos}>
{(todo) => (
<div class={todo.pending ? 'opacity-50' : ''}>
{todo.text}
{todo.pending && <span>保存中...</span>}
</div>
)}
</For>
</div>
);
}
// context/theme.tsx
import { createContext, useContext, type ParentComponent } from 'solid-js';
import { createStore } from 'solid-js/store';
interface ThemeContextValue {
theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void;
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextValue>();
export const ThemeProvider: ParentComponent = (props) => {
const [state, setState] = createStore({ theme: 'light' as const });
const value: ThemeContextValue = {
get theme() {
return state.theme;
},
setTheme(theme) {
setState('theme', theme);
document.documentElement.classList.toggle('dark', theme === 'dark');
},
toggle() {
value.setTheme(state.theme === 'light' ? 'dark' : 'light');
},
};
return (
<ThemeContext.Provider value={value}>
{props.children}
</ThemeContext.Provider>
);
};
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
import { Portal, Show } from 'solid-js/web';
import { createSignal } from 'solid-js';
function Modal(props: { isOpen: boolean; onClose: () => void; children: JSX.Element }) {
return (
<Show when={props.isOpen}>
<Portal mount={document.body}>
<div class="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
class="absolute inset-0 bg-black/50"
onClick={props.onClose}
/>
{/* Content */}
<div class="relative z-10 rounded-lg bg-background p-6 shadow-lg">
{props.children}
</div>
</div>
</Portal>
</Show>
);
}
// 使用
function App() {
const [isOpen, setIsOpen] = createSignal(false);
return (
<>
<button onClick={() => setIsOpen(true)}>打开模态框</button>
<Modal isOpen={isOpen()} onClose={() => setIsOpen(false)}>
<h2>标题</h2>
<p>内容</p>
<button onClick={() => setIsOpen(false)}>关闭</button>
</Modal>
</>
);
}
Solid.js 的核心优势是细粒度更新,无需手动优化:
// 组件不会因为父组件更新而重新执行
function Parent() {
const [count, setCount] = createSignal(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>
Count: {count()}
</button>
{/* Child 组件只创建一次 */}
<Child />
</div>
);
}
function Child() {
console.log('Child rendered'); // 只执行一次
return <div>I'm a child</div>;
}
import { lazy, Suspense } from 'solid-js';
// 懒加载重型组件
const Chart = lazy(() => import('./components/Chart'));
const DataTable = lazy(() => import('./components/DataTable'));
function Dashboard() {
return (
<div>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<DataTable />
</Suspense>
</div>
);
}
import { For, Index } from 'solid-js';
// For - 适合对象数组,按引用追踪
<For each={users()}>
{(user, index) => (
<div>{index()}: {user.name}</div>
)}
</For>
// Index - 适合原始值数组,按索引追踪
<Index each={numbers()}>
{(num, index) => (
<div>{index}: {num()}</div>
)}
</Index>
// 路由预加载
export const route = {
load: ({ params }) => {
// 预加载数据
void getUser({ id: params.id });
void getUserPosts({ userId: params.id });
},
};
// 链接预加载
<A href="/users" preload>
用户管理
</A>
A:核心区别:
// React - 组件每次状态变化都重新执行
function ReactComponent() {
const [count, setCount] = useState(0);
console.log('render'); // 每次更新都打印
return <div>{count}</div>;
}
// Solid - 组件只执行一次,只有访问 signal 的地方更新
function SolidComponent() {
const [count, setCount] = createSignal(0);
console.log('setup'); // 只打印一次
return <div>{count()}</div>; // 只有这里更新
}
A:使用 createResource 或 createAsync:
// createResource - 更细粒度控制
const [data, { refetch, mutate }] = createResource(source, fetcher);
// createAsync - SolidStart 路由集成
const data = createAsync(() => getData());
A:三种方式:
// stores/counter.ts
export const [count, setCount] = createSignal(0);
const CounterContext = createContext();
const [state, setState] = makePersisted(createStore({}), { name: 'key' });
A:使用受控组件或 @modular-forms/solid:
// 受控组件
const [email, setEmail] = createSignal('');
<input value={email()} onInput={(e) => setEmail(e.currentTarget.value)} />
// 或使用表单库
import { createForm } from '@modular-forms/solid';
const [form, { Form, Field }] = createForm<LoginForm>();
A:使用中间件或路由 load 函数:
// middleware.ts
export default createMiddleware({
onRequest: [authMiddleware],
});
// 或在路由中
export const route = {
load: ({ location }) => {
if (!isAuthenticated()) {
throw redirect('/login');
}
},
};
| 功能 | Solid.js 版本 | Vue 版本 | Next.js 版本 | Remix 版本 |
|---|---|---|---|---|
| 状态管理 | Signals + Store | Pinia | Zustand | Zustand |
| 数据获取 | createAsync | TanStack Query | TanStack Query | Loader/Action |
| 表单验证 | 自定义 + Zod | VeeValidate + Zod | React Hook Form + Zod | 渐进增强 |
| 服务端 | SolidStart 内置 | 独立后端 / Nuxt | API Routes | 内置 |
| 组件库 | Kobalte | shadcn-vue | shadcn/ui | Radix UI |
| 路由 | 文件路由 | Vue Router | App Router | 文件路由 |
| 响应式 | 细粒度 Signals | Proxy-based | Hooks | Hooks |
| Bundle 大小 | ~7KB | ~33KB | ~85KB | ~70KB |
| 运行时性能 | 极高 | 高 | 中 | 中 |
HaloLight SvelteKit 版本基于 SvelteKit 2 构建,采用 Svelte 5 Runes + TypeScript,具备编译时优化和极致性能。
在线预览:https://halolight-svelte.h7ml.cn
GitHub:https://github.com/halolight/halolight-svelte
| 技术 | 版本 | 说明 |
|---|---|---|
| SvelteKit | 2.x | Svelte 全栈框架 |
| Svelte | 5.x | 编译时框架 (Runes) |
| TypeScript | 5.9 | 类型安全 |
| Tailwind CSS | 4.x | 原子化 CSS |
| shadcn-svelte | latest | UI 组件库 |
| Superforms | 2.x | 表单处理 |
| TanStack Query | 5.x | 服务端状态 |
| Mock.js | 1.x | 数据模拟 |
halolight-svelte/
├── src/
│ ├── routes/ # 文件路由
│ │ ├── (auth)/ # 认证页面
│ │ └── (dashboard)/ # 仪表盘页面
│ ├── lib/
│ │ ├── components/ # 组件
│ │ │ ├── ui/ # 基础 UI 组件
│ │ │ ├── layout/ # 布局组件
│ │ │ └── dashboard/ # 仪表盘组件
│ │ ├── stores/ # 状态管理 (Runes)
│ │ ├── utils/ # 工具库
│ │ ├── mock/ # Mock 数据
│ │ └── types/ # 类型定义
│ ├── hooks.server.ts # 服务端钩子
│ └── app.css # 全局样式
├── static/ # 静态资源
├── svelte.config.js # Svelte 配置
└── package.json
git clone https://github.com/halolight/halolight-svelte.git
cd halolight-svelte
pnpm install
cp .env.example .env
# .env
VITE_API_URL=/api
VITE_MOCK=true
[email protected]
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=false
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight
pnpm dev
pnpm build
pnpm preview
// lib/stores/auth.ts
import { browser } from '$app/environment';
interface User {
id: number;
name: string;
email: string;
permissions: string[];
}
class AuthStore {
user = $state<User | null>(null);
token = $state<string | null>(null);
isAuthenticated = $derived(!!this.token && !!this.user);
permissions = $derived(this.user?.permissions ?? []);
constructor() {
if (browser) {
const saved = localStorage.getItem('auth');
if (saved) {
const { user, token } = JSON.parse(saved);
this.user = user;
this.token = token;
}
}
}
async login(credentials: { email: string; password: string }) {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
headers: { 'Content-Type': 'application/json' },
});
const data = await response.json();
this.user = data.user;
this.token = data.token;
this.persist();
}
logout() {
this.user = null;
this.token = null;
localStorage.removeItem('auth');
}
hasPermission(permission: string): boolean {
return this.permissions.some(
(p) =>
p === '*' || p === permission || (p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
);
}
private persist() {
if (browser) {
localStorage.setItem(
'auth',
JSON.stringify({
user: this.user,
token: this.token,
})
);
}
}
}
export const authStore = new AuthStore();
// routes/(dashboard)/+layout.ts
import type { LayoutLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: LayoutLoad = async ({ parent, url }) => {
const { user } = await parent();
if (!user) {
throw redirect(302, `/auth/login?redirect=${url.pathname}`);
}
return { user };
};
<!-- routes/(dashboard)/dashboard/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<h1>欢迎, {data.user.name}!</h1>
<!-- lib/components/PermissionGuard.svelte -->
<script lang="ts">
import { authStore } from '$lib/stores/auth';
interface Props {
permission: string;
children: import('svelte').Snippet;
fallback?: import('svelte').Snippet;
}
let { permission, children, fallback }: Props = $props();
const hasPermission = $derived(authStore.hasPermission(permission));
</script>
{#if hasPermission}
{@render children()}
{:else if fallback}
{@render fallback()}
{/if}
<!-- 使用示例 -->
<PermissionGuard permission="users:delete">
{#snippet children()}
<Button variant="destructive">删除</Button>
{/snippet}
{#snippet fallback()}
<span class="text-muted-foreground">无权限</span>
{/snippet}
</PermissionGuard>
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
import GridLayout from '$lib/components/dashboard/GridLayout.svelte';
// 响应式 Set 管理小部件
let activeWidgets = new SvelteSet(['stats', 'chart', 'recent']);
const layout = $state([
{ i: 'stats', x: 0, y: 0, w: 4, h: 2 },
{ i: 'chart', x: 4, y: 0, w: 8, h: 4 },
{ i: 'recent', x: 0, y: 2, w: 4, h: 2 },
]);
function onLayoutChange(newLayout: typeof layout) {
layout.splice(0, layout.length, ...newLayout);
localStorage.setItem('dashboard-layout', JSON.stringify(newLayout));
}
</script>
<GridLayout {layout} on:change={onLayoutChange}>
{#each [...activeWidgets] as widget}
<div data-grid-item={widget}>
<Widget type={widget} />
</div>
{/each}
</GridLayout>
支持 11 种预设皮肤,通过快捷设置面板切换:
| 皮肤 | 主色调 | CSS 变量 |
|---|---|---|
| Default | 紫色 | --primary: 51.1% 0.262 276.97 |
| Blue | 蓝色 | --primary: 54.8% 0.243 264.05 |
| Emerald | 翠绿 | --primary: 64.6% 0.178 142.49 |
| Orange | 橙色 | --primary: 68.9% 0.181 40.84 |
| Rose | 玫瑰 | --primary: 60.7% 0.234 11.63 |
| Teal | 青色 | --primary: 62.8% 0.149 186.07 |
| Yellow | 黄色 | --primary: 82.3% 0.165 92.14 |
| Violet | 紫罗兰 | --primary: 58.9% 0.264 292.85 |
| Cyan | 青蓝 | --primary: 73.2% 0.152 196.85 |
| Pink | 粉红 | --primary: 70.5% 0.226 340.54 |
| Indigo | 靛蓝 | --primary: 52.4% 0.218 270.32 |
/* app.css */
@layer base {
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 98% 0.007 285.89;
--secondary: 96.1% 0.006 286.32;
--secondary-foreground: 14.9% 0.017 285.75;
--muted: 96.1% 0.006 286.32;
--muted-foreground: 45.5% 0.026 285.82;
--accent: 96.1% 0.006 286.32;
--accent-foreground: 14.9% 0.017 285.75;
--destructive: 61.1% 0.246 29.23;
--destructive-foreground: 98% 0.007 285.89;
--border: 92.1% 0.011 286.32;
--input: 92.1% 0.011 286.32;
--ring: 51.1% 0.262 276.97;
--radius: 0.5rem;
}
.dark {
--background: 22.4% 0.015 285.88;
--foreground: 98% 0.007 285.89;
--primary: 61.1% 0.262 276.97;
--primary-foreground: 98% 0.007 285.89;
/* ... */
}
}
<script lang="ts">
function toggleTheme() {
if (!document.startViewTransition) {
document.documentElement.classList.toggle('dark');
return;
}
document.startViewTransition(() => {
document.documentElement.classList.toggle('dark');
});
}
</script>
<button onclick={toggleTheme}>切换主题</button>
<style>
:global(::view-transition-old(root)),
:global(::view-transition-new(root)) {
animation-duration: 0.3s;
}
</style>
| 路径 | 页面 | 权限 |
|---|---|---|
/ |
首页(重定向) | 公开 |
/auth/login |
登录 | 公开 |
/auth/register |
注册 | 公开 |
/auth/forgot-password |
忘记密码 | 公开 |
/auth/reset-password |
重置密码 | 公开 |
/dashboard |
仪表盘 | dashboard:view |
/dashboard/users |
用户管理 | users:view |
/dashboard/analytics |
数据分析 | analytics:view |
/dashboard/calendar |
日程管理 | calendar:view |
/dashboard/documents |
文档管理 | documents:view |
/dashboard/files |
文件存储 | files:view |
/dashboard/messages |
消息中心 | messages:view |
/dashboard/notifications |
通知中心 | notifications:view |
/dashboard/settings |
系统设置 | settings:view |
/dashboard/profile |
个人资料 | settings:view |
pnpm dev # 启动开发服务器
pnpm build # 生产构建
pnpm preview # 预览生产构建
pnpm lint # 代码检查
pnpm lint:fix # 自动修复
pnpm format # 格式化代码
pnpm check # 类型检查 (svelte-check)
pnpm test # 运行测试
pnpm test:coverage # 测试覆盖率
pnpm ci # 完整 CI 检查
项目默认配置 Cloudflare Pages 适配器:
// svelte.config.js
import adapter from '@sveltejs/adapter-cloudflare';
export default {
kit: {
adapter: adapter(),
},
};
pnpm build
# Cloudflare Pages 会自动部署 main 分支
docker build -t halolight-svelte .
docker run -p 3000:3000 halolight-svelte
| 角色 | 邮箱 | 密码 |
|---|---|---|
| 管理员 | [email protected] | 123456 |
| 普通用户 | [email protected] | 123456 |
pnpm test # 运行测试(watch 模式)
pnpm test:run # 单次运行
pnpm test:coverage # 覆盖率报告
pnpm test:ui # Vitest UI 界面
// tests/auth.test.ts
import { describe, it, expect } from 'vitest';
import { authStore } from '$lib/stores/auth';
describe('AuthStore', () => {
it('should initialize with null user', () => {
expect(authStore.user).toBeNull();
expect(authStore.isAuthenticated).toBe(false);
});
it('should authenticate user', async () => {
await authStore.login({
email: '[email protected]',
password: '123456',
});
expect(authStore.isAuthenticated).toBe(true);
expect(authStore.user?.email).toBe('[email protected]');
});
it('should check permissions', () => {
expect(authStore.hasPermission('users:view')).toBe(true);
expect(authStore.hasPermission('invalid')).toBe(false);
});
});
// svelte.config.js
import adapter from '@sveltejs/adapter-cloudflare';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
alias: {
$components: 'src/lib/components',
$stores: 'src/lib/stores',
$utils: 'src/lib/utils',
},
},
};
// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
environment: 'jsdom',
},
});
项目配置了完整的 GitHub Actions CI 工作流:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm format:check
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm check
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
<script lang="ts">
import { SvelteSet, SvelteMap } from 'svelte/reactivity';
// 响应式 Set
let selectedIds = new SvelteSet<string>();
function toggleSelection(id: string) {
if (selectedIds.has(id)) {
selectedIds.delete(id);
} else {
selectedIds.add(id);
}
}
// 响应式 Map
let itemStatus = new SvelteMap<string, 'pending' | 'done'>();
function markDone(id: string) {
itemStatus.set(id, 'done');
}
</script>
<p>已选择: {selectedIds.size}</p>
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get('token');
if (token) {
// 验证 token 并设置用户信息
event.locals.user = await validateToken(token);
}
// 路由保护
if (event.url.pathname.startsWith('/dashboard')) {
if (!event.locals.user) {
return new Response(null, {
status: 302,
headers: { Location: '/auth/login' },
});
}
}
return resolve(event);
};
<script lang="ts">
const HeavyComponent = $lazy(() => import('$lib/components/Heavy.svelte'));
</script>
{#await HeavyComponent}
<div>加载中...</div>
{:then component}
<svelte:component this={component} />
{/await}
<script lang="ts">
import { preloadData } from '$app/navigation';
function handleMouseEnter() {
preloadData('/dashboard/analytics');
}
</script>
<a href="/dashboard/analytics" onmouseenter={handleMouseEnter}>
数据分析
</a>
<script lang="ts">
import { onMount } from 'svelte';
let visible = $state(false);
onMount(() => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
visible = true;
observer.disconnect();
}
});
observer.observe(element);
});
</script>
{#if visible}
<img src="/large-image.jpg" alt="优化图片" />
{:else}
<div class="placeholder" />
{/if}
A:SvelteKit 推荐使用内置的 Load 函数进行数据加载,但也可以结合 TanStack Query:
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
const query = createQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
});
</script>
{#if $query.isLoading}
<p>加载中...</p>
{:else if $query.error}
<p>错误: {$query.error.message}</p>
{:else if $query.data}
<ul>
{#each $query.data as user}
<li>{user.name}</li>
{/each}
</ul>
{/if}
A:推荐使用 Superforms + Zod:
// routes/users/create/+page.server.ts
import { superValidate } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(2),
email: z.string().email(),
});
export const actions = {
default: async ({ request }) => {
const form = await superValidate(request, zod(schema));
if (!form.valid) {
return fail(400, { form });
}
// 处理表单数据
return { form };
},
};
A:切换到 Vercel 适配器:
pnpm add -D @sveltejs/adapter-vercel
// svelte.config.js
import adapter from '@sveltejs/adapter-vercel';
export default {
kit: {
adapter: adapter(),
},
};
| 特性 | SvelteKit | Next.js | Vue |
|---|---|---|---|
| SSR/SSG | ✅ | ✅ | ✅ (Nuxt) |
| 状态管理 | Svelte 5 Runes | Zustand | Pinia |
| 路由 | 文件路由 | App Router | Vue Router |
| 构建工具 | Vite | Turbopack | Vite |
| 运行时 | 无虚拟 DOM | 虚拟 DOM | 虚拟 DOM |
| 表单 | Superforms | React Hook Form | VeeValidate |
| 组件库 | shadcn-svelte | shadcn/ui | shadcn-vue |
HaloLight UI 是基于 Stencil 的跨框架 Web Components 组件库,内置 Tailwind 主题与 OKLch 配色系统。
GitHub:https://github.com/halolight/halolight-ui
npm:@halolight/ui
| 技术 | 版本 | 说明 |
|---|---|---|
| Stencil | 4.22.x | Web Components 编译器 |
| TypeScript | 5.x | 类型系统 |
| Tailwind CSS | 4.0 | 工具类 CSS(开发时) |
| Jest | 29.x | 单元测试 |
| Puppeteer | 23.x | E2E 测试 |
| 组件 | 标签 | 说明 |
|---|---|---|
| Button | <hl-button> |
按钮组件,支持多种变体和尺寸 |
| Input | <hl-input> |
输入框,支持验证和错误状态 |
| Select | <hl-select> |
下拉选择器 |
| Modal | <hl-modal> |
弹窗对话框 |
| Card | <hl-card> |
卡片容器 |
| Table | <hl-table> |
数据表格,支持排序和选择 |
| Form | <hl-form> |
表单容器,支持验证 |
halolight-ui/
├── src/
│ ├── components/ # 组件源码
│ │ ├── hl-button/ # 按钮组件
│ │ │ ├── hl-button.tsx # 组件逻辑
│ │ │ ├── hl-button.css # 组件样式
│ │ │ ├── readme.md # 组件文档
│ │ │ └── test/ # 组件测试
│ │ ├── hl-input/ # 输入框
│ │ ├── hl-select/ # 选择器
│ │ ├── hl-modal/ # 弹窗
│ │ ├── hl-table/ # 表格
│ │ ├── hl-card/ # 卡片
│ │ └── hl-form/ # 表单
│ ├── global/ # 全局样式
│ │ └── global.css # OKLch 主题变量
│ ├── utils/ # 工具函数
│ ├── themes/ # 主题配置
│ ├── components.d.ts # 自动生成的类型定义
│ └── index.ts # 导出入口
├── dist/ # 构建产物
├── loader/ # 运行时加载器
├── www/ # 开发预览站点
├── stencil.config.ts # Stencil 配置
├── tailwind.config.js # Tailwind 配置
├── tsconfig.json
└── package.json
npm install @halolight/ui
# 或
pnpm add @halolight/ui
任意框架均需一次性调用:
import { defineCustomElements } from '@halolight/ui/loader';
defineCustomElements();
<!DOCTYPE html>
<html>
<head>
<script type="module">
import { defineCustomElements } from 'https://unpkg.com/@halolight/ui/loader/index.js';
defineCustomElements();
</script>
</head>
<body>
<hl-button variant="primary">Click Me</hl-button>
</body>
</html>
import { defineCustomElements } from '@halolight/ui/loader';
// 在应用入口调用一次
defineCustomElements();
function App() {
return (
<div>
<hl-button variant="primary">Click Me</hl-button>
</div>
);
}
TypeScript 类型支持:
// vite-env.d.ts
/// <reference types="@halolight/ui/dist/types/components" />
import { JSX as HaloLightJSX } from '@halolight/ui/dist/types/components';
declare global {
namespace JSX {
interface IntrinsicElements extends HaloLightJSX.IntrinsicElements {}
}
}
<template>
<hl-button variant="primary" @hl-click="handleClick">
Click Me
</hl-button>
</template>
<script setup lang="ts">
import { defineCustomElements } from '@halolight/ui/loader';
defineCustomElements();
const handleClick = () => {
console.log('Button clicked!');
};
</script>
// app.module.ts
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { defineCustomElements } from '@halolight/ui/loader';
defineCustomElements();
@NgModule({
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AppModule {}
<!-- app.component.html -->
<hl-button variant="primary" (hlClick)="handleClick()">
Click Me
</hl-button>
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
variant |
'primary' | 'secondary' | 'outline' | 'ghost' |
'primary' |
按钮变体 |
size |
'sm' | 'md' | 'lg' |
'md' |
按钮尺寸 |
disabled |
boolean |
false |
禁用状态 |
loading |
boolean |
false |
加载状态 |
事件:hlClick
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
type |
'text' | 'password' | 'email' | 'number' |
'text' |
输入类型 |
placeholder |
string |
'' |
占位文本 |
disabled |
boolean |
false |
禁用状态 |
error |
string |
'' |
错误信息 |
事件:hlChange、hlInput、hlFocus、hlBlur
在父级元素添加 dark 类启用暗色主题:
<div class="dark">
<hl-button variant="primary">Dark Mode Button</hl-button>
</div>
// 动态切换
document.body.classList.toggle('dark');
使用 OKLch 色彩空间定义主题变量:
:root {
/* 主色 - 使用 OKLch */
--hl-color-primary: oklch(0.65 0.15 250);
--hl-color-primary-hover: oklch(0.55 0.18 250);
--hl-color-primary-light: oklch(0.75 0.12 250);
/* 语义色 */
--hl-color-success: oklch(0.7 0.18 145);
--hl-color-danger: oklch(0.63 0.26 25);
--hl-color-warning: oklch(0.78 0.16 75);
--hl-color-info: oklch(0.73 0.15 195);
/* 中性色 */
--hl-bg-base: oklch(1 0 0);
--hl-text-primary: oklch(0.2 0 0);
--hl-border-color: oklch(0.9 0 0);
/* 圆角和间距 */
--hl-border-radius: 0.5rem;
--hl-spacing-md: 1rem;
}
/* 暗色模式 */
.dark {
--hl-color-primary: oklch(0.7 0.15 250);
--hl-bg-base: oklch(0.15 0 0);
--hl-text-primary: oklch(0.98 0 0);
}
OKLch 是基于人类视觉感知的色彩空间:
/* oklch(明度 色度 色相 / 透明度) */
oklch(0.65 0.15 250) /* 蓝色 */
oklch(0.7 0.18 145) /* 绿色 */
oklch(0.63 0.26 25 / 0.8) /* 半透明红色 */
# 安装依赖
npm install
# 启动开发服务器
npm start
# 生产构建
npm run build
# 运行测试
npm test
# 生成新组件
npm run generate
npm run generate
# 输入组件名称(不含 hl- 前缀)
import { Component, Prop, Event, EventEmitter, h, Host } from '@stencil/core';
@Component({
tag: 'hl-example',
styleUrl: 'hl-example.css',
shadow: true,
})
export class HlExample {
@Prop() size: 'sm' | 'md' | 'lg' = 'md';
@Event() hlChange: EventEmitter<string>;
render() {
return (
<Host>
<div class={`hl-example hl-example--${this.size}`}>
<slot></slot>
</div>
</Host>
);
}
}
hl-{component-name}.hl-button--primaryhl{EventName} (小驼峰)OKLch 色彩空间支持:
对于旧版浏览器,可使用 PostCSS 插件进行降级转换。
HaloLight Vercel 部署版本,针对 Vercel 平台优化的部署方案,提供最佳的 Next.js 部署体验。
在线预览:https://halolight-vercel.h7ml.cn
GitHub:https://github.com/halolight/halolight-vercel
点击按钮后:
# 安装 Vercel CLI
npm install -g vercel
# 登录 Vercel
vercel login
# 克隆项目
git clone https://github.com/halolight/halolight-vercel.git
cd halolight-vercel
# 安装依赖
pnpm install
# 本地开发
pnpm dev
# 部署到预览环境
vercel
# 部署到生产环境
vercel --prod
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "pnpm build",
"outputDirectory": ".next",
"framework": "nextjs",
"regions": ["hkg1", "sin1", "nrt1"],
"functions": {
"api/**/*.ts": {
"memory": 1024,
"maxDuration": 10
}
},
"crons": [
{
"path": "/api/cron/daily-report",
"schedule": "0 9 * * *"
}
],
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
},
{
"key": "Referrer-Policy",
"value": "strict-origin-when-cross-origin"
}
]
},
{
"source": "/_next/static/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
}
],
"rewrites": [
{
"source": "/api/proxy/:path*",
"destination": "https://api.example.com/:path*"
}
],
"redirects": [
{
"source": "/old-path",
"destination": "/new-path",
"permanent": true
}
]
}
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**.h7ml.cn",
},
{
protocol: "https",
hostname: "avatars.githubusercontent.com",
},
],
formats: ["image/avif", "image/webp"],
},
experimental: {
serverActions: {
bodySizeLimit: "2mb",
},
},
async headers() {
return [
{
source: "/:path*",
headers: [
{
key: "X-DNS-Prefetch-Control",
value: "on",
},
],
},
];
},
};
export default nextConfig;
在 Vercel 控制台 → Settings → Environment Variables 设置:
| 变量名 | 说明 | 示例 |
|---|---|---|
NEXT_PUBLIC_API_URL |
API 基础 URL | /api |
NEXT_PUBLIC_MOCK |
启用 Mock 数据 | false |
NEXT_PUBLIC_APP_TITLE |
应用标题 | Admin Pro |
DATABASE_URL |
数据库连接字符串 | postgresql://... |
JWT_SECRET |
JWT 密钥 | your-secret-key |
VERCEL_URL |
部署 URL (自动) | your-app.vercel.app |
KV_REST_API_URL |
Vercel KV URL | https://xxx.kv.vercel-storage.com |
KV_REST_API_TOKEN |
Vercel KV Token | xxx |
Production - 生产环境 (main 分支)
Preview - 预览环境 (其他分支/PR)
Development - 本地开发 (vercel dev)
# 查看环境变量
vercel env ls
# 添加环境变量
vercel env add VARIABLE_NAME
# 删除环境变量
vercel env rm VARIABLE_NAME
# 拉取到本地 .env.local
vercel env pull
// app/api/edge/route.ts
import { NextRequest, NextResponse } from "next/server";
export const runtime = "edge";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const name = searchParams.get("name") || "World";
return NextResponse.json({
message: `Hello, ${name}!`,
timestamp: new Date().toISOString(),
region: request.headers.get("x-vercel-id")?.split("::")[0],
});
}
// app/api/geo/route.ts
import { NextRequest, NextResponse } from "next/server";
import { geolocation } from "@vercel/functions";
export const runtime = "edge";
export async function GET(request: NextRequest) {
const geo = geolocation(request);
return NextResponse.json({
country: geo.country,
city: geo.city,
region: geo.countryRegion,
latitude: geo.latitude,
longitude: geo.longitude,
ip: request.ip,
});
}
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
};
export function middleware(request: NextRequest) {
// 获取地理位置
const country = request.geo?.country || "US";
// 基于位置的重定向
if (country === "CN" && !request.nextUrl.pathname.startsWith("/cn")) {
return NextResponse.redirect(new URL("/cn" + request.nextUrl.pathname, request.url));
}
// 添加自定义头
const response = NextResponse.next();
response.headers.set("x-country", country);
return response;
}
// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1");
const limit = parseInt(searchParams.get("limit") || "10");
// 获取用户列表
const users = await getUsers({ page, limit });
return NextResponse.json({
success: true,
data: users,
pagination: { page, limit },
});
}
export async function POST(request: NextRequest) {
const body = await request.json();
// 创建用户
const user = await createUser(body);
return NextResponse.json({
success: true,
data: user,
}, { status: 201 });
}
// app/api/stream/route.ts
import { NextRequest } from "next/server";
export const runtime = "edge";
export async function GET(request: NextRequest) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
const data = JSON.stringify({ count: i, timestamp: Date.now() });
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
await new Promise((resolve) => setTimeout(resolve, 1000));
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
// lib/kv.ts
import { kv } from "@vercel/kv";
// 设置值
export async function setUser(id: string, user: User) {
await kv.set(`user:${id}`, JSON.stringify(user));
await kv.expire(`user:${id}`, 3600); // 1 小时过期
}
// 获取值
export async function getUser(id: string): Promise<User | null> {
const data = await kv.get<string>(`user:${id}`);
return data ? JSON.parse(data) : null;
}
// 哈希操作
export async function setSession(sessionId: string, data: SessionData) {
await kv.hset(`session:${sessionId}`, data);
}
// 列表操作
export async function addNotification(userId: string, notification: string) {
await kv.lpush(`notifications:${userId}`, notification);
await kv.ltrim(`notifications:${userId}`, 0, 99); // 保留最近 100 条
}
// lib/blob.ts
import { put, del, list } from "@vercel/blob";
// 上传文件
export async function uploadFile(file: File, folder: string) {
const blob = await put(`${folder}/${file.name}`, file, {
access: "public",
contentType: file.type,
});
return blob.url;
}
// 删除文件
export async function deleteFile(url: string) {
await del(url);
}
// 列出文件
export async function listFiles(prefix: string) {
const { blobs } = await list({ prefix });
return blobs;
}
// lib/postgres.ts
import { sql } from "@vercel/postgres";
// 查询
export async function getUsers() {
const { rows } = await sql`SELECT * FROM users ORDER BY created_at DESC`;
return rows;
}
// 插入
export async function createUser(email: string, name: string) {
const { rows } = await sql`
INSERT INTO users (email, name)
VALUES (${email}, ${name})
RETURNING *
`;
return rows[0];
}
// 事务
export async function transferCredits(fromId: string, toId: string, amount: number) {
await sql`BEGIN`;
try {
await sql`UPDATE users SET credits = credits - ${amount} WHERE id = ${fromId}`;
await sql`UPDATE users SET credits = credits + ${amount} WHERE id = ${toId}`;
await sql`COMMIT`;
} catch (error) {
await sql`ROLLBACK`;
throw error;
}
}
// vercel.json
{
"crons": [
{
"path": "/api/cron/daily-report",
"schedule": "0 9 * * *"
},
{
"path": "/api/cron/cleanup",
"schedule": "0 0 * * 0"
}
]
}
// app/api/cron/daily-report/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
// 验证 Cron 密钥
const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// 执行定时任务
await generateDailyReport();
return NextResponse.json({ success: true });
}
# 登录
vercel login
# 部署预览
vercel
# 部署生产
vercel --prod
# 本地开发 (模拟 Vercel 环境)
vercel dev
# 查看项目
vercel ls
# 查看部署
vercel inspect <deployment-url>
# 查看日志
vercel logs <deployment-url>
# 回滚
vercel rollback
# 域名管理
vercel domains ls
vercel domains add example.com
# 环境变量
vercel env ls
vercel env pull
# 项目设置
vercel project ls
vercel link
vercel unlink
// app/layout.tsx
import { Analytics } from "@vercel/analytics/react";
import { SpeedInsights } from "@vercel/speed-insights/next";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
}
// lib/analytics.ts
import { track } from "@vercel/analytics";
// 追踪自定义事件
export function trackEvent(name: string, properties?: Record<string, string | number>) {
track(name, properties);
}
// 使用示例
trackEvent("button_click", { button_id: "submit", page: "/login" });
trackEvent("purchase", { amount: 99.99, currency: "USD" });
# CLI 方式
vercel domains add halolight-vercel.h7ml.cn
# 查看域名
vercel domains ls
# 删除域名
vercel domains rm halolight-vercel.h7ml.cn
# A 记录
类型: A
名称: halolight-vercel
值: 76.76.21.21
# CNAME 记录 (推荐)
类型: CNAME
名称: halolight-vercel
值: cname.vercel-dns.com
# 添加通配符域名
vercel domains add "*.halolight.h7ml.cn"
A:检查以下几点:
pnpm-lock.yaml 已提交A:使用以下方式:
# CLI 回滚
vercel rollback
# 或在控制台
# Deployments → 选择之前的部署 → Promote to Production
A:优化建议:
A:在页面中配置 revalidate:
// app/posts/[id]/page.tsx
export const revalidate = 60; // 60 秒后重新验证
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await getPost(params.id);
return <Post data={post} />;
}
A:使用以下方法:
vercel logs <url> 查看实时日志console.log 输出到日志| 计划 | 价格 | 特性 |
|---|---|---|
| Hobby | 免费 | 100GB 带宽,Serverless 100GB-Hrs |
| Pro | $20/成员/月 | 1TB 带宽,1000GB-Hrs,预览保护 |
| Enterprise | 联系销售 | 无限带宽,SLA,专属支持 |
| 资源 | Hobby 免费额度 | Pro 免费额度 | 超出价格 |
|---|---|---|---|
| 带宽 | 100GB | 1TB | $0.15/GB |
| Serverless | 100GB-Hrs | 1000GB-Hrs | $0.18/GB-Hr |
| Edge Functions | 500K 调用 | 1M 调用 | $0.65/M |
| Edge Middleware | 1M 调用 | 1M 调用 | $0.65/M |
| Image Optimization | 1000 次 | 5000 次 | $5/1000 次 |
| 特性 | Vercel | Netlify | Cloudflare |
|---|---|---|---|
| Next.js 支持 | ✅ 官方最佳 | ✅ | ⚠️ 有限 |
| Edge Functions | ✅ | ✅ | ✅ Workers |
| 预览部署 | ✅ | ✅ | ✅ |
| 内置存储 | ✅ KV/Blob/Postgres | ❌ | ✅ KV/R2/D1 |
| 免费带宽 | 100GB | 100GB | 无限 |
| 免费构建 | 6000 分钟 | 300 分钟 | 500 次 |
| ISR 支持 | ✅ 原生 | ⚠️ 有限 | ❌ |
HaloLight Vue 版本基于 Vue 3.5 + Vite 7 构建,采用 Composition API + TypeScript。
在线预览:https://halolight-vue.h7ml.cn/
GitHub:https://github.com/halolight/halolight-vue
| 技术 | 版本 | 说明 |
|---|---|---|
| Vue | 3.5.x | 渐进式框架 |
| Vite | 7.x (Rolldown) | 构建工具 |
| TypeScript | 5.x | 类型安全 |
| Vue Router | 4.x | 路由管理 |
| Pinia | 2.x | 状态管理 |
| TanStack Query | 5.x | 服务端状态 |
| VeeValidate | 4.x | 表单验证 |
| Zod | 3.x | 数据验证 |
| Tailwind CSS | 4.x | 原子化 CSS |
| shadcn-vue | latest | UI 组件库 |
| grid-layout-plus | 1.x | 拖拽布局 |
| ECharts | 5.x | 图表可视化 |
| Mock.js | 1.x | 数据模拟 |
halolight-vue/
├── src/
│ ├── views/ # 页面视图
│ │ ├── (auth)/ # 认证页面
│ │ └── (dashboard)/ # 仪表盘页面
│ ├── components/ # 组件
│ │ ├── ui/ # 基础 UI 组件
│ │ ├── layout/ # 布局组件
│ │ └── dashboard/ # 仪表盘组件
│ ├── composables/ # 组合式函数
│ ├── stores/ # Pinia 状态管理
│ ├── lib/ # 工具库
│ ├── mocks/ # Mock 数据
│ └── types/ # 类型定义
├── public/ # 静态资源
├── vite.config.ts
└── package.json
git clone https://github.com/halolight/halolight-vue.git
cd halolight-vue
pnpm install
cp .env.example .env.local
# .env.local
VITE_API_URL=/api
VITE_USE_MOCK=true
[email protected]
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=false
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight
pnpm dev
pnpm build
pnpm preview
| 角色 | 邮箱 | 密码 |
|---|---|---|
| 管理员 | [email protected] | 123456 |
| 普通用户 | [email protected] | 123456 |
// stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref<User | null>(null)
const token = ref<string | null>(null)
// Getters
const isAuthenticated = computed(() => !!token.value && !!user.value)
const permissions = computed(() => user.value?.permissions || [])
// Actions
async function login(credentials: LoginCredentials) {
const response = await authService.login(credentials)
user.value = response.user
token.value = response.token
}
function logout() {
user.value = null
token.value = null
}
function hasPermission(permission: string): boolean {
return permissions.value.some(p =>
p === '*' || p === permission ||
(p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
)
}
return {
user,
token,
isAuthenticated,
permissions,
login,
logout,
hasPermission,
}
}, {
persist: {
paths: ['token', 'user'],
},
})
// composables/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
import { userService } from '@/services/users'
export function useUsers(params?: Ref<UserQueryParams>) {
return useQuery({
queryKey: ['users', params],
queryFn: () => userService.getList(unref(params)),
})
}
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: userService.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}
// composables/usePermission.ts
import { useAuthStore } from '@/stores/auth'
export function usePermission() {
const authStore = useAuthStore()
function hasPermission(permission: string): boolean {
return authStore.hasPermission(permission)
}
function hasAnyPermission(permissions: string[]): boolean {
return permissions.some(p => hasPermission(p))
}
function hasAllPermissions(permissions: string[]): boolean {
return permissions.every(p => hasPermission(p))
}
return {
hasPermission,
hasAnyPermission,
hasAllPermissions,
}
}
// directives/permission.ts
import { useAuthStore } from '@/stores/auth'
export const vPermission = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const authStore = useAuthStore()
if (!authStore.hasPermission(binding.value)) {
el.parentNode?.removeChild(el)
}
},
}
// 注册指令
app.directive('permission', vPermission)
<!-- 使用权限指令 -->
<button v-permission="'users:delete'">删除</button>
<!-- 使用权限组件 -->
<PermissionGuard permission="users:delete">
<DeleteButton />
<template #fallback>
<span>无权限</span>
</template>
</PermissionGuard>
<!-- components/dashboard/DashboardGrid.vue -->
<script setup lang="ts">
import { GridLayout, GridItem } from 'grid-layout-plus'
import { useDashboardStore } from '@/stores/dashboard'
const dashboardStore = useDashboardStore()
const { layout, isEditing } = storeToRefs(dashboardStore)
</script>
<template>
<GridLayout
v-model:layout="layout"
:col-num="12"
:row-height="80"
:is-draggable="isEditing"
:is-resizable="isEditing"
:margin="[16, 16]"
>
<GridItem
v-for="item in layout"
:key="item.i"
:x="item.x"
:y="item.y"
:w="item.w"
:h="item.h"
>
<WidgetWrapper :widget="getWidget(item.i)" />
</GridItem>
</GridLayout>
</template>
支持 11 种预设皮肤,通过快捷设置面板切换:
| 皮肤 | 主色调 | CSS 变量 |
|---|---|---|
| Default | 紫色 | --primary: 51.1% 0.262 276.97 |
| Blue | 蓝色 | --primary: 54.8% 0.243 264.05 |
| Emerald | 翠绿 | --primary: 64.6% 0.178 142.49 |
| Orange | 橙色 | --primary: 69.7% 0.196 49.27 |
| Rose | 玫瑰 | --primary: 63.4% 0.243 357.61 |
| Amber | 琥珀 | --primary: 79.1% 0.177 77.54 |
| Cyan | 青色 | --primary: 74.4% 0.167 197.13 |
| Violet | 紫罗兰 | --primary: 57.2% 0.267 285.75 |
| Lime | 青柠 | --primary: 78.8% 0.184 127.38 |
| Pink | 粉色 | --primary: 70.9% 0.254 347.58 |
| Teal | 青蓝 | --primary: 67.8% 0.157 181.02 |
/* 示例变量定义 */
:root {
--background: 100% 0 0;
--foreground: 14.9% 0.017 285.75;
--primary: 51.1% 0.262 276.97;
--primary-foreground: 100% 0 0;
--secondary: 97.3% 0.006 285.75;
--secondary-foreground: 17.9% 0.018 285.75;
--muted: 97.3% 0.006 285.75;
--muted-foreground: 49.5% 0.023 285.75;
--accent: 97.3% 0.006 285.75;
--accent-foreground: 17.9% 0.018 285.75;
--destructive: 59.9% 0.24 29.23;
--destructive-foreground: 98.3% 0.002 285.75;
--border: 91.9% 0.010 285.75;
--input: 91.9% 0.010 285.75;
--ring: 51.1% 0.262 276.97;
--radius: 0.5rem;
}
// composables/useTheme.ts
import { ref, computed, watch } from 'vue'
export function useTheme() {
const theme = ref<'light' | 'dark' | 'system'>('system')
const skin = ref<SkinPreset>('default')
const actualTheme = computed(() => {
if (theme.value === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
return theme.value
})
async function toggleTheme(event?: MouseEvent) {
const newTheme = actualTheme.value === 'dark' ? 'light' : 'dark'
// View Transitions API
if (!document.startViewTransition) {
theme.value = newTheme
return
}
await document.startViewTransition(() => {
theme.value = newTheme
}).ready
// 圆形展开动画
if (event) {
const { clientX, clientY } = event
const radius = Math.hypot(
Math.max(clientX, window.innerWidth - clientX),
Math.max(clientY, window.innerHeight - clientY)
)
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${clientX}px ${clientY}px)`,
`circle(${radius}px at ${clientX}px ${clientY}px)`,
],
},
{
duration: 500,
easing: 'ease-in-out',
pseudoElement: '::view-transition-new(root)',
}
)
}
}
watch([theme, skin], () => {
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(actualTheme.value)
document.documentElement.setAttribute('data-skin', skin.value)
}, { immediate: true })
return { theme, skin, actualTheme, toggleTheme }
}
| 路径 | 页面 | 权限 |
|---|---|---|
/ |
重定向到 /dashboard |
- |
/login |
登录 | 公开 |
/register |
注册 | 公开 |
/forgot-password |
忘记密码 | 公开 |
/reset-password |
重置密码 | 公开 |
/dashboard |
仪表盘 | dashboard:view |
/users |
用户管理 | users:view |
/analytics |
数据分析 | analytics:view |
/calendar |
日程管理 | calendar:view |
/documents |
文档管理 | documents:view |
/files |
文件存储 | files:view |
/messages |
消息中心 | messages:view |
/notifications |
通知中心 | notifications:view |
/settings |
系统设置 | settings:view |
/profile |
个人资料 | settings:view |
# .env.local
VITE_API_URL=/api
VITE_USE_MOCK=true
[email protected]
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=false
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight
| 变量名 | 说明 | 默认值 |
|---|---|---|
VITE_API_URL |
API 基础路径 | /api |
VITE_USE_MOCK |
是否使用 Mock 数据 | true |
VITE_DEMO_EMAIL |
演示账号邮箱 | [email protected] |
VITE_DEMO_PASSWORD |
演示账号密码 | 123456 |
VITE_SHOW_DEMO_HINT |
是否显示演示提示 | false |
VITE_APP_TITLE |
应用标题 | Admin Pro |
VITE_BRAND_NAME |
品牌名称 | Halolight |
// 在代码中使用
const apiUrl = import.meta.env.VITE_API_URL
const useMock = import.meta.env.VITE_USE_MOCK === 'true'
const appTitle = import.meta.env.VITE_APP_TITLE
pnpm dev # 启动开发服务器
pnpm build # 生产构建
pnpm preview # 预览生产构建
pnpm lint # 代码检查
pnpm lint:fix # 自动修复
pnpm type-check # 类型检查
pnpm test # 运行测试
pnpm test:coverage # 测试覆盖率
pnpm test # 运行测试(watch 模式)
pnpm test:run # 单次运行
pnpm test:coverage # 覆盖率报告
pnpm test:ui # Vitest UI 界面
// tests/components/Button.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from '@/components/ui/Button.vue'
describe('Button', () => {
it('renders properly', () => {
const wrapper = mount(Button, {
props: { variant: 'default' },
slots: { default: 'Click me' }
})
expect(wrapper.text()).toContain('Click me')
})
it('emits click event', async () => {
const wrapper = mount(Button)
await wrapper.trigger('click')
expect(wrapper.emitted()).toHaveProperty('click')
})
})
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
open: true,
},
build: {
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'ui-vendor': ['@tanstack/vue-query'],
},
},
},
},
})
docker build -t halolight-vue .
docker run -p 3000:3000 halolight-vue
项目配置了完整的 GitHub Actions CI 工作流:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm type-check
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm audit --audit-level=high
<script setup lang="ts">
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, BarChart, PieChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { useTheme } from '@/composables/useTheme'
use([CanvasRenderer, LineChart, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent])
const { actualTheme } = useTheme()
const option = computed(() => ({
backgroundColor: 'transparent',
textStyle: {
color: actualTheme.value === 'dark' ? '#e5e5e5' : '#333',
},
xAxis: {
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
},
yAxis: {
type: 'value'
},
series: [{
data: [820, 932, 901, 934, 1290, 1330, 1320],
type: 'line'
}]
}))
</script>
<template>
<VChart :option="option" autoresize />
</template>
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [...routes]
})
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
// 需要认证的页面
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({ name: 'login', query: { redirect: to.fullPath } })
return
}
// 权限检查
if (to.meta.permission && !authStore.hasPermission(to.meta.permission)) {
next({ name: '403' })
return
}
next()
})
export default router
<script setup lang="ts">
const imageSrc = computed(() => {
const { width } = useWindowSize()
if (width.value < 768) return '/images/mobile.webp'
if (width.value < 1024) return '/images/tablet.webp'
return '/images/desktop.webp'
})
</script>
<template>
<img
:src="imageSrc"
loading="lazy"
decoding="async"
alt="响应式图片"
>
</template>
// router/routes.ts
const routes = [
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { requiresAuth: true }
},
{
path: '/users',
component: () => import('@/views/Users.vue'),
meta: { requiresAuth: true, permission: 'users:view' }
},
]
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
onMounted(() => {
// 预加载常用路由
router.resolve({ name: 'users' })
router.resolve({ name: 'settings' })
})
</script>
A:使用 useTheme composable:
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
const { theme, toggleTheme, skin } = useTheme()
// 切换明暗主题
function handleToggle(event: MouseEvent) {
toggleTheme(event)
}
// 切换皮肤
function changeSkin(newSkin: SkinPreset) {
skin.value = newSkin
}
</script>
<template>
<button @click="handleToggle">切换主题</button>
<select v-model="skin">
<option value="default">Default</option>
<option value="blue">Blue</option>
<option value="emerald">Emerald</option>
</select>
</template>
A:在认证响应中添加权限字符串:
// types/auth.ts
interface User {
id: string
name: string
email: string
permissions: string[] // ['users:*', 'posts:view', 'posts:create']
}
// 使用通配符
// 'users:*' - 用户模块所有权限
// '*' - 所有权限
// 'users:view' - 特定权限
A:通过 Dashboard Store 管理布局:
// stores/dashboard.ts
import { defineStore } from 'pinia'
export const useDashboardStore = defineStore('dashboard', () => {
const layout = ref([
{ i: 'widget-1', x: 0, y: 0, w: 6, h: 4 },
{ i: 'widget-2', x: 6, y: 0, w: 6, h: 4 },
])
function saveLayout(newLayout: Layout[]) {
layout.value = newLayout
// 保存到服务器
}
return { layout, saveLayout }
})
| 特性 | Vue | Next.js | Angular |
|---|---|---|---|
| SSR/SSG | ❌ (需要 Nuxt) | ✅ | ✅ (需要 Angular Universal) |
| 状态管理 | Pinia | Zustand | RxJS/Signals |
| 路由 | Vue Router | App Router | Angular Router |
| 构建工具 | Vite | Next.js | Angular CLI |
| 学习曲线 | 中 | 中 | 高 |
| 生态系统 | 丰富 | 丰富 | 企业级 |
HaloLight Web3 提供 EVM + Solana + IPFS 的统一接入,包含 Core/React/Vue 三个包。
GitHub:https://github.com/halolight/halolight-web3
npm:
@halolight/web3-core - 核心功能 (框架无关)@halolight/web3-react - React 组件和 Hooks@halolight/web3-vue - Vue 3 组件和 Composables| 技术 | 版本 | 说明 |
|---|---|---|
| wagmi | ^2.12.x | EVM 钱包与合约调用 |
| viem | ^2.21.x | EVM RPC & ABI 工具 |
| @solana/web3.js | ^1.95.x | Solana JavaScript SDK |
| @solana/wallet-adapter | latest | Solana 钱包适配器 |
| @web3-storage/w3up-client | ^16.0.x | IPFS/web3.storage 客户端 |
| siwe | ^2.3.x | Sign-In with Ethereum |
| TypeScript | ^5.7.x | 类型系统 |
| Turborepo | ^2.3.x | Monorepo 构建工具 |
halolight-web3/
├── packages/
│ ├── core/ # 核心包 (@halolight/web3-core)
│ │ ├── src/
│ │ │ ├── evm/ # EVM/Ethereum 功能
│ │ │ │ ├── wallet.ts # Wagmi 配置
│ │ │ │ ├── chains.ts # 链配置
│ │ │ │ ├── siwe.ts # Sign-In with Ethereum
│ │ │ │ └── contracts.ts # 智能合约交互
│ │ │ ├── solana/ # Solana 功能
│ │ │ │ ├── wallet.ts # 钱包适配器
│ │ │ │ └── auth.ts # 签名认证
│ │ │ ├── storage/ # 存储功能
│ │ │ │ └── ipfs.ts # IPFS 上传
│ │ │ ├── types/ # TypeScript 类型
│ │ │ └── utils/ # 工具函数
│ │ └── package.json
│ │
│ ├── react/ # React 包 (@halolight/web3-react)
│ │ ├── src/
│ │ │ ├── providers/
│ │ │ │ └── Web3Provider.tsx
│ │ │ ├── components/
│ │ │ │ ├── WalletButton.tsx
│ │ │ │ ├── TokenBalance.tsx
│ │ │ │ ├── NftGallery.tsx
│ │ │ │ └── ContractCall.tsx
│ │ │ └── hooks/
│ │ └── package.json
│ │
│ └── vue/ # Vue 包 (@halolight/web3-vue)
│ ├── src/
│ │ ├── composables/
│ │ │ └── useWallet.ts
│ │ └── components/
│ │ ├── WalletButton.vue
│ │ └── TokenBalance.vue
│ └── package.json
│
├── .env.example
├── turbo.json
├── pnpm-workspace.yaml
└── package.json
# 使用 pnpm (推荐)
pnpm add @halolight/web3-core @halolight/web3-react
# 或 Vue
pnpm add @halolight/web3-core @halolight/web3-vue
# 使用 npm
npm install @halolight/web3-core @halolight/web3-react
# 使用 yarn
yarn add @halolight/web3-core @halolight/web3-react
复制 .env.example 到 .env 并配置:
# EVM - RPC 节点
NEXT_PUBLIC_ALCHEMY_API_KEY=your_alchemy_key
NEXT_PUBLIC_INFURA_API_KEY=your_infura_key
# EVM - WalletConnect
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=your_project_id
# Solana
NEXT_PUBLIC_SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
# IPFS/web3.storage
NEXT_PUBLIC_WEB3_STORAGE_TOKEN=your_web3_storage_token
import { Web3Provider, WalletButton, TokenBalance } from '@halolight/web3-react';
function App() {
return (
<Web3Provider
evmNetwork="mainnet"
solanaCluster="mainnet-beta"
enableEvm={true}
enableSolana={true}
>
<div>
<WalletButton chain="evm" />
<WalletButton chain="solana" />
<TokenBalance
chain="evm"
tokenAddress="0x..." // USDC on Ethereum
showSymbol
/>
</div>
</Web3Provider>
);
}
<script setup lang="ts">
import { WalletButton, TokenBalance, useEvmWallet } from '@halolight/web3-vue';
import { WagmiPlugin } from '@wagmi/vue';
import { createWagmiConfig } from '@halolight/web3-core';
const config = createWagmiConfig('mainnet');
</script>
<template>
<div>
<WalletButton />
<TokenBalance
token-address="0x..."
:show-symbol="true"
/>
</div>
</template>
import {
createWagmiConfig,
getTokenBalance,
uploadToIpfs,
authenticateWithSiwe,
} from '@halolight/web3-core';
// EVM: 获取代币余额
const balance = await getTokenBalance(client, tokenAddress, walletAddress);
// IPFS: 上传文件
const result = await uploadToIpfs(file);
console.log(result.cid, result.gateway);
// SIWE: 用户认证
const auth = await authenticateWithSiwe({
domain: 'example.com',
address: walletAddress,
chainId: 1,
signMessage: async (msg) => wallet.signMessage(msg),
});
统一的 EVM + Solana Provider:
<Web3Provider
evmNetwork="mainnet" // 或 "testnet" | "development"
solanaCluster="mainnet-beta" // 或 "devnet" | "testnet"
enableEvm={true} // 启用 EVM 支持
enableSolana={true} // 启用 Solana 支持
>
{children}
</Web3Provider>
import { WalletButton, DefaultWalletButton } from '@halolight/web3-react';
// EVM 钱包
<WalletButton
chain="evm"
connectText="Connect Ethereum"
className="custom-class"
/>
// Solana 钱包
<WalletButton
chain="solana"
className="custom-class"
/>
// 带默认样式
<DefaultWalletButton chain="evm" />
import { TokenBalance } from '@halolight/web3-react';
<TokenBalance
chain="evm"
tokenAddress="0x..." // ERC-20 合约地址
showSymbol
decimals={4}
loadingComponent={<Spinner />}
errorComponent={(error) => <div>Error: {error}</div>}
/>
import { NftGallery } from '@halolight/web3-react';
<NftGallery
contractAddress="0x..." // ERC-721 合约
maxDisplay={50}
columns={3}
renderNft={(nft) => (
<div>
<img src={nft.image} alt={nft.name} />
<h3>{nft.name}</h3>
</div>
)}
/>
import { ContractCallButton, useContractCall } from '@halolight/web3-react';
// 使用组件
<ContractCallButton
contract={{
address: '0x...',
abi: MyABI,
functionName: 'mint',
args: [tokenId],
}}
type="write"
buttonText="Mint NFT"
onSuccess={(data) => console.log('Minted!', data)}
/>
// 使用 Hook
const { call, loading, error, txHash } = useContractCall(
{
address: '0x...',
abi: MyABI,
functionName: 'balanceOf',
args: [address],
},
'read'
);
import { useEvmWallet } from '@halolight/web3-vue';
const { address, isConnected, connect, disconnect } = useEvmWallet();
import { useTokenBalance } from '@halolight/web3-vue';
const { balance, loading, error, refresh } = useTokenBalance('0x...');
import { useNativeBalance } from '@halolight/web3-vue';
const { balance, formatted, loading } = useNativeBalance();
import {
createWagmiConfig,
formatAddress,
isValidAddress,
} from '@halolight/web3-core';
// 创建 wagmi 配置
const config = createWagmiConfig('mainnet');
// 格式化地址
const short = formatAddress('0x1234...5678'); // "0x1234...5678"
// 验证地址
const valid = isValidAddress('0x...'); // true/false
import {
readContract,
writeContract,
getTokenBalance,
transferToken,
ERC20_ABI,
} from '@halolight/web3-core';
// 读取合约
const name = await readContract(publicClient, {
address: '0x...',
abi: ERC20_ABI,
functionName: 'name',
});
// 写入合约
const hash = await writeContract(walletClient, publicClient, {
address: '0x...',
abi: ERC20_ABI,
functionName: 'transfer',
args: [toAddress, amount],
});
import {
createSiweMessage,
formatSiweMessage,
verifySiweMessage,
authenticateWithSiwe,
} from '@halolight/web3-core';
// 完整认证流程
const result = await authenticateWithSiwe({
domain: 'example.com',
address: walletAddress,
chainId: 1,
signMessage: async (message) => {
return await wallet.signMessage(message);
},
});
if (result.success) {
console.log('Authenticated:', result.address);
}
import {
createSolanaConnection,
getSolBalance,
transferSol,
} from '@halolight/web3-core';
// 创建连接
const connection = createSolanaConnection('mainnet-beta');
// 获取 SOL 余额
const balance = await getSolBalance(connection, walletAddress);
// 转账 SOL
const signature = await transferSol(
connection,
wallet,
toAddress,
1.0 // 1 SOL
);
import {
uploadToIpfs,
uploadJsonToIpfs,
fetchJsonFromIpfs,
ipfsToHttp,
} from '@halolight/web3-core';
// 上传文件
const result = await uploadToIpfs(file);
console.log(result.cid); // "QmXxx..."
console.log(result.gateway); // "https://w3s.link/ipfs/QmXxx..."
// 上传 JSON
const metadata = await uploadJsonToIpfs({
name: 'My NFT',
description: 'Cool NFT',
image: 'ipfs://QmYyy...',
});
// 获取 JSON
const data = await fetchJsonFromIpfs('QmXxx...');
// 转换 IPFS URL 到 HTTP
const url = ipfsToHttp('ipfs://QmXxx...'); // "https://w3s.link/ipfs/QmXxx..."
# 安装依赖
pnpm install
# 构建所有包
pnpm build
# 开发模式 (watch)
pnpm dev
# 运行测试
pnpm test
# 类型检查
pnpm type-check
# 代码检查
pnpm lint
# 清理构建产物
pnpm clean
# 在 packages/core 目录下
pnpm build
pnpm dev
pnpm test
# 或从根目录
pnpm --filter @halolight/web3-core build
pnpm --filter @halolight/web3-react dev