feat(knowledge): add retry endpoint and 20-char text name limit

Add POST /api/tenant/knowledge/items/retry/:id to re-queue failed parse
tasks, plus a 20-rune cap on text item names with matching frontend
validation. Wires the retry button into the knowledge table with a
colored status tag for clearer state feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 20:19:08 +08:00
parent c1f07bfb65
commit 39ef1a6b7f
6 changed files with 203 additions and 10 deletions
+3
View File
@@ -703,6 +703,9 @@ export const knowledgeApi = {
removeItem(id: number) {
return apiClient.remove<null>(`/api/tenant/knowledge/items/${id}`);
},
retryItem(id: number) {
return apiClient.post<KnowledgeItem, null>(`/api/tenant/knowledge/items/retry/${id}`, null);
},
};
export const imagesApi = {
+1
View File
@@ -53,6 +53,7 @@ const errorMessageMap: Record<string, string> = {
keyword_exists: "关键词已存在",
keyword_limit_reached: "关键词数量已达当前套餐上限",
question_limit_reached: "当前关键词下的问题数量已达上限",
knowledge_text_name_too_long: "文本名称不能超过 20 个字",
brand_not_found: "品牌不存在或已删除",
keyword_not_found: "关键词不存在或已删除",
publish_cover_required: "当前选择的平台要求上传封面图,请先上传封面图",
+81 -9
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { DeleteOutlined, EyeOutlined, MoreOutlined, PlusOutlined, UploadOutlined } from "@ant-design/icons-vue";
import { DeleteOutlined, EyeOutlined, MoreOutlined, PlusOutlined, ReloadOutlined, UploadOutlined } from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message, Modal, type TableColumnsType, type UploadProps } from "ant-design-vue";
import type { KnowledgeGroup, KnowledgeItem } from "@geo/shared-types";
@@ -13,6 +13,7 @@ import { formatError } from "@/lib/errors";
type KnowledgeSourceType = "document" | "website" | "text";
type KnowledgeGroupLevel = "root" | "child";
const MAX_WEBSITE_URLS = 3;
const MAX_TEXT_NAME_LENGTH = 20;
interface TreeNode {
title: string;
@@ -62,7 +63,7 @@ const columns: TableColumnsType<KnowledgeItem> = [
{ title: "大小", dataIndex: "size_bytes", key: "size_bytes", width: 120 },
{ title: "AI学习", dataIndex: "status", key: "status", width: 120 },
{ title: "上传时间", dataIndex: "created_at", key: "created_at", width: 180 },
{ title: "操作", key: "actions", width: 120, fixed: "right", align: "right" },
{ title: "操作", key: "actions", width: 160, fixed: "right", align: "right" },
];
const rootGroups = computed(() => groupsQuery.data.value ?? []);
@@ -177,6 +178,10 @@ const itemMutation = useMutation({
if (validTexts.length === 0) {
throw new Error("请至少输入一项完整的文本内容");
}
const oversizedText = validTexts.find((item) => [...item.name.trim()].length > MAX_TEXT_NAME_LENGTH);
if (oversizedText) {
throw new Error(`文本名称不能超过 ${MAX_TEXT_NAME_LENGTH} 个字`);
}
for (const txt of validTexts) {
promises.push(
knowledgeApi.createTextItem({
@@ -227,6 +232,15 @@ const deleteItemMutation = useMutation({
onError: (error) => message.error(formatError(error)),
});
const retryItemMutation = useMutation({
mutationFn: (id: number) => knowledgeApi.retryItem(id),
onSuccess: async () => {
message.success("已重新提交学习");
await queryClient.invalidateQueries({ queryKey: ["knowledge"] });
},
onError: (error) => message.error(formatError(error)),
});
watch(
() => groupsQuery.data.value,
(groups) => {
@@ -486,6 +500,22 @@ function formatStatus(value: string): string {
}
}
function statusColor(value: string): string {
switch (value) {
case "completed":
return "success";
case "failed":
return "error";
case "processing":
case "pending":
return "processing";
case "deleted":
return "default";
default:
return "default";
}
}
function formatSize(value: number): string {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
@@ -582,15 +612,27 @@ function closeItemDetail(): void {
{{ formatSize(record.size_bytes) }}
</template>
<template v-else-if="column.key === 'status'">
<span class="knowledge-status-text">
<a-tag :color="statusColor(record.status)" class="knowledge-status-tag">
{{ formatStatus(record.status) }}
</span>
</a-tag>
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<div class="table-actions-row">
<a-tooltip v-if="record.status === 'failed'" title="重试">
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-retry"
:loading="retryItemMutation.isPending.value && retryItemMutation.variables.value === record.id"
@click="retryItemMutation.mutate(record.id)"
>
<ReloadOutlined />
</a-button>
</a-tooltip>
<a-tooltip title="查看">
<a-button type="text" shape="circle" size="small" class="action-btn action-eye" @click="openItemDetail(record.id)">
<EyeOutlined />
@@ -698,7 +740,13 @@ function closeItemDetail(): void {
<div v-for="(txt, index) in textItems" :key="index" class="text-item-box">
<div class="text-item-row">
<span class="text-item-label">文本名称</span>
<a-input v-model:value="txt.name" placeholder="请输入文本名称" style="flex: 1;" />
<a-input
v-model:value="txt.name"
:maxlength="MAX_TEXT_NAME_LENGTH"
show-count
placeholder="请输入文本名称"
style="flex: 1;"
/>
</div>
<div class="text-item-row">
<span class="text-item-label">文本内容</span>
@@ -793,7 +841,7 @@ function closeItemDetail(): void {
}
.knowledge-sidebar__add {
color: #666;
color: #1677ff;
font-size: 13px;
cursor: pointer;
display: flex;
@@ -802,7 +850,7 @@ function closeItemDetail(): void {
}
.knowledge-sidebar__add:hover {
color: #1677ff;
color: #4096ff;
}
.tree-node-wrapper {
@@ -836,8 +884,31 @@ function closeItemDetail(): void {
color: #1677ff;
}
.knowledge-status-text {
color: #6b7280;
:deep(.ant-tree) {
width: 100%;
}
:deep(.ant-tree-treenode) {
width: 100%;
padding-bottom: 4px;
}
:deep(.ant-tree-node-content-wrapper) {
flex: 1 1 auto;
min-width: 0;
min-height: 36px;
display: flex;
align-items: center;
}
:deep(.ant-tree-title) {
flex: 1;
min-width: 0;
}
.knowledge-status-tag {
margin-inline-end: 0;
font-weight: 500;
}
/* Actions */
@@ -852,6 +923,7 @@ function closeItemDetail(): void {
color: #8c8c8c;
font-size: 14px;
}
.action-retry:hover { color: #1677ff; background: #e6f4ff; }
.action-eye:hover { color: #13c2c2; background: #e6fffb; }
.action-delete:hover { color: #ff4d4f; background: #fff2f0; }