rag-frontend/CLAUDE.md
向宁 49d8abf933 feat: add AI chat, knowledge base, workflow pages
- 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
2026-05-20 20:28:24 +08:00

19 KiB

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

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:

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 /apilocalhost: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-apilocalhost: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)

// 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:

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:

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:

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:

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:

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:

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:

export * from './feature-name';

Step 2: Create Route Module

Create src/router/routes/modules/feature-name.ts:

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.

<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.

<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).

<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:

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

customRender: ({ text }: { text: string | null }) =>
  text ? new Date(text).toLocaleString() : '-',

Pagination Template (copy-paste)

:pagination="{
  current: pageIndex,
  pageSize,
  total,
  showSizeChanger: true,
  showTotal: (t: number) => `共 ${t} 条`,
}"

Modal + Form Template

<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

<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/codesstring[]

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:

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 blockh comes from the main import { h, ... } from 'vue' on line 1