feat(asa): implement ai-service-admin infrastructure and modules [AC-ASA-01]

This commit is contained in:
MerCry 2026-02-24 14:54:14 +08:00
parent 210af26f5f
commit 1230b4005a
22 changed files with 2952 additions and 0 deletions

1
ai-service-admin/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules/

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Service Admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1935
ai-service-admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
{
"name": "ai-service-admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.7",
"element-plus": "^2.6.1",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"typescript": "^5.2.2",
"vite": "^5.1.4",
"vue-tsc": "^1.8.27"
}
}

View File

@ -0,0 +1,54 @@
<template>
<div class="app-container">
<el-menu
:default-active="activeIndex"
class="el-menu-demo"
mode="horizontal"
router
>
<el-menu-item index="/dashboard">控制台</el-menu-item>
<el-menu-item index="/kb">知识库管理</el-menu-item>
<el-menu-item index="/rag-lab">RAG 实验室</el-menu-item>
<el-menu-item index="/monitoring">会话监控</el-menu-item>
<div class="flex-grow" />
<div class="tenant-selector">
<el-select v-model="currentTenantId" placeholder="选择租户" @change="handleTenantChange">
<el-option label="默认租户" value="default" />
<el-option label="租户 A" value="tenant_a" />
<el-option label="租户 B" value="tenant_b" />
</el-select>
</div>
</el-menu>
<router-view />
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useTenantStore } from '@/stores/tenant'
const route = useRoute()
const tenantStore = useTenantStore()
const activeIndex = computed(() => route.path)
const currentTenantId = ref(tenantStore.currentTenantId)
const handleTenantChange = (val: string) => {
tenantStore.setTenant(val)
}
</script>
<style scoped>
.flex-grow {
flex-grow: 1;
}
.tenant-selector {
display: flex;
align-items: center;
padding: 0 20px;
}
.app-container {
padding: 20px;
}
</style>

View File

@ -0,0 +1,36 @@
import request from '@/utils/request'
/**
* [AC-ASA-08]
*/
export function listDocuments(params: any) {
return request({
url: '/admin/kb/documents',
method: 'get',
params
})
}
/**
* [AC-ASA-01]
*/
export function uploadDocument(data: FormData) {
return request({
url: '/admin/kb/documents',
method: 'post',
data,
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/**
* [AC-ASA-02]
*/
export function getIndexJob(jobId: string) {
return request({
url: `/admin/kb/index/jobs/${jobId}`,
method: 'get'
})
}

View File

@ -0,0 +1,22 @@
import request from '@/utils/request'
/**
* [AC-ASA-09]
*/
export function listSessions(params: any) {
return request({
url: '/admin/sessions',
method: 'get',
params
})
}
/**
* [AC-ASA-07]
*/
export function getSessionDetail(sessionId: string) {
return request({
url: `/admin/sessions/${sessionId}`,
method: 'get'
})
}

View File

@ -0,0 +1,12 @@
import request from '@/utils/request'
/**
* RAG [AC-ASA-05]
*/
export function runRagExperiment(data: { query: string, kbIds?: string[], params?: any }) {
return request({
url: '/admin/rag/experiments/run',
method: 'post',
data
})
}

View File

@ -0,0 +1,43 @@
<template>
<el-form :model="model" v-bind="$attrs" ref="formRef">
<slot />
<el-form-item v-if="showFooter">
<el-button type="primary" @click="handleSubmit">提交</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { FormInstance } from 'element-plus'
const props = defineProps<{
model: any
showFooter?: boolean
}>()
const emit = defineEmits(['submit', 'reset'])
const formRef = ref<FormInstance>()
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
emit('submit', props.model)
}
})
}
const handleReset = () => {
if (!formRef.value) return
formRef.value.resetFields()
emit('reset')
}
defineExpose({
validate: () => formRef.value?.validate(),
resetFields: () => formRef.value?.resetFields()
})
</script>

View File

