References: Vue Official Style Guide, Element Plus Best Practices
UI Style Constraints
Strictly Prohibited (Common AI Style)
- ❌ Blue-purple neon gradients, glowing outlines, glassmorphism
- ❌ Large-area gradients, excessive decorative geometric shapes
- ❌ Cyberpunk style, dark tech style, AI-style UI
- ❌ Using emoji in UI copy
Back-office System (Default Style)
| Element | Requirement |
|---|---|
| Theme | Use the component library's default theme |
| Color Scheme | Primarily black, white, and gray + 1 primary color accent |
| Motion | Restrained, retain only necessary interaction feedback |
Tech Stack
| Tier | Vue (Preferred) | React (Alternative) |
|---|---|---|
| Framework | Vue 3 + TypeScript | React 18 + TypeScript |
| Build | Vite | Vite |
| Routing | Vue Router 4 | React Router 6 |
| State | Pinia | Zustand |
| UI Library | Element Plus | Ant Design |
Vue Coding Standards
Component Basics
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { User } from '@/types'
// Props & Emits
const props = defineProps<{ userId: number }>()
const emit = defineEmits<{ (e: 'update', value: string): void }>()
// 响应式状态
const loading = ref(false)
const user = ref<User | null>(null)
// 计算属性
const displayName = computed(() => user.value?.name ?? '未知用户')
// 生命周期
onMounted(async () => { await fetchUser() })
// 方法
async function fetchUser() {
loading.value = true
try {
user.value = await api.getUser(props.userId)
} finally {
loading.value = false
}
}
</script>
<template>
<div class="user-card">
<h3>{{ displayName }}</h3>
</div>
</template>
<style scoped>
.user-card { padding: 16px; }
</style>
Naming Conventions
| Type | Convention | Example |
|---|---|---|
| Component File | PascalCase.vue | UserCard.vue |
| Composables | useXxx.ts | useAuth.ts |
| Store | useXxxStore.ts | useUserStore.ts |
State Management (Pinia)
// stores/user.ts
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const token = ref<string>('')
const isLoggedIn = computed(() => !!token.value)
async function login(username: string, password: string) {
const res = await api.login(username, password)
token.value = res.token
user.value = res.user
}
return { user, token, isLoggedIn, login }
})
Interaction State Handling
States That Must Be Handled: loading、empty、error、disabled、submitting
<template>
<el-skeleton v-if="loading" :rows="5" animated />
<el-result v-else-if="error" icon="error" :title="error">
<template #extra>
<el-button @click="fetchData">重试</el-button>
</template>
</el-result>
<el-empty v-else-if="list.length === 0" description="暂无数据" />
<template v-else>
<!-- 正常内容 -->
</template>
</template>
TypeScript Standards
// types/user.ts
export interface User {
id: number
username: string
role: 'admin' | 'user'
}
export interface ApiResponse<T = unknown> {
code: number
message: string
data: T
}
API Call Type Safety
| Rule | Description |
|---|---|
| ✅ API utility functions support generics | get<T>(url): Promise<T> instead of returning unknown |
| ✅ Specify generics or assertions at the call site | get<UserInfo>(url) or data as typeof ref.value |
❌ Prohibit as any bypass |
Masking type issues leads to pitfalls in later maintenance |
// ❌ get() 返回 unknown,赋值报 TS2322
const data = await get('/contact/config')
contact.value = data
// ❌ as any 绕过
contact.value = data as any
// ✅ 泛型约束(推荐)
const data = await get<ContactConfig>('/contact/config')
contact.value = data
// ✅ 类型断言(最小改动)
contact.value = data as typeof contact.value
Performance Optimization
| Scenario | Solution |
|---|---|
| Large lists | Virtual scrolling |
| Routing | Lazy loading () => import() |
| Computation | Use computed caching |
| Large data | Use shallowRef |
// 路由懒加载
const routes = [
{ path: '/dashboard', component: () => import('@/views/Dashboard.vue') }
]
// 请求防抖
import { useDebounceFn } from '@vueuse/core'
const debouncedSearch = useDebounceFn((keyword) => api.search(keyword), 300)
Directory Structure
src/
├── assets/
│ └── styles/ # 全局/共享样式
├── api/ # API 请求
├── components/ # 通用组件
├── composables/ # 组合式函数
├── router/ # 路由配置
├── stores/ # Pinia stores
├── types/ # TypeScript 类型
├── utils/ # 工具函数
├── views/ # 页面组件
├── App.vue
└── main.ts
Style Management Standards
| Rule | Description |
|---|---|
❌ Prohibited in .vue writing large blocks of styles |
<style> block must not exceed 20 lines |
❌ Prohibited in .tsx writing large blocks of inline styles |
Style objects/CSS-in-JS must not exceed 20 lines |
✅ Extract shared styles to src/assets/styles/ |
Split files by module |
| ✅ Keep only minimal styles within components | Vue: scoped fine-tuning; React: className references |
src/assets/styles/
├── variables.scss # 变量(颜色、间距、字号)
├── common.scss # 通用样式
└── [module].scss # 按模块拆分
Request Body Completeness Standards
| Rule | Description |
|---|---|
| ❌ Prohibit not passing optional UI fields to the API | Fields selected/entered by the user must all be passed in the request body |
| ✅ Submit function corresponds one-to-one with form fields | Use a TypeScript interface to constrain the request body |
// ❌ UI 有支付方式选择器,但请求体没传 payMethod
const payMethod = ref<'wechat' | 'points' | 'mixed'>('wechat')
async function createOrder() {
await api.createOrder({
items: orderItems.value,
addressId: selectedAddress.value.id,
// payMethod 忘记传了!支付方式选择 UI 形同虚设
})
}
// ✅ 请求体与 UI 表单字段对应
interface CreateOrderRequest {
items: OrderItem[]
addressId: number
payMethod: 'wechat' | 'points' | 'mixed' // 类型约束确保不遗漏
}
async function createOrder() {
const request: CreateOrderRequest = {
items: orderItems.value,
addressId: selectedAddress.value.id,
payMethod: payMethod.value, // TypeScript 会提示缺少字段
}
await api.createOrder(request)
}
API Error Handling Standards
| Rule | Description |
|---|---|
| ❌ Must not silently ignore non-success responses | res.code !== 200 must prompt the user |
| ✅ Unified error prompts | Unified for non-success responses message.error prompt |
| ✅ Network exceptions must also be handled | try/catch catch request exceptions |
// ❌ 只处理成功,非 200 静默忽略
const res = await api.getList(params)
if (res.code === 200) {
list.value = res.data
}
// ✅ 成功 + 失败都处理
try {
const res = await api.getList(params)
if (res.code === 200) {
list.value = res.data
} else {
message.error(res.message || '加载失败')
}
} catch (e) {
message.error('网络异常,请稍后重试')
}
React/antd high-frequency pitfalls
This section applies to React + Ant Design projects (Vue + Element Plus users can skip). For similar Vue issues see references/frontend-style.md.
Pitfall #1: controlled vs uncontrolled misuse → user operations fail
<Table pagination={{ pageSize: 20 }}> Hardcoding pageSize to 20 → the component treats it as a controlled prop, so when the user switches "10 / 50 / 100" it is immediately overwritten back to 20. The bottom dropdown looks clickable but is actually ineffective.
| Prop | controlled form | uncontrolled form (recommended) |
|---|---|---|
| antd Table page size | pagination={{ pageSize, onChange }} |
pagination={{ defaultPageSize: 20 }} |
| antd Tree expand | expandedKeys + onExpand |
defaultExpandedKeys or defaultExpandAll |
| antd Input content | value + onChange |
defaultValue |
| antd Form fields | Form.Item + form instance | initialValue |
| antd Tabs current page | activeKey + onChange |
defaultActiveKey |
// ❌ controlled 但没 onChange → 用户切换无效
<Table pagination={{ pageSize: 20, showSizeChanger: true }} />
// ✅ uncontrolled,组件自己管
<Table pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
}} />
// ✅ 或完整 controlled
const [pageSize, setPageSize] = useState(20);
<Table pagination={{
pageSize,
onShowSizeChange: (_, size) => setPageSize(size),
showSizeChanger: true,
}} />
Decision rule: either provide the full set of "pass value + listen", or use defaultXxx。Do not pass a value without listening。
Pitfall #2: Tree defaultExpandAll + setState → reset back to fully expanded after editing
<Tree defaultExpandAll> is an uncontrolled default value, so every time setCategories(...) the Tree is re-rendered it gets reset → all the nodes the user painstakingly collapsed pop back open.
// ❌ 编辑分类名后整棵树被重置成全展开
<Tree treeData={treeData} defaultExpandAll />
// ✅ controlled expandedKeys + 只在初次 mount 时填充
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
const initialExpandDoneRef = useRef(false);
const loadCategories = async () => {
const data = (await getCategoryTree()).data || [];
setCategories(data);
if (!initialExpandDoneRef.current && data.length > 0) {
setExpandedKeys(collectAllCategoryKeys(data));
initialExpandDoneRef.current = true; // 关键:之后再 loadCategories 不重置
}
};
<Tree
treeData={treeData}
expandedKeys={expandedKeys}
onExpand={setExpandedKeys}
/>
Pitfall #3: useEffect double polling conflict → timer explosion
One useEffect uses setTimeout recursive polling, while another useEffect that depends on status also starts setInterval. Both existing together → each cycle triggers 2 API calls; status changes cause the second effect to rebuild the interval → leftover timers accumulate more and more.
// ❌ 双 polling 共存
useEffect(() => {
let timerId: NodeJS.Timeout | null = null;
const pollOnce = async () => {
const s = (await getStatus()).data;
setRefreshState(s);
if (s.status === 'RUNNING') {
timerId = setTimeout(pollOnce, 3000); // 第一套
}
};
pollOnce();
return () => { if (timerId) clearTimeout(timerId); };
}, []);
useEffect(() => {
if (refreshState?.status !== 'RUNNING') return;
const timerId = setInterval(async () => { // 第二套
setRefreshState((await getStatus()).data);
}, 3000);
return () => clearInterval(timerId);
}, [refreshState?.status]);
// ✅ 只能一套:初次 fetch 在 mount effect,polling 由 status effect 接管
useEffect(() => {
let cancelled = false;
getStatus().then(resp => { if (!cancelled) setRefreshState(resp.data); });
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (refreshState?.status !== 'RUNNING') return;
const timerId = setInterval(async () => {
const s = (await getStatus()).data;
setRefreshState(s);
if (s.status !== 'RUNNING') clearInterval(timerId);
}, 3000);
return () => clearInterval(timerId);
}, [refreshState?.status]);
See
cc-async-task-patternskill - Pitfall #5
Pitfall #4: Cross-component state synchronization using prop drilling or a global store is too heavy → revision counter pattern
Scenario: the category management tab changes a category name, and the dropdown in the product list tab needs to refresh. Introducing Redux/Zustand has a high cost, and passing props layer by layer is troublesome.
// ✅ revision counter:父级维护一个数字,子组件 useEffect 监听
const ProductPage = () => {
const [categoryRevision, setCategoryRevision] = useState(0);
const bumpCategoryRevision = () => setCategoryRevision(n => n + 1);
return <Tabs items={[
{ key: 'products', children: <ProductManagement categoryRevision={categoryRevision} /> },
{ key: 'categories', children: <CategoryManagement onCategoriesChanged={bumpCategoryRevision} /> },
]} />;
};
// ProductManagement 内部
useEffect(() => {
if (categoryRevision !== undefined && categoryRevision > 0) {
loadCategories(); // 任意 > 0 的变化都触发重新拉取
}
}, [categoryRevision]);
Why not use useContext? When components are far apart, changes are infrequent, and you don't want all Consumers to re-render, a revision counter is lighter than Context.
Pitfall #5: Asynchronous operations may time out → should switch to async task mode
When the frontend encounters a 30s API timeout, repeated user clicks, or a loading spinner stuck forever, the root cause is often that the backend is a synchronous API.Do not add timeout retries on the frontend, have the backend switch to trigger + polling mode (see cc-async-task-pattern skill). Frontend coordination:
| UX item | Implementation |
|---|---|
| Trigger button RUNNING state | loading + disabled, text changes to "回填中 N%" |
| Top progress prompt | <Alert type="info"> + <Progress percent={N} /> |
| Any user coming in sees the status | Call once on mount getStatus() |
| Automatically refresh the main list on completion | useEffect listen for status changes, on SUCCESS loadData() |
| Error pass-through | state.errorMessage directly message.error() |
Type reuse conventions
| Rule | Description |
|---|---|
| ❌ Do not repeatedly define the same interface in multiple files | PageResponse、BaseResult etc. |
✅ Common types are unified under @/types/common.ts |
global exports, referenced everywhere |
// ❌ 每个 api 文件都定义一遍
// api/user.ts
interface PageResponse<T> { list: T[]; total: number }
// api/order.ts
interface PageResponse<T> { list: T[]; total: number } // 重复
// ✅ 统一定义,各处引用
// types/common.ts
export interface PageResponse<T> {
list: T[]
total: number
}
// api/user.ts
import type { PageResponse } from '@/types/common'
Detailed reference
| File | Content |
|---|---|
references/frontend-style.md |
UI style, Vue 3 conventions, Pinia, API encapsulation, performance optimization |
references/miniapp-pitfalls.md |
uni-app pitfalls: page stack is read-only, Storage cleanup timing, lifecycle double triggering, frontend validation mirroring |
references/date-time.md |
dayjs/date-fns date addition and subtraction, accounting period calculation, month-end alignment prohibited |
📋 This reply follows:
cc-frontend-dev- [specific section]