Frontend Standards (Vue 3 + Naive UI)
Check your work against this list before writing a page or wiring up an API. The stack is <script setup> + Naive UI + Pinia (persisted) + vue-router + vue-i18n + VueUse. In an app the path alias is @ → src, and the kernel's components, composables, stores and API primitives all come through import { … } from 'smart-admin-web'; inside the kernel package the alias is #/ → src. See Core Concepts for the overall architecture, web/COMPONENTS.md for component usage, and web/DESIGN.md for the design system.
Where things go
- Pages are organized by module/entity:
views/<module>/<entity>/index.vue— in an app that'ssrc/views/, collected into the page table bycreateSmartAdmin({ views }); follow the kernel package'sviews/system/menu/index.vuefor a full CRUD example (SmartTable+ aFormContainermodal form +useConfirmconfirmation). composables/(use*) holds the single source of logic, decoupled from the UI library by default, with error and message callbacks injected by the view. The explicit exceptions are ones that genuinely need a Naive provider in context:useConfirmcallsuseDialog/useMessagedirectly,useThemecallsdarkThemedirectly, and both can only be called from insidesetup.- An app's
api/:client.ts(createApiClient<paths>()) + one<domain>.tsper domain + the generatedschema.d.ts; the kernel package'sapi/isclient.ts+index.ts(built-in endpoints grouped by domain) +schema.d.ts. See project structure for the other directories' responsibilities.
API contract
schema.d.ts is a generated artifact — don't hand-edit it
schema.d.ts is generated from the backend's OpenAPI (npm run gen:api, which needs the backend running to fetch /openapi/v1.json); hand-edits are overwritten the next time you generate — to change a type, change the backend endpoint/DTO and regenerate. This endpoint isn't mounted in production; see the FAQ for details.
- API calls are centralized in the
api/layer, grouped by domain (authApi/userApi/moduleApi/menuApi… in the kernel package'sapi/index.ts; your own modules insrc/api/<domain>.ts, using the app's./clientand importingunwrap/pageParams/toPagefromsmart-admin-web); each method is shaped likeclient.X(...).then(r => unwrap<T>(r))— never callclientbare in a view. unwrapunwraps the envelope uniformly; failures (code≠0or non-2xx) all normalize toApiError(carryingcode/msgKey), and the viewcatches it and produces copy withtranslateError(e).- Pagination is normalized at the API layer into
{ items, total }to fit SmartTable'sfetcher(the backend returnsPagedList<T>{current,size,total,items}). - Query parameter names use PascalCase (required by ASP.NET model binding).
- Set
VITE_API_BASEat build time only when the frontend and backend are genuinely cross-origin (CDN / separate domain) — the template'smain.tshands it to the kernel asapiBase— and the backend must then explicitly configureSmartAdmin:Api:Cors:AllowedOrigins(deny-all by default). See the HTTP request layer for the auth / 401-refresh middleware and consuming backend responses for the envelope-unwrapping details.
Routing
router/routes.tsholds only static routes (login, error, shell/layout); the real menu tree is fetched from the backend after login and injected as dynamic routes (in-memory only, never persisted).- A menu node's
componentstring (e.g.system/user/index) is a page-table key — the path afterviews/, minus.vue; a page with the same key in an app'ssrc/views/replaces the built-in one. A route'sname = menu-${id}, mounted underlayout. - Logout / app switching uses
registerDynamic/resetRouterto add/remove dynamic routes precisely, not resetting the whole route tree. - An external-link menu (
pathholds a URL,componentleft empty) and an embedded-iframe menu (componentholds a URL) reuse existing fields instead of adding a new menu type;views/**/detail.vueis a convention-based detail route (/<module>/:id/detail), paired with theDetailPagecomponent anduseTabTitle(). Both conventions' mechanics are in Routing & Dynamic Menus.
Don't persist routesReady / menuTree
Persisting them skips the refresh-rebuild flow and sends you straight to a 404 after a refresh — these two pieces of state must live in memory only. See routing & dynamic menus for the rebuild mechanism.
State (Pinia)
defineStore+actions; persist selectively withpick— don't blindly persist a whole store in full.authpersists onlycurrentModuleId;tabspersists onlytabs, tosessionStorage.user's tokens and profile would log you out on a refresh if any one were missing, so they go through a customserializerthat writes the whole thing tolocalStorage; in Cookie-session mode that same serializer forces the token fields to empty strings and keeps only thecookieSessionflag.- Existing stores:
auth(module/menu/permission codes/routesReady),user(token/login state),app(theme/preferences),tabs(tab pages),dict(dictionary cache, session-scoped memory only, never persisted, invalidated viainvalidateafter any create/update/delete). Logout goes throughreset()to clear the auth state and the tabs.
Composables
- Named
use*, returning reactive refs and methods. - List pages uniformly use
smart-naive-table'sSmartTablein remote mode: pass it:fetcherwith the signature(p: { page, pageSize, ...params }) => Promise<{ items, total }>, and SmartTable manages pagination and loading itself. - Existing ones include
useConfirm(confirmation dialogs),useTabTitle(a detail page's dynamic tab title), anduseRealtime(the SignalR real-time client, started when the authenticated shell mounts) — usage is in each one's own source header comment and inweb/COMPONENTS.md.
Button-level permissions
<n-button v-auth="'POST:/api/v1/sys/user'">Add</n-button>- A single permission code takes a string; an array is OR by default, and the
.andmodifier does AND; a non-match hides the element withdisplay: none(reactive — it shows again once a permission refresh grants it), the element itself stays in the DOM. - Permission-code values are the backend's normalized routes (same source as
[RolePermission]), not custom-invented permission strings. See frontend permissions for details.
Shared components
- The admin backend has no component-demo menu; component usage is consolidated in
web/COMPONENTS.md— read it before writing a page to avoid reinventing the wheel, and update it when you add a new general-purpose component. - Existing ones include SmartTable / FormContainer /
useConfirm/ StatusSwitch / the dict components (DictSelect, DictTag) / OrgTreeSelect / FileUpload (chunkedfor resumable upload) / ApiSelect (from which UserSelect derives) / UserPicker / PasswordStrength / Chart / CodeBlock / MarkdownEditor / DetailPage (the detail-page shell, paired withuseTabTitle) / IconPicker, and more — treatweb/COMPONENTS.mdas the authoritative full list, and see the kernel package'scomponents/<component>/README.mdfor each one's detailed API. SmartTable is imported fromsmart-naive-table, everything else fromsmart-admin-web.
i18n
- All visible text in views goes through
t('...')— hardcoded Chinese/English literals are forbidden. - Error copy never comes from the backend: it sends
code+msgKey, andtranslateErrorresolves bymsgKeyfirst, falling back to a built-in numeric-code whitelist (CODE_MSG_KEYinutils/error.ts, covering only the kernel's own codes) when there's nomsgKey. So a locale key must mirror the backend's[MsgKey]string exactly — a backend taggingerror.dict.typeNotFoundneeds a frontend dictionary entry of the same name; a custom error code isn't in that whitelist, so a numeric entry likeerror: { 60001: '...' }is never read either — it still has to go through[MsgKey]. Built-in copy lives in the kernel package'slocales/zh-CN.ts/en-US.ts; your own goes in the app'ssrc/locales/ext/<locale>/<module>.ts, merged in throughcreateSmartAdmin({ locales }). See Internationalization for the mechanism.
Design system
- Business code consumes only the role-token layer (e.g.
--color-text-primary), never the primitive layer directly (e.g.--color-gray-500); the single source of tokens is the kernel package'sstyles/tokens.css, shipped to the app insidesmart-admin-web/style.css. - Component styles use
scoped+ CSS variables (var(--gap-card), etc.), never hardcoded colors/spacing. - Light/dark switches on
<html data-theme="dark">, defaulting to light when unset; role tokens / primary color / semantic colors / shadows all flip as a group under it. Seeweb/DESIGN.mdand Theme & Icons for the full spec.
Before committing
npm run lint # oxlint (lint:fix to autofix)
npm run typecheck # vue-tsc --noEmit
npm run build # vue-tsc --noEmit && vite buildOnly when all three pass is it done — don't run just one and assume you're fine. An app pulled from the template has no lint configured; the other two still have to pass. A change under the kernel repo's web/ also runs npm run format:check and npm test, which CI's frontend check runs as well.