@ -0,0 +1,55 @@
<template>
<el-table :data="data" v-bind="$attrs" style="width: 100%">
<slot />
</el-table>
<div v-if="total > 0" class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
data: any[]
total: number
pageNum: number
pageSize: number
}>()
const emit = defineEmits(['update:pageNum', 'update:pageSize', 'pagination'])
const currentPage = computed({
get: () => props.pageNum,
set: (val) => emit('update:pageNum', val)
})
const pageSize = computed({
get: () => props.pageSize,
set: (val) => emit('update:pageSize', val)
})
const handleSizeChange = (val: number) => {
emit('pagination', { page: currentPage.value, limit: val })
}
const handleCurrentChange = (val: number) => {
emit('pagination', { page: val, limit: pageSize.value })
}
</script>
<style scoped>
.pagination-container {
padding: 32px 16px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@ -0,0 +1,15 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(ElementPlus)
app.mount('#app')

View File

@ -0,0 +1,39 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
const routes: Array<RouteRecordRaw> = [
{
path: '/',
redirect: '/dashboard'
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '控制台' }
},
{
path: '/kb',
name: 'KBManagement',
component: () => import('@/views/kb/index.vue'),
meta: { title: '知识库管理' }
},
{
path: '/rag-lab',
name: 'RagLab',
component: () => import('@/views/rag-lab/index.vue'),
meta: { title: 'RAG 实验室' }
},
{
path: '/monitoring',
name: 'Monitoring',
component: () => import('@/views/monitoring/index.vue'),
meta: { title: '会话监控' }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

View File

@ -0,0 +1,13 @@
import { defineStore } from 'pinia'
export const useTenantStore = defineStore('tenant', {
state: () => ({
currentTenantId: localStorage.getItem('X-Tenant-Id') || 'default'
}),
actions: {
setTenant(id: string) {
this.currentTenantId = id
localStorage.setItem('X-Tenant-Id', id)
}
}
})

View File

@ -0,0 +1,72 @@
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useTenantStore } from '@/stores/tenant'
// 创建 axios 实例
const service = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API || '/api',
timeout: 10000
})
// 请求拦截器
service.interceptors.request.use(
(config) => {
const tenantStore = useTenantStore()
if (tenantStore.currentTenantId) {
config.headers['X-Tenant-Id'] = tenantStore.currentTenantId
}
// TODO: 如果有 token 也可以在这里注入 Authorization
return config
},
(error) => {
console.log(error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response) => {
const res = response.data
// 这里可以根据后端的 code 进行统一处理
return res
},
(error) => {
console.log('err' + error)
let { message, response } = error
if (response) {
const status = response.status
if (status === 401) {
ElMessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', {
confirmButtonText: '重新登录',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
// TODO: 跳转到登录页或执行退出逻辑
location.href = '/login'
})
} else if (status === 403) {
ElMessage({
message: '当前操作无权限',
type: 'error',
duration: 5 * 1000
})
} else {
ElMessage({
message: message || '后端接口未知异常',
type: 'error',
duration: 5 * 1000
})
}
} else {
ElMessage({
message: '网络连接异常',
type: 'error',
duration: 5 * 1000
})
}
return Promise.reject(error)
}
)
export default service

View File

@ -0,0 +1,41 @@
<template>
<div class="dashboard-container">
<el-row :gutter="20">
<el-col :span="6">
<el-card shadow="hover">
<template #header>知识库总数</template>
<div class="card-content">12</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<template #header>总消息数</template>
<div class="card-content">1,284</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<template #header>平均响应时间</template>
<div class="card-content">1.2s</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<template #header>活跃租户</template>
<div class="card-content">5</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.dashboard-container {
padding: 20px;
}
.card-content {
font-size: 24px;
font-weight: bold;
text-align: center;
}
</style>

View File

