inertia-rails-cookbook
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseInertia Rails Cookbook
Inertia Rails 实用指南
Practical recipes for common patterns and integrations in Inertia Rails applications.
Inertia Rails应用中常见模式与集成的实用方案。
Working with the Official Starter Kits
使用官方入门套件
The official starter kits provide a complete foundation. Here's how to customize them for your needs.
官方入门套件提供了完整的基础框架。以下是根据需求自定义套件的方法。
Starter Kit Structure (React)
React版入门套件结构
app/
├── controllers/
│ ├── application_controller.rb # Shared data setup
│ ├── dashboard_controller.rb # Example authenticated page
│ ├── home_controller.rb # Public landing page
│ ├── sessions_controller.rb # Login/logout
│ ├── users_controller.rb # Registration
│ ├── identity/ # Password reset
│ └── settings/ # User settings
├── frontend/
│ ├── components/
│ │ ├── ui/ # shadcn/ui components
│ │ ├── nav-main.tsx # Main navigation
│ │ ├── app-sidebar.tsx # Sidebar component
│ │ └── user-menu-content.tsx # User dropdown
│ ├── hooks/
│ │ ├── use-flash.tsx # Flash message hook
│ │ └── use-appearance.tsx # Dark mode hook
│ ├── layouts/
│ │ ├── app-layout.tsx # Main app layout
│ │ ├── auth-layout.tsx # Auth pages layout
│ │ └── app/
│ │ ├── app-sidebar-layout.tsx
│ │ └── app-header-layout.tsx
│ ├── pages/
│ │ ├── dashboard/index.tsx # Dashboard page
│ │ ├── home/index.tsx # Landing page
│ │ ├── sessions/new.tsx # Login page
│ │ ├── users/new.tsx # Registration page
│ │ └── settings/ # Settings pages
│ └── types/
│ └── index.ts # Shared TypeScript typesapp/
├── controllers/
│ ├── application_controller.rb # 共享数据设置
│ ├── dashboard_controller.rb # 示例认证页面
│ ├── home_controller.rb # 公开落地页
│ ├── sessions_controller.rb # 登录/登出
│ ├── users_controller.rb # 注册功能
│ ├── identity/ # 密码重置
│ └── settings/ # 用户设置
├── frontend/
│ ├── components/
│ │ ├── ui/ # shadcn/ui组件
│ │ ├── nav-main.tsx # 主导航
│ │ ├── app-sidebar.tsx # 侧边栏组件
│ │ └── user-menu-content.tsx # 用户下拉菜单
│ ├── hooks/
│ │ ├── use-flash.tsx # 提示消息钩子
│ │ └── use-appearance.tsx # 暗黑模式钩子
│ ├── layouts/
│ │ ├── app-layout.tsx # 主应用布局
│ │ ├── auth-layout.tsx # 认证页面布局
│ │ └── app/
│ │ ├── app-sidebar-layout.tsx
│ │ └── app-header-layout.tsx
│ ├── pages/
│ │ ├── dashboard/index.tsx # 仪表盘页面
│ │ ├── home/index.tsx # 落地页
│ │ ├── sessions/new.tsx # 登录页面
│ │ ├── users/new.tsx # 注册页面
│ │ └── settings/ # 设置页面
│ └── types/
│ └── index.ts # 共享TypeScript类型Adding a New Resource
添加新资源
1. Generate the controller:
bash
bin/rails generate controller Products index show new create edit update destroy2. Create the page components:
tsx
// app/frontend/pages/products/index.tsx
import { Head, Link } from '@inertiajs/react'
import AppLayout from '@/layouts/app-layout'
import { Button } from '@/components/ui/button'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow
} from '@/components/ui/table'
interface Product {
id: number
name: string
price: number
}
interface Props {
products: Product[]
}
export default function ProductsIndex({ products }: Props) {
return (
<AppLayout>
<Head title="Products" />
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Products</h1>
<Button asChild>
<Link href="/products/new">Add Product</Link>
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Price</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.map((product) => (
<TableRow key={product.id}>
<TableCell>{product.name}</TableCell>
<TableCell>${product.price}</TableCell>
<TableCell>
<Link href={`/products/${product.id}/edit`}>Edit</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</AppLayout>
)
}3. Update navigation:
tsx
// app/frontend/components/nav-main.tsx
const navItems = [
{ title: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ title: 'Products', href: '/products', icon: Package }, // Add this
// ...
]4. Add route:
ruby
undefined1. 生成控制器:
bash
bin/rails generate controller Products index show new create edit update destroy2. 创建页面组件:
tsx
// app/frontend/pages/products/index.tsx
import { Head, Link } from '@inertiajs/react'
import AppLayout from '@/layouts/app-layout'
import { Button } from '@/components/ui/button'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow
} from '@/components/ui/table'
interface Product {
id: number
name: string
price: number
}
interface Props {
products: Product[]
}
export default function ProductsIndex({ products }: Props) {
return (
<AppLayout>
<Head title="Products" />
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Products</h1>
<Button asChild>
<Link href="/products/new">Add Product</Link>
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Price</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.map((product) => (
<TableRow key={product.id}>
<TableCell>{product.name}</TableCell>
<TableCell>${product.price}</TableCell>
<TableCell>
<Link href={`/products/${product.id}/edit`}>Edit</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</AppLayout>
)
}3. 更新导航:
tsx
// app/frontend/components/nav-main.tsx
const navItems = [
{ title: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ title: 'Products', href: '/products', icon: Package }, // 添加此项
// ...
]4. 添加路由:
ruby
undefinedconfig/routes.rb
config/routes.rb
resources :products
undefinedresources :products
undefinedAdding New shadcn/ui Components
添加新的shadcn/ui组件
The starter kit includes many components, but you can add more:
bash
undefined入门套件已包含许多组件,但你可以添加更多:
bash
undefinedAdd a specific component
添加指定组件
npx shadcn@latest add toast
npx shadcn@latest add calendar
npx shadcn@latest add data-table
npx shadcn@latest add toast
npx shadcn@latest add calendar
npx shadcn@latest add data-table
See all available components
查看所有可用组件
npx shadcn@latest add
undefinednpx shadcn@latest add
undefinedCustomizing the Layout
自定义布局
Switch between sidebar and header layouts:
tsx
// app/frontend/layouts/app-layout.tsx
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout'
import AppHeaderLayout from '@/layouts/app/app-header-layout'
// Use sidebar (default)
export default function AppLayout({ children }: Props) {
return <AppSidebarLayout>{children}</AppSidebarLayout>
}
// Or use header layout
export default function AppLayout({ children }: Props) {
return <AppHeaderLayout>{children}</AppHeaderLayout>
}在侧边栏和头部布局之间切换:
tsx
// app/frontend/layouts/app-layout.tsx
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout'
import AppHeaderLayout from '@/layouts/app/app-header-layout'
// 使用侧边栏布局(默认)
export default function AppLayout({ children }: Props) {
return <AppSidebarLayout>{children}</AppSidebarLayout>
}
// 或使用头部布局
export default function AppLayout({ children }: Props) {
return <AppHeaderLayout>{children}</AppHeaderLayout>
}Extending Types
扩展类型定义
tsx
// app/frontend/types/index.ts
export interface User {
id: number
name: string
email: string
avatar_url: string | null
}
// Add your own types
export interface Product {
id: number
name: string
description: string
price: number
created_at: string
}
export interface PageProps {
auth: {
user: User | null
}
flash: {
success?: string
error?: string
}
}tsx
// app/frontend/types/index.ts
export interface User {
id: number
name: string
email: string
avatar_url: string | null
}
// 添加自定义类型
export interface Product {
id: number
name: string
description: string
price: number
created_at: string
}
export interface PageProps {
auth: {
user: User | null
}
flash: {
success?: string
error?: string
}
}Using the Flash Hook
使用提示消息钩子
The starter kit includes a flash message system with Sonner toasts:
tsx
// Already set up in the layout, just use flash in your controller
class ProductsController < ApplicationController
def create
@product = Product.create(product_params)
redirect_to products_path, notice: 'Product created!'
end
endThe hook automatically displays flash messages as toasts.
use-flash入门套件包含基于Sonner提示框的消息系统:
tsx
// 已在布局中配置完成,只需在控制器中使用flash
class ProductsController < ApplicationController
def create
@product = Product.create(product_params)
redirect_to products_path, notice: 'Product created!'
end
enduse-flashRemoving Features You Don't Need
移除不需要的功能
Remove settings pages:
bash
rm -rf app/frontend/pages/settings
rm -rf app/controllers/settings移除设置页面:
bash
rm -rf app/frontend/pages/settings
rm -rf app/controllers/settingsRemove routes in config/routes.rb
在config/routes.rb中移除对应路由
**Remove authentication (for internal tools):**
```bash
rm -rf app/frontend/pages/sessions
rm -rf app/frontend/pages/users
rm -rf app/frontend/pages/identity
rm app/controllers/sessions_controller.rb
rm app/controllers/users_controller.rb
rm -rf app/controllers/identity
**移除认证功能(适用于内部工具):**
```bash
rm -rf app/frontend/pages/sessions
rm -rf app/frontend/pages/users
rm -rf app/frontend/pages/identity
rm app/controllers/sessions_controller.rb
rm app/controllers/users_controller.rb
rm -rf app/controllers/identityUpdate routes and ApplicationController
更新路由和ApplicationController
---
---Inertia Modal - Render Pages as Dialogs
Inertia模态框 - 将页面渲染为对话框
The gem and package let you render any Inertia page as a modal dialog.
inertia_rails-contrib@inertiaui/modalinertia_rails-contrib@inertiaui/modalInstallation
安装
bash
undefinedbash
undefinedRuby gem (optional, for base_url helper)
Ruby gem(可选,用于base_url辅助方法)
bundle add inertia_rails-contrib
bundle add inertia_rails-contrib
NPM package (Vue)
NPM包(Vue)
npm install @inertiaui/modal-vue
npm install @inertiaui/modal-vue
NPM package (React)
NPM包(React)
npm install @inertiaui/modal-react
undefinednpm install @inertiaui/modal-react
undefinedSetup (Vue)
配置(Vue)
javascript
// app/frontend/entrypoints/application.js
import { createInertiaApp } from '@inertiajs/vue3'
import { renderApp } from '@inertiaui/modal-vue'
import { createSSRApp, h } from 'vue'
createInertiaApp({
resolve: (name) => pages[`../pages/${name}.vue`],
setup({ el, App, props, plugin }) {
createSSRApp({
render: () => renderApp(App, props), // Use renderApp
})
.use(plugin)
.mount(el)
},
})javascript
// app/frontend/entrypoints/application.js
import { createInertiaApp } from '@inertiajs/vue3'
import { renderApp } from '@inertiaui/modal-vue'
import { createSSRApp, h } from 'vue'
createInertiaApp({
resolve: (name) => pages[`../pages/${name}.vue`],
setup({ el, App, props, plugin }) {
createSSRApp({
render: () => renderApp(App, props), // 使用renderApp
})
.use(plugin)
.mount(el)
},
})Tailwind Configuration
Tailwind配置
javascript
// tailwind.config.js (v3)
module.exports = {
content: [
// ... your content paths
'./node_modules/@inertiaui/modal-vue/src/**/*.vue',
],
}css
/* For Tailwind v4 */
@import "tailwindcss";
@source '../../../node_modules/@inertiaui/modal-vue';javascript
// tailwind.config.js (v3)
module.exports = {
content: [
// ... 你的内容路径
'./node_modules/@inertiaui/modal-vue/src/**/*.vue',
],
}css
/* 适用于Tailwind v4 */
@import "tailwindcss";
@source '../../../node_modules/@inertiaui/modal-vue';Basic Usage
基础用法
Open a page as modal:
vue
<script setup>
import { ModalLink } from '@inertiaui/modal-vue'
</script>
<template>
<ModalLink href="/users/create">
Create User
</ModalLink>
</template>Wrap page content in Modal:
vue
<!-- pages/users/create.vue -->
<script setup>
import { Modal } from '@inertiaui/modal-vue'
defineProps(['roles'])
</script>
<template>
<Modal>
<h2>Create User</h2>
<UserForm :roles="roles" />
</Modal>
</template>以模态框形式打开页面:
vue
<script setup>
import { ModalLink } from '@inertiaui/modal-vue'
</script>
<template>
<ModalLink href="/users/create">
Create User
</ModalLink>
</template>将页面内容包裹在模态框中:
vue
<!-- pages/users/create.vue -->
<script setup>
import { Modal } from '@inertiaui/modal-vue'
defineProps(['roles'])
</script>
<template>
<Modal>
<h2>Create User</h2>
<UserForm :roles="roles" />
</Modal>
</template>Modal with Base URL
带基础URL的模态框
Enable URL updates and browser history:
Controller:
ruby
class UsersController < ApplicationController
def create
render inertia_modal: {
roles: Role.all.as_json
}, base_url: users_path
end
endLink with navigation:
vue
<ModalLink href="/users/create" navigate>
Create User
</ModalLink>Now the URL changes to when opened, supports browser back button, and can be bookmarked.
/users/create启用URL更新和浏览器历史记录:
控制器:
ruby
class UsersController < ApplicationController
def create
render inertia_modal: {
roles: Role.all.as_json
}, base_url: users_path
end
end带导航的链接:
vue
<ModalLink href="/users/create" navigate>
Create User
</ModalLink>现在打开模态框时URL会变为,支持浏览器返回按钮,并且可以添加书签。
/users/createSlideover Variant
侧边滑入变体
vue
<template>
<Modal slideover>
<h2>User Details</h2>
<!-- Content slides in from the side -->
</Modal>
</template>vue
<template>
<Modal slideover>
<h2>User Details</h2>
<!-- 内容从侧边滑入 -->
</Modal>
</template>Nested Modals
嵌套模态框
vue
<template>
<Modal>
<h2>Edit User</h2>
<UserForm />
<!-- Open another modal from within -->
<ModalLink href="/roles/create">
Add New Role
</ModalLink>
</Modal>
</template>vue
<template>
<Modal>
<h2>Edit User</h2>
<UserForm />
<!-- 在模态框内打开另一个模态框 -->
<ModalLink href="/roles/create">
Add New Role
</ModalLink>
</Modal>
</template>Closing Modals
关闭模态框
vue
<script setup>
import { Modal } from '@inertiaui/modal-vue'
const emit = defineEmits(['close'])
</script>
<template>
<Modal @close="emit('close')">
<button @click="emit('close')">Cancel</button>
</Modal>
</template>vue
<script setup>
import { Modal } from '@inertiaui/modal-vue'
const emit = defineEmits(['close'])
</script>
<template>
<Modal @close="emit('close')">
<button @click="emit('close')">Cancel</button>
</Modal>
</template>Integrating shadcn/ui
集成shadcn/ui
Use shadcn/ui components with Inertia Rails for a polished UI.
将shadcn/ui组件与Inertia Rails结合使用,打造精致的UI界面。
Setup (Vue)
配置(Vue)
bash
undefinedbash
undefinedInitialize shadcn/ui
初始化shadcn/ui
npx shadcn-vue@latest init
npx shadcn-vue@latest init
Add components
添加组件
npx shadcn-vue@latest add button input card form
undefinednpx shadcn-vue@latest add button input card form
undefinedForm with shadcn/ui
使用shadcn/ui的表单
vue
<script setup>
import { useForm } from '@inertiajs/vue3'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
const form = useForm({
email: '',
password: '',
})
function submit() {
form.post('/login')
}
</script>
<template>
<Card class="w-[400px]">
<CardHeader>
<CardTitle>Login</CardTitle>
</CardHeader>
<CardContent>
<form @submit.prevent="submit" class="space-y-4">
<div class="space-y-2">
<Label for="email">Email</Label>
<Input
id="email"
v-model="form.email"
type="email"
placeholder="you@example.com"
/>
<p v-if="form.errors.email" class="text-sm text-red-500">
{{ form.errors.email }}
</p>
</div>
<div class="space-y-2">
<Label for="password">Password</Label>
<Input
id="password"
v-model="form.password"
type="password"
/>
</div>
<Button type="submit" :disabled="form.processing" class="w-full">
{{ form.processing ? 'Signing in...' : 'Sign in' }}
</Button>
</form>
</CardContent>
</Card>
</template>vue
<script setup>
import { useForm } from '@inertiajs/vue3'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
const form = useForm({
email: '',
password: '',
})
function submit() {
form.post('/login')
}
</script>
<template>
<Card class="w-[400px]">
<CardHeader>
<CardTitle>Login</CardTitle>
</CardHeader>
<CardContent>
<form @submit.prevent="submit" class="space-y-4">
<div class="space-y-2">
<Label for="email">Email</Label>
<Input
id="email"
v-model="form.email"
type="email"
placeholder="you@example.com"
/>
<p v-if="form.errors.email" class="text-sm text-red-500">
{{ form.errors.email }}
</p>
</div>
<div class="space-y-2">
<Label for="password">Password</Label>
<Input
id="password"
v-model="form.password"
type="password"
/>
</div>
<Button type="submit" :disabled="form.processing" class="w-full">
{{ form.processing ? 'Signing in...' : 'Sign in' }}
</Button>
</form>
</CardContent>
</Card>
</template>Data Table with Sorting and Filtering
带排序和筛选的数据表格
vue
<script setup>
import { router } from '@inertiajs/vue3'
import { ref, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow
} from '@/components/ui/table'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
const props = defineProps(['users', 'filters'])
const search = ref(props.filters.search || '')
const sort = ref(props.filters.sort || 'name')
const direction = ref(props.filters.direction || 'asc')
const debouncedSearch = useDebounceFn(() => {
router.get('/users', {
search: search.value,
sort: sort.value,
direction: direction.value,
}, {
preserveState: true,
replace: true,
})
}, 300)
watch(search, debouncedSearch)
function toggleSort(column) {
if (sort.value === column) {
direction.value = direction.value === 'asc' ? 'desc' : 'asc'
} else {
sort.value = column
direction.value = 'asc'
}
debouncedSearch()
}
</script>
<template>
<div class="space-y-4">
<Input
v-model="search"
placeholder="Search users..."
class="max-w-sm"
/>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Button variant="ghost" @click="toggleSort('name')">
Name
<span v-if="sort === 'name'">{{ direction === 'asc' ? '↑' : '↓' }}</span>
</Button>
</TableHead>
<TableHead>
<Button variant="ghost" @click="toggleSort('email')">
Email
<span v-if="sort === 'email'">{{ direction === 'asc' ? '↑' : '↓' }}</span>
</Button>
</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="user in users" :key="user.id">
<TableCell>{{ user.name }}</TableCell>
<TableCell>{{ user.email }}</TableCell>
<TableCell>
<Link :href="`/users/${user.id}/edit`">Edit</Link>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</template>vue
<script setup>
import { router } from '@inertiajs/vue3'
import { ref, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow
} from '@/components/ui/table'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
const props = defineProps(['users', 'filters'])
const search = ref(props.filters.search || '')
const sort = ref(props.filters.sort || 'name')
const direction = ref(props.filters.direction || 'asc')
const debouncedSearch = useDebounceFn(() => {
router.get('/users', {
search: search.value,
sort: sort.value,
direction: direction.value,
}, {
preserveState: true,
replace: true,
})
}, 300)
watch(search, debouncedSearch)
function toggleSort(column) {
if (sort.value === column) {
direction.value = direction.value === 'asc' ? 'desc' : 'asc'
} else {
sort.value = column
direction.value = 'asc'
}
debouncedSearch()
}
</script>
<template>
<div class="space-y-4">
<Input
v-model="search"
placeholder="Search users..."
class="max-w-sm"
/>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Button variant="ghost" @click="toggleSort('name')">
Name
<span v-if="sort === 'name'">{{ direction === 'asc' ? '↑' : '↓' }}</span>
</Button>
</TableHead>
<TableHead>
<Button variant="ghost" @click="toggleSort('email')">
Email
<span v-if="sort === 'email'">{{ direction === 'asc' ? '↑' : '↓' }}</span>
</Button>
</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="user in users" :key="user.id">
<TableCell>{{ user.name }}</TableCell>
<TableCell>{{ user.email }}</TableCell>
<TableCell>
<Link :href="`/users/${user.id}/edit`">Edit</Link>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</template>Search with Filters
带筛选的搜索
Controller
控制器
ruby
class UsersController < ApplicationController
def index
users = User.all
# Apply search
if params[:search].present?
users = users.where('name ILIKE ? OR email ILIKE ?',
"%#{params[:search]}%", "%#{params[:search]}%")
end
# Apply filters
users = users.where(role: params[:role]) if params[:role].present?
users = users.where(active: params[:active]) if params[:active].present?
# Apply sorting
sort_column = %w[name email created_at].include?(params[:sort]) ? params[:sort] : 'name'
sort_direction = params[:direction] == 'desc' ? 'desc' : 'asc'
users = users.order("#{sort_column} #{sort_direction}")
# Paginate
users = users.page(params[:page]).per(20)
render inertia: {
users: users.as_json(only: [:id, :name, :email, :role, :active]),
filters: {
search: params[:search],
role: params[:role],
active: params[:active],
sort: sort_column,
direction: sort_direction,
},
pagination: {
current_page: users.current_page,
total_pages: users.total_pages,
total_count: users.total_count,
}
}
end
endruby
class UsersController < ApplicationController
def index
users = User.all
# 应用搜索
if params[:search].present?
users = users.where('name ILIKE ? OR email ILIKE ?',
"%#{params[:search]}%", "%#{params[:search]}%")
end
# 应用筛选
users = users.where(role: params[:role]) if params[:role].present?
users = users.where(active: params[:active]) if params[:active].present?
# 应用排序
sort_column = %w[name email created_at].include?(params[:sort]) ? params[:sort] : 'name'
sort_direction = params[:direction] == 'desc' ? 'desc' : 'asc'
users = users.order("#{sort_column} #{sort_direction}")
# 分页
users = users.page(params[:page]).per(20)
render inertia: {
users: users.as_json(only: [:id, :name, :email, :role, :active]),
filters: {
search: params[:search],
role: params[:role],
active: params[:active],
sort: sort_column,
direction: sort_direction,
},
pagination: {
current_page: users.current_page,
total_pages: users.total_pages,
total_count: users.total_count,
}
}
end
endFrontend with URL Sync
与URL同步的前端实现
vue
<script setup>
import { router } from '@inertiajs/vue3'
import { ref, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
const props = defineProps(['users', 'filters', 'pagination'])
const search = ref(props.filters.search || '')
const role = ref(props.filters.role || '')
const active = ref(props.filters.active || '')
function applyFilters() {
router.get('/users', {
search: search.value || undefined,
role: role.value || undefined,
active: active.value || undefined,
}, {
preserveState: true,
replace: true,
})
}
const debouncedSearch = useDebounceFn(applyFilters, 300)
watch(search, debouncedSearch)
function clearFilters() {
search.value = ''
role.value = ''
active.value = ''
applyFilters()
}
</script>
<template>
<div class="space-y-4">
<div class="flex gap-4">
<input v-model="search" placeholder="Search..." class="input" />
<select v-model="role" @change="applyFilters" class="select">
<option value="">All Roles</option>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
<select v-model="active" @change="applyFilters" class="select">
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
<button @click="clearFilters">Clear</button>
</div>
<UserTable :users="users" />
<Pagination :pagination="pagination" />
</div>
</template>vue
<script setup>
import { router } from '@inertiajs/vue3'
import { ref, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
const props = defineProps(['users', 'filters', 'pagination'])
const search = ref(props.filters.search || '')
const role = ref(props.filters.role || '')
const active = ref(props.filters.active || '')
function applyFilters() {
router.get('/users', {
search: search.value || undefined,
role: role.value || undefined,
active: active.value || undefined,
}, {
preserveState: true,
replace: true,
})
}
const debouncedSearch = useDebounceFn(applyFilters, 300)
watch(search, debouncedSearch)
function clearFilters() {
search.value = ''
role.value = ''
active.value = ''
applyFilters()
}
</script>
<template>
<div class="space-y-4">
<div class="flex gap-4">
<input v-model="search" placeholder="Search..." class="input" />
<select v-model="role" @change="applyFilters" class="select">
<option value="">All Roles</option>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
<select v-model="active" @change="applyFilters" class="select">
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
<button @click="clearFilters">Clear</button>
</div>
<UserTable :users="users" />
<Pagination :pagination="pagination" />
</div>
</template>Multi-Step Wizard
多步骤向导
Controller
控制器
ruby
class OnboardingController < ApplicationController
def show
step = params[:step]&.to_i || 1
render inertia: "onboarding/step#{step}", props: {
step: step,
total_steps: 4,
data: session[:onboarding] || {}
}
end
def update
step = params[:step].to_i
# Merge step data into session
session[:onboarding] ||= {}
session[:onboarding].merge!(step_params.to_h)
if step < 4
redirect_to onboarding_path(step: step + 1)
else
# Complete onboarding
User.create!(session[:onboarding])
session.delete(:onboarding)
redirect_to dashboard_path, notice: 'Welcome!'
end
end
private
def step_params
case params[:step].to_i
when 1 then params.permit(:name, :email)
when 2 then params.permit(:company, :role)
when 3 then params.permit(:preferences)
when 4 then params.permit(:terms_accepted)
end
end
endruby
class OnboardingController < ApplicationController
def show
step = params[:step]&.to_i || 1
render inertia: "onboarding/step#{step}", props: {
step: step,
total_steps: 4,
data: session[:onboarding] || {}
}
end
def update
step = params[:step].to_i
# 将步骤数据合并到会话中
session[:onboarding] ||= {}
session[:onboarding].merge!(step_params.to_h)
if step < 4
redirect_to onboarding_path(step: step + 1)
else
# 完成引导流程
User.create!(session[:onboarding])
session.delete(:onboarding)
redirect_to dashboard_path, notice: 'Welcome!'
end
end
private
def step_params
case params[:step].to_i
when 1 then params.permit(:name, :email)
when 2 then params.permit(:company, :role)
when 3 then params.permit(:preferences)
when 4 then params.permit(:terms_accepted)
end
end
endWizard Component
向导组件
vue
<script setup>
import { useForm, router } from '@inertiajs/vue3'
const props = defineProps(['step', 'total_steps', 'data'])
const form = useForm({
...props.data
})
function next() {
form.post(`/onboarding?step=${props.step}`)
}
function back() {
router.get(`/onboarding?step=${props.step - 1}`)
}
</script>
<template>
<div>
<!-- Progress indicator -->
<div class="flex gap-2 mb-8">
<div
v-for="i in total_steps"
:key="i"
:class="[
'w-8 h-8 rounded-full flex items-center justify-center',
i <= step ? 'bg-blue-500 text-white' : 'bg-gray-200'
]"
>
{{ i }}
</div>
</div>
<form @submit.prevent="next">
<!-- Step content via slot -->
<slot :form="form" />
<div class="flex gap-4 mt-8">
<button v-if="step > 1" type="button" @click="back">
Back
</button>
<button type="submit" :disabled="form.processing">
{{ step === total_steps ? 'Complete' : 'Next' }}
</button>
</div>
</form>
</div>
</template>vue
<script setup>
import { useForm, router } from '@inertiajs/vue3'
const props = defineProps(['step', 'total_steps', 'data'])
const form = useForm({
...props.data
})
function next() {
form.post(`/onboarding?step=${props.step}`)
}
function back() {
router.get(`/onboarding?step=${props.step - 1}`)
}
</script>
<template>
<div>
<!-- 进度指示器 -->
<div class="flex gap-2 mb-8">
<div
v-for="i in total_steps"
:key="i"
:class="[
'w-8 h-8 rounded-full flex items-center justify-center',
i <= step ? 'bg-blue-500 text-white' : 'bg-gray-200'
]"
>
{{ i }}
</div>
</div>
<form @submit.prevent="next">
<!-- 通过插槽传入步骤内容 -->
<slot :form="form" />
<div class="flex gap-4 mt-8">
<button v-if="step > 1" type="button" @click="back">
Back
</button>
<button type="submit" :disabled="form.processing">
{{ step === total_steps ? 'Complete' : 'Next' }}
</button>
</div>
</form>
</div>
</template>Flash Messages with Toast
基于提示框的消息系统
Shared Data Setup
共享数据设置
ruby
undefinedruby
undefinedapp/controllers/application_controller.rb
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
inertia_share flash: -> {
{
success: flash.notice,
error: flash.alert,
info: flash[:info],
warning: flash[:warning]
}.compact
}
end
undefinedclass ApplicationController < ActionController::Base
inertia_share flash: -> {
{
success: flash.notice,
error: flash.alert,
info: flash[:info],
warning: flash[:warning]
}.compact
}
end
undefinedToast Component (Vue)
提示框组件(Vue)
vue
<!-- components/FlashMessages.vue -->
<script setup>
import { usePage } from '@inertiajs/vue3'
import { watch, ref } from 'vue'
const page = usePage()
const toasts = ref([])
watch(() => page.props.flash, (flash) => {
Object.entries(flash).forEach(([type, message]) => {
if (message) {
const id = Date.now()
toasts.value.push({ id, type, message })
setTimeout(() => {
toasts.value = toasts.value.filter(t => t.id !== id)
}, 5000)
}
})
}, { immediate: true })
</script>
<template>
<div class="fixed top-4 right-4 space-y-2 z-50">
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
:class="[
'px-4 py-3 rounded-lg shadow-lg',
{
'bg-green-500 text-white': toast.type === 'success',
'bg-red-500 text-white': toast.type === 'error',
'bg-blue-500 text-white': toast.type === 'info',
'bg-yellow-500 text-black': toast.type === 'warning',
}
]"
>
{{ toast.message }}
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
</style>vue
<!-- components/FlashMessages.vue -->
<script setup>
import { usePage } from '@inertiajs/vue3'
import { watch, ref } from 'vue'
const page = usePage()
const toasts = ref([])
watch(() => page.props.flash, (flash) => {
Object.entries(flash).forEach(([type, message]) => {
if (message) {
const id = Date.now()
toasts.value.push({ id, type, message })
setTimeout(() => {
toasts.value = toasts.value.filter(t => t.id !== id)
}, 5000)
}
})
}, { immediate: true })
</script>
<template>
<div class="fixed top-4 right-4 space-y-2 z-50">
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
:class="[
'px-4 py-3 rounded-lg shadow-lg',
{
'bg-green-500 text-white': toast.type === 'success',
'bg-red-500 text-white': toast.type === 'error',
'bg-blue-500 text-white': toast.type === 'info',
'bg-yellow-500 text-black': toast.type === 'warning',
}
]"
>
{{ toast.message }}
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
</style>Usage in Layout
在布局中使用
vue
<!-- layouts/AppLayout.vue -->
<script setup>
import FlashMessages from '@/components/FlashMessages.vue'
</script>
<template>
<div>
<FlashMessages />
<nav><!-- ... --></nav>
<main>
<slot />
</main>
</div>
</template>vue
<!-- layouts/AppLayout.vue -->
<script setup>
import FlashMessages from '@/components/FlashMessages.vue'
</script>
<template>
<div>
<FlashMessages />
<nav><!-- ... --></nav>
<main>
<slot />
</main>
</div>
</template>Confirmation Dialogs
确认对话框
Reusable Confirm Component
可复用的确认组件
vue
<!-- components/ConfirmDialog.vue -->
<script setup>
import { ref } from 'vue'
const isOpen = ref(false)
const resolvePromise = ref(null)
const options = ref({})
function confirm(opts = {}) {
options.value = {
title: 'Are you sure?',
message: 'This action cannot be undone.',
confirmText: 'Confirm',
cancelText: 'Cancel',
destructive: false,
...opts
}
isOpen.value = true
return new Promise((resolve) => {
resolvePromise.value = resolve
})
}
function handleConfirm() {
isOpen.value = false
resolvePromise.value?.(true)
}
function handleCancel() {
isOpen.value = false
resolvePromise.value?.(false)
}
defineExpose({ confirm })
</script>
<template>
<Teleport to="body">
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/50" @click="handleCancel" />
<div class="relative bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold">{{ options.title }}</h3>
<p class="mt-2 text-gray-600">{{ options.message }}</p>
<div class="mt-6 flex gap-3 justify-end">
<button @click="handleCancel" class="btn-secondary">
{{ options.cancelText }}
</button>
<button
@click="handleConfirm"
:class="options.destructive ? 'btn-danger' : 'btn-primary'"
>
{{ options.confirmText }}
</button>
</div>
</div>
</div>
</Teleport>
</template>vue
<!-- components/ConfirmDialog.vue -->
<script setup>
import { ref } from 'vue'
const isOpen = ref(false)
const resolvePromise = ref(null)
const options = ref({})
function confirm(opts = {}) {
options.value = {
title: 'Are you sure?',
message: 'This action cannot be undone.',
confirmText: 'Confirm',
cancelText: 'Cancel',
destructive: false,
...opts
}
isOpen.value = true
return new Promise((resolve) => {
resolvePromise.value = resolve
})
}
function handleConfirm() {
isOpen.value = false
resolvePromise.value?.(true)
}
function handleCancel() {
isOpen.value = false
resolvePromise.value?.(false)
}
defineExpose({ confirm })
</script>
<template>
<Teleport to="body">
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/50" @click="handleCancel" />
<div class="relative bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold">{{ options.title }}</h3>
<p class="mt-2 text-gray-600">{{ options.message }}</p>
<div class="mt-6 flex gap-3 justify-end">
<button @click="handleCancel" class="btn-secondary">
{{ options.cancelText }}
</button>
<button
@click="handleConfirm"
:class="options.destructive ? 'btn-danger' : 'btn-primary'"
>
{{ options.confirmText }}
</button>
</div>
</div>
</div>
</Teleport>
</template>Usage
使用方法
vue
<script setup>
import { ref } from 'vue'
import { router } from '@inertiajs/vue3'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
const confirmDialog = ref(null)
async function deleteUser(user) {
const confirmed = await confirmDialog.value.confirm({
title: 'Delete User',
message: `Are you sure you want to delete ${user.name}?`,
confirmText: 'Delete',
destructive: true,
})
if (confirmed) {
router.delete(`/users/${user.id}`)
}
}
</script>
<template>
<div>
<button @click="deleteUser(user)">Delete</button>
<ConfirmDialog ref="confirmDialog" />
</div>
</template>vue
<script setup>
import { ref } from 'vue'
import { router } from '@inertiajs/vue3'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
const confirmDialog = ref(null)
async function deleteUser(user) {
const confirmed = await confirmDialog.value.confirm({
title: 'Delete User',
message: `Are you sure you want to delete ${user.name}?`,
confirmText: 'Delete',
destructive: true,
})
if (confirmed) {
router.delete(`/users/${user.id}`)
}
}
</script>
<template>
<div>
<button @click="deleteUser(user)">Delete</button>
<ConfirmDialog ref="confirmDialog" />
</div>
</template>Handling Rails Validation Error Types
处理Rails验证错误类型
Rails returns different error formats. Handle them consistently:
ruby
undefinedRails返回不同的错误格式,需要统一处理:
ruby
undefinedController helper
控制器辅助方法
def format_errors(model)
model.errors.to_hash.transform_values { |messages| messages.first }
end
def format_errors(model)
model.errors.to_hash.transform_values { |messages| messages.first }
end
Usage
使用示例
redirect_to edit_user_url(user), inertia: { errors: format_errors(user) }
```javascript
// Frontend - errors are now { field: 'message' } format
form.errors.email // "can't be blank"redirect_to edit_user_url(user), inertia: { errors: format_errors(user) }
```javascript
// 前端 - 错误现在是{ field: 'message' }格式
form.errors.email // "can't be blank"Nested Model Errors
嵌套模型错误
ruby
undefinedruby
undefinedFor nested attributes
处理嵌套属性
def format_nested_errors(model)
errors = {}
model.errors.each do |error|
key = error.attribute.to_s.gsub('.', '_')
errors[key] = error.message
end
errors
end
---def format_nested_errors(model)
errors = {}
model.errors.each do |error|
key = error.attribute.to_s.gsub('.', '_')
errors[key] = error.message
end
errors
end
---Real-Time Features with ActionCable
基于ActionCable的实时功能
Setup Turbo Streams Alternative
替代Turbo Streams的配置
javascript
// channels/notifications_channel.js
import { router } from '@inertiajs/vue3'
import consumer from './consumer'
consumer.subscriptions.create('NotificationsChannel', {
received(data) {
if (data.reload) {
router.reload({ only: ['notifications'] })
}
}
})javascript
// channels/notifications_channel.js
import { router } from '@inertiajs/vue3'
import consumer from './consumer'
consumer.subscriptions.create('NotificationsChannel', {
received(data) {
if (data.reload) {
router.reload({ only: ['notifications'] })
}
}
})Controller Broadcast
控制器广播
ruby
class NotificationsController < ApplicationController
def create
notification = current_user.notifications.create!(notification_params)
ActionCable.server.broadcast(
"notifications_#{current_user.id}",
{ reload: true }
)
redirect_to notifications_path
end
endruby
class NotificationsController < ApplicationController
def create
notification = current_user.notifications.create!(notification_params)
ActionCable.server.broadcast(
"notifications_#{current_user.id}",
{ reload: true }
)
redirect_to notifications_path
end
endFile Downloads
文件下载
Triggering Downloads
触发下载
ruby
def download
report = Report.find(params[:id])
# Return download URL as prop
render inertia: {
download_url: rails_blob_path(report.file, disposition: 'attachment')
}
endjavascript
// Trigger download without navigation
function downloadFile(url) {
window.location.href = url
}
// Or use inertia_location for non-Inertia responses
router.visit(url, { method: 'get' })ruby
def download
report = Report.find(params[:id])
# 将下载URL作为属性返回
render inertia: {
download_url: rails_blob_path(report.file, disposition: 'attachment')
}
endjavascript
// 无需导航即可触发下载
function downloadFile(url) {
window.location.href = url
}
// 或使用inertia_location处理非Inertia响应
router.visit(url, { method: 'get' })External Redirect for Downloads
用于下载的外部重定向
ruby
def export
# Generate file...
inertia_location export_download_path(token: token)
endruby
def export
# 生成文件...
inertia_location export_download_path(token: token)
end