- AI chat page with SSE streaming - Knowledge base management with chunk/vector preview - Workflow definition list page - Chunked file upload (>5MB auto-slicing) - Removed demo menus - Added file request client proxy config
689 lines
19 KiB
Markdown
689 lines
19 KiB
Markdown
# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
|
|
> **Cross-repo rules** — see `/Users/wen/project/rag/CLAUDE.md` for full workspace conventions. Key integration:
|
|
> - .NET backends share JWT key, response format: `{ code: 0, data, message: "ok" }`
|
|
> - Pagination: request `{ pageIndex: 1, pageSize: 20 }`, response `{ items: T[], total: number }`
|
|
> - userId from JWT: `useUserStore().userInfo?.userId` (GUID string)
|
|
> - Other repos: `rag-backend` (5211), `im-system` (5212), `work-flow`, `file-system` (8080 Go)
|
|
|
|
## Build & Dev Commands
|
|
|
|
```bash
|
|
pnpm dev:antd # Dev server (port 5666, API proxied to localhost:5211)
|
|
pnpm build:antd # Production build
|
|
pnpm lint # Lint (oxfmt + oxlint + eslint + stylelint)
|
|
pnpm format # Format
|
|
pnpm check:type # Type check (turbo typecheck)
|
|
pnpm test:unit # Unit tests (vitest)
|
|
pnpm test:unit -- --grep "pattern" # Single test by name
|
|
```
|
|
|
|
Node ^22.18.0, pnpm 10.33.0. If `pnpm` not in PATH: `corepack pnpm`.
|
|
|
|
If turbo fails to find pnpm, run type check directly:
|
|
```bash
|
|
cd apps/web-antd && npx vue-tsc --noEmit --skipLibCheck
|
|
```
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
Vue Vben Admin 5.7.0 monorepo. Single app: `apps/web-antd` + Ant Design Vue. `#/*` alias maps to `./src/*`.
|
|
|
|
### Three Backends, Three Request Clients
|
|
|
|
All clients defined in `src/api/request.ts`:
|
|
|
|
| Backend | Port | Client Variable | Proxy | Response Format |
|
|
|---------|------|----------------|-------|-----------------|
|
|
| Main .NET | 5211 | `requestClient` | `/api` → `localhost:5211` | `{ code: 0, data, message }` — interceptor strips to `data` |
|
|
| IM .NET | 5212 | `imRequestClient` (in `im.ts`) | direct | Same as main |
|
|
| File Go | 8080 | `fileRequestClient` | `/file-api` → `localhost:8080` | Raw response, PascalCase fields |
|
|
|
|
Bearer token from `useAccessStore()`. Auth refresh uses `baseRequestClient` (no interceptors) to avoid recursion.
|
|
|
|
### Routing
|
|
|
|
Route modules in `src/router/routes/modules/*.ts` are **auto-discovered** via `import.meta.glob('./modules/**/*.ts', { eager: true })`. No manual registration. Routes require authentication by default; core routes (auth, 404) bypass.
|
|
|
|
### State Management
|
|
|
|
All stores use **Pinia setup store** pattern (function-based, not options API). `defineStore('name', () => { ... })` with `ref()` for state, `computed()` for derived, plain functions for actions.
|
|
|
|
---
|
|
|
|
## Code Conventions (MUST follow)
|
|
|
|
### Import Order (strict, 4 blocks)
|
|
|
|
```typescript
|
|
// Block 1: Vue core — always first
|
|
import { computed, h, onMounted, ref } from 'vue';
|
|
|
|
// Block 2: Vben framework
|
|
import { Page } from '@vben/common-ui';
|
|
import { useUserStore } from '@vben/stores';
|
|
|
|
// Block 3: Ant Design Vue — destructure only what's used
|
|
import { Button, Form, Input, message, Modal, Space, Table, Tag } from 'ant-design-vue';
|
|
|
|
// Block 4: App API/types — use #/ alias
|
|
import { getXxxApi, SomeEnum, type XxxDto } from '#/api/core';
|
|
```
|
|
|
|
Never split Vue imports across multiple import statements. Never place app imports before framework imports.
|
|
|
|
### API Function Naming
|
|
|
|
`<verb><Resource>Api` — camelCase, always ends with `Api`:
|
|
|
|
```typescript
|
|
getWorkflowDefinitionsApi()
|
|
createWorkflowDefinitionApi(data)
|
|
deleteWorkflowDefinitionApi(id)
|
|
publishWorkflowDefinitionApi(id)
|
|
```
|
|
|
|
Resource IDs are bare `string` parameters, not wrapped in objects.
|
|
|
|
### Type Organization
|
|
|
|
Use `export namespace XxxApi` to group types within an API module:
|
|
|
|
```typescript
|
|
export namespace AiChatApi {
|
|
export interface Conversation { id: string; title: string; }
|
|
export interface Message { id: string; role: string; content: string; }
|
|
}
|
|
```
|
|
|
|
Enums (for status codes) are exported at file top level, not inside namespaces:
|
|
|
|
```typescript
|
|
export enum DefinitionStatus { Draft = 0, Published = 1, Disabled = 2 }
|
|
```
|
|
|
|
### Error Handling in Pages
|
|
|
|
Always use bare `catch` with explicit `message.error`. Always use `finally` for loading state in `loadData`:
|
|
|
|
```typescript
|
|
async function loadData() {
|
|
loading.value = true;
|
|
try {
|
|
const res = await getXxxApi(params);
|
|
data.value = res.items ?? []; // always ?? [] fallback
|
|
total.value = res.total ?? 0;
|
|
} catch {
|
|
message.error('加载xxx失败'); // Chinese error message
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
```
|
|
|
|
Action handlers (create, delete, approve) — no `finally`, just try/catch:
|
|
|
|
```typescript
|
|
async function handleCreate() {
|
|
try {
|
|
await createXxxApi(formState.value);
|
|
message.success('创建成功');
|
|
createVisible.value = false;
|
|
loadData();
|
|
} catch {
|
|
message.error('创建失败');
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## How to Add a New Feature Page
|
|
|
|
### Step 1: Create API Module
|
|
|
|
Create `src/api/core/feature-name.ts`:
|
|
|
|
```typescript
|
|
import { requestClient } from '#/api/request';
|
|
|
|
// Enums at top level
|
|
export enum FeatureStatus { Active = 0, Inactive = 1 }
|
|
|
|
// Types in namespace
|
|
export namespace FeatureApi {
|
|
export interface FeatureDto {
|
|
id: string;
|
|
name: string;
|
|
status: number;
|
|
createdAt: string;
|
|
}
|
|
}
|
|
|
|
// Generic pagination result (reuse per module)
|
|
export interface PagedFeatureResult {
|
|
items: FeatureApi.FeatureDto[];
|
|
total: number;
|
|
}
|
|
|
|
// API functions
|
|
export function getFeaturesApi(params?: { pageIndex?: number; pageSize?: number; status?: number }) {
|
|
return requestClient.get<PagedFeatureResult>('/features', { params });
|
|
}
|
|
|
|
export function createFeatureApi(data: { name: string; description?: string }) {
|
|
return requestClient.post('/features', data);
|
|
}
|
|
|
|
export function deleteFeatureApi(id: string) {
|
|
return requestClient.delete(`/features/${id}`);
|
|
}
|
|
```
|
|
|
|
Add to `src/api/core/index.ts`:
|
|
```typescript
|
|
export * from './feature-name';
|
|
```
|
|
|
|
### Step 2: Create Route Module
|
|
|
|
Create `src/router/routes/modules/feature-name.ts`:
|
|
|
|
```typescript
|
|
import type { RouteRecordRaw } from 'vue-router';
|
|
|
|
const routes: RouteRecordRaw[] = [
|
|
{
|
|
meta: {
|
|
icon: 'lucide:some-icon', // use lucide: prefix
|
|
order: 7, // controls menu sort
|
|
title: '功能管理',
|
|
},
|
|
name: 'Feature',
|
|
path: '/feature',
|
|
children: [
|
|
{
|
|
name: 'FeatureList',
|
|
path: '/feature/list',
|
|
component: () => import('#/views/feature/list/index.vue'),
|
|
meta: { title: '功能列表', icon: 'lucide:list' },
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
export default routes;
|
|
```
|
|
|
|
### Step 3: Create Vue Page
|
|
|
|
Create `src/views/feature/list/index.vue` using the template below.
|
|
|
|
---
|
|
|
|
## Page Templates
|
|
|
|
### Template A: Table CRUD Page (most common)
|
|
|
|
For list pages with pagination, create/edit modals, and row actions.
|
|
|
|
```vue
|
|
<script lang="ts" setup>
|
|
import { h, onMounted, ref } from 'vue';
|
|
|
|
import { Page } from '@vben/common-ui';
|
|
|
|
import {
|
|
Button,
|
|
Form,
|
|
Input,
|
|
message,
|
|
Modal,
|
|
Popconfirm,
|
|
Space,
|
|
Table,
|
|
Tag,
|
|
} from 'ant-design-vue';
|
|
|
|
import {
|
|
createFeatureApi,
|
|
deleteFeatureApi,
|
|
getFeaturesApi,
|
|
FeatureStatus,
|
|
type FeatureApi,
|
|
} from '#/api/core';
|
|
|
|
// Pagination state (always this exact set of 5 refs)
|
|
const loading = ref(false);
|
|
const data = ref<FeatureApi.FeatureDto[]>([]);
|
|
const total = ref(0);
|
|
const pageIndex = ref(1);
|
|
const pageSize = ref(20);
|
|
|
|
// Modal state
|
|
const createVisible = ref(false);
|
|
const formState = ref({ name: '', description: '' });
|
|
|
|
// Status map (always Record<number, { color: string; text: string }>)
|
|
const statusMap: Record<number, { color: string; text: string }> = {
|
|
[FeatureStatus.Active]: { color: 'green', text: '启用' },
|
|
[FeatureStatus.Inactive]: { color: 'red', text: '禁用' },
|
|
};
|
|
|
|
// Columns (plain const, not ref/computed)
|
|
const columns = [
|
|
{ title: '名称', dataIndex: 'name', key: 'name' },
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'status',
|
|
key: 'status',
|
|
width: 100,
|
|
customRender: ({ record }: { record: FeatureApi.FeatureDto }) => {
|
|
const s = statusMap[record.status];
|
|
return h(Tag, { color: s?.color }, () => s?.text ?? '未知');
|
|
},
|
|
},
|
|
{ title: '操作', key: 'actions', width: 200 },
|
|
];
|
|
|
|
async function loadData() {
|
|
loading.value = true;
|
|
try {
|
|
const res = await getFeaturesApi({
|
|
pageIndex: pageIndex.value,
|
|
pageSize: pageSize.value,
|
|
});
|
|
data.value = res.items ?? [];
|
|
total.value = res.total ?? 0;
|
|
} catch {
|
|
message.error('加载数据失败');
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function handleTableChange(pagination: any) {
|
|
pageIndex.value = pagination.current ?? 1;
|
|
pageSize.value = pagination.pageSize ?? 20;
|
|
loadData();
|
|
}
|
|
|
|
function openCreate() {
|
|
formState.value = { name: '', description: '' };
|
|
createVisible.value = true;
|
|
}
|
|
|
|
async function handleCreate() {
|
|
try {
|
|
await createFeatureApi(formState.value);
|
|
message.success('创建成功');
|
|
createVisible.value = false;
|
|
loadData();
|
|
} catch {
|
|
message.error('创建失败');
|
|
}
|
|
}
|
|
|
|
async function handleDelete(id: string) {
|
|
try {
|
|
await deleteFeatureApi(id);
|
|
message.success('删除成功');
|
|
loadData();
|
|
} catch {
|
|
message.error('删除失败');
|
|
}
|
|
}
|
|
|
|
onMounted(() => loadData());
|
|
</script>
|
|
|
|
<template>
|
|
<Page auto-content-height>
|
|
<div class="mb-4 flex justify-between">
|
|
<h3 class="text-lg font-semibold">功能管理</h3>
|
|
<Button type="primary" @click="openCreate">新建</Button>
|
|
</div>
|
|
|
|
<Table
|
|
:columns="columns"
|
|
:data-source="data"
|
|
:loading="loading"
|
|
:pagination="{
|
|
current: pageIndex,
|
|
pageSize,
|
|
total,
|
|
showSizeChanger: true,
|
|
showTotal: (t: number) => `共 ${t} 条`,
|
|
}"
|
|
row-key="id"
|
|
@change="handleTableChange"
|
|
>
|
|
<template #bodyCell="{ column, record }">
|
|
<template v-if="column.key === 'actions'">
|
|
<Space>
|
|
<Popconfirm title="确定删除?" @confirm="handleDelete(record.id)">
|
|
<Button size="small" danger>删除</Button>
|
|
</Popconfirm>
|
|
</Space>
|
|
</template>
|
|
</template>
|
|
</Table>
|
|
|
|
<Modal v-model:open="createVisible" title="新建" @ok="handleCreate">
|
|
<Form layout="vertical">
|
|
<Form.Item label="名称" required>
|
|
<Input v-model:value="formState.name" placeholder="请输入名称" />
|
|
</Form.Item>
|
|
<Form.Item label="描述">
|
|
<Input.TextArea v-model:value="formState.description" placeholder="可选" :rows="3" />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</Page>
|
|
</template>
|
|
```
|
|
|
|
### Template B: Dashboard / Monitor Page
|
|
|
|
For statistics cards + table, no pagination on the table.
|
|
|
|
```vue
|
|
<script lang="ts" setup>
|
|
import { computed, h, onMounted, ref } from 'vue';
|
|
|
|
import { Page } from '@vben/common-ui';
|
|
|
|
import { Card, message, Statistic, Table } from 'ant-design-vue';
|
|
|
|
import { getMonitorApi, type MonitorDto, type OverdueItemDto } from '#/api/core';
|
|
|
|
const loading = ref(false);
|
|
const monitor = ref<MonitorDto>({ /* default zeros */ });
|
|
const overdueData = ref<OverdueItemDto[]>([]);
|
|
|
|
const stats = computed(() => [
|
|
{ title: '总数', value: monitor.value.total, color: undefined },
|
|
{ title: '运行中', value: monitor.value.running, color: '#52c41a' },
|
|
]);
|
|
|
|
const columns = [
|
|
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
|
|
{ title: '处理人', dataIndex: 'assigneeId', key: 'assigneeId', width: 200 },
|
|
];
|
|
|
|
async function loadMonitor() {
|
|
loading.value = true;
|
|
try { monitor.value = await getMonitorApi(); }
|
|
catch { message.error('加载监控数据失败'); }
|
|
finally { loading.value = false; }
|
|
}
|
|
|
|
async function loadOverdue() {
|
|
try { const res = await getOverdueApi(); overdueData.value = res.items ?? []; }
|
|
catch { message.error('加载逾期数据失败'); }
|
|
}
|
|
|
|
onMounted(() => { loadMonitor(); loadOverdue(); });
|
|
</script>
|
|
|
|
<template>
|
|
<Page auto-content-height>
|
|
<div class="mb-4">
|
|
<h3 class="text-lg font-semibold">监控</h3>
|
|
</div>
|
|
<div class="mb-6 grid grid-cols-4 gap-4">
|
|
<Card v-for="stat in stats" :key="stat.title" :loading="loading">
|
|
<Statistic
|
|
:title="stat.title"
|
|
:value="stat.value"
|
|
:value-style="stat.color ? { color: stat.color } : undefined"
|
|
/>
|
|
</Card>
|
|
</div>
|
|
<Table :columns="columns" :data-source="overdueData" :loading="loading" :pagination="false" row-key="id" />
|
|
</Page>
|
|
</template>
|
|
```
|
|
|
|
### Template C: Approval / Action Page
|
|
|
|
For task lists with multiple action modals (approve, reject, delegate).
|
|
|
|
```vue
|
|
<script lang="ts" setup>
|
|
import { h, onMounted, ref } from 'vue';
|
|
|
|
import { Page } from '@vben/common-ui';
|
|
|
|
import { useUserStore } from '@vben/stores';
|
|
|
|
import { Button, Form, Input, message, Modal, Space, Table, Tag } from 'ant-design-vue';
|
|
|
|
import {
|
|
approveTaskApi,
|
|
rejectTaskApi,
|
|
getPendingTasksApi,
|
|
type TaskDto,
|
|
} from '#/api/core';
|
|
|
|
const userStore = useUserStore();
|
|
const userId = computed(() => userStore.userInfo?.userId ?? '');
|
|
import { computed } from 'vue';
|
|
|
|
const loading = ref(false);
|
|
const data = ref<TaskDto[]>([]);
|
|
const total = ref(0);
|
|
const pageIndex = ref(1);
|
|
const pageSize = ref(20);
|
|
|
|
// Each action gets its own visible ref + form refs
|
|
const approveVisible = ref(false);
|
|
const rejectVisible = ref(false);
|
|
const taskId = ref('');
|
|
const commentText = ref('');
|
|
|
|
const columns = [
|
|
{ title: '任务标题', dataIndex: 'title', key: 'title', ellipsis: true },
|
|
{ title: '操作', key: 'actions', width: 280 },
|
|
];
|
|
|
|
async function loadData() {
|
|
loading.value = true;
|
|
try {
|
|
const res = await getPendingTasksApi({
|
|
userId: userId.value,
|
|
pageIndex: pageIndex.value,
|
|
pageSize: pageSize.value,
|
|
});
|
|
data.value = res.items ?? [];
|
|
total.value = res.total ?? 0;
|
|
} catch {
|
|
message.error('加载任务失败');
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function openApprove(id: string) {
|
|
taskId.value = id;
|
|
commentText.value = '';
|
|
approveVisible.value = true;
|
|
}
|
|
|
|
async function handleApprove() {
|
|
try {
|
|
await approveTaskApi(taskId.value, userId.value, commentText.value || undefined);
|
|
message.success('审批通过');
|
|
approveVisible.value = false;
|
|
loadData();
|
|
} catch {
|
|
message.error('审批失败');
|
|
}
|
|
}
|
|
|
|
// ... openReject / handleReject follow same pattern
|
|
|
|
function handleTableChange(pagination: any) {
|
|
pageIndex.value = pagination.current ?? 1;
|
|
pageSize.value = pagination.pageSize ?? 20;
|
|
loadData();
|
|
}
|
|
|
|
onMounted(() => loadData());
|
|
</script>
|
|
|
|
<template>
|
|
<Page auto-content-height>
|
|
<div class="mb-4">
|
|
<h3 class="text-lg font-semibold">我的待办</h3>
|
|
</div>
|
|
<Table
|
|
:columns="columns" :data-source="data" :loading="loading"
|
|
:pagination="{ current: pageIndex, pageSize, total, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` }"
|
|
row-key="id" @change="handleTableChange"
|
|
>
|
|
<template #bodyCell="{ column, record }">
|
|
<template v-if="column.key === 'actions'">
|
|
<Space>
|
|
<Button size="small" type="primary" @click="openApprove(record.id)">审批</Button>
|
|
<Button size="small" danger @click="openReject(record.id)">拒绝</Button>
|
|
</Space>
|
|
</template>
|
|
</template>
|
|
</Table>
|
|
|
|
<Modal v-model:open="approveVisible" title="审批通过" @ok="handleApprove">
|
|
<Form layout="vertical">
|
|
<Form.Item label="审批意见">
|
|
<Input.TextArea v-model:value="commentText" placeholder="可选" :rows="3" />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</Page>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
## Reusable Patterns
|
|
|
|
### Status Tag in customRender
|
|
|
|
Always use this exact pattern for status columns:
|
|
|
|
```typescript
|
|
customRender: ({ record }: { record: XxxDto }) => {
|
|
const s = statusMap[record.status];
|
|
return h(Tag, { color: s?.color }, () => s?.text ?? '未知');
|
|
},
|
|
```
|
|
|
|
The `h(Tag, { color }, () => text)` 3-arg form is required. Third argument must be an arrow function.
|
|
|
|
### Date Formatting in customRender
|
|
|
|
```typescript
|
|
customRender: ({ text }: { text: string | null }) =>
|
|
text ? new Date(text).toLocaleString() : '-',
|
|
```
|
|
|
|
### Pagination Template (copy-paste)
|
|
|
|
```html
|
|
:pagination="{
|
|
current: pageIndex,
|
|
pageSize,
|
|
total,
|
|
showSizeChanger: true,
|
|
showTotal: (t: number) => `共 ${t} 条`,
|
|
}"
|
|
```
|
|
|
|
### Modal + Form Template
|
|
|
|
```html
|
|
<Modal v-model:open="someVisible" title="标题" @ok="handleSome">
|
|
<Form layout="vertical">
|
|
<Form.Item label="字段名" required>
|
|
<Input v-model:value="formState.field" placeholder="请输入" />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
```
|
|
|
|
### Delete with Confirmation
|
|
|
|
```html
|
|
<Popconfirm title="确定删除?" @confirm="handleDelete(record.id)">
|
|
<Button size="small" danger>删除</Button>
|
|
</Popconfirm>
|
|
```
|
|
|
|
---
|
|
|
|
## Backend Integration Context
|
|
|
|
### API Response Format (all .NET backends)
|
|
|
|
Success: `{ code: 0, data: { ... }, message: "ok" }` — `requestClient` interceptor strips to just `data`.
|
|
Error: `{ code: <httpStatus>, message: "错误描述", data: null }` — displayed by `errorMessageResponseInterceptor`.
|
|
|
|
### Pagination Convention
|
|
|
|
Backend uses page-based pagination:
|
|
- Request: `{ pageIndex: 1, pageSize: 20, status?: number }`
|
|
- Response: `{ items: T[], total: number }`
|
|
- Page indices start at 1 (not 0)
|
|
|
|
### Authentication
|
|
|
|
- Login: `POST /api/auth/login` with `{ username, password }` → `{ accessToken }`
|
|
- Token: JWT Bearer, attached by request interceptor
|
|
- userId: Extracted from JWT claims via `useUserStore().userInfo?.userId` (is a GUID string)
|
|
- Access codes (permissions): `GET /api/auth/codes` → `string[]`
|
|
|
|
### Backend Port Map
|
|
|
|
| Service | Dev Port | API Prefix | Proxy |
|
|
|---------|----------|------------|-------|
|
|
| rag-backend | 5211 | `/api` | vite proxy |
|
|
| im-system | 5212 | `/api` | direct (no proxy) |
|
|
| file-system | 8080 | `/` | `/file-api` → rewrite |
|
|
| work-flow | configurable | `/api` | vite proxy (same as rag-backend) |
|
|
|
|
### SSE Streaming (AI Chat)
|
|
|
|
Bypasses `requestClient` entirely. Uses native `fetch()` with Bearer token:
|
|
```typescript
|
|
const response = await fetch(`${apiUrl}/chat/conversations/${id}/stream`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({ content }),
|
|
});
|
|
return response.body; // ReadableStream<Uint8Array>
|
|
```
|
|
|
|
### SignalR (IM Chat)
|
|
|
|
Connects to `http://localhost:5212/hubs/chat` with `?access_token=` query parameter. Events: `OnMessageReceived`, `OnMessageRead`, `OnUserTyping`, `OnUserOnline`, `OnUserOffline`.
|
|
|
|
### File Upload
|
|
|
|
Uses `fileRequestClient` with `FormData` + `multipart/form-data` Content-Type. `X-API-Key` header for Go backend auth.
|
|
|
|
---
|
|
|
|
## Things to Avoid
|
|
|
|
- **No `computed` import split** — keep all Vue imports in one `import { ... } from 'vue'` statement
|
|
- **No `Record<string, any>` in templates** — cast `record` in `#bodyCell` if needed: `record as XxxDto`
|
|
- **No inline action API calls in `@click`** — always extract to a named function
|
|
- **No `useForm` composable** — form state is managed with plain `ref()`
|
|
- **No options API stores** — always use `defineStore('name', () => { ... })`
|
|
- **No hardcoded colors in status maps** — use Ant Design tag color names (blue, green, red, orange, cyan, purple)
|
|
- **No separate `h` import block** — `h` comes from the main `import { h, ... } from 'vue'` on line 1
|