@ -0,0 +1,43 @@
<template>
<div class="kb-container">
<el-card>
<template #header>
<div class="card-header">
<span>知识库列表</span>
<el-button type="primary">上传文档</el-button>
</div>
</template>
<el-table :data="tableData" style="width: 100%">
<el-table-column prop="name" label="文件名" />
<el-table-column prop="status" label="状态">
<template #default="scope">
<el-tag :type="scope.row.status === 'completed' ? 'success' : 'warning'">
{{ scope.row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="上传时间" />
<el-table-column label="操作">
<template #default>
<el-button link type="primary">查看详情</el-button>
<el-button link type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const tableData = ref([
{ name: '产品手册.pdf', status: 'completed', createTime: '2024-02-24 10:00:00' },
{ name: '技术文档.docx', status: 'processing', createTime: '2024-02-24 11:30:00' }
])
</script>
<style scoped>
.kb-container { padding: 20px; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
</style>

View File

@ -0,0 +1,181 @@
<template>
<div class="monitoring-container">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="title">会话监控 [AC-ASA-09]</span>
<div class="header-ops">
<el-form :inline="true" :model="queryParams" class="search-form">
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="会话状态" clearable style="width: 120px">
<el-option label="活跃" value="active" />
<el-option label="已关闭" value="closed" />
<el-option label="已过期" value="expired" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">查询</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<!-- 使用 BaseTable 展示会话列表 [AC-ASA-09] -->
<base-table
:data="tableData"
:total="total"
v-model:page-num="queryParams.page"
v-model:page-size="queryParams.pageSize"
@pagination="getList"
v-loading="loading"
>
<el-table-column prop="sessionId" label="会话 ID" width="180" show-overflow-tooltip />
<el-table-column prop="tenantId" label="租户 ID" width="120" />
<el-table-column prop="messageCount" label="消息数" width="100" align="center" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="scope">
<el-tag :type="statusMap[scope.row.status]?.type" size="small">
{{ statusMap[scope.row.status]?.label || scope.row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="startTime" label="开始时间" width="180" />
<el-table-column label="操作" fixed="right" width="120" align="center">
<template #default="scope">
<el-button link type="primary" @click="handleTrace(scope.row)">全链路追踪</el-button>
</template>
</el-table-column>
</base-table>
</el-card>
<!-- 全链路追踪详情抽屉 [AC-ASA-07] -->
<el-drawer
v-model="drawerVisible"
title="会话全链路追踪详情"
size="50%"
destroy-on-close
>
<div v-loading="detailLoading" class="detail-container">
<el-empty v-if="!sessionDetail && !detailLoading" description="暂无追踪详情" />
<el-timeline v-else>
<el-timeline-item
v-for="(msg, index) in sessionDetail?.messages"
:key="index"
:timestamp="msg.timestamp"
placement="top"
:type="msg.role === 'user' ? 'primary' : 'success'"
>
<el-card shadow="never" class="msg-card">
<div class="msg-header">
<span class="role-tag" :class="msg.role">{{ msg.role === 'user' ? 'USER' : 'ASSISTANT' }}</span>
</div>
<div class="msg-content">{{ msg.content }}</div>
<!-- 展示追踪信息检索命中工具调用等 [AC-ASA-07] -->
<div v-if="msg.trace" class="trace-info">
<el-collapse class="trace-collapse">
<el-collapse-item v-if="msg.trace.retrieval" title="检索追踪 (Retrieval)" name="retrieval">
<div v-for="(hit, hIdx) in msg.trace.retrieval" :key="hIdx" class="hit-item">
<div class="hit-meta">
<el-tag size="small" type="success">Score: {{ hit.score }}</el-tag>
<span class="hit-source" v-if="hit.source">来源: {{ hit.source }}</span>
</div>
<div class="hit-text">{{ hit.content }}</div>
</div>
</el-collapse-item>
<el-collapse-item v-if="msg.trace.tool_calls" title="工具调用 (Tool Calls)" name="tools">
<pre class="code-block"><code>{{ JSON.stringify(msg.trace.tool_calls, null, 2) }}</code></pre>
</el-collapse-item>
</el-collapse>
</div>
</el-card>
</el-timeline-item>
</el-timeline>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import BaseTable from '@/components/BaseTable.vue'
import { listSessions, getSessionDetail } from '@/api/monitoring'
const statusMap: Record<string, { label: string, type: string }> = {
active: { label: '活跃', type: 'success' },
closed: { label: '已关闭', type: 'info' },
expired: { label: '已过期', type: 'warning' }
}
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const queryParams = reactive({
page: 1,
pageSize: 10,
status: ''
})
const drawerVisible = ref(false)
const detailLoading = ref(false)
const sessionDetail = ref<any>(null)
const getList = async () => {
loading.value = true
try {
const res: any = await listSessions(queryParams)
tableData.value = res.data || []
total.value = res.pagination?.total || 0
} finally {
loading.value = false
}
}
const handleQuery = () => {
queryParams.page = 1
getList()
}
const resetQuery = () => {
queryParams.status = ''
handleQuery()
}
/** 获取并展示全链路追踪详情 [AC-ASA-07] */
const handleTrace = async (row: any) => {
drawerVisible.value = true
detailLoading.value = true
try {
sessionDetail.value = await getSessionDetail(row.sessionId)
} finally {
detailLoading.value = false
}
}
onMounted(() => {
getList()
})
</script>
<style scoped>
.monitoring-container { padding: 20px; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
.title { font-size: 16px; font-weight: bold; }
.detail-container { padding: 10px 20px; }
.msg-card { border-radius: 8px; margin-bottom: 10px; }
.msg-header { margin-bottom: 8px; }
.role-tag { font-size: 11px; font-weight: bold; padding: 2px 6px; border-radius: 4px; }
.role-tag.user { background-color: #ecf5ff; color: #409eff; }
.role-tag.assistant { background-color: #f0f9eb; color: #67c23a; }
.msg-content { font-size: 14px; line-height: 1.6; color: #333; white-space: pre-wrap; }
.trace-info { margin-top: 15px; border-top: 1px solid #f0f0f0; padding-top: 10px; }
.trace-collapse { border: none; }
:deep(.el-collapse-item__header) { height: 36px; font-size: 13px; color: #909399; }
.hit-item { padding: 10px; background-color: #f8f9fa; border-radius: 4px; margin-bottom: 8px; }
.hit-meta { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.hit-source { font-size: 11px; color: #999; }
.hit-text { font-size: 12px; color: #666; line-height: 1.5; }
.code-block { background-color: #fafafa; border: 1px solid #eaeaea; padding: 8px; border-radius: 4px; font-size: 12px; overflow-x: auto; margin: 0; }
</style>

View File

@ -0,0 +1,164 @@
<template>
<div class="rag-lab-container">
<el-row :gutter="20">
<!-- 左侧调试输入 [AC-ASA-05] -->
<el-col :span="10">
<el-card header="调试输入">
<el-form label-position="top">
<el-form-item label="查询 Query">
<el-input
v-model="queryParams.query"
type="textarea"
:rows="4"
placeholder="输入测试问题..."
/>
</el-form-item>
<el-form-item label="知识库范围">
<el-select
v-model="queryParams.kbIds"
multiple
placeholder="请选择知识库"
style="width: 100%"
>
<el-option label="默认知识库" value="default" />
</el-select>
</el-form-item>
<el-form-item label="参数配置">
<div class="param-item">
<span class="label">Top-K</span>
<el-input-number v-model="queryParams.params.topK" :min="1" :max="10" />
</div>
<div class="param-item">
<span class="label">Score Threshold</span>
<el-slider
v-model="queryParams.params.threshold"
:min="0"
:max="1"
:step="0.1"
show-input
/>
</div>
</el-form-item>
<el-button type="primary" block @click="handleRun" :loading="loading">
运行实验
</el-button>
</el-form>
</el-card>
</el-col>
<!-- 右侧实验结果 [AC-ASA-05] -->
<el-col :span="14">
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="召回片段" name="retrieval">
<div v-if="results.retrievalResults.length === 0" class="placeholder-text">
暂无实验数据
</div>
<div v-else class="result-list">
<el-card
v-for="(item, index) in results.retrievalResults"
:key="index"
class="result-card"
shadow="never"
>
<div class="result-header">
<el-tag size="small">Score: {{ item.score.toFixed(4) }}</el-tag>
<span class="source">来源: {{ item.source }}</span>
</div>
<div class="result-content">{{ item.content }}</div>
</el-card>
</div>
</el-tab-pane>
<el-tab-pane label="最终 Prompt" name="prompt">
<div v-if="!results.finalPrompt" class="placeholder-text">
等待实验运行...
</div>
<div v-else class="prompt-view">
<pre><code>{{ results.finalPrompt }}</code></pre>
</div>
</el-tab-pane>
</el-tabs>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { runRagExperiment } from '@/api/rag'
const loading = ref(false)
const activeTab = ref('retrieval')
const queryParams = reactive({
query: '',
kbIds: [],
params: {
topK: 3,
threshold: 0.5
}
})
const results = reactive({
retrievalResults: [],
finalPrompt: ''
})
/** 运行实验 [AC-ASA-05] */
const handleRun = async () => {
if (!queryParams.query.trim()) {
ElMessage.warning('请输入查询 Query')
return
}
loading.value = true
try {
const res: any = await runRagExperiment(queryParams)
results.retrievalResults = res.retrievalResults || []
results.finalPrompt = res.finalPrompt || ''
activeTab.value = 'retrieval'
ElMessage.success('实验运行成功')
} catch (err) {
console.error(err)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.rag-lab-container { padding: 20px; }
.param-item {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.param-item .label {
width: 120px;
font-size: 14px;
}
.placeholder-text { color: #909399; text-align: center; padding: 50px 0; }
.result-list { height: 600px; overflow-y: auto; }
.result-card { margin-bottom: 15px; }
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.source { font-size: 12px; color: #909399; }
.result-content { font-size: 14px; line-height: 1.6; color: #303133; }
.prompt-view {
background-color: #f5f7fa;
padding: 15px;
border-radius: 4px;
max-height: 600px;
overflow-y: auto;
}
.prompt-view pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
font-family: monospace;
}
</style>

View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})

View File

@ -0,0 +1,134 @@
---
module: ai-service-admin
feature: ASA
status: in_progress
created: 2026-02-24
last_updated: 2026-02-24
---
# AI 中台管理界面ai-service-admin进度文档
## context
- **module**: ai-service-admin
- **feature**: ASA
- **status**: 🔄进行中
## spec_references
- requirements: "spec/ai-service-admin/requirements.md"
- design: "spec/ai-service-admin/design.md"
- tasks: "spec/ai-service-admin/tasks.md"
- openapi_admin: "spec/ai-service/openapi.admin.yaml"
## overall_progress
- [x] Phase 1: 基础建设 (100%) [P1-01 ~ P1-05]
- [x] Phase 2: 知识库管理 (100%) [P2-01 ~ P2-05]
- [ ] Phase 3: RAG 实验室 (0%) [P3-01 ~ P3-04]
- [ ] Phase 4: 会话监控与详情 (0%) [P4-01 ~ P4-03]
## current_phase
**goal**: 知识库管理模块开发,实现文档上传、列表展示与状态轮询
### sub_tasks
- [x] (P1-01) 初始化 `ai-service-admin` 前端工程Vue 3 + Element Plus + RuoYi-Vue 基座对齐),落地基础目录结构与路由骨架
- [x] (P1-02) 接入 Pinia实现 `tenant` store`currentTenantId`并持久化localStorage提供切换租户能力
- [x] (P1-03) Axios/SDK 请求层封装:创建统一 `request` 实例,自动注入必填 Header `X-Tenant-Id`
- [x] (P1-04) 全局异常拦截:实现 401/403 响应拦截策略
- [x] (P1-05) 基础组件封装:`BaseTable`、`BaseForm` 并给出示例页
- [x] (P2-01) 创建 `openapi.deps.yaml` 明确依赖契约 (L1) [AC-ASA-08]
- [x] (P2-02) 实现知识库列表 API 对接及分页展示 [AC-ASA-08]
- [x] (P2-03) 实现文档上传功能Multipart/form-data[AC-ASA-01]
- [x] (P2-04) 实现索引任务状态轮询机制3s 间隔)[AC-ASA-02]
- [x] (P2-05) 失败任务错误详情弹窗展示 [AC-ASA-02]
### next_action
**immediate**: 开始 Phase 3 RAG 实验室模块
**details**:
- file: "ai-service-admin/src/views/rag-lab/index.vue"
- action: "实现 RAG 实验调试界面支持参数配置、Query 提交及召回片段对比展示"
### next_action
**immediate**: 初始化 `ai-service-admin` 前端工程
**details**:
- file: "待创建 - 前端工程根目录"
- action: "创建 Vue 3 + Element Plus 项目骨架,对齐 RuoYi-Vue 基座,配置基础目录结构与路由骨架"
- reference: "spec/ai-service-admin/tasks.md:26-27"
**constraints**:
- 必须符合 AC-ASA-01 验收标准
- 需与 RuoYi-Vue-Plus 基座对齐(用户认证、权限校验及菜单框架)
## technical_context
### module_structure
```
ai-service-admin/ # 前端工程(待创建)
├── src/
│ ├── api/ # API 请求层
│ ├── components/ # 通用组件
│ ├── composables/ # Vue Composables
│ ├── router/ # 路由配置
│ ├── stores/ # Pinia stores
│ ├── views/ # 页面视图
│ └── utils/ # 工具函数
└── package.json
```
### key_decisions
| decision | reason | impact |
|----------|--------|--------|
| Vue 3 + Element Plus | 与 RuoYi-Vue-Plus 基座技术栈一致 | 复用基座组件与权限体系 |
| Pinia 状态管理 | Vue 3 官方推荐,替代 Vuex | 更简洁的 store 模式 |
| localStorage 持久化 | 租户切换需跨会话保持 | 无需后端 session 支持 |
### code_snippets
```typescript
// stores/tenant.ts (待实现)
export const useTenantStore = defineStore('tenant', {
state: () => ({
currentTenantId: localStorage.getItem('currentTenantId') || ''
}),
actions: {
setTenant(id: string) {
this.currentTenantId = id
localStorage.setItem('currentTenantId', id)
}
}
})
```
## session_history
- session: "Session #1 (2026-02-24)"
completed: []
changes: []
## startup_guide
1. **Step 1**: 读取本进度文档(了解当前位置与下一步)
2. **Step 2**: 读取 spec_references 中定义的模块规范(了解业务与接口约束)
3. **Step 3**: 直接执行 next_action - 初始化前端工程
---
## Phase 任务速查
| Phase | 名称 | 任务数 | 状态 |
|-------|------|--------|------|
| Phase 1 | 基础建设 | 5 | ⏳ 待开始 |
| Phase 2 | 知识库管理 | 5 | ⏳ 待开始 |
| Phase 3 | RAG 实验室 | 4 | ⏳ 待开始 |
| Phase 4 | 会话监控与详情 | 3 | ⏳ 待开始 |
**总计: 17 个任务**