
Shadmin Dev
- 1 installs
- 164 repo stars
- Updated July 27, 2026
- ahaodev/shadmin
shadmin-dev is a Claude Code skill that applies Shadmin's full-stack feature-development standards for a Go/Gin/Ent backend and a React/TypeScript frontend.
About
shadmin-dev is a Claude Code skill that applies Shadmin's feature-development standards across a Go/Gin/Ent backend and a React/TypeScript frontend. It lays out the clean-architecture layer order for CRUD modules, API routes, controllers, usecases, repositories, Ent schemas, and the matching frontend types, services, hooks, tables, and routes. Developers use it when adding or modifying features so code compiles and follows the project's established patterns.
- Guides full-stack feature development in the Shadmin project's clean architecture
- Backend layers: domain, Ent schema, repository, usecase, controller, route, factory (Go + Gin + Ent)
- Frontend layers: types, services, features, routes, stores (React 19 + TypeScript + Vite + TanStack)
Shadmin Dev by the numbers
- 1 all-time installs (skills.sh)
- Ranked #3,830 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
shadmin-dev capabilities & compatibility
- Capabilities
- full stack development · crud scaffolding · api development · clean architecture · frontend development
- Use cases
- api development · frontend · database
- IDEs
- vscode
What shadmin-dev says it does
Guide full-stack feature development through Shadmin's clean architecture, producing code that compiles, passes lint/tests, and follows established patterns.
Backend (Go + Gin + Ent ORM)
npx skills add https://github.com/ahaodev/shadmin --skill shadmin-devAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 164 |
| Last updated | July 27, 2026 |
| Repository | ahaodev/shadmin ↗ |
What it does
Guide full-stack feature and CRUD development in the Shadmin Go/Gin/Ent + React/TypeScript codebase.
Who is it for?
Adding or modifying features and CRUD modules inside the Shadmin project following its clean-architecture layers.
Skip if: Projects that do not use the Shadmin Go/Gin/Ent + React/TanStack stack.
When should I use this skill?
You are adding or modifying a feature, CRUD module, API route, Ent schema, or React page in the Shadmin project.
What you get
A feature is implemented across backend and frontend layers in the correct order following Shadmin conventions.
By the numbers
- 8 backend layers
- 6 frontend layers
- 4-step full-stack workflow
Files
Shadmin Feature Development
Guide full-stack feature development through Shadmin's clean architecture, producing code that compiles, passes lint/tests, and follows established patterns. Most features require both backend and frontend changes — this skill covers the end-to-end workflow.
Architecture Overview
┌─────────────────────────────────────────────────────────────────────┐
│ Frontend (React 19 + TypeScript + Vite) │
│ Route File → Page Component → TanStack Query Hook → API Service │
│ ↕ Zustand (auth-store) ↕ Permission checks │
├─────────────────────────────────────────────────────────────────────┤
│ HTTP (Axios apiClient ← Bearer Token injection) │
├─────────────────────────────────────────────────────────────────────┤
│ Backend (Go + Gin + Ent ORM) │
│ Route → [JWT MW → Casbin MW] → Controller → Usecase → Repository │
│ ↕ Domain (contracts, DTOs, errors) ↕ Ent (DB, migrations) │
└─────────────────────────────────────────────────────────────────────┘Backend layers — each has exactly one responsibility:
| Layer | Directory | Responsibility |
|---|---|---|
| Domain | domain/ | Entity structs, DTOs, Repository/UseCase interfaces, errors, response helpers |
| Schema | ent/schema/ | DB schema → run go generate ./ent after changes |
| Repository | repository/ | Data access via Ent, domain↔ent conversion, pagination |
| Usecase | usecase/ | Business logic, validation, context.WithTimeout |
| Controller | api/controller/ | HTTP parsing only, Swagger annotations, status code mapping |
| Route | api/route/ | Route registration, middleware wiring |
| Factory | api/route/factory.go | DI: repo → usecase → controller construction |
| Bootstrap | bootstarp/ | App init, DB, Casbin, seeds (directory name typo is intentional) |
Frontend layers:
| Layer | Directory | Responsibility |
|---|---|---|
| Types | web/src/types/ | TypeScript interfaces matching backend DTOs |
| Services | web/src/services/ | Axios API wrappers, date parsing |
| Features | web/src/features/ | Page components, tables, dialogs, forms, hooks |
| Routes | web/src/routes/ | TanStack Router file-based routing |
| Stores | web/src/stores/ | Zustand state (auth, permissions) |
| Constants | web/src/constants/ | Permission strings, enums |
Full-Stack Development Workflow
Step 1: Clarify Scope (before writing code)
State explicitly:
- What entities/fields are involved
- API endpoints: path, method, request/response shapes
- Whether Casbin permission checks are needed
- Frontend: pages, tables, forms, dialogs
- Permission strings (e.g.,
system:project:add)
Step 2: List All Touched Files
Group by layer — this catches missing pieces early:
# Backend (implement in this order)
domain/<resource>.go
ent/schema/<resource>.go
repository/<resource>_repository.go
usecase/<resource>_usecase.go
api/controller/<resource>_controller.go
api/route/<resource>_routes.go (or modify system_routes.go)
api/route/factory.go
# Frontend (implement in this order)
web/src/types/<resource>.ts
web/src/services/<resource>Api.ts
web/src/features/<module>/<resource>/components/*-provider.tsx
web/src/features/<module>/<resource>/hooks/use-<resource>.ts
web/src/features/<module>/<resource>/components/*-columns.tsx
web/src/features/<module>/<resource>/components/*-table.tsx
web/src/features/<module>/<resource>/components/*-form-dialog.tsx
web/src/features/<module>/<resource>/components/*-dialogs.tsx
web/src/features/<module>/<resource>/components/*-primary-buttons.tsx
web/src/features/<module>/<resource>/data/schema.ts
web/src/features/<module>/<resource>/index.tsx
web/src/routes/_authenticated/<module>/<resource>.tsx
web/src/constants/permissions.ts (add new permission keys)Step 3: Implement Backend
Follow the layer order strictly — each layer depends on the one above.
Read `references/backend.md` for complete code templates and patterns.
Quick reference for key conventions:
- IDs:
xid.New().String()in Ent schemaDefaultFunc - Partial updates: pointer fields in
Update*Request(*string) - Pagination: embed
domain.QueryParams, calldomain.ValidateQueryParams() - Response:
domain.RespSuccess(data)(code=0) /domain.RespError(msg)(code=1) - Usecase: every method starts with
context.WithTimeout+defer cancel() - Errors: sentinel errors in domain,
%wwrapping, map to HTTP status in controller - Factory: repo → usecase → controller, dependencies from
f.db,f.app,f.timeout - Routes: protected system routes use
casbinMiddleware.CheckAPIPermission()
Step 4: Implement Frontend
Follow the order: types → service → feature module → route file.
Read `references/frontend.md` for complete code templates and patterns.
Quick reference for key conventions:
- API response:
response.data.data(outer.data= Axios, inner.data=domain.Response.Data) - Date parsing: API service converts string dates to
Dateobjects - Query params:
URLSearchParamsconstruction, snake_case to match backend - Table state:
useTableUrlStatehook syncs pagination/filters with URL - Dialog state: string-based via context provider (
open === 'add' | 'edit' | 'delete') - Permissions:
usePermission()hook,PERMISSIONS.SYSTEM.RESOURCE.ACTIONconstants - Toast:
sonnerfor success/error notifications - Forms: React Hook Form + Zod, single hook handles create/edit
- Route file: Zod schema validates URL search params with
.catch()defaults
Step 5: Wire Permissions
Shadmin uses a dual-layer permission model:
Backend (API access): Casbin checks (userID, path, method)
Frontend (UI visibility): Permission strings like "system:project:add"These are linked through the Role → Menu → API Resources binding: 1. Backend auto-scans routes into API resources on startup (bootstrap.InitApiResources) 2. API resource IDs are deterministic: METHOD:/api/v1/path (e.g., GET:/api/v1/system/project) 3. Admin assigns menus to roles, each menu binds to API resources 4. Frontend fetches permissions from /api/v1/resources and stores in Zustand
To add permissions for a new feature: 1. Backend: routes auto-register as API resources on restart 2. Frontend: add permission constants in web/src/constants/permissions.ts 3. Admin panel: create menu entries, bind API resources, assign to roles
Step 6: Generate & Verify
# Backend
go generate ./ent # If schema changed
go fmt ./... && go vet ./... # Format + static analysis
go test ./... # Run tests
swag init -g main.go --output ./docs # If Swagger annotations changed
# Frontend (from web/)
pnpm lint # ESLint
pnpm format:check # Prettier
pnpm build # Recommended if routes/build config changedKey Response Format
// Success (HTTP 200/201)
type Response struct {
Code int `json:"code"` // 0 = success
Msg string `json:"msg"` // "OK"
Data interface{} `json:"data"` // payload
}
// Error (HTTP 400/404/500)
// Code = 1, Msg = error description, Data = nil
// Paginated response (in Data field)
type PagedResult[T any] struct {
List []T `json:"list"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}Boundaries
Things to never do:
- No business logic in controllers — controllers parse HTTP, call usecase, return response
- No HTTP/permission logic in repositories — repositories do data access only
- No bypassing Casbin on protected APIs
- No new globals — use factory's
f.db,f.app,f.timeout - No inline API calls in React — all API access goes through
services/wrappers - No direct localStorage for auth — use
useAuthStore - No editing `components/ui/` — shadcn-generated primitives
- No hardcoded menus — menus come from backend
/api/v1/resources - No unnecessary dependencies — frontend or backend
- Minimal changes — only touch files relevant to the feature
Reference Files
For detailed code templates and implementation patterns, read these as needed:
- `references/backend.md` — Complete Go/Gin/Ent code templates for domain, schema, repository, usecase, controller, routes, and factory. Read when implementing backend features.
- `references/frontend.md` — Complete React/TypeScript code templates for types, API services, feature modules, hooks, tables, forms, dialogs, routes, and permissions. Read when implementing frontend features.
Further Documentation
The docs/getting-started/ directory contains comprehensive guides:
quickstart.zh.md/quickstart.en.md— Quick start guidearchitecture.zh.md/architecture.en.md— Architecture deep-divedevelopment.zh.md/development.en.md— Full CRUD walkthrough with exampledeployment.zh.md/deployment.en.md— Production deployment guide
Backend Development Patterns
Detailed Go/Gin/Ent code templates for Shadmin backend features. Read this when implementing backend layers.
Domain Layer (domain/<resource>.go)
Define contracts first — everything else implements these.
package domain
import (
"context"
"errors"
"time"
)
// Entity — JSON tags use snake_case
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
Description string `json:"description"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Create request — required fields use binding:"required"
type CreateProjectRequest struct {
Name string `json:"name" binding:"required"`
Code string `json:"code" binding:"required"`
Description string `json:"description,omitempty"`
Status string `json:"status,omitempty"`
}
// Update request — pointer fields distinguish "not provided" from "set to empty"
type UpdateProjectRequest struct {
Name *string `json:"name,omitempty"`
Code *string `json:"code,omitempty"`
Description *string `json:"description,omitempty"`
Status *string `json:"status,omitempty"`
}
// Query params — embed QueryParams for pagination support
type ProjectQueryParams struct {
Name string `json:"name,omitempty" form:"name"`
Code string `json:"code,omitempty" form:"code"`
Status string `json:"status,omitempty" form:"status"`
Search string `json:"search,omitempty" form:"search"`
QueryParams
}
// Repository interface
type ProjectRepository interface {
Create(ctx context.Context, project *Project) error
GetByID(ctx context.Context, id string) (*Project, error)
Fetch(ctx context.Context, params ProjectQueryParams) (*PagedResult[*Project], error)
Update(ctx context.Context, id string, updates UpdateProjectRequest) error
Delete(ctx context.Context, id string) error
}
// UseCase interface
type ProjectUseCase interface {
CreateProject(ctx context.Context, req *CreateProjectRequest) (*Project, error)
GetProjectByID(ctx context.Context, id string) (*Project, error)
ListProjects(ctx context.Context, params ProjectQueryParams) (*PagedResult[*Project], error)
UpdateProject(ctx context.Context, id string, req UpdateProjectRequest) error
DeleteProject(ctx context.Context, id string) error
}
// Sentinel errors — used by controller to map HTTP status codes
var (
ErrProjectNotFound = errors.New("project not found")
ErrProjectAlreadyExists = errors.New("project already exists")
)
// Type alias for Swagger documentation
type ProjectPagedResult = PagedResult[*Project]QueryParams & PagedResult (built-in)
These are defined in domain/request.go and domain/response.go:
// QueryParams — embedded in resource-specific query params
type QueryParams struct {
IsAdmin bool `json:"-" form:"-"`
Page int `json:"page" form:"page"`
PageSize int `json:"page_size" form:"page_size"`
SortBy string `json:"sort_by" form:"sort_by"`
Order string `json:"order" form:"order"`
}
// ValidateQueryParams — sets defaults: Page=1, PageSize=10, Max=10000
func ValidateQueryParams(params *QueryParams) error
// PagedResult — generic paginated response
type PagedResult[T any] struct {
List []T `json:"list"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
func NewPagedResult[T any](data []T, total, page, pageSize int) *PagedResult[T]
// Response helpers
func RespSuccess(data interface{}) Response // Code=0, Msg="OK"
func RespError(msg interface{}) Response // Code=1, accepts string or errorEnt Schema (ent/schema/<resource>.go)
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/index"
"github.com/rs/xid"
)
type Project struct {
ent.Schema
}
func (Project) Fields() []ent.Field {
return []ent.Field{
field.String("id").
DefaultFunc(func() string { return xid.New().String() }),
field.String("name").
NotEmpty().
Comment("Project name"),
field.String("code").
Unique().
NotEmpty().
Comment("Project code"),
field.String("description").
Optional().
Default(""),
field.String("status").
Default("active").
Comment("active or archived"),
field.Time("created_at").
Default(time.Now),
field.Time("updated_at").
Default(time.Now).
UpdateDefault(time.Now),
}
}
func (Project) Indexes() []ent.Index {
return []ent.Index{
index.Fields("name"),
index.Fields("status"),
}
}After creating or modifying, always run:
go generate ./entRepository (repository/<resource>_repository.go)
package repository
import (
"context"
"fmt"
"shadmin/domain"
"shadmin/ent"
"shadmin/ent/project"
)
type entProjectRepository struct {
client *ent.Client
}
func NewProjectRepository(client *ent.Client) domain.ProjectRepository {
return &entProjectRepository{client: client}
}
// Convert Ent entity to domain entity
func (r *entProjectRepository) convertToDomain(e *ent.Project) *domain.Project {
return &domain.Project{
ID: e.ID,
Name: e.Name,
Code: e.Code,
Description: e.Description,
Status: e.Status,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
}
func (r *entProjectRepository) Create(ctx context.Context, p *domain.Project) error {
created, err := r.client.Project.Create().
SetName(p.Name).
SetCode(p.Code).
SetDescription(p.Description).
SetStatus(p.Status).
Save(ctx)
if err != nil {
return fmt.Errorf("failed to create project: %w", err)
}
p.ID = created.ID
p.CreatedAt = created.CreatedAt
p.UpdatedAt = created.UpdatedAt
return nil
}
func (r *entProjectRepository) GetByID(ctx context.Context, id string) (*domain.Project, error) {
e, err := r.client.Project.Get(ctx, id)
if err != nil {
if ent.IsNotFound(err) {
return nil, domain.ErrProjectNotFound
}
return nil, fmt.Errorf("failed to get project: %w", err)
}
return r.convertToDomain(e), nil
}
func (r *entProjectRepository) Fetch(ctx context.Context, params domain.ProjectQueryParams) (*domain.PagedResult[*domain.Project], error) {
domain.ValidateQueryParams(¶ms.QueryParams)
query := r.client.Project.Query()
// Apply filters
if params.Name != "" {
query = query.Where(project.NameContains(params.Name))
}
if params.Status != "" {
query = query.Where(project.StatusEQ(params.Status))
}
if params.Search != "" {
query = query.Where(
project.Or(
project.NameContains(params.Search),
project.CodeContains(params.Search),
),
)
}
// Clone query for count (before pagination)
total, err := query.Clone().Count(ctx)
if err != nil {
return nil, fmt.Errorf("failed to count projects: %w", err)
}
// Apply sorting
if params.SortBy != "" {
if params.Order == "desc" {
query = query.Order(ent.Desc(params.SortBy))
} else {
query = query.Order(ent.Asc(params.SortBy))
}
} else {
query = query.Order(ent.Desc(project.FieldCreatedAt))
}
// Apply pagination
offset := (params.GetPage() - 1) * params.GetPageSize()
entities, err := query.
Offset(offset).
Limit(params.GetPageSize()).
All(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch projects: %w", err)
}
// Convert to domain entities
items := make([]*domain.Project, len(entities))
for i, e := range entities {
items[i] = r.convertToDomain(e)
}
return domain.NewPagedResult(items, total, params.GetPage(), params.GetPageSize()), nil
}
func (r *entProjectRepository) Update(ctx context.Context, id string, updates domain.UpdateProjectRequest) error {
mutation := r.client.Project.UpdateOneID(id)
// Check pointer fields for partial updates
if updates.Name != nil {
mutation = mutation.SetName(*updates.Name)
}
if updates.Code != nil {
mutation = mutation.SetCode(*updates.Code)
}
if updates.Description != nil {
mutation = mutation.SetDescription(*updates.Description)
}
if updates.Status != nil {
mutation = mutation.SetStatus(*updates.Status)
}
_, err := mutation.Save(ctx)
if err != nil {
if ent.IsNotFound(err) {
return domain.ErrProjectNotFound
}
return fmt.Errorf("failed to update project: %w", err)
}
return nil
}
func (r *entProjectRepository) Delete(ctx context.Context, id string) error {
err := r.client.Project.DeleteOneID(id).Exec(ctx)
if err != nil {
if ent.IsNotFound(err) {
return domain.ErrProjectNotFound
}
return fmt.Errorf("failed to delete project: %w", err)
}
return nil
}Key repository patterns:
ent.IsNotFound(err)→ return domain sentinel error- Clone query for count before applying offset/limit
- Pointer checks for partial updates
- Default sort by
created_atdescending - Error wrapping with
%w
Usecase (usecase/<resource>_usecase.go)
package usecase
import (
"context"
"fmt"
"time"
"shadmin/domain"
"shadmin/ent"
)
type projectUsecase struct {
client *ent.Client
projectRepository domain.ProjectRepository
contextTimeout time.Duration
}
func NewProjectUsecase(client *ent.Client, repo domain.ProjectRepository, timeout time.Duration) domain.ProjectUseCase {
return &projectUsecase{client: client, projectRepository: repo, contextTimeout: timeout}
}
func (pu *projectUsecase) CreateProject(ctx context.Context, req *domain.CreateProjectRequest) (*domain.Project, error) {
ctx, cancel := context.WithTimeout(ctx, pu.contextTimeout)
defer cancel()
// Set defaults
if req.Status == "" {
req.Status = "active"
}
// Business validation here (uniqueness, enum checks, etc.)
p := &domain.Project{
Name: req.Name,
Code: req.Code,
Description: req.Description,
Status: req.Status,
}
if err := pu.projectRepository.Create(ctx, p); err != nil {
return nil, fmt.Errorf("failed to create project: %w", err)
}
return p, nil
}
func (pu *projectUsecase) GetProjectByID(ctx context.Context, id string) (*domain.Project, error) {
ctx, cancel := context.WithTimeout(ctx, pu.contextTimeout)
defer cancel()
return pu.projectRepository.GetByID(ctx, id)
}
func (pu *projectUsecase) ListProjects(ctx context.Context, params domain.ProjectQueryParams) (*domain.PagedResult[*domain.Project], error) {
ctx, cancel := context.WithTimeout(ctx, pu.contextTimeout)
defer cancel()
domain.ValidateQueryParams(¶ms.QueryParams)
return pu.projectRepository.Fetch(ctx, params)
}
func (pu *projectUsecase) UpdateProject(ctx context.Context, id string, req domain.UpdateProjectRequest) error {
ctx, cancel := context.WithTimeout(ctx, pu.contextTimeout)
defer cancel()
return pu.projectRepository.Update(ctx, id, req)
}
func (pu *projectUsecase) DeleteProject(ctx context.Context, id string) error {
ctx, cancel := context.WithTimeout(ctx, pu.contextTimeout)
defer cancel()
return pu.projectRepository.Delete(ctx, id)
}Key usecase patterns:
- Every method:
context.WithTimeout+defer cancel() - Business validation belongs here (status enums, uniqueness, cross-entity)
- Error wrapping with
%w - Constructor takes
*ent.Client, repository interface, timeout duration
Controller (api/controller/<resource>_controller.go)
package controller
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"shadmin/domain"
)
type ProjectController struct {
ProjectUseCase domain.ProjectUseCase
}
// @Summary List projects
// @Tags Project
// @Security BearerAuth
// @Param page query int false "Page number"
// @Param page_size query int false "Page size"
// @Param search query string false "Search keyword"
// @Param status query string false "Filter by status"
// @Success 200 {object} domain.Response{data=domain.ProjectPagedResult}
// @Failure 500 {object} domain.Response
// @Router /api/v1/system/project [get]
func (pc *ProjectController) ListProjects(c *gin.Context) {
var params domain.ProjectQueryParams
if err := c.ShouldBindQuery(¶ms); err != nil {
c.JSON(http.StatusBadRequest, domain.RespError(err.Error()))
return
}
result, err := pc.ProjectUseCase.ListProjects(c, params)
if err != nil {
c.JSON(http.StatusInternalServerError, domain.RespError(err.Error()))
return
}
c.JSON(http.StatusOK, domain.RespSuccess(result))
}
// @Summary Create project
// @Tags Project
// @Security BearerAuth
// @Param body body domain.CreateProjectRequest true "Project data"
// @Success 201 {object} domain.Response{data=domain.Project}
// @Failure 400 {object} domain.Response
// @Router /api/v1/system/project [post]
func (pc *ProjectController) CreateProject(c *gin.Context) {
var req domain.CreateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, domain.RespError(err.Error()))
return
}
project, err := pc.ProjectUseCase.CreateProject(c, &req)
if err != nil {
if errors.Is(err, domain.ErrProjectAlreadyExists) {
c.JSON(http.StatusConflict, domain.RespError(err.Error()))
return
}
c.JSON(http.StatusInternalServerError, domain.RespError(err.Error()))
return
}
c.JSON(http.StatusCreated, domain.RespSuccess(project))
}
// @Summary Get project by ID
// @Tags Project
// @Security BearerAuth
// @Param id path string true "Project ID"
// @Success 200 {object} domain.Response{data=domain.Project}
// @Failure 404 {object} domain.Response
// @Router /api/v1/system/project/{id} [get]
func (pc *ProjectController) GetProject(c *gin.Context) {
id := c.Param("id")
project, err := pc.ProjectUseCase.GetProjectByID(c, id)
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
c.JSON(http.StatusNotFound, domain.RespError(err.Error()))
return
}
c.JSON(http.StatusInternalServerError, domain.RespError(err.Error()))
return
}
c.JSON(http.StatusOK, domain.RespSuccess(project))
}
// @Summary Update project
// @Tags Project
// @Security BearerAuth
// @Param id path string true "Project ID"
// @Param body body domain.UpdateProjectRequest true "Update data"
// @Success 200 {object} domain.Response
// @Failure 404 {object} domain.Response
// @Router /api/v1/system/project/{id} [put]
func (pc *ProjectController) UpdateProject(c *gin.Context) {
id := c.Param("id")
var req domain.UpdateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, domain.RespError(err.Error()))
return
}
if err := pc.ProjectUseCase.UpdateProject(c, id, req); err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
c.JSON(http.StatusNotFound, domain.RespError(err.Error()))
return
}
c.JSON(http.StatusInternalServerError, domain.RespError(err.Error()))
return
}
c.JSON(http.StatusOK, domain.RespSuccess(nil))
}
// @Summary Delete project
// @Tags Project
// @Security BearerAuth
// @Param id path string true "Project ID"
// @Success 200 {object} domain.Response
// @Failure 404 {object} domain.Response
// @Router /api/v1/system/project/{id} [delete]
func (pc *ProjectController) DeleteProject(c *gin.Context) {
id := c.Param("id")
if err := pc.ProjectUseCase.DeleteProject(c, id); err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
c.JSON(http.StatusNotFound, domain.RespError(err.Error()))
return
}
c.JSON(http.StatusInternalServerError, domain.RespError(err.Error()))
return
}
c.JSON(http.StatusOK, domain.RespSuccess(nil))
}HTTP status code mapping:
| Scenario | Status | Response |
|---|---|---|
| Success | 200 OK | domain.RespSuccess(data) |
| Created | 201 Created | domain.RespSuccess(entity) |
| Bad request/validation | 400 Bad Request | domain.RespError(err.Error()) |
| Not found | 404 Not Found | domain.RespError(err.Error()) |
| Already exists | 409 Conflict | domain.RespError(err.Error()) |
| Server error | 500 Internal Server Error | domain.RespError(err.Error()) |
Swagger annotation pattern:
@Summary— brief description@Tags— resource name (used for grouping)@Security BearerAuth— required for protected endpoints@Param— query/path/body parameters@Success/@Failure— response with type hint@Router— path and method
Routes (api/route/)
Add a setup function in the appropriate route file (usually system_routes.go):
func (pr *ProtectedRoutes) setupProjectManagement(systemGroup *gin.RouterGroup, casbinMiddleware *middleware.CasbinMiddleware) {
group := systemGroup.Group("/project")
group.Use(casbinMiddleware.CheckAPIPermission())
ctrl := pr.factory.CreateProjectController()
group.GET("", ctrl.ListProjects)
group.POST("", ctrl.CreateProject)
group.GET("/:id", ctrl.GetProject)
group.PUT("/:id", ctrl.UpdateProject)
group.DELETE("/:id", ctrl.DeleteProject)
}Then call it from setupSystemRoutes:
func (pr *ProtectedRoutes) setupSystemRoutes(group *gin.RouterGroup, casbinMiddleware *middleware.CasbinMiddleware) {
systemGroup := group.Group("/system")
// ... existing setup calls ...
pr.setupProjectManagement(systemGroup, casbinMiddleware)
}REST conventions:
| Method | Path | Action |
|---|---|---|
| GET | /system/project | List (paginated) |
| POST | /system/project | Create |
| GET | /system/project/:id | Get by ID |
| PUT | /system/project/:id | Update |
| DELETE | /system/project/:id | Delete |
Factory (api/route/factory.go)
func (f *ControllerFactory) CreateProjectController() *controller.ProjectController {
repo := repository.NewProjectRepository(f.db)
uc := usecase.NewProjectUsecase(f.db, repo, f.timeout)
return &controller.ProjectController{ProjectUseCase: uc}
}ControllerFactory fields available:
f.db—*ent.Clientfor database accessf.app—*bootstrap.Application(includesCasManager,FileRepo,ApiEngine)f.timeout—time.Durationfor context timeouts
If a controller needs Casbin: pass f.app.CasManager to the repository.
Auth & Middleware
JWT middleware context keys:
x-user-id— user's unique IDx-user-name— usernamex-user-email— emailx-user-is-admin— boolean admin flagx-user-roles— comma-separated role names
Casbin middleware:
CheckAPIPermission() checks (userID, requestPath, requestMethod) against Casbin policies.
- Returns 403 Forbidden if permission denied
- Returns 500 if check fails
- Auto-skips whitelisted paths (health, swagger, etc.)
API resource auto-scan:
On startup, bootstrap.InitApiResources scans all Gin routes and creates DB records:
- ID format:
METHOD:/api/v1/path(e.g.,GET:/api/v1/system/project) - Skips public, swagger, and whitelisted paths
- Preserves existing menu→API resource associations
Frontend Development Patterns
Detailed React/TypeScript code templates for Shadmin frontend features. Read this when implementing frontend layers.
Tech Stack
- React 19 + TypeScript (strict mode)
- Vite with SWC compiler + TanStack Router plugin
- TanStack Router — file-based routing under
web/src/routes/ - TanStack Query — data fetching, caching, mutations
- TanStack Table — headless table with server-side pagination
- Zustand — lightweight global state (auth, permissions)
- React Hook Form + Zod — form validation
- Shadcn UI (Radix primitives + Tailwind CSS v4) — component library
- Axios — HTTP client via
apiClientsingleton inservices/config.ts - Sonner — toast notifications
- Lucide React — icons
Types (web/src/types/<resource>.ts)
Align field names with backend domain/ structs. Use snake_case for JSON fields:
export interface Project {
id: string
name: string
code: string
description: string
status: string
created_at: Date
updated_at: Date
}
export interface CreateProjectRequest {
name: string
code: string
description?: string
status?: string
}
// Pointer fields in Go become optional fields in TypeScript
export interface UpdateProjectRequest {
name?: string
code?: string
description?: string
status?: string
}
export interface ProjectQueryParams {
page?: number
page_size?: number
name?: string
status?: string
search?: string
}Import the shared PagedResult type or define locally:
export interface PagedResult<T> {
list: T[]
total: number
page: number
page_size: number
total_pages: number
}API Service (web/src/services/<resource>Api.ts)
import { apiClient } from '@/services/config'
import type { Project, CreateProjectRequest, UpdateProjectRequest, ProjectQueryParams } from '@/types/project'
import type { PagedResult } from '@/types/api'
// Date parser — convert API string dates to Date objects
const parseProject = (p: any): Project => ({
...p,
created_at: new Date(p.created_at),
updated_at: new Date(p.updated_at),
})
export const getProjects = async (params?: ProjectQueryParams): Promise<PagedResult<Project>> => {
const searchParams = new URLSearchParams()
if (params?.page) searchParams.append('page', params.page.toString())
if (params?.page_size) searchParams.append('page_size', params.page_size.toString())
if (params?.search) searchParams.append('search', params.search)
if (params?.status) searchParams.append('status', params.status)
if (params?.name) searchParams.append('name', params.name)
const response = await apiClient.get(`/api/v1/system/project?${searchParams}`)
const data = response.data.data // outer .data = Axios, inner .data = domain.Response.Data
return { ...data, list: (data.list || []).map(parseProject) }
}
export const getProject = async (id: string): Promise<Project> => {
const response = await apiClient.get(`/api/v1/system/project/${id}`)
return parseProject(response.data.data)
}
export const createProject = async (data: CreateProjectRequest): Promise<Project> => {
const response = await apiClient.post('/api/v1/system/project', data)
return parseProject(response.data.data)
}
export const updateProject = async (id: string, data: UpdateProjectRequest): Promise<void> => {
await apiClient.put(`/api/v1/system/project/${id}`, data)
}
export const deleteProject = async (id: string): Promise<void> => {
await apiClient.delete(`/api/v1/system/project/${id}`)
}API client (services/config.ts):
- Dynamic base URL from
window.location(works with Nginx reverse proxy) - Automatic
Authorization: Bearer <token>injection via request interceptor - Token stored in
localStorageunderACCESS_TOKENkey - 300s timeout
Key conventions:
response.data.data— always double.data(Axios wrapper + domain Response)URLSearchParamsfor query string construction- Date fields parsed via a converter function
- Endpoint pattern:
/api/v1/{module}/{resource}
Feature Module Structure
web/src/features/system/projects/
├── components/
│ ├── projects-provider.tsx # Context Provider (dialog state)
│ ├── projects-columns.tsx # Table column definitions
│ ├── projects-table.tsx # Data table with pagination
│ ├── projects-dialogs.tsx # Dialog aggregation
│ ├── projects-primary-buttons.tsx # Header action buttons
│ └── project-form-dialog.tsx # Create/edit form dialog
├── data/
│ └── schema.ts # Zod schemas (data + form validation)
├── hooks/
│ └── use-projects.ts # TanStack Query hooks
└── index.tsx # Page entry componentContext Provider (components/<resource>-provider.tsx)
Manages dialog open/close state and current row selection:
import React, { useState, createContext, useContext } from 'react'
import { useDialogState } from '@/hooks/use-dialog-state'
import type { Project } from '@/types/project'
type ProjectsDialogType = 'add' | 'edit' | 'delete'
type ProjectsContextType = {
open: ProjectsDialogType | null
setOpen: (str: ProjectsDialogType | null) => void
currentRow: Project | null
setCurrentRow: React.Dispatch<React.SetStateAction<Project | null>>
}
const ProjectsContext = createContext<ProjectsContextType | undefined>(undefined)
export function ProjectsProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useDialogState<ProjectsDialogType>(null)
const [currentRow, setCurrentRow] = useState<Project | null>(null)
return (
<ProjectsContext.Provider value={{ open, setOpen, currentRow, setCurrentRow }}>
{children}
</ProjectsContext.Provider>
)
}
export const useProjects = () => {
const context = useContext(ProjectsContext)
if (!context) {
throw new Error('useProjects must be used within <ProjectsProvider>')
}
return context
}TanStack Query Hooks (hooks/use-<resource>.ts)
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { getProjects, getProject, createProject, updateProject, deleteProject } from '@/services/projectApi'
import type { ProjectQueryParams, UpdateProjectRequest } from '@/types/project'
const PROJECTS_KEY = 'projects'
export function useProjectList(params?: ProjectQueryParams) {
return useQuery({
queryKey: [PROJECTS_KEY, params],
queryFn: () => getProjects(params),
staleTime: 5 * 60 * 1000,
})
}
export function useProject(id: string) {
return useQuery({
queryKey: [PROJECTS_KEY, id],
queryFn: () => getProject(id),
enabled: !!id,
})
}
export function useCreateProject() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createProject,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [PROJECTS_KEY] })
toast.success('项目创建成功')
},
onError: (error: any) => {
toast.error(error?.response?.data?.msg || '创建失败')
},
})
}
export function useUpdateProject() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateProjectRequest }) =>
updateProject(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [PROJECTS_KEY] })
toast.success('项目更新成功')
},
onError: (error: any) => {
toast.error(error?.response?.data?.msg || '更新失败')
},
})
}
export function useDeleteProjects() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (ids: string[]) => Promise.all(ids.map(deleteProject)),
onSuccess: (_, ids) => {
queryClient.invalidateQueries({ queryKey: [PROJECTS_KEY] })
toast.success(`已删除 ${ids.length} 个项目`)
},
onError: (error: any) => {
toast.error(error?.response?.data?.msg || '删除失败')
},
})
}Key conventions:
- String constants for query keys
staleTime: 5 * 60 * 1000as defaultinvalidateQuerieson mutation success to refetch liststoast.success/toast.errorvia sonner- Error message from
error.response.data.msg(backend'sdomain.Response.Msg) - Batch delete via
Promise.all
Table Columns (components/<resource>-columns.tsx)
import { ColumnDef } from '@tanstack/react-table'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import { DataTableRowActions } from '@/components/data-table/data-table-row-actions'
import { LongText } from '@/components/long-text'
import type { Project } from '@/types/project'
import { useProjects } from './projects-provider'
import { usePermission } from '@/hooks/use-permission'
import { PERMISSIONS } from '@/constants/permissions'
export function useProjectColumns(): ColumnDef<Project>[] {
const { setOpen, setCurrentRow } = useProjects()
const { hasPermission } = usePermission()
return [
// Checkbox selection column
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Select all'
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row'
/>
),
enableSorting: false,
enableHiding: false,
},
// Data columns
{
accessorKey: 'name',
header: '项目名称',
cell: ({ row }) => <LongText className='max-w-36'>{row.getValue('name')}</LongText>,
},
{
accessorKey: 'code',
header: '项目编码',
},
{
accessorKey: 'status',
header: '状态',
cell: ({ row }) => {
const status = row.getValue('status') as string
return <Badge variant={status === 'active' ? 'default' : 'secondary'}>{status}</Badge>
},
filterFn: (row, id, value) => value.includes(row.getValue(id)),
},
{
accessorKey: 'created_at',
header: '创建时间',
},
// Actions column
{
id: 'actions',
cell: ({ row }) => {
const canEdit = hasPermission(PERMISSIONS.SYSTEM.PROJECT.EDIT)
const canDelete = hasPermission(PERMISSIONS.SYSTEM.PROJECT.DELETE)
if (!canEdit && !canDelete) return null
return (
<DataTableRowActions
row={row}
onEdit={canEdit ? () => { setCurrentRow(row.original); setOpen('edit') } : undefined}
onDelete={canDelete ? () => { setCurrentRow(row.original); setOpen('delete') } : undefined}
/>
)
},
},
]
}Data Table (components/<resource>-table.tsx)
import { getRouteApi } from '@tanstack/react-router'
import { useReactTable, getCoreRowModel, getPaginationRowModel } from '@tanstack/react-table'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { DataTable } from '@/components/data-table/data-table'
import { DataTablePagination } from '@/components/data-table/data-table-pagination'
import type { Project } from '@/types/project'
import { useProjectColumns } from './projects-columns'
const route = getRouteApi('/_authenticated/system/projects')
interface ProjectsTableProps {
data: Project[]
search: Record<string, unknown>
navigate: (opts: any) => void
totalCount: number
}
export function ProjectsTable({ data, search, navigate, totalCount }: ProjectsTableProps) {
const columns = useProjectColumns()
const { pagination, onPaginationChange, columnFilters, onColumnFiltersChange } =
useTableUrlState({ search, navigate })
const table = useReactTable({
data,
columns,
pageCount: Math.ceil(totalCount / (pagination.pageSize || 10)),
state: { pagination, columnFilters },
onPaginationChange,
onColumnFiltersChange,
manualPagination: true, // Server-side pagination
manualFiltering: true, // Server-side filtering
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
return (
<div className='space-y-4'>
<DataTable table={table} />
<DataTablePagination table={table} />
</div>
)
}Key table patterns:
manualPagination: true+manualFiltering: true— server handles thesepageCountcalculated from total / pageSizeuseTableUrlStatesyncs pagination and filters with URL search paramsgetRouteApiconnects to TanStack Router for type-safe URL state
Dialog Aggregation (components/<resource>-dialogs.tsx)
import { useProjects } from './projects-provider'
import { ProjectFormDialog } from './project-form-dialog'
export function ProjectsDialogs() {
const { open, setOpen, currentRow, setCurrentRow } = useProjects()
return (
<>
{/* Create dialog */}
<ProjectFormDialog
key='project-add'
open={open === 'add'}
onOpenChange={() => setOpen('add')}
/>
{/* Edit dialog — only renders when currentRow is set */}
{currentRow && (
<ProjectFormDialog
key={`project-edit-${currentRow.id}`}
open={open === 'edit'}
onOpenChange={() => {
setOpen('edit')
setTimeout(() => setCurrentRow(null), 500) // Cleanup after animation
}}
currentRow={currentRow}
/>
)}
</>
)
}Key dialog patterns:
- String-based dialog type from provider (
'add' | 'edit' | 'delete') keyprop forces remount on different entities- 500ms setTimeout for cleanup after dialog close animation
currentRowdata passed only to edit/delete dialogs
Primary Buttons (components/<resource>-primary-buttons.tsx)
import { Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { usePermission } from '@/hooks/use-permission'
import { PERMISSIONS } from '@/constants/permissions'
import { useProjects } from './projects-provider'
export function ProjectsPrimaryButtons() {
const { setOpen } = useProjects()
const { hasPermission } = usePermission()
return (
<div className='flex gap-2'>
{hasPermission(PERMISSIONS.SYSTEM.PROJECT.ADD) && (
<Button className='space-x-1' onClick={() => setOpen('add')}>
<span>添加项目</span> <Plus size={18} />
</Button>
)}
</div>
)
}Form Dialog (components/<resource>-form-dialog.tsx)
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect } from 'react'
import {
Dialog, DialogClose, DialogContent, DialogDescription,
DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import {
Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import type { Project } from '@/types/project'
import { useCreateProject, useUpdateProject } from '../hooks/use-projects'
const formSchema = z.object({
name: z.string().min(1, '项目名称为必填项'),
code: z.string().min(1, '项目编码为必填项'),
description: z.string().optional(),
status: z.string().default('active'),
})
type FormData = z.infer<typeof formSchema>
interface ProjectFormDialogProps {
open: boolean
onOpenChange: () => void
currentRow?: Project
}
export function ProjectFormDialog({ open, onOpenChange, currentRow }: ProjectFormDialogProps) {
const isEdit = !!currentRow
const createMutation = useCreateProject()
const updateMutation = useUpdateProject()
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: currentRow?.name ?? '',
code: currentRow?.code ?? '',
description: currentRow?.description ?? '',
status: currentRow?.status ?? 'active',
},
})
// Reset form when currentRow changes
useEffect(() => {
if (currentRow) {
form.reset({
name: currentRow.name,
code: currentRow.code,
description: currentRow.description,
status: currentRow.status,
})
}
}, [currentRow, form])
const onSubmit = async (values: FormData) => {
if (isEdit) {
await updateMutation.mutateAsync({ id: currentRow!.id, data: values })
} else {
await createMutation.mutateAsync(values)
}
form.reset()
onOpenChange()
}
const isSubmitting = createMutation.isPending || updateMutation.isPending
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isEdit ? '编辑项目' : '添加项目'}</DialogTitle>
<DialogDescription>
{isEdit ? '修改项目信息。' : '填写项目信息创建新项目。'}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>项目名称</FormLabel>
<FormControl>
<Input placeholder='输入项目名称' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='code'
render={({ field }) => (
<FormItem>
<FormLabel>项目编码</FormLabel>
<FormControl>
<Input placeholder='输入项目编码' {...field} disabled={isEdit} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>描述</FormLabel>
<FormControl>
<Textarea placeholder='输入项目描述(可选)' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
<Button type='button' variant='outline'>取消</Button>
</DialogClose>
<Button type='submit' disabled={isSubmitting}>
{isSubmitting ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}Zod Schemas (data/schema.ts)
import { z } from 'zod'
// Data schema — validates API responses
export const projectSchema = z.object({
id: z.string(),
name: z.string(),
code: z.string(),
description: z.string(),
status: z.string(),
created_at: z.coerce.date(),
updated_at: z.coerce.date(),
})
export type ProjectSchema = z.infer<typeof projectSchema>
// Form schema — validates user input
export const projectFormSchema = z.object({
name: z.string().min(1, '项目名称为必填项'),
code: z.string().min(1, '项目编码为必填项'),
description: z.string().optional(),
status: z.string().default('active'),
isEdit: z.boolean(),
})
export type ProjectFormData = z.infer<typeof projectFormSchema>Schema conventions:
z.coerce.date()for date fields from API.min(1, 'message')for required fields with Chinese error messages- Separate data schemas (API validation) from form schemas (input validation)
z.infer<typeof schema>for type export
Page Entry (index.tsx)
import { getRouteApi } from '@tanstack/react-router'
import { Header } from '@/components/layout/header'
import { Main } from '@/components/layout/main'
import { Search } from '@/components/search'
import { ThemeSwitch } from '@/components/theme-switch'
import { ProfileDropdown } from '@/components/profile-dropdown'
import { Skeleton } from '@/components/ui/skeleton'
import { ProjectsProvider } from './components/projects-provider'
import { ProjectsPrimaryButtons } from './components/projects-primary-buttons'
import { ProjectsTable } from './components/projects-table'
import { ProjectsDialogs } from './components/projects-dialogs'
import { useProjectList } from './hooks/use-projects'
const route = getRouteApi('/_authenticated/system/projects')
export function Projects() {
const search = route.useSearch()
const navigate = route.useNavigate()
const queryParams = {
page: search.page || 1,
page_size: search.page_size || 10,
search: search.search || undefined,
status: search.status || undefined,
}
const { data, isLoading, error } = useProjectList(queryParams)
return (
<ProjectsProvider>
<Header fixed>
<Search />
<div className='ms-auto flex items-center space-x-4'>
<ThemeSwitch />
<ProfileDropdown />
</div>
</Header>
<Main>
<div className='mb-2 space-y-2'>
<div className='flex items-center justify-between'>
<h2 className='text-2xl font-bold tracking-tight'>项目管理</h2>
<ProjectsPrimaryButtons />
</div>
<p className='text-muted-foreground'>管理项目信息。</p>
</div>
<div className='-mx-4 flex-1 overflow-auto px-4 py-1'>
{isLoading ? (
<div className='space-y-4'>
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className='h-16 w-full' />
))}
</div>
) : error ? (
<div className='flex h-32 items-center justify-center text-muted-foreground'>
加载失败,请重试
</div>
) : (
<ProjectsTable
data={data?.list || []}
search={search}
navigate={(opts) => navigate(opts)}
totalCount={data?.total || 0}
/>
)}
</div>
</Main>
<ProjectsDialogs />
</ProjectsProvider>
)
}Page layout pattern:
getRouteApi()for type-safe URL search params- URL params → API query params mapping
<ProjectsProvider>wraps page for dialog state- Header → Main → Dialogs structure
- Loading skeleton, error state, data state
Route File (web/src/routes/_authenticated/<path>.tsx)
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { Projects } from '@/features/system/projects'
const searchSchema = z.object({
page: z.number().optional().catch(1),
page_size: z.number().optional().catch(10),
search: z.string().optional().catch(''),
status: z.string().optional().catch(''),
})
export const Route = createFileRoute('/_authenticated/system/projects')({
validateSearch: searchSchema,
component: Projects,
})Route conventions:
- Zod schema validates URL search params with
.catch()for safe defaults - Component import from feature module's
index.tsx - Route path matches backend API structure
- After creating, restart
pnpm devto regeneraterouteTree.gen.ts
Permission Constants (web/src/constants/permissions.ts)
Add new permissions following the existing hierarchical pattern:
export const PERMISSIONS = {
SYSTEM: {
// ... existing entries ...
PROJECT: {
ADD: 'system:project:add',
EDIT: 'system:project:edit',
DELETE: 'system:project:delete',
},
},
} as constPermission usage:
import { usePermission } from '@/hooks/use-permission'
import { PERMISSIONS } from '@/constants/permissions'
const { hasPermission } = usePermission()
// In JSX — conditional rendering
{hasPermission(PERMISSIONS.SYSTEM.PROJECT.ADD) && <Button>Add</Button>}How hasPermission works (auth-store):
- Admin users (
is_admin) bypass all checks automatically - Non-admin: checks if the permission string exists in the user's permissions array
- Permissions fetched from
/api/v1/resourcesendpoint after login
Naming Conventions
| Type | Naming | Example |
|---|---|---|
| Components | PascalCase .tsx | ProjectsTable.tsx |
| Hooks | use-kebab-case.ts | use-projects.ts |
| Services | camelCaseApi.ts | projectApi.ts |
| Types | kebab-case.ts | project.ts |
| Route files | match URL path | system/projects.tsx |
Import Rules
Always use the @/ alias. Never use relative paths beyond ./ within a feature:
// ✓ Correct
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { useAuthStore } from '@/stores/auth-store'
// ✗ Wrong
import { cn } from '../../../lib/utils'Import order (enforced by Prettier plugin): 1. React / Node modules 2. Third-party (zod, axios, date-fns) 3. Radix UI + form libraries 4. TanStack packages 5. Project aliases (@/stores → @/lib → @/components → @/features) 6. Relative imports (./)