Implement a complete full-stack feature module (backend + frontend). Use when adding a new entity, CRUD endpoints, service, or any module in src/dotnet AND the corresponding Vue frontend. Covers: DB schema design with user review, Entity + AppDbContext, Request/Response DTOs, FluentValidation, ErrorCode assignment (4000+ range), Options config, Service interface + implementation (JfYu.Data), DI registration, Controller (CustomController), Vue router, API file (requestClient), VxeGrid list page, drawer form, and i18n locales (zh-CN + en-US error/system/page JSON). Follow project conventions end-to-end.
When the user asks to implement a feature in the dotnet backend, follow this workflow in order, pausing for user review at marked checkpoints.
[Required], [MaxLength(n)], nullable ?, default valueBaseEntity already provides Id (int), Status, CreatedTime, UpdatedTime — do NOT redeclare these.⏸ CHECKPOINT — Present fields to the user for review before coding. Ask: "Are these fields correct? Any changes, additions, or removals?"
New entity → create src/dotnet/WebApi/Entity/<Name>.cs:
using JfYu.Data.Model;
using System.ComponentModel.DataAnnotations;
namespace WebApi.Entity
{
public class Product : BaseEntity
{
[Required]
[MaxLength(100)]
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
// navigation properties as needed
}
}
Register in AppDbContext → add DbSet<T> to Entity/AppDbContext.cs:
public DbSet<Product> Products { get; set; }
Add EF Core migration (remind the user to run after review):
dotnet ef migrations add Add<Name> --project src/dotnet/WebApi
dotnet ef database update --project src/dotnet/WebApi
Create a module folder under src/dotnet/WebApi/Model/<Name>/ and place all DTOs there:
| File | Purpose |
|---|---|
Model/<Name>/Create<Name>Request.cs | Fields for creation |
Model/<Name>/Update<Name>Request.cs | Fields for update (Id + changeable fields) |
Model/<Name>/Feature<Name>Request.cs | Fields for business logic (if needed) |
Model/<Name>/<Name>Response.cs | Fields returned to client (no sensitive data) |
Example for a Product module:
Model/
Product/
CreateProductRequest.cs
UpdateProductRequest.cs
ProductResponse.cs
Namespace follows the folder: WebApi.Model.Product.
Use plain C# classes — no inheritance needed.
All fields in Update<Name>Request must be nullable. The frontend only sends fields that actually changed; the controller applies only the fields that are non-null.
// ✅ Correct — every field nullable
public class UpdateProductRequest
{
public string? Name { get; set; }
public decimal? Price { get; set; }
public int? Status { get; set; }
}
Controller apply-pattern — guard every field before assigning:
if (request.Name != null) item.Name = request.Name;
if (request.Price.HasValue) item.Price = request.Price.Value;
if (request.Status.HasValue) item.Status = request.Status.Value;
Frontend UpdateParams mirrors this: every property is optional (?). Callers only include changed keys in the payload — omitted keys are simply not sent.
export interface UpdateProductParams {
name?: string;
price?: number;
status?: number;
}
This means a status-only toggle (
{ status: 0 }) and a full form save both use the samePUT /{id}endpoint — no separate toggle endpoint is needed.
Create validation files in src/dotnet/WebApi/Validations/ for each request DTO:
Create<Name>RequestValidation.csUpdate<Name>RequestValidation.csFeature<Name>RequestValidation.cs (if applicable)using FluentValidation;
using WebApi.Model.Product;
namespace WebApi.Validations
{
public class CreateProductRequestValidation : AbstractValidator<CreateProductRequest>
{
public CreateProductRequestValidation()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.Price).GreaterThan(0);
}
}
}
Use the module namespace WebApi.Model.<Name> matching the DTO folder.
Validators are auto-discovered — no manual registration needed.
Open src/dotnet/WebApi/Constants/ErrorCode.cs.
Current module ranges (4000+ space, 50–100 codes per module):
| Range | Module |
|---|---|
| 4000–4049 | General client errors |
| 4100–4149 | Auth |
| 4150–4199 | User |
| 4200–4249 | Role |
| 4250–4299 | Permission |
| 4300+ | Other / unassigned |
Decision logic:
#region, continuing its sequential values.If it overflows, let me know.#region at the next available 50-step boundary (e.g., 4250, 4300 if free) and comment the range.Pattern:
#region Product
[Description("Product not found.")]
ProductNotFound = 4250,
[Description("Duplicate product name.")]
DuplicateProduct,
#endregion
Only when the feature requires settings stored in appsettings.json (no secrets/passwords).
src/dotnet/WebApi/Options/<Name>Settings.cs:namespace WebApi.Options
{
public class ProductSettings
{
public int MaxItemsPerPage { get; set; } = 50;
}
}
Extensions/OptionsExtension.cs:services.Configure<ProductSettings>(configuration.GetSection("ProductSettings"));
IOptions<ProductSettings> in services/controllers.Services/Interfaces/I<Name>Service.csIService<TEntity, AppDbContext>:public interface IProductService : IService<Product, AppDbContext>
{
Task<PagedResult<ProductResponse>> GetPagedAsync(QueryRequest query);
}
Services/<Name>Service.csService<TEntity, AppDbContext>:public class ProductService(AppDbContext context, ReadonlyDBContext<AppDbContext> readonlyDBContext)
: Service<Product, AppDbContext>(context, readonlyDBContext), IProductService
{
// AddAsync, UpdateAsync, DeleteAsync, GetOneAsync, GetAllAsync are inherited — do NOT re-implement
// Only add custom business methods
public async Task<PagedResult<ProductResponse>> GetPagedAsync(QueryRequest query)
{
var q = _readonlyContext.Products.AsQueryable();
if (!string.IsNullOrWhiteSpace(query.SearchKey))
q = q.Where(p => p.Name.Contains(query.SearchKey));
if (query.Status.HasValue)
q = q.Where(p => p.Status == query.Status.Value);
if (query.StartTime.HasValue)
q = q.Where(p => p.CreatedTime >= query.StartTime.Value);
if (query.EndTime.HasValue)
q = q.Where(p => p.CreatedTime <= query.EndTime.Value);
var paged = await q.ToPagedAsync(q => q.Adapt<IEnumerable<ProductResponse>>(), query.PageIndex, query.PageSize);
return new PagedResult<ProductResponse>
{
Items = paged.Data?.ToList() ?? [],
Total = paged.TotalCount
};
}
}
Business errors → throw BusinessException(ErrorCode.XxxError) — the global handler converts these to structured BadRequest responses automatically.
every business error need to mapping to one error code.
Add to Extensions/InjectionExtension.cs inside AddCustomInjection():
//#if (EnableRBAC) ← wrap in feature flag if DB-related
services.AddScoped<IProductService, ProductService>();
//#endif
Open src/dotnet/WebApi/Constants/PermissionCodes.cs and add constants for the new module.
Convention:
public const string <Name> = "<name>"; — value is lowercasepublic const string <Name><Action> = "<name>:<action>"; — both parts lowercasepublic const string Product = "product";
public const string ProductGet = "product:get";
public const string ProductAdd = "product:add";
public const string ProductEdit = "product:edit";
public const string ProductDelete = "product:delete";
Current modules in PermissionCodes.cs (for reference):
| Constant group | String values |
|---|---|
| System | "system" |
| Role | "role", "role:add", "role:get", "role:edit", "role:assign" |
| Permission | "permission", "permission:get", "permission:add", "permission:edit", "permission:delete", "permission:sync" |
| User | "user", "user:get", "user:edit" |
Parent code determines where this module appears in the permission tree:
System = "system", Dashboard = "dashboard").⏸ CHECKPOINT — Confirm parent code with user if not obvious. Ask: "This module's menu permission will be placed under
PermissionCodes.System. Is that correct, or should it be under a different parent?"
Create Controllers/<Name>Controller.cs.
[Permission(PermissionCodes.<Name>, PermissionType.Menu, parentCode: PermissionCodes.<Parent>)] — this registers it as a menu entry.[Permission(PermissionCodes.<Name><Action>)] — type defaults to Button.using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using WebApi.Attributes;
using WebApi.Constants;
using WebApi.Model.Product;
using WebApi.Services.Interfaces;
namespace WebApi.Controllers
{
[ApiController]
[Route("api/[controller]")]
[Permission(PermissionCodes.Product, PermissionType.Menu, parentCode: PermissionCodes.System)]
public class ProductController(IProductService productService) : CustomController
{
private readonly IProductService _productService = productService;
[HttpGet]
[Permission(PermissionCodes.ProductGet)]
public async Task<IActionResult> GetAllAsync([FromQuery] QueryRequest query)
{
var result = await _productService.GetPagedAsync(query);
return Ok(result);
}
[HttpGet("{id}")]
[Permission(PermissionCodes.ProductGet)]
public async Task<IActionResult> GetByIdAsync(int id)
{
var item = await _productService.GetOneAsync(q => q.Id == id);
if (item == null)
return BadRequest(ErrorCode.ProductNotFound);
return Ok(item);
}
[HttpPost]
[Permission(PermissionCodes.ProductAdd)]
public async Task<IActionResult> CreateAsync([FromBody][Required] CreateProductRequest request)
{
// check duplicates, adapt, save
await _productService.AddAsync(item);
return Ok();
}
[HttpPut("{id}")]
[Permission(PermissionCodes.ProductEdit)]
public async Task<IActionResult> UpdateAsync(int id, [FromBody][Required] UpdateProductRequest request)
{
var item = await _productService.GetOneAsync(q => q.Id == id);
if (item == null)
return BadRequest(ErrorCode.ProductNotFound);
// apply changes
await _productService.UpdateAsync(item);
return Ok();
}
}
}
Conventions:
IActionResult — never ActionResult<T> or raw objects.Ok<T>(data) / Ok() / BadRequest(ErrorCode) from CustomController.[Authorize] directly — [Permission] handles authentication + authorization..Result or .Wait().[Required] on [FromBody] parameters.Check src/vue/apps/web-antd/src/router/routes/modules/.
system.ts) → add a child route entry.<domain>.ts and add its default export to src/router/routes/index.ts.Route pattern:
{
name: 'ProductManagement', // PascalCase, unique
path: '/system/product',
component: () => import('#/views/system/product/index.vue'),
meta: {
icon: 'lucide:package', // Lucide icon name
title: $t('page.system.product'), // i18n key
},
},
Create src/vue/apps/web-antd/src/api/system/<domain>.ts:
Note: API files for features live in
src/api/system/, notsrc/api/core/. Export chain:src/api/system/<domain>.ts→src/api/system/index.ts→src/api/core/index.ts→src/api/index.ts
import { requestClient } from "#/api/request";
export namespace SystemProductApi {
export interface SystemProduct {
id: number;
name: string;
price: number;
status: number;
createdTime: string;
}
export interface CreateProductParams {
name: string;
price: number;
}
export interface UpdateProductParams {
name?: string;
price?: number;
status?: number;
}
}
export async function getProductList(params: Record<string, any>) {
return requestClient.get<{
items: SystemProductApi.SystemProduct[];
total: number;
}>("/product", { params });
}
export async function createProduct(
data: SystemProductApi.CreateProductParams,
) {
return requestClient.post("/product", data);
}
export async function updateProduct(
id: number,
data: SystemProductApi.UpdateProductParams,
) {
return requestClient.put(`/product/${id}`, data);
}
export async function deleteProduct(id: number) {
return requestClient.delete(`/product/${id}`);
}
Then re-export from src/api/system/index.ts:
export * from "./<domain>";
Create three files mirroring the role module structure:
src/views/<domain>/<name>/data.tsDefines form schemas and column configs using $t() for all labels:
import type { VbenFormSchema } from "#/adapter/form";
import type { OnActionClickFn, VxeTableGridOptions } from "#/adapter/vxe-table";
import { $t } from "#/locales";
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: "Input",
fieldName: "name",
label: $t("system.product.name"),
rules: "required",
},
// ...
];
}
export function useGridFormSchema(): VbenFormSchema[] {
/* search bar schema */
}
export function useColumns<T = SystemProductApi.SystemProduct>(
onActionClick: OnActionClickFn<T>,
onStatusChange?: (newStatus: any, row: T) => PromiseLike<boolean | undefined>,
): VxeTableGridOptions["columns"] {
return [
// ... other columns ...
{
// Status toggle column — use CellSwitch when onStatusChange is provided, CellTag otherwise
cellRender: {
attrs: { beforeChange: onStatusChange },
name: onStatusChange ? "CellSwitch" : "CellTag",
},
field: "status",
title: $t("system.product.status"),
width: 120,
},
// ... operation column ...
];
}
src/views/<domain>/<name>/modules/form.vueDrawer form for create/edit — uses useVbenForm + useVbenDrawer:
<script lang="ts" setup>
import { ref } from "vue";
import { useVbenForm, useVbenDrawer } from "@vben/common-ui";
import { createProduct, updateProduct } from "#/api";
import { useFormSchema } from "../data";
const emits = defineEmits(["success"]);
const id = ref<number>();
const [Form, formApi] = useVbenForm({
schema: useFormSchema(),
showDefaultActions: false,
});
const [Drawer, drawerApi] = useVbenDrawer({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
const values = await formApi.getValues();
drawerApi.lock();
(id.value ? updateProduct(id.value, values) : createProduct(values))
.then(() => {
emits("success");
drawerApi.close();
})
.catch(() => drawerApi.unlock());
},
async onOpenChange(isOpen) {
if (!isOpen) return;
formApi.resetForm();
const data = drawerApi.getData<SystemProductApi.SystemProduct>();
id.value = data?.id;
if (data) {
await nextTick();
formApi.setValues(data);
}
},
});
</script>
src/views/<domain>/<name>/index.vueMain list page — uses useVbenVxeGrid with proxy config pointing to the list API:
Critical: Always use
<Page auto-content-height>on list pages that useheight: 'auto'on VxeGrid. Withoutauto-content-height, the grid has no fixed-height parent and enters an infinite ResizeObserver feedback loop, causingvxe-table--row-expanded-wrapperheight to grow endlessly.
Icons: Use named Lucide icon components from
@vben/icons(e.g.RotateCw,Plus). Do NOT import from@ant-design/icons-vue— that package is not available in this project.
<script lang="ts" setup>
import { Page, useVbenDrawer } from "@vben/common-ui";
import { Plus } from "@vben/icons";
import { Button } from "ant-design-vue";
import { useVbenVxeGrid } from "#/adapter/vxe-table";
import { getProductList } from "#/api";
import { useColumns, useGridFormSchema } from "./data";
import Form from "./modules/form.vue";
const [FormDrawer, formDrawerApi] = useVbenDrawer({
connectedComponent: Form,
destroyOnClose: true,
});
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useColumns(onActionClick, onStatusChange),
height: "auto",
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) =>
getProductList({
pageIndex: page.currentPage,
pageSize: page.pageSize,
...formValues,
}),
},
},
rowConfig: { keyField: "id" },
toolbarConfig: {
custom: true,
export: false,
refresh: true,
search: true,
zoom: false,
},
},
formOptions: { schema: useGridFormSchema(), submitOnChange: true },
});
function onActionClick(e) {
if (e.code === "edit") formDrawerApi.setData(e.row).open();
}
function confirm(content: string, title: string) {
return new Promise((resolve, reject) => {
Modal.confirm({
content,
onCancel() {
reject(new Error("cancelled"));
},
onOk() {
resolve(true);
},
title,
});
});
}
// Status toggle — uses i18n keys from system.common (prefix-change / mid-change / suffix-change)
async function onStatusChange(
newStatus: number,
row: SystemProductApi.SystemProduct,
): Promise<boolean> {
const label = newStatus === 1 ? $t("common.enabled") : $t("common.disabled");
try {
await confirm(
`${$t("system.common.prefix-change")} ${row.name} ${$t("system.common.mid-change")} ${$t("system.product.status")} ${$t("system.common.suffix-change")}【 ${label}】?`,
$t("common.edit"),
);
await updateProduct(row.id, { status: newStatus });
return true;
} catch {
return false;
}
}
function onRefresh() {
gridApi.query();
}
</script>
<template>
<Page auto-content-height>
<FormDrawer @success="onRefresh" />
<Grid>
<template #toolbar-actions>
<Button type="primary" @click="formDrawerApi.open()">
<Plus class="size-5" />
{{ $t("common.create") }}
</Button>
</template>
</Grid>
</Page>
</template>
Update all four locale files:
ErrorCode enum value to its numeric int keysrc/locales/langs/zh-CN/error.json:
{
"4250": "商品不存在",
"4251": "商品名称已存在"
}
src/locales/langs/en-US/error.json:
{
"4250": "Product not found",
"4251": "Duplicate product name"
}
src/locales/langs/zh-CN/system.json — add module key under the domain object:
{
"product": {
"title": "商品管理",
"list": "商品列表",
"name": "商品名称",
"price": "价格",
"status": "状态",
"createdTime": "创建时间",
"operation": "操作"
}
}
src/locales/langs/en-US/system.json — English equivalent.
src/locales/langs/zh-CN/page.json — add to corresponding domain:
{
"system": {
"product": "商品管理"
}
}
src/locales/langs/en-US/page.json — English equivalent.
AppDbContextCreate<Name>Request, Update<Name>Request, <Name>Response createdErrorCode.cs in the correct range/regionOptionsExtension registration added (if needed)InjectionExtensionPermissionCodes.cs (module + actions), parent confirmed with userCustomController, with [Permission] on class and all actionssrc/api/core/, exported from index.tsdata.ts created with useFormSchema, useGridFormSchema, useColumnsmodules/form.vue created (drawer create/edit form)index.vue created (list page with VxeGrid)error.json updated in both zh-CN and en-US with all new error codessystem.json updated in both zh-CN and en-US with all UI labelspage.json updated in both locales if a new route/menu title was added