feat: restore learning feedback and knowledge capabilities
This commit is contained in:
Generated
+6131
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': set this to true or false
|
||||
esbuild: set this to true or false
|
||||
@@ -83,6 +83,30 @@ export type PracticeAssignmentRequest = {
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type PracticeDifficulty = {
|
||||
scenarioId: string;
|
||||
scenarioName: string;
|
||||
cause: string;
|
||||
count: number;
|
||||
affectedPeople: number;
|
||||
averageScore: number;
|
||||
latestTime: string;
|
||||
position?: string;
|
||||
assignmentStatus?: string;
|
||||
attempts?: number;
|
||||
mistakeCount?: number;
|
||||
difficultyScore?: number;
|
||||
};
|
||||
|
||||
export type PracticeAssignmentBatchResponse = {
|
||||
requestId: string;
|
||||
total: number;
|
||||
created: number;
|
||||
duplicate: number;
|
||||
failed: number;
|
||||
results: Array<{ extPartyId: string; traineeName: string; status: 'CREATED' | 'DUPLICATE' | 'FAILED'; message: string }>;
|
||||
};
|
||||
|
||||
export type PromotionEvidence = {
|
||||
extPartyId: string;
|
||||
currentLevel: string;
|
||||
@@ -244,6 +268,38 @@ export function createPracticeAssignment(data: PracticeAssignmentRequest): Promi
|
||||
});
|
||||
}
|
||||
|
||||
export function listPracticeDifficulties(params?: { windowDays?: number; position?: string }): Promise<ApiResult<PracticeDifficulty[]>> {
|
||||
type RawDifficulty = Omit<PracticeDifficulty, 'cause' | 'count'> & { attempts: number; mistakeCount: number };
|
||||
return request<unknown, ApiResult<RawDifficulty[]>>({
|
||||
url: '/api/aihr/mobile/practice/difficulties',
|
||||
method: 'get',
|
||||
params
|
||||
}).then((result) => ({
|
||||
...result,
|
||||
data: (result.data || []).map((item) => ({
|
||||
...item,
|
||||
cause: item.mistakeCount >= Math.max(2, Math.ceil(item.attempts / 2)) ? '高频错题或低分' : '近期低分记录',
|
||||
count: item.mistakeCount
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
export function assignPracticeDifficulty(data: {
|
||||
scenarioId: string;
|
||||
cause: string;
|
||||
dueDate: string;
|
||||
requestId: string;
|
||||
windowDays: number;
|
||||
position?: string;
|
||||
}): Promise<ApiResult<PracticeAssignmentBatchResponse>> {
|
||||
return request({
|
||||
url: '/api/aihr/mobile/practice/difficulties/assign',
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export function syncOrgSnapshot(data: OrgSyncRequest): Promise<ApiResult<OrgSyncResponse>> {
|
||||
return request({
|
||||
url: '/api/aihr/org/sync',
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
type ApiResult<T> = { code: number; msg: string; data: T };
|
||||
|
||||
export type AdminQuestionAnswer = {
|
||||
id: number;
|
||||
questionId: number;
|
||||
content: string;
|
||||
authorName: string;
|
||||
authorPosition?: string;
|
||||
status: string;
|
||||
best: boolean;
|
||||
recommended?: boolean;
|
||||
createTime: string;
|
||||
};
|
||||
|
||||
export type AdminQuestion = {
|
||||
id: number;
|
||||
type: 'HELP' | 'BOUNTY' | string;
|
||||
title: string;
|
||||
content: string;
|
||||
category: string;
|
||||
authorName: string;
|
||||
status: string;
|
||||
rewardPoints: number;
|
||||
rewardAccountType: 'POINTS' | 'STUDY_CREDIT' | string;
|
||||
answerCount: number;
|
||||
deadline?: string | null;
|
||||
createTime: string;
|
||||
bestAnswerId?: number | null;
|
||||
answers?: AdminQuestionAnswer[];
|
||||
};
|
||||
|
||||
export type IncentiveRule = {
|
||||
id?: number;
|
||||
ruleCode: string;
|
||||
displayName: string;
|
||||
accountType: 'POINTS' | 'STUDY_CREDIT';
|
||||
value: number;
|
||||
dailyCap?: number | null;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type RawAdminQuestionAnswer = Omit<AdminQuestionAnswer, 'createTime'> & { createdAt: string };
|
||||
type RawAdminQuestion = Omit<AdminQuestion, 'createTime' | 'answers'> & {
|
||||
createdAt: string;
|
||||
answers?: RawAdminQuestionAnswer[];
|
||||
};
|
||||
|
||||
const normalizeAnswer = (item: RawAdminQuestionAnswer): AdminQuestionAnswer => ({
|
||||
...item,
|
||||
createTime: item.createdAt
|
||||
});
|
||||
|
||||
const normalizeQuestion = (item: RawAdminQuestion): AdminQuestion => ({
|
||||
...item,
|
||||
createTime: item.createdAt,
|
||||
answers: (item.answers || []).map(normalizeAnswer)
|
||||
});
|
||||
|
||||
export const listAdminQuestions = (status?: string): Promise<ApiResult<AdminQuestion[]>> =>
|
||||
request<unknown, ApiResult<RawAdminQuestion[]>>({
|
||||
url: '/api/aihr/question-admin/questions', method: 'get', params: { status }
|
||||
}).then((result) => ({ ...result, data: (result.data || []).map(normalizeQuestion) }));
|
||||
|
||||
export const createBountyQuestion = (data: { title: string; content: string; category: string; rewardPoints: number; deadline: string }): Promise<ApiResult<AdminQuestion>> =>
|
||||
request<unknown, ApiResult<RawAdminQuestion>>({
|
||||
url: '/api/aihr/question-admin/questions', method: 'post', data: { ...data, ruleCode: 'BEST_ANSWER' }, headers: { repeatSubmit: false }
|
||||
}).then((result) => ({ ...result, data: normalizeQuestion(result.data) }));
|
||||
|
||||
export const reviewQuestion = (id: number, decision: 'APPROVE' | 'REJECT', note = ''): Promise<ApiResult<AdminQuestion>> =>
|
||||
request<unknown, ApiResult<RawAdminQuestion>>({
|
||||
url: `/api/aihr/question-admin/questions/${id}/review`, method: 'post', data: { decision, note }, headers: { repeatSubmit: false }
|
||||
}).then((result) => ({ ...result, data: normalizeQuestion(result.data) }));
|
||||
|
||||
export const reviewQuestionAnswer = (id: number, decision: 'APPROVE' | 'REJECT', note = ''): Promise<ApiResult<AdminQuestionAnswer>> =>
|
||||
request<unknown, ApiResult<RawAdminQuestionAnswer>>({
|
||||
url: `/api/aihr/question-admin/answers/${id}/review`, method: 'post', data: { decision, note }, headers: { repeatSubmit: false }
|
||||
}).then((result) => ({ ...result, data: normalizeAnswer(result.data) }));
|
||||
|
||||
export const selectBestAnswer = (questionId: number, answerId: number): Promise<ApiResult<unknown>> => request({
|
||||
url: `/api/aihr/question-admin/questions/${questionId}/best-answer`, method: 'post', data: { answerId }, headers: { repeatSubmit: false }
|
||||
});
|
||||
|
||||
export const listIncentiveRules = (): Promise<ApiResult<IncentiveRule[]>> => request({
|
||||
url: '/api/aihr/incentive-admin/rules', method: 'get'
|
||||
});
|
||||
|
||||
export const saveIncentiveRule = (data: IncentiveRule): Promise<ApiResult<IncentiveRule>> => request({
|
||||
url: '/api/aihr/incentive-admin/rules',
|
||||
method: data.id ? 'put' : 'post', data, headers: { repeatSubmit: false }
|
||||
});
|
||||
@@ -16,6 +16,9 @@ export type SopDoc = {
|
||||
export type SopSnippet = {
|
||||
title: string;
|
||||
text: string;
|
||||
fragmentId?: number | null;
|
||||
spaceCode?: string | null;
|
||||
sourceType?: string;
|
||||
};
|
||||
|
||||
export type SopRecord = {
|
||||
@@ -29,6 +32,7 @@ export type SopRecord = {
|
||||
};
|
||||
|
||||
export type SopSearchResponse = {
|
||||
requestId?: string;
|
||||
queryText: string;
|
||||
category: string;
|
||||
answer: string;
|
||||
@@ -41,6 +45,31 @@ export type SopSearchResponse = {
|
||||
cautions?: string[];
|
||||
promptVersion?: string | null;
|
||||
reviewId?: number;
|
||||
citations?: SopCitation[];
|
||||
usedSpaceCodes?: string[];
|
||||
noEvidence?: boolean;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
export type SopCitation = {
|
||||
spaceCode?: string | null;
|
||||
sourceType: string;
|
||||
docId?: string | null;
|
||||
title: string;
|
||||
snippet: string;
|
||||
fragmentId?: number | null;
|
||||
};
|
||||
|
||||
type UnifiedQueryResponse = {
|
||||
requestId: string;
|
||||
queryText: string;
|
||||
answer: string;
|
||||
citations: SopCitation[];
|
||||
usedSpaceCodes: string[];
|
||||
noEvidence: boolean;
|
||||
promptVersion?: string | null;
|
||||
legacy?: SopSearchResponse | null;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
export type SopReviewResponse = {
|
||||
@@ -92,12 +121,46 @@ export function searchKnowledgeSop(data: {
|
||||
position?: string;
|
||||
source?: string;
|
||||
limit: number;
|
||||
spaceCodes?: string[];
|
||||
toolCode?: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY';
|
||||
}): Promise<ApiResult<SopSearchResponse>> {
|
||||
return request({
|
||||
url: '/api/knowledge/search',
|
||||
return request<unknown, ApiResult<UnifiedQueryResponse>>({
|
||||
url: '/api/knowledge/query',
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { repeatSubmit: false }
|
||||
}).then((result) => {
|
||||
const response = result.data;
|
||||
const legacy = response.legacy || ({} as SopSearchResponse);
|
||||
const snippets = legacy.snippets?.length
|
||||
? legacy.snippets
|
||||
: (response.citations || []).map((item) => ({
|
||||
title: item.title,
|
||||
text: item.snippet,
|
||||
fragmentId: item.fragmentId,
|
||||
spaceCode: item.spaceCode,
|
||||
sourceType: item.sourceType
|
||||
}));
|
||||
return {
|
||||
...result,
|
||||
data: {
|
||||
...legacy,
|
||||
requestId: response.requestId,
|
||||
queryText: response.queryText,
|
||||
category: legacy.category || data.category || 'sop',
|
||||
answer: response.answer,
|
||||
reference: legacy.reference || response.citations.map((item) => item.title).join('、'),
|
||||
docs: legacy.docs || [],
|
||||
snippets,
|
||||
training: legacy.training || [],
|
||||
records: legacy.records || [],
|
||||
citations: response.citations || [],
|
||||
usedSpaceCodes: response.usedSpaceCodes || [],
|
||||
noEvidence: response.noEvidence,
|
||||
promptVersion: response.promptVersion,
|
||||
data: response.data
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,6 +212,7 @@ export type UploadItem = {
|
||||
batchId: string;
|
||||
fileName: string;
|
||||
category: string;
|
||||
spaceCodes: string[];
|
||||
status: number;
|
||||
statusLabel: string;
|
||||
error: string | null;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
type ApiResult<T> = { code: number; msg: string; data: T };
|
||||
|
||||
export type KnowledgeSpace = {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
spaceType: 'PUBLIC' | 'BUSINESS' | 'MANAGEMENT' | 'EXTERNAL';
|
||||
sensitivityLevel: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL';
|
||||
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
|
||||
documentCount: number;
|
||||
fragmentCount: number;
|
||||
};
|
||||
|
||||
export type SpaceForm = Pick<KnowledgeSpace, 'code' | 'name' | 'description' | 'spaceType' | 'sensitivityLevel' | 'status'>;
|
||||
|
||||
export type SpaceGrant = {
|
||||
principalType: 'ROLE' | 'USER';
|
||||
principalValue: string;
|
||||
permission: 'READ' | 'MANAGE';
|
||||
status: 'ACTIVE' | 'DISABLED';
|
||||
};
|
||||
|
||||
export type KnowledgeDocument = {
|
||||
attachId: number;
|
||||
docId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
ossId?: number | null;
|
||||
fragmentCount: number;
|
||||
updateTime?: string | null;
|
||||
};
|
||||
|
||||
export type UnbindDocumentResult = {
|
||||
attachId: number;
|
||||
spaceCode: string;
|
||||
ossId?: number | null;
|
||||
ossDeleted: boolean;
|
||||
};
|
||||
|
||||
export type KnowledgeApp = {
|
||||
id: number;
|
||||
appCode: string;
|
||||
appName: string;
|
||||
authType: 'SESSION' | 'API_TOKEN';
|
||||
internalClientKey?: string | null;
|
||||
status: 'ACTIVE' | 'DISABLED';
|
||||
rateLimitPerMinute: number;
|
||||
expiresTime?: string | null;
|
||||
lastUsedTime?: string | null;
|
||||
spaceCodes: string[];
|
||||
};
|
||||
|
||||
export type AppForm = {
|
||||
appCode: string;
|
||||
appName: string;
|
||||
authType: 'SESSION' | 'API_TOKEN';
|
||||
internalClientKey?: string | null;
|
||||
status: 'ACTIVE' | 'DISABLED';
|
||||
rateLimitPerMinute: number;
|
||||
expiresTime?: string | null;
|
||||
spaceCodes: string[];
|
||||
};
|
||||
|
||||
export type IssuedToken = { appCode: string; token: string; expiresTime?: string | null };
|
||||
export type CreatedApp = { app: KnowledgeApp; token?: string | null };
|
||||
|
||||
export const listKnowledgeSpaces = (): Promise<ApiResult<KnowledgeSpace[]>> =>
|
||||
request({ url: '/api/knowledge/admin/spaces', method: 'get' });
|
||||
|
||||
export const createKnowledgeSpace = (data: SpaceForm): Promise<ApiResult<KnowledgeSpace>> =>
|
||||
request({ url: '/api/knowledge/admin/spaces', method: 'post', data });
|
||||
|
||||
export const updateKnowledgeSpace = (id: number, data: SpaceForm): Promise<ApiResult<KnowledgeSpace>> =>
|
||||
request({ url: `/api/knowledge/admin/spaces/${id}`, method: 'put', data });
|
||||
|
||||
export const listSpaceDocuments = (id: number): Promise<ApiResult<KnowledgeDocument[]>> =>
|
||||
request({ url: `/api/knowledge/admin/spaces/${id}/documents`, method: 'get' });
|
||||
|
||||
export const unbindSpaceDocument = (id: number, attachId: number): Promise<ApiResult<UnbindDocumentResult>> =>
|
||||
request({ url: `/api/knowledge/admin/spaces/${id}/documents/${attachId}`, method: 'delete' });
|
||||
|
||||
export const listSpaceGrants = (id: number): Promise<ApiResult<SpaceGrant[]>> =>
|
||||
request({ url: `/api/knowledge/admin/spaces/${id}/grants`, method: 'get' });
|
||||
|
||||
export const replaceSpaceGrants = (id: number, grants: SpaceGrant[]): Promise<ApiResult<SpaceGrant[]>> =>
|
||||
request({ url: `/api/knowledge/admin/spaces/${id}/grants`, method: 'put', data: { grants } });
|
||||
|
||||
export const listKnowledgeApps = (): Promise<ApiResult<KnowledgeApp[]>> =>
|
||||
request({ url: '/api/knowledge/admin/apps', method: 'get' });
|
||||
|
||||
export const createKnowledgeApp = (data: AppForm): Promise<ApiResult<CreatedApp>> =>
|
||||
request({ url: '/api/knowledge/admin/apps', method: 'post', data });
|
||||
|
||||
export const updateKnowledgeApp = (
|
||||
id: number,
|
||||
data: Pick<AppForm, 'appName' | 'status' | 'rateLimitPerMinute' | 'expiresTime'>
|
||||
): Promise<ApiResult<KnowledgeApp>> => request({ url: `/api/knowledge/admin/apps/${id}`, method: 'put', data });
|
||||
|
||||
export const replaceKnowledgeAppSpaces = (id: number, spaceCodes: string[]): Promise<ApiResult<KnowledgeApp>> =>
|
||||
request({ url: `/api/knowledge/admin/apps/${id}/spaces`, method: 'put', data: { spaceCodes } });
|
||||
|
||||
export const rotateKnowledgeAppToken = (id: number, expiresTime?: string | null): Promise<ApiResult<IssuedToken>> =>
|
||||
request({ url: `/api/knowledge/admin/apps/${id}/rotate-token`, method: 'post', data: { expiresTime } });
|
||||
@@ -0,0 +1,47 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
type ApiResult<T> = { code: number; msg: string; data: T };
|
||||
|
||||
export interface WebSearchProvider {
|
||||
id: number;
|
||||
name: string;
|
||||
providerCode: 'tavily' | string;
|
||||
endpoint: string;
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
lastTestOk: boolean;
|
||||
lastTestTime?: string | null;
|
||||
}
|
||||
|
||||
export interface WebSearchProviderForm {
|
||||
id?: number;
|
||||
name: string;
|
||||
providerCode: 'tavily';
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export const listWebSearchProviders = (): Promise<ApiResult<WebSearchProvider[]>> => request({
|
||||
url: '/api/aihr/web-search/providers',
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
export const saveWebSearchProvider = (data: WebSearchProviderForm): Promise<ApiResult<WebSearchProvider>> => request({
|
||||
url: '/api/aihr/web-search/providers',
|
||||
method: data.id ? 'put' : 'post',
|
||||
data,
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
|
||||
export const toggleWebSearchProvider = (id: number, enabled: boolean): Promise<ApiResult<WebSearchProvider>> => request({
|
||||
url: `/api/aihr/web-search/providers/${id}/enabled`,
|
||||
method: 'patch',
|
||||
data: { enabled },
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
|
||||
export const testWebSearchProvider = (id: number): Promise<ApiResult<{ ok: boolean; sourceCount: number; message: string }>> => request({
|
||||
url: `/api/aihr/web-search/providers/${id}/test`,
|
||||
method: 'post',
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
@@ -43,11 +43,14 @@ const demoMenuTitles = new Set([
|
||||
'三角色对练',
|
||||
'复盘校准',
|
||||
'案例沉淀',
|
||||
'知识空间',
|
||||
'SOP知识库',
|
||||
'资料处理',
|
||||
'模型配置',
|
||||
'全网AI配置',
|
||||
'组织与权限',
|
||||
'成长激励'
|
||||
'成长激励',
|
||||
'问题榜运营'
|
||||
]);
|
||||
const getRouteMenuTitle = (route: RouteRecordRaw) => route.children?.find((child) => !child.hidden)?.meta?.title || route.meta?.title;
|
||||
const sidebarRouters = computed<RouteRecordRaw[]>(() =>
|
||||
|
||||
@@ -118,6 +118,24 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/train/reviews.vue'),
|
||||
name: 'TrainReviews',
|
||||
meta: { title: '复盘校准', icon: 'finish' }
|
||||
},
|
||||
{
|
||||
path: 'difficulties',
|
||||
component: () => import('@/views/train/difficulties.vue'),
|
||||
name: 'TrainDifficulties',
|
||||
meta: { title: '常见难题', icon: 'chart' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/community/questions',
|
||||
component: Layout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: () => import('@/views/community/questions.vue'),
|
||||
name: 'CommunityQuestions',
|
||||
meta: { title: '问题榜运营', icon: 'message' }
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -133,6 +151,18 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/knowledge/spaces',
|
||||
component: Layout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: () => import('@/views/knowledge/spaces.vue'),
|
||||
name: 'KnowledgeSpaces',
|
||||
meta: { title: '知识空间', icon: 'tree-table' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/knowledge/sop',
|
||||
component: Layout,
|
||||
@@ -175,6 +205,12 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/system/prompts.vue'),
|
||||
name: 'SystemPromptTemplates',
|
||||
meta: { title: '提示词模板', icon: 'edit' }
|
||||
},
|
||||
{
|
||||
path: 'web-search',
|
||||
component: () => import('@/views/system/web-search.vue'),
|
||||
name: 'SystemWebSearch',
|
||||
meta: { title: '全网AI配置', icon: 'search' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="community-page">
|
||||
<section class="page-head">
|
||||
<div><h1>问题榜运营</h1><p>审核真实工作问题与回答、选出最佳答案,并按规则发放积分或学习学分。</p></div>
|
||||
<div class="head-actions"><el-button type="danger" @click="openBounty">发布有奖征题</el-button><el-button :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button></div>
|
||||
</section>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="问题与回答" name="questions">
|
||||
<section class="filter-panel panel">
|
||||
<el-select v-model="status" clearable placeholder="全部状态" @change="loadQuestions">
|
||||
<el-option label="待审核" value="PENDING" /><el-option label="开放中" value="PUBLISHED" /><el-option label="已解决" value="CLOSED" /><el-option label="已拒绝" value="REJECTED" />
|
||||
</el-select>
|
||||
</section>
|
||||
<section class="panel table-panel">
|
||||
<el-table v-loading="loading" :data="questions" empty-text="暂无问题" @row-click="selectQuestion">
|
||||
<el-table-column prop="title" label="问题" min-width="260" show-overflow-tooltip />
|
||||
<el-table-column prop="type" label="类型" width="100"><template #default="{ row }"><el-tag :type="row.type === 'BOUNTY' ? 'warning' : 'info'">{{ row.type === 'BOUNTY' ? '有奖征题' : '员工求助' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="category" label="分类" width="120" />
|
||||
<el-table-column prop="authorName" label="作者" width="110" />
|
||||
<el-table-column prop="answerCount" label="回答" width="80" />
|
||||
<el-table-column label="奖励" width="120"><template #default="{ row }">{{ row.rewardPoints }} {{ rewardUnit(row.rewardAccountType) }}</template></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="120" />
|
||||
<el-table-column prop="createTime" label="时间" width="170" />
|
||||
</el-table>
|
||||
</section>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="奖励规则" name="rules">
|
||||
<el-alert type="info" :closable="false" show-icon title="积分与学习学分分账;本阶段无现金、提现或支付能力。" />
|
||||
<section class="panel rule-panel">
|
||||
<div class="panel-head"><h2>积分规则</h2><el-button type="danger" @click="editRule()">新增规则</el-button></div>
|
||||
<el-table :data="rules" empty-text="暂无奖励规则">
|
||||
<el-table-column prop="displayName" label="规则" min-width="180" /><el-table-column prop="ruleCode" label="编码" min-width="180" /><el-table-column prop="accountType" label="账户" width="130" /><el-table-column prop="value" label="分值" width="90" /><el-table-column prop="dailyCap" label="每日上限" width="100" /><el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</el-tag></template></el-table-column><el-table-column label="操作" width="90"><template #default="{ row }"><el-button link type="primary" @click="editRule(row)">编辑</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-drawer v-model="drawerVisible" title="问题审核" size="640px">
|
||||
<template v-if="selected">
|
||||
<div class="detail-head"><el-tag>{{ selected.category }}</el-tag><el-tag v-if="selected.rewardPoints" type="warning">{{ selected.rewardPoints }} {{ rewardUnit(selected.rewardAccountType) }}</el-tag></div>
|
||||
<h2>{{ selected.title }}</h2><p class="question-copy">{{ selected.content }}</p><p class="muted">{{ selected.authorName }} · {{ selected.createTime }}</p>
|
||||
<div v-if="selected.status === 'PENDING'" class="review-actions"><el-button type="danger" @click="reviewSelected('APPROVE')">审核通过</el-button><el-button @click="reviewSelected('REJECT')">拒绝</el-button></div>
|
||||
<el-divider>回答({{ selected.answers?.length || 0 }})</el-divider>
|
||||
<div class="answer-list">
|
||||
<div v-for="answer in selected.answers || []" :key="answer.id" class="answer-card" :class="{ best: answer.best }">
|
||||
<div class="answer-head"><strong>{{ answer.authorName }}</strong><el-tag v-if="answer.best" type="warning">最佳答案</el-tag><el-tag v-else>{{ answer.status }}</el-tag></div>
|
||||
<p>{{ answer.content }}</p>
|
||||
<div class="answer-actions">
|
||||
<el-button v-if="answer.status === 'PENDING'" link type="success" @click="reviewAnswer(answer.id, 'APPROVE')">通过</el-button>
|
||||
<el-button v-if="answer.status === 'PENDING'" link type="danger" @click="reviewAnswer(answer.id, 'REJECT')">拒绝</el-button>
|
||||
<el-button v-if="answer.status === 'PUBLISHED' && !selected.bestAnswerId" link type="warning" @click="makeBest(answer.id)">设为最佳</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="bountyVisible" title="发布有奖征题" width="560px">
|
||||
<el-form label-position="top"><el-form-item label="标题"><el-input v-model="bounty.title" maxlength="80" /></el-form-item><el-form-item label="分类"><el-input v-model="bounty.category" /></el-form-item><el-form-item label="问题说明"><el-input v-model="bounty.content" type="textarea" :rows="4" maxlength="1000" /></el-form-item><div class="form-grid"><el-form-item label="奖励数值(最多500)"><el-input-number v-model="bounty.rewardPoints" :min="0" :max="500" /></el-form-item><el-form-item label="截止日期"><el-date-picker v-model="bounty.deadline" type="date" value-format="YYYY-MM-DD" /></el-form-item></div></el-form>
|
||||
<template #footer><el-button @click="bountyVisible = false">取消</el-button><el-button type="danger" :loading="saving" @click="submitBounty">发布</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="ruleVisible" title="奖励规则" width="520px">
|
||||
<el-form label-position="top"><el-form-item label="规则名称"><el-input v-model="ruleForm.displayName" /></el-form-item><el-form-item label="规则编码"><el-input v-model="ruleForm.ruleCode" :disabled="Boolean(ruleForm.id)" /></el-form-item><div class="form-grid"><el-form-item label="账户"><el-select v-model="ruleForm.accountType"><el-option label="奖励积分" value="POINTS" /><el-option label="学习学分" value="STUDY_CREDIT" /></el-select></el-form-item><el-form-item label="分值"><el-input-number v-model="ruleForm.value" :min="0" :max="10000" /></el-form-item></div><div class="form-grid"><el-form-item label="每日上限"><el-input-number v-model="ruleDailyCap" :min="0" :max="100000" /></el-form-item><el-form-item label="状态"><el-switch v-model="ruleForm.enabled" active-text="启用" /></el-form-item></div></el-form>
|
||||
<template #footer><el-button @click="ruleVisible = false">取消</el-button><el-button type="danger" :loading="saving" @click="submitRule">保存</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="CommunityQuestions" lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { createBountyQuestion, listAdminQuestions, listIncentiveRules, reviewQuestion, reviewQuestionAnswer, saveIncentiveRule, selectBestAnswer, type AdminQuestion, type IncentiveRule } from '@/api/aihr/questions';
|
||||
const activeTab=ref('questions'); const loading=ref(false); const saving=ref(false); const status=ref(''); const questions=ref<AdminQuestion[]>([]); const rules=ref<IncentiveRule[]>([]); const selected=ref<AdminQuestion|null>(null); const drawerVisible=ref(false); const bountyVisible=ref(false); const ruleVisible=ref(false);
|
||||
const rewardUnit=(accountType?:string)=>accountType==='STUDY_CREDIT'?'学分':'积分';
|
||||
const bounty=reactive({title:'',content:'',category:'服务提升',rewardPoints:20,deadline:''}); const emptyRule=():IncentiveRule=>({ruleCode:'',displayName:'',accountType:'POINTS',value:0,dailyCap:null,enabled:true}); const ruleForm=reactive<IncentiveRule>(emptyRule()); const ruleDailyCap=computed({get:()=>ruleForm.dailyCap??0,set:(value:number)=>{ruleForm.dailyCap=value||null;}});
|
||||
const loadQuestions=async()=>{loading.value=true;try{questions.value=(await listAdminQuestions(status.value||undefined)).data||[];if(selected.value)selected.value=questions.value.find((item)=>item.id===selected.value?.id)||null;}catch{questions.value=[];ElMessage.error('问题列表读取失败');}finally{loading.value=false;}};
|
||||
const loadRules=async()=>{try{rules.value=(await listIncentiveRules()).data||[];}catch{rules.value=[];}}; const loadAll=()=>Promise.all([loadQuestions(),loadRules()]);
|
||||
const selectQuestion=(row:AdminQuestion)=>{selected.value=row;drawerVisible.value=true;}; const refreshSelected=async()=>{await loadQuestions();selected.value=questions.value.find((item)=>item.id===selected.value?.id)||null;};
|
||||
const reviewSelected=async(decision:'APPROVE'|'REJECT')=>{if(!selected.value)return;await reviewQuestion(selected.value.id,decision);ElMessage.success('审核结果已保存');await refreshSelected();};
|
||||
const reviewAnswer=async(id:number,decision:'APPROVE'|'REJECT')=>{await reviewQuestionAnswer(id,decision);ElMessage.success('回答审核已保存');await refreshSelected();};
|
||||
const makeBest=async(answerId:number)=>{if(!selected.value)return;await selectBestAnswer(selected.value.id,answerId);ElMessage.success('最佳答案奖励已按账户规则幂等记账');await refreshSelected();};
|
||||
const openBounty=()=>{Object.assign(bounty,{title:'',content:'',category:'服务提升',rewardPoints:20,deadline:''});bountyVisible.value=true;};
|
||||
const submitBounty=async()=>{if(!bounty.title.trim()||!bounty.content.trim())return ElMessage.warning('请完整填写征题内容');saving.value=true;try{await createBountyQuestion({...bounty,title:bounty.title.trim(),content:bounty.content.trim()});bountyVisible.value=false;ElMessage.success('有奖征题已发布');await loadQuestions();}finally{saving.value=false;}};
|
||||
const editRule=(row?:IncentiveRule)=>{Object.assign(ruleForm,emptyRule(),row||{});ruleVisible.value=true;}; const submitRule=async()=>{if(!ruleForm.ruleCode.trim()||!ruleForm.displayName.trim())return ElMessage.warning('请填写规则名称和编码');saving.value=true;try{await saveIncentiveRule({...ruleForm});ruleVisible.value=false;ElMessage.success('奖励规则已保存');await loadRules();}finally{saving.value=false;}};
|
||||
onMounted(loadAll);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.community-page{padding:24px;color:#172033}.page-head,.head-actions,.filter-panel,.panel-head,.detail-head,.answer-head,.answer-actions,.review-actions{display:flex;gap:12px;align-items:center}.page-head,.panel-head,.answer-head{justify-content:space-between}.page-head{margin-bottom:16px}.page-head h1{margin:0;font-size:28px}.page-head p{margin:8px 0 0;color:#667085}.panel{border:1px solid #e4e7ec;border-radius:12px;background:#fff;box-shadow:0 4px 18px rgba(16,24,40,.05)}.filter-panel{padding:14px}.table-panel,.rule-panel{margin-top:14px;padding:8px}.rule-panel{padding:18px}.question-copy,.answer-card p{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.7}.muted{color:#667085}.review-actions{margin:18px 0}.answer-list{display:grid;gap:12px}.answer-card{padding:14px;border:1px solid #e4e7ec;border-radius:10px}.answer-card.best{border-color:#e6a23c;background:#fffaf0}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}@media(max-width:1100px){.community-page{padding:16px}.page-head{align-items:flex-start}.form-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="processing-page">
|
||||
<input ref="fileInput" class="hidden-input" type="file" multiple :accept="acceptTypes" @change="handlePickedFiles" />
|
||||
<input ref="directoryInput" class="hidden-input" type="file" multiple webkitdirectory directory @change="handlePickedFiles" />
|
||||
|
||||
<section class="page-head">
|
||||
<div>
|
||||
@@ -9,13 +8,10 @@
|
||||
<p>批量导入业务资料,跟踪解析、切片、向量化与入库状态。</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<label class="auto-classify-toggle">
|
||||
<span>智能归类</span>
|
||||
<el-switch v-model="autoClassify" active-color="#e60012" />
|
||||
</label>
|
||||
<el-select v-model="uploadSpaceCodes" multiple collapse-tags placeholder="选择目标知识空间" style="width: 280px">
|
||||
<el-option v-for="space in manageableSpaces" :key="space.code" :label="space.name" :value="space.code" />
|
||||
</el-select>
|
||||
<el-button type="danger" :icon="Upload" :loading="uploading" @click="fileInput?.click()">批量导入</el-button>
|
||||
<el-button :icon="FolderChecked" :loading="uploading" @click="directoryInput?.click()">选择目录</el-button>
|
||||
<el-button :icon="FolderOpened" :loading="serverImporting || runningImport" @click="importServerDirectory">服务端导入</el-button>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadOverview">刷新</el-button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -49,6 +45,7 @@
|
||||
</ul>
|
||||
<el-table v-if="uploadBatch.length" :data="uploadBatch" size="small" max-height="260" class="batch-table">
|
||||
<el-table-column prop="fileName" label="文件名" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="目标空间" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.spaceCodes?.join('、') || '-' }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="96">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="batchItemTagType(row.status)">{{ row.statusLabel }}</el-tag>
|
||||
@@ -106,18 +103,18 @@
|
||||
<main class="panel task-panel">
|
||||
<div class="panel-title task-title">
|
||||
<h2>解析任务</h2>
|
||||
<el-select v-model="fileType" class="type-select" size="small">
|
||||
<el-select v-model="fileType" class="type-select" size="small" @change="resetTaskPage">
|
||||
<el-option v-for="item in fileTypes" :key="item" :label="`文件类型:${item}`" :value="item" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="task-tabs">
|
||||
<button v-for="tab in statusTabs" :key="tab" type="button" :class="{ active: activeStatus === tab }" @click="activeStatus = tab">
|
||||
<button v-for="tab in statusTabs" :key="tab" type="button" :class="{ active: activeStatus === tab }" @click="selectTaskStatus(tab)">
|
||||
{{ tab }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="filteredTasks" class="task-table" size="small">
|
||||
<el-table v-loading="loading" :data="pagedTasks" class="task-table" size="small">
|
||||
<el-table-column prop="fileName" label="文件名" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<div class="file-cell">
|
||||
@@ -156,7 +153,15 @@
|
||||
|
||||
<footer class="table-footer">
|
||||
<span>共 {{ filteredTasks.length }} 条</span>
|
||||
<el-pagination small background layout="prev, pager, next, sizes" :total="filteredTasks.length" :page-size="10" />
|
||||
<el-pagination
|
||||
v-model:current-page="taskPage"
|
||||
v-model:page-size="taskPageSize"
|
||||
small
|
||||
background
|
||||
layout="prev, pager, next, sizes"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="filteredTasks.length"
|
||||
/>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
@@ -211,41 +216,6 @@
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section v-if="importTasks.length > 0 || importTaskLoading" class="panel import-panel">
|
||||
<div class="panel-title compact">
|
||||
<h2>导入任务</h2>
|
||||
<el-button :icon="Refresh" :loading="importTaskLoading" size="small" @click="loadImportTasks">刷新</el-button>
|
||||
</div>
|
||||
<el-table v-loading="importTaskLoading" :data="importTasks" size="small" class="import-table">
|
||||
<el-table-column prop="directory" label="目录" min-width="180" />
|
||||
<el-table-column prop="category" label="分类" width="120" />
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<span class="status-pill" :class="`pill-${row.statusType}`">{{ row.status }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="进度" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="row.progress" :stroke-width="6" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="文件" width="150">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.importedFiles }}/{{ row.totalFiles }}</span>
|
||||
<span v-if="row.failedFiles > 0" class="failed-count">失败 {{ row.failedFiles }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="currentFile" label="当前文件" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="updatedAt" label="更新时间" width="138" />
|
||||
<el-table-column label="操作" width="104">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status === '导入中'" link type="danger" @click="cancelImportTask(row)">取消</el-button>
|
||||
<el-button v-if="row.status !== '导入中'" link type="primary" @click="retryImportTask(row)">重试</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="panel event-panel">
|
||||
<div class="panel-title compact">
|
||||
<h2>最近事件</h2>
|
||||
@@ -267,32 +237,14 @@
|
||||
</template>
|
||||
|
||||
<script setup name="KnowledgeProcessing" lang="ts">
|
||||
import {
|
||||
CircleCheck,
|
||||
Document,
|
||||
Files,
|
||||
FolderChecked,
|
||||
FolderOpened,
|
||||
Operation,
|
||||
Refresh,
|
||||
Search,
|
||||
Upload,
|
||||
WarningFilled
|
||||
} from '@element-plus/icons-vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { CircleCheck, Document, Files, Operation, Refresh, Search, Upload, WarningFilled } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import {
|
||||
cancelLocalKnowledgeImportTask,
|
||||
getProcessingOverview,
|
||||
listLocalKnowledgeImportTasks,
|
||||
startLocalKnowledgeImportTask,
|
||||
type LocalImportTask,
|
||||
type ProcessingOverview
|
||||
} from '@/api/aihr/processing';
|
||||
import { getProcessingOverview, type ProcessingOverview, type ProcessingTask } from '@/api/aihr/processing';
|
||||
import { uploadKnowledgeDocAsync, listUploadItems, retryUploadItem, type UploadItem } from '@/api/aihr/sop';
|
||||
import { listKnowledgeSpaces, type KnowledgeSpace } from '@/api/aihr/space';
|
||||
|
||||
const acceptTypes = '.txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v,.zip';
|
||||
const autoCategory = '__auto__';
|
||||
const supportedPattern = /\.(txt|md|markdown|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|webp|bmp|mp4|mov|avi|mkv|webm|m4v|zip)$/i;
|
||||
const videoPattern = /\.(mp4|mov|avi|mkv|webm|m4v)$/i;
|
||||
const archivePattern = /\.zip$/i;
|
||||
@@ -303,25 +255,23 @@ const browserBatchWarnCount = 20;
|
||||
const browserBatchWarnSize = 200 * 1024 * 1024;
|
||||
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
const directoryInput = ref<HTMLInputElement>();
|
||||
const loading = ref(false);
|
||||
const uploading = ref(false);
|
||||
const serverImporting = ref(false);
|
||||
const importTaskLoading = ref(false);
|
||||
const overview = ref<ProcessingOverview | null>(null);
|
||||
const overviewFailed = ref(false);
|
||||
const importTasks = ref<LocalImportTask[]>([]);
|
||||
const categoryKeyword = ref('');
|
||||
const selectedCategory = ref('住宅 SOP');
|
||||
const autoClassify = ref(true);
|
||||
const manageableSpaces = ref<KnowledgeSpace[]>([]);
|
||||
const uploadSpaceCodes = ref<string[]>([]);
|
||||
const activeStatus = ref('全部');
|
||||
const fileType = ref('全部');
|
||||
const taskPage = ref(1);
|
||||
const taskPageSize = ref(10);
|
||||
const uploadBatch = ref<UploadItem[]>([]);
|
||||
const uploadBatchId = ref('');
|
||||
const enqueueFailures = ref<string[]>([]);
|
||||
const retryingId = ref<number | null>(null);
|
||||
let importPollTimer: ReturnType<typeof window.setTimeout> | undefined;
|
||||
let batchPollTimer: ReturnType<typeof window.setTimeout> | undefined;
|
||||
let batchPollTimer: number | undefined;
|
||||
|
||||
const statusTabs = ['全部', '等待解析', '解析中', '已完成', '失败'];
|
||||
const fileTypes = ['全部', 'Word', 'PDF', 'PPT', 'Excel', '图片', '视频'];
|
||||
@@ -456,15 +406,15 @@ const filteredCategories = computed(() => {
|
||||
return displayOverview.value.categories.filter((item) => !keyword || item.name.toLowerCase().includes(keyword));
|
||||
});
|
||||
const filteredTasks = computed(() => displayOverview.value.tasks.filter((item) => matchStatus(item.status) && matchFileType(item.fileType)));
|
||||
const runningImport = computed(() => importTasks.value.some((item) => item.status === '导入中'));
|
||||
|
||||
const pagedTasks = computed(() => {
|
||||
const start = (taskPage.value - 1) * taskPageSize.value;
|
||||
return filteredTasks.value.slice(start, start + taskPageSize.value);
|
||||
});
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadOverview(), loadImportTasks()]);
|
||||
scheduleImportPoll();
|
||||
await Promise.all([loadOverview(), loadManageableSpaces()]);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearImportPoll();
|
||||
clearBatchPoll();
|
||||
// 置空批次 ID,让在途的轮询请求返回后直接丢弃,不再重新挂起定时器
|
||||
uploadBatchId.value = '';
|
||||
@@ -477,6 +427,7 @@ async function loadOverview() {
|
||||
overview.value = data;
|
||||
overviewFailed.value = false;
|
||||
selectedCategory.value = data.categories[0]?.name || selectedCategory.value;
|
||||
resetTaskPage();
|
||||
} catch {
|
||||
overview.value = null;
|
||||
overviewFailed.value = true;
|
||||
@@ -485,6 +436,15 @@ async function loadOverview() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadManageableSpaces() {
|
||||
try {
|
||||
manageableSpaces.value = ((await listKnowledgeSpaces()).data || []).filter((item) => item.status === 'ACTIVE');
|
||||
uploadSpaceCodes.value = manageableSpaces.value.slice(0, 1).map((item) => item.code);
|
||||
} catch {
|
||||
manageableSpaces.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePickedFiles(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = Array.from(input.files || []);
|
||||
@@ -493,6 +453,10 @@ async function handlePickedFiles(event: Event) {
|
||||
}
|
||||
|
||||
async function uploadFiles(files: File[]) {
|
||||
if (uploadSpaceCodes.value.length === 0) {
|
||||
ElMessage.warning('请先选择至少一个可管理的知识空间');
|
||||
return;
|
||||
}
|
||||
const readyFiles = files.filter(
|
||||
(file) =>
|
||||
supportedPattern.test(file.name) &&
|
||||
@@ -509,7 +473,7 @@ async function uploadFiles(files: File[]) {
|
||||
|
||||
const totalSize = readyFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
if (readyFiles.length > browserBatchWarnCount || totalSize > browserBatchWarnSize) {
|
||||
ElMessage.warning('浏览器批量适合少量文件;大批量资料请放到导入根目录后用服务端导入任务');
|
||||
ElMessage.warning('文件较多,建议压缩为 ZIP 后批量导入');
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
@@ -523,7 +487,7 @@ async function uploadFiles(files: File[]) {
|
||||
for (const file of readyFiles) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('category', categoryPayload());
|
||||
uploadSpaceCodes.value.forEach((code) => form.append('spaceCodes', code));
|
||||
form.append('batchId', batchId);
|
||||
try {
|
||||
await uploadKnowledgeDocAsync(form);
|
||||
@@ -622,91 +586,6 @@ function uploadFailureMessage(error: unknown) {
|
||||
return '上传失败';
|
||||
}
|
||||
|
||||
async function importServerDirectory() {
|
||||
let directory = '.';
|
||||
try {
|
||||
const result = await ElMessageBox.prompt('只允许导入后端 AIHR_IMPORT_ROOT / aihr.import.root 下的相对目录。', '服务端目录导入', {
|
||||
confirmButtonText: '开始导入',
|
||||
cancelButtonText: '取消',
|
||||
inputValue: '.',
|
||||
inputPlaceholder: '例如:住宅SOP 或 .'
|
||||
});
|
||||
directory = String(result.value || '.').trim() || '.';
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
serverImporting.value = true;
|
||||
try {
|
||||
const { data } = await startLocalKnowledgeImportTask({ directory, category: categoryPayload(), limit: 500 });
|
||||
ElMessage.success(`导入任务已启动:${data.totalFiles} 个文件`);
|
||||
await Promise.all([loadImportTasks(), loadOverview()]);
|
||||
scheduleImportPoll();
|
||||
} finally {
|
||||
serverImporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadImportTasks() {
|
||||
importTaskLoading.value = true;
|
||||
try {
|
||||
const { data } = await listLocalKnowledgeImportTasks();
|
||||
importTasks.value = data || [];
|
||||
} catch {
|
||||
importTasks.value = [];
|
||||
} finally {
|
||||
importTaskLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function retryImportTask(row: LocalImportTask) {
|
||||
serverImporting.value = true;
|
||||
try {
|
||||
await startLocalKnowledgeImportTask({ directory: row.directory, category: row.category, limit: 500 });
|
||||
ElMessage.success('已重新启动导入任务');
|
||||
await loadImportTasks();
|
||||
scheduleImportPoll();
|
||||
} finally {
|
||||
serverImporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelImportTask(row: LocalImportTask) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认取消导入任务「${row.directory}」?`, '取消导入', {
|
||||
confirmButtonText: '取消任务',
|
||||
cancelButtonText: '返回',
|
||||
type: 'warning'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
importTaskLoading.value = true;
|
||||
try {
|
||||
await cancelLocalKnowledgeImportTask(row.id);
|
||||
ElMessage.success('已取消导入任务');
|
||||
await Promise.all([loadImportTasks(), loadOverview()]);
|
||||
} finally {
|
||||
importTaskLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleImportPoll() {
|
||||
clearImportPoll();
|
||||
if (!runningImport.value) return;
|
||||
importPollTimer = window.setTimeout(async () => {
|
||||
await Promise.all([loadImportTasks(), loadOverview()]);
|
||||
scheduleImportPoll();
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
function clearImportPoll() {
|
||||
if (importPollTimer) {
|
||||
window.clearTimeout(importPollTimer);
|
||||
importPollTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function matchStatus(status: string) {
|
||||
if (activeStatus.value === '全部') return true;
|
||||
if (activeStatus.value === '等待解析') return ['等待解析', 'OCR排队'].includes(status);
|
||||
@@ -717,6 +596,15 @@ function matchFileType(type: string) {
|
||||
return fileType.value === '全部' || type === fileType.value;
|
||||
}
|
||||
|
||||
function selectTaskStatus(status: string) {
|
||||
activeStatus.value = status;
|
||||
resetTaskPage();
|
||||
}
|
||||
|
||||
function resetTaskPage() {
|
||||
taskPage.value = 1;
|
||||
}
|
||||
|
||||
function task(
|
||||
id: number,
|
||||
category: string,
|
||||
@@ -757,10 +645,6 @@ function task(
|
||||
};
|
||||
}
|
||||
|
||||
function categoryPayload() {
|
||||
return autoClassify.value ? autoCategory : selectedCategory.value;
|
||||
}
|
||||
|
||||
function metricIcon(key: string) {
|
||||
if (key === 'completed') return CircleCheck;
|
||||
if (key === 'processing') return Operation;
|
||||
@@ -921,19 +805,6 @@ function fileBadgeClass(type: string) {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.import-panel {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.import-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.failed-count {
|
||||
margin-left: 8px;
|
||||
color: #e60012;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<div class="head-actions">
|
||||
<span class="status-pill" :class="apiPill.type">{{ apiPill.label }}</span>
|
||||
<span class="status-pill" :class="flowStatus.type">{{ flowStatus.label }}</span>
|
||||
<el-select v-model="uploadSpaceCodes" multiple collapse-tags placeholder="选择上传空间" style="width: 230px">
|
||||
<el-option v-for="space in manageableSpaces" :key="space.code" :label="space.name" :value="space.code" />
|
||||
</el-select>
|
||||
<el-upload
|
||||
accept=".txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"
|
||||
:auto-upload="false"
|
||||
@@ -183,6 +186,7 @@ import {
|
||||
type SopRecord,
|
||||
type SopSearchResponse
|
||||
} from '@/api/aihr/sop';
|
||||
import { listKnowledgeSpaces, type KnowledgeSpace } from '@/api/aihr/space';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -286,6 +290,8 @@ const pendingReviewStatus = ref('待复核');
|
||||
const pendingReviewLoading = ref(false);
|
||||
const reviewingFeedbackId = ref<number | null>(null);
|
||||
const pendingReviews = ref<AnswerFeedbackItem[]>([]);
|
||||
const manageableSpaces = ref<KnowledgeSpace[]>([]);
|
||||
const uploadSpaceCodes = ref<string[]>([]);
|
||||
|
||||
const activeSeed = computed(() => sopSeeds[selectedCategory.value]);
|
||||
const answerText = computed(() => apiResult.value?.answer ?? activeSeed.value.answer);
|
||||
@@ -364,6 +370,7 @@ async function runSearch() {
|
||||
const { data } = await searchKnowledgeSop({
|
||||
queryText: queryText.value,
|
||||
category: selectedCategory.value,
|
||||
spaceCodes: uploadSpaceCodes.value,
|
||||
limit: 3
|
||||
});
|
||||
apiResult.value = data;
|
||||
@@ -437,12 +444,16 @@ async function handleDocSelected(uploadFile: UploadFile) {
|
||||
ElMessage.warning('文件不能超过 100MB');
|
||||
return;
|
||||
}
|
||||
if (uploadSpaceCodes.value.length === 0) {
|
||||
ElMessage.warning('请先选择至少一个可管理的知识空间');
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', raw);
|
||||
form.append('category', selectedCategory.value);
|
||||
uploadSpaceCodes.value.forEach((code) => form.append('spaceCodes', code));
|
||||
const { data } = await uploadKnowledgeDoc(form);
|
||||
uploadedRecords.value.unshift({
|
||||
title: data.fileName,
|
||||
@@ -469,8 +480,14 @@ async function generateTraining() {
|
||||
ElMessage.success('已基于引用片段生成训练题');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
loadPendingReviews();
|
||||
try {
|
||||
manageableSpaces.value = ((await listKnowledgeSpaces()).data || []).filter((item) => item.status === 'ACTIVE');
|
||||
uploadSpaceCodes.value = manageableSpaces.value.slice(0, 1).map((item) => item.code);
|
||||
} catch {
|
||||
manageableSpaces.value = [];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const frontendRoot = existsSync(resolve(process.cwd(), 'src')) ? process.cwd() : resolve(process.cwd(), 'frontend');
|
||||
const src = (...parts: string[]) => readFileSync(resolve(frontendRoot, 'src', ...parts), 'utf8');
|
||||
|
||||
describe('knowledge space administration contract', () => {
|
||||
it('registers the controlled route and sidebar entry', () => {
|
||||
expect(src('router', 'index.ts')).toContain("path: '/knowledge/spaces'");
|
||||
expect(src('router', 'index.ts')).toContain("title: '知识空间'");
|
||||
expect(src('layout', 'components', 'Sidebar', 'index.vue')).toContain("'知识空间'");
|
||||
});
|
||||
|
||||
it('contains spaces grants and applications tabs without exposing token hash', () => {
|
||||
const page = src('views', 'knowledge', 'spaces.vue');
|
||||
expect(page).toContain('label="知识空间"');
|
||||
expect(page).toContain('label="空间授权"');
|
||||
expect(page).toContain('label="调用应用"');
|
||||
expect(page).toContain('仅显示一次');
|
||||
expect(src('api', 'aihr', 'space.ts')).not.toContain('tokenHash');
|
||||
});
|
||||
|
||||
it('unbinds only the selected document-space membership', () => {
|
||||
const page = src('views', 'knowledge', 'spaces.vue');
|
||||
const api = src('api', 'aihr', 'space.ts');
|
||||
expect(page).toContain('文档成员');
|
||||
expect(page).toContain('解除当前空间绑定');
|
||||
expect(page).toContain('其他空间的同一文件不会删除');
|
||||
expect(api).toContain("method: 'delete'");
|
||||
expect(api).toContain('/documents/${attachId}');
|
||||
});
|
||||
|
||||
it('uploads to explicit space codes', () => {
|
||||
expect(src('views', 'knowledge', 'sop.vue')).toContain("form.append('spaceCodes', code)");
|
||||
expect(src('views', 'knowledge', 'processing.vue')).toContain("form.append('spaceCodes', code)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<div class="space-page">
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>知识空间</h1>
|
||||
<p>按租户管理知识边界、访问主体和调用应用;空间编码创建后不可修改。</p>
|
||||
</div>
|
||||
<el-button :loading="loading" @click="loadAll">刷新</el-button>
|
||||
</section>
|
||||
|
||||
<el-tabs v-model="activeTab" class="space-tabs">
|
||||
<el-tab-pane label="知识空间" name="spaces">
|
||||
<div class="toolbar"><el-button type="danger" @click="openSpace()">新建空间</el-button></div>
|
||||
<el-table :data="spaces" v-loading="loading">
|
||||
<el-table-column prop="name" label="空间名称" min-width="170" />
|
||||
<el-table-column prop="code" label="稳定编码" min-width="180" />
|
||||
<el-table-column prop="spaceType" label="类型" width="130" />
|
||||
<el-table-column prop="sensitivityLevel" label="密级" width="130" />
|
||||
<el-table-column prop="documentCount" label="文档" width="80" />
|
||||
<el-table-column prop="fragmentCount" label="片段" width="80" />
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column label="操作" width="180"><template #default="{ row }"><el-button link type="primary" @click="openSpace(row)">编辑</el-button><el-button link type="primary" @click="openDocuments(row)">文档成员</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="空间授权" name="grants">
|
||||
<div class="toolbar">
|
||||
<el-select v-model="grantSpaceId" placeholder="选择知识空间" style="width: 280px" @change="loadGrants">
|
||||
<el-option v-for="space in spaces" :key="space.id" :label="`${space.name} (${space.code})`" :value="space.id" />
|
||||
</el-select>
|
||||
<el-button :disabled="!grantSpaceId" @click="addGrant">新增授权</el-button>
|
||||
<el-button type="danger" :disabled="!grantSpaceId" :loading="saving" @click="saveGrants">保存全部授权</el-button>
|
||||
</div>
|
||||
<el-table :data="grants">
|
||||
<el-table-column label="主体类型" width="150"><template #default="{ row }"><el-select v-model="row.principalType"><el-option label="角色" value="ROLE" /><el-option label="用户" value="USER" /></el-select></template></el-table-column>
|
||||
<el-table-column label="角色编码 / 用户ID" min-width="220"><template #default="{ row }"><el-input v-model="row.principalValue" placeholder="如 employee / 10001" /></template></el-table-column>
|
||||
<el-table-column label="权限" width="150"><template #default="{ row }"><el-select v-model="row.permission"><el-option label="读取" value="READ" /><el-option label="管理" value="MANAGE" /></el-select></template></el-table-column>
|
||||
<el-table-column label="状态" width="150"><template #default="{ row }"><el-select v-model="row.status"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></template></el-table-column>
|
||||
<el-table-column label="操作" width="80"><template #default="{ $index }"><el-button link type="danger" @click="grants.splice($index, 1)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="调用应用" name="apps">
|
||||
<div class="toolbar"><el-button type="danger" @click="openApp()">新建应用</el-button></div>
|
||||
<el-table :data="apps" v-loading="loading">
|
||||
<el-table-column prop="appName" label="应用名称" min-width="160" />
|
||||
<el-table-column prop="appCode" label="应用编码" min-width="160" />
|
||||
<el-table-column prop="authType" label="认证方式" width="130" />
|
||||
<el-table-column label="授权空间" min-width="230"><template #default="{ row }">{{ row.spaceCodes.join('、') || '未绑定' }}</template></el-table-column>
|
||||
<el-table-column prop="rateLimitPerMinute" label="每分钟限流" width="110" />
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column label="操作" width="190"><template #default="{ row }"><el-button link type="primary" @click="openApp(row)">编辑</el-button><el-button v-if="row.authType === 'API_TOKEN'" link type="danger" @click="rotateToken(row)">轮换令牌</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="spaceDialog" :title="spaceEditingId ? '编辑知识空间' : '新建知识空间'" width="560px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="稳定编码"><el-input v-model="spaceForm.code" :disabled="Boolean(spaceEditingId)" placeholder="如 yc_property_sop" /></el-form-item>
|
||||
<el-form-item label="空间名称"><el-input v-model="spaceForm.name" /></el-form-item>
|
||||
<el-form-item label="说明"><el-input v-model="spaceForm.description" type="textarea" /></el-form-item>
|
||||
<el-form-item label="类型"><el-select v-model="spaceForm.spaceType"><el-option v-for="value in spaceTypes" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="密级"><el-select v-model="spaceForm.sensitivityLevel"><el-option v-for="value in sensitivities" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="spaceForm.status"><el-option label="草稿" value="DRAFT" /><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="spaceDialog = false">取消</el-button><el-button type="danger" :loading="saving" @click="saveSpace">保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="appDialog" :title="appEditingId ? '编辑调用应用' : '新建调用应用'" width="620px">
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="应用编码"><el-input v-model="appForm.appCode" :disabled="Boolean(appEditingId)" /></el-form-item>
|
||||
<el-form-item label="应用名称"><el-input v-model="appForm.appName" /></el-form-item>
|
||||
<el-form-item label="认证方式"><el-select v-model="appForm.authType" :disabled="Boolean(appEditingId)"><el-option label="内部登录态" value="SESSION" /><el-option label="外部令牌" value="API_TOKEN" /></el-select></el-form-item>
|
||||
<el-form-item v-if="appForm.authType === 'SESSION'" label="客户端标识"><el-input v-model="appForm.internalClientKey" placeholder="pc / app" /></el-form-item>
|
||||
<el-form-item label="授权空间"><el-select v-model="appForm.spaceCodes" multiple style="width: 100%"><el-option v-for="space in activeSpaces" :key="space.code" :label="space.name" :value="space.code" /></el-select></el-form-item>
|
||||
<el-form-item label="每分钟限流"><el-input-number v-model="appForm.rateLimitPerMinute" :min="1" :max="10000" /></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="appForm.status"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="closeAppDialog">取消</el-button><el-button type="danger" :loading="saving" @click="saveApp">保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="tokenDialog" title="访问令牌(仅显示一次)" width="620px" :close-on-click-modal="false" @closed="issuedToken = ''">
|
||||
<el-alert type="warning" :closable="false" title="令牌仅显示一次,请立即交给受信任的服务端保存。不要写入浏览器、小程序包或聊天记录。" />
|
||||
<el-input v-model="issuedToken" readonly type="textarea" :rows="4" class="token-box" />
|
||||
<template #footer><el-button type="danger" @click="tokenDialog = false">我已安全保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="documentDialog" :title="`${documentSpace?.name || ''} · 文档成员`" width="840px">
|
||||
<el-alert type="info" :closable="false" title="解绑只移除当前空间的文档成员和检索片段;其他空间仍可使用同一原始文件。仅当没有任何空间再引用时,系统才删除 OSS 文件。" />
|
||||
<el-table :data="documents" v-loading="documentLoading" class="document-table">
|
||||
<el-table-column prop="name" label="文件名" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="type" label="类型" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="110" />
|
||||
<el-table-column prop="fragmentCount" label="片段" width="80" />
|
||||
<el-table-column label="操作" width="150"><template #default="{ row }"><el-button link type="danger" @click="unbindDocument(row)">解除当前空间绑定</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<template #footer><el-button @click="documentDialog = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="KnowledgeSpaces" lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import {
|
||||
createKnowledgeApp, createKnowledgeSpace, listKnowledgeApps, listKnowledgeSpaces, listSpaceDocuments, listSpaceGrants,
|
||||
replaceKnowledgeAppSpaces, replaceSpaceGrants, rotateKnowledgeAppToken, unbindSpaceDocument, updateKnowledgeApp, updateKnowledgeSpace,
|
||||
type AppForm, type KnowledgeApp, type KnowledgeDocument, type KnowledgeSpace, type SpaceForm, type SpaceGrant
|
||||
} from '@/api/aihr/space';
|
||||
|
||||
const activeTab = ref('spaces');
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const spaces = ref<KnowledgeSpace[]>([]);
|
||||
const apps = ref<KnowledgeApp[]>([]);
|
||||
const grants = ref<SpaceGrant[]>([]);
|
||||
const grantSpaceId = ref<number>();
|
||||
const spaceDialog = ref(false);
|
||||
const appDialog = ref(false);
|
||||
const tokenDialog = ref(false);
|
||||
const documentDialog = ref(false);
|
||||
const documentLoading = ref(false);
|
||||
const issuedToken = ref('');
|
||||
const documents = ref<KnowledgeDocument[]>([]);
|
||||
const documentSpace = ref<KnowledgeSpace>();
|
||||
const spaceEditingId = ref<number>();
|
||||
const appEditingId = ref<number>();
|
||||
const spaceTypes = ['PUBLIC', 'BUSINESS', 'MANAGEMENT', 'EXTERNAL'] as const;
|
||||
const sensitivities = ['PUBLIC', 'INTERNAL', 'CONFIDENTIAL'] as const;
|
||||
const activeSpaces = computed(() => spaces.value.filter((item) => item.status === 'ACTIVE'));
|
||||
const blankSpace = (): SpaceForm => ({ code: '', name: '', description: '', spaceType: 'BUSINESS', sensitivityLevel: 'INTERNAL', status: 'DRAFT' });
|
||||
const blankApp = (): AppForm => ({ appCode: '', appName: '', authType: 'SESSION', internalClientKey: 'pc', status: 'ACTIVE', rateLimitPerMinute: 60, expiresTime: null, spaceCodes: [] });
|
||||
const spaceForm = reactive<SpaceForm>(blankSpace());
|
||||
const appForm = reactive<AppForm>(blankApp());
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [spaceResult, appResult] = await Promise.all([listKnowledgeSpaces(), listKnowledgeApps()]);
|
||||
spaces.value = spaceResult.data || [];
|
||||
apps.value = appResult.data || [];
|
||||
} catch (error) { ElMessage.error(errorMessage(error)); } finally { loading.value = false; }
|
||||
}
|
||||
|
||||
function openSpace(row?: KnowledgeSpace) {
|
||||
spaceEditingId.value = row?.id;
|
||||
Object.assign(spaceForm, row ? { code: row.code, name: row.name, description: row.description, spaceType: row.spaceType, sensitivityLevel: row.sensitivityLevel, status: row.status } : blankSpace());
|
||||
spaceDialog.value = true;
|
||||
}
|
||||
|
||||
async function saveSpace() {
|
||||
saving.value = true;
|
||||
try {
|
||||
if (spaceEditingId.value) await updateKnowledgeSpace(spaceEditingId.value, spaceForm);
|
||||
else await createKnowledgeSpace(spaceForm);
|
||||
spaceDialog.value = false; ElMessage.success('知识空间已保存'); await loadAll();
|
||||
} catch (error) { ElMessage.error(errorMessage(error)); } finally { saving.value = false; }
|
||||
}
|
||||
|
||||
async function openDocuments(space: KnowledgeSpace) {
|
||||
documentSpace.value = space;
|
||||
documentDialog.value = true;
|
||||
await loadDocuments();
|
||||
}
|
||||
|
||||
async function loadDocuments() {
|
||||
if (!documentSpace.value) return;
|
||||
documentLoading.value = true;
|
||||
try { documents.value = (await listSpaceDocuments(documentSpace.value.id)).data || []; }
|
||||
catch (error) { ElMessage.error(errorMessage(error)); }
|
||||
finally { documentLoading.value = false; }
|
||||
}
|
||||
|
||||
async function unbindDocument(document: KnowledgeDocument) {
|
||||
if (!documentSpace.value) return;
|
||||
await ElMessageBox.confirm(
|
||||
`确认将“${document.name}”从“${documentSpace.value.name}”解绑?其他空间的同一文件不会删除。`,
|
||||
'解除当前空间绑定',
|
||||
{ type: 'warning', confirmButtonText: '确认解绑' }
|
||||
);
|
||||
try {
|
||||
await unbindSpaceDocument(documentSpace.value.id, document.attachId);
|
||||
ElMessage.success('已解除当前空间绑定');
|
||||
await Promise.all([loadDocuments(), loadAll()]);
|
||||
} catch (error) { ElMessage.error(errorMessage(error)); }
|
||||
}
|
||||
|
||||
async function loadGrants() {
|
||||
if (!grantSpaceId.value) return;
|
||||
try { grants.value = (await listSpaceGrants(grantSpaceId.value)).data || []; } catch (error) { ElMessage.error(errorMessage(error)); }
|
||||
}
|
||||
|
||||
function addGrant() { grants.value.push({ principalType: 'ROLE', principalValue: '', permission: 'READ', status: 'ACTIVE' }); }
|
||||
async function saveGrants() {
|
||||
if (!grantSpaceId.value) return;
|
||||
saving.value = true;
|
||||
try { grants.value = (await replaceSpaceGrants(grantSpaceId.value, grants.value)).data || []; ElMessage.success('空间授权已保存'); }
|
||||
catch (error) { ElMessage.error(errorMessage(error)); } finally { saving.value = false; }
|
||||
}
|
||||
|
||||
function openApp(row?: KnowledgeApp) {
|
||||
appEditingId.value = row?.id;
|
||||
Object.assign(appForm, row ? { appCode: row.appCode, appName: row.appName, authType: row.authType, internalClientKey: row.internalClientKey, status: row.status, rateLimitPerMinute: row.rateLimitPerMinute, expiresTime: row.expiresTime, spaceCodes: [...row.spaceCodes] } : blankApp());
|
||||
appDialog.value = true;
|
||||
}
|
||||
|
||||
function closeAppDialog() { appDialog.value = false; Object.assign(appForm, blankApp()); }
|
||||
async function saveApp() {
|
||||
saving.value = true;
|
||||
try {
|
||||
if (appEditingId.value) {
|
||||
await updateKnowledgeApp(appEditingId.value, appForm);
|
||||
await replaceKnowledgeAppSpaces(appEditingId.value, appForm.spaceCodes);
|
||||
} else {
|
||||
const result = await createKnowledgeApp(appForm);
|
||||
if (result.data.token) showIssuedToken(result.data.token);
|
||||
}
|
||||
closeAppDialog(); ElMessage.success('调用应用已保存'); await loadAll();
|
||||
} catch (error) { ElMessage.error(errorMessage(error)); } finally { saving.value = false; }
|
||||
}
|
||||
|
||||
async function rotateToken(row: KnowledgeApp) {
|
||||
await ElMessageBox.confirm('轮换后旧令牌立即失效,是否继续?', '轮换访问令牌', { type: 'warning' });
|
||||
try { showIssuedToken((await rotateKnowledgeAppToken(row.id, row.expiresTime)).data.token); }
|
||||
catch (error) { ElMessage.error(errorMessage(error)); }
|
||||
}
|
||||
|
||||
function showIssuedToken(token: string) { issuedToken.value = token; tokenDialog.value = true; }
|
||||
function errorMessage(error: unknown) { return error instanceof Error && error.message ? error.message : '操作失败,请检查权限或参数'; }
|
||||
onMounted(loadAll);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.space-page { min-height: calc(100vh - 84px); padding: 22px; background: #f5f7fb; color: #162033; }
|
||||
.page-head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 16px; h1 { margin: 0 0 8px; font-size: 24px; } p { margin: 0; color: #657184; } }
|
||||
.space-tabs { padding: 16px 18px; border: 1px solid #e4e9f2; border-radius: 10px; background: #fff; }
|
||||
.toolbar { display: flex; gap: 10px; margin-bottom: 14px; }
|
||||
.token-box { margin-top: 16px; }
|
||||
.document-table { margin-top: 14px; }
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="web-search-page">
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>全网 AI 配置</h1>
|
||||
<p>独立配置公开互联网检索,不与企业 SOP 知识库混用。仅启用的 HTTPS 提供方会出现在员工端,启用前应先完成连接测试。</p>
|
||||
</div>
|
||||
<el-button type="danger" @click="openEditor()">新增提供方</el-button>
|
||||
</section>
|
||||
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="外部服务边界"
|
||||
description="只允许 HTTPS 公网接口;密钥不会回显。员工查询会显示互联网来源提示,结果不能替代公司 SOP 或正式制度。"
|
||||
/>
|
||||
|
||||
<section class="provider-grid">
|
||||
<article v-for="item in providers" :key="item.id" class="provider-card">
|
||||
<div class="provider-head">
|
||||
<div>
|
||||
<h2>{{ item.name }}</h2>
|
||||
<p>{{ item.providerCode }}</p>
|
||||
</div>
|
||||
<el-tag :type="item.enabled ? 'success' : 'info'">{{ item.enabled ? '已启用' : '未启用' }}</el-tag>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>接口地址</dt><dd>{{ item.endpoint }}</dd></div>
|
||||
<div><dt>访问密钥</dt><dd>{{ item.configured ? '已安全保存' : '未配置' }}</dd></div>
|
||||
<div>
|
||||
<dt>连接测试</dt>
|
||||
<dd>
|
||||
<el-tag :type="item.lastTestOk ? 'success' : 'warning'">
|
||||
{{ item.lastTestOk ? '已通过' : '未通过或配置已变更' }}
|
||||
</el-tag>
|
||||
<span v-if="item.lastTestTime" class="test-time">{{ formatTestTime(item.lastTestTime) }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="provider-actions">
|
||||
<el-button @click="openEditor(item)">编辑</el-button>
|
||||
<el-button :loading="testingId === item.id" @click="testProvider(item)">连接测试</el-button>
|
||||
<el-button
|
||||
:type="item.enabled ? 'default' : 'danger'"
|
||||
:loading="togglingId === item.id"
|
||||
:disabled="!item.enabled && !item.lastTestOk"
|
||||
@click="toggleProvider(item)"
|
||||
>{{ item.enabled ? '停用' : '启用' }}</el-button>
|
||||
</div>
|
||||
</article>
|
||||
<el-empty v-if="!loading && !providers.length" description="尚未配置全网检索提供方" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="editorVisible" :title="form.id ? '编辑提供方' : '新增提供方'" width="560px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="名称"><el-input v-model="form.name" maxlength="100" placeholder="例如:Tavily 生产检索" /></el-form-item>
|
||||
<el-form-item label="提供方"><el-input model-value="Tavily" disabled /></el-form-item>
|
||||
<el-form-item label="HTTPS 接口地址"><el-input v-model="form.endpoint" placeholder="https://api.tavily.com/search" /></el-form-item>
|
||||
<el-form-item :label="form.id ? '访问密钥(留空表示不修改)' : '访问密钥'">
|
||||
<el-input v-model="form.apiKey" type="password" show-password autocomplete="new-password" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editorVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="saving" @click="submit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="SystemWebSearch" lang="ts">
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import {
|
||||
listWebSearchProviders,
|
||||
saveWebSearchProvider,
|
||||
testWebSearchProvider,
|
||||
toggleWebSearchProvider,
|
||||
type WebSearchProvider,
|
||||
type WebSearchProviderForm
|
||||
} from '@/api/aihr/web-search';
|
||||
|
||||
const providers = ref<WebSearchProvider[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const testingId = ref(0);
|
||||
const togglingId = ref(0);
|
||||
const editorVisible = ref(false);
|
||||
const emptyForm = (): WebSearchProviderForm => ({
|
||||
name: 'Tavily 全网检索',
|
||||
providerCode: 'tavily',
|
||||
endpoint: 'https://api.tavily.com/search',
|
||||
apiKey: ''
|
||||
});
|
||||
const form = reactive<WebSearchProviderForm>(emptyForm());
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
providers.value = (await listWebSearchProviders()).data || [];
|
||||
} catch {
|
||||
providers.value = [];
|
||||
ElMessage.error('全网检索配置读取失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openEditor = (item?: WebSearchProvider) => {
|
||||
Object.assign(form, emptyForm(), item ? {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
providerCode: 'tavily',
|
||||
endpoint: item.endpoint,
|
||||
apiKey: ''
|
||||
} : {});
|
||||
editorVisible.value = true;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim() || !form.endpoint.trim() || (!form.id && !form.apiKey.trim())) {
|
||||
ElMessage.warning('请完整填写名称、接口地址和访问密钥');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveWebSearchProvider({ ...form, name: form.name.trim(), endpoint: form.endpoint.trim() });
|
||||
editorVisible.value = false;
|
||||
ElMessage.success('配置已保存,启用前请先执行连接测试');
|
||||
await load();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const testProvider = async (item: WebSearchProvider) => {
|
||||
testingId.value = item.id;
|
||||
try {
|
||||
const result = (await testWebSearchProvider(item.id)).data;
|
||||
ElMessage({ type: result.ok ? 'success' : 'warning', message: `${result.message}(${result.sourceCount} 个来源)` });
|
||||
await load();
|
||||
} finally {
|
||||
testingId.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTestTime = (value: string) => value.replace('T', ' ').slice(0, 19);
|
||||
|
||||
const toggleProvider = async (item: WebSearchProvider) => {
|
||||
togglingId.value = item.id;
|
||||
try {
|
||||
await toggleWebSearchProvider(item.id, !item.enabled);
|
||||
ElMessage.success(item.enabled ? '已停用全网检索' : '已启用全网检索');
|
||||
await load();
|
||||
} finally {
|
||||
togglingId.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.web-search-page{padding:24px;color:#172033}.page-head,.provider-head,.provider-actions{display:flex;gap:12px;align-items:center}.page-head,.provider-head{justify-content:space-between}.page-head{margin-bottom:18px}.page-head h1,.provider-head h2{margin:0}.page-head h1{font-size:28px}.page-head p,.provider-head p{margin:7px 0 0;color:#667085}.provider-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:16px;margin-top:18px}.provider-card{padding:20px;border:1px solid #e4e7ec;border-radius:14px;background:#fff;box-shadow:0 4px 18px rgba(16,24,40,.05)}dl{display:grid;gap:12px;margin:20px 0}dl div{display:grid;grid-template-columns:90px minmax(0,1fr);gap:12px}dt{color:#667085}dd{margin:0;overflow-wrap:anywhere}.test-time{margin-left:8px;color:#667085;font-size:12px}.provider-actions{justify-content:flex-end;flex-wrap:wrap}@media(max-width:760px){.web-search-page{padding:16px}.page-head{align-items:flex-start}.provider-grid{grid-template-columns:1fr}.provider-actions{justify-content:flex-start}}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="difficulty-page">
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>常见难题</h1>
|
||||
<p>按岗位和项目权限聚合低分、红线与错题原因,并一键生成定向专项。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
|
||||
</section>
|
||||
|
||||
<section class="filter-panel panel">
|
||||
<el-select v-model="position" clearable placeholder="全部岗位" @change="load">
|
||||
<el-option v-for="item in positions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
<el-select v-model="windowDays" placeholder="统计窗口" @change="load">
|
||||
<el-option label="近7天" :value="7" />
|
||||
<el-option label="近30天" :value="30" />
|
||||
<el-option label="近90天" :value="90" />
|
||||
</el-select>
|
||||
<el-select v-model="sortKey" placeholder="排序方式">
|
||||
<el-option label="出现次数" value="count" />
|
||||
<el-option label="涉及人数" value="people" />
|
||||
<el-option label="低分优先" value="score" />
|
||||
<el-option label="最近发生" value="latest" />
|
||||
</el-select>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid">
|
||||
<div class="metric-card"><span>难题组合</span><strong>{{ rows.length }}</strong></div>
|
||||
<div class="metric-card"><span>累计出现</span><strong>{{ totalCount }}</strong></div>
|
||||
<div class="metric-card"><span>单项最多涉及</span><strong>{{ totalPeople }}</strong></div>
|
||||
<div class="metric-card"><span>平均得分</span><strong>{{ averageScore }}</strong></div>
|
||||
</section>
|
||||
|
||||
<section class="panel table-panel">
|
||||
<el-table v-loading="loading" :data="sortedRows" empty-text="当前项目范围内暂无常见难题">
|
||||
<el-table-column prop="scenarioName" label="场景" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="position" label="岗位" width="110" />
|
||||
<el-table-column prop="cause" label="归因" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="count" label="出现次数" width="96" sortable />
|
||||
<el-table-column prop="affectedPeople" label="涉及人数" width="96" sortable />
|
||||
<el-table-column prop="averageScore" label="平均分" width="90" sortable>
|
||||
<template #default="{ row }"><span :class="row.averageScore < 80 ? 'low-score' : ''">{{ row.averageScore }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="latestTime" label="最近发生" width="170" />
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template #default="{ row }"><el-button type="danger" link @click="openAssign(row)">一键派专项</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="assignVisible" title="一键生成定向专项" width="520px">
|
||||
<div v-if="activeRow" class="assign-summary">
|
||||
<strong>{{ activeRow.scenarioName }}</strong>
|
||||
<span>{{ activeRow.cause }} · {{ activeRow.affectedPeople }} 人涉及</span>
|
||||
<el-alert type="info" :closable="false" title="人员范围由服务端按当前主管项目和该难题低分记录确定,前端不能越权指定。" />
|
||||
<el-date-picker v-model="dueDate" value-format="YYYY-MM-DD" type="date" :disabled-date="disablePast" placeholder="截止日期" />
|
||||
<div class="quick-dates">
|
||||
<el-button v-for="item in quickDates" :key="item.days" size="small" @click="dueDate = dateAfter(item.days)">{{ item.label }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="assignVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="assigning" :disabled="!dueDate" @click="submitAssign">确认派发</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="resultVisible" title="派发结果" width="620px">
|
||||
<el-result v-if="batchResult" :icon="batchResult.failed ? 'warning' : 'success'" :title="`成功 ${batchResult.created + batchResult.duplicate} 人,失败 ${batchResult.failed} 人`" />
|
||||
<el-table v-if="batchResult?.results?.length" :data="batchResult.results" max-height="320">
|
||||
<el-table-column prop="traineeName" label="员工" min-width="120" />
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column prop="message" label="说明" min-width="180" />
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="TrainDifficulties" lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import {
|
||||
assignPracticeDifficulty,
|
||||
listPracticeDifficulties,
|
||||
type PracticeAssignmentBatchResponse,
|
||||
type PracticeDifficulty
|
||||
} from '@/api/aihr/mobile-admin';
|
||||
|
||||
const positions = ['生活顾问', '客服', '保安', '保洁', '保修'];
|
||||
const quickDates = [{ label: '当天', days: 0 }, { label: '明天', days: 1 }, { label: '3天内', days: 3 }, { label: '7天内', days: 7 }];
|
||||
const rows = ref<PracticeDifficulty[]>([]);
|
||||
const loading = ref(false);
|
||||
const assigning = ref(false);
|
||||
const position = ref('');
|
||||
const windowDays = ref(30);
|
||||
const sortKey = ref<'count' | 'people' | 'score' | 'latest'>('count');
|
||||
const assignVisible = ref(false);
|
||||
const resultVisible = ref(false);
|
||||
const activeRow = ref<PracticeDifficulty | null>(null);
|
||||
const dueDate = ref('');
|
||||
const batchResult = ref<PracticeAssignmentBatchResponse | null>(null);
|
||||
const sortedRows = computed(() => [...rows.value].sort((a, b) => {
|
||||
if (sortKey.value === 'people') return b.affectedPeople - a.affectedPeople;
|
||||
if (sortKey.value === 'score') return a.averageScore - b.averageScore;
|
||||
if (sortKey.value === 'latest') return String(b.latestTime).localeCompare(String(a.latestTime));
|
||||
return b.count - a.count;
|
||||
}));
|
||||
const totalCount = computed(() => rows.value.reduce((sum, item) => sum + item.count, 0));
|
||||
const totalPeople = computed(() => rows.value.length ? Math.max(0, ...rows.value.map((item) => item.affectedPeople)) : 0);
|
||||
const averageScore = computed(() => rows.value.length
|
||||
? Math.round(rows.value.reduce((sum, item) => sum + item.averageScore * item.count, 0) / Math.max(1, totalCount.value))
|
||||
: '--');
|
||||
const formatDate = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
const dateAfter = (days: number) => { const date = new Date(); date.setHours(12, 0, 0, 0); date.setDate(date.getDate() + days); return formatDate(date); };
|
||||
const disablePast = (date: Date) => date.getTime() < new Date(new Date().setHours(0, 0, 0, 0)).getTime();
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await listPracticeDifficulties({ windowDays: windowDays.value, position: position.value || undefined });
|
||||
rows.value = response.data || [];
|
||||
} catch {
|
||||
rows.value = [];
|
||||
ElMessage.error('常见难题读取失败');
|
||||
} finally { loading.value = false; }
|
||||
};
|
||||
const openAssign = (row: PracticeDifficulty) => { activeRow.value = row; dueDate.value = dateAfter(1); batchResult.value = null; assignVisible.value = true; };
|
||||
const submitAssign = async () => {
|
||||
if (!activeRow.value || !dueDate.value || assigning.value) return;
|
||||
assigning.value = true;
|
||||
try {
|
||||
const response = await assignPracticeDifficulty({
|
||||
scenarioId: activeRow.value.scenarioId,
|
||||
cause: activeRow.value.cause,
|
||||
dueDate: dueDate.value,
|
||||
requestId: `difficulty:${activeRow.value.scenarioId}:${Date.now()}`,
|
||||
windowDays: windowDays.value,
|
||||
position: activeRow.value.position
|
||||
});
|
||||
batchResult.value = response.data;
|
||||
assignVisible.value = false;
|
||||
resultVisible.value = true;
|
||||
await load();
|
||||
} catch { ElMessage.error('专项派发失败'); }
|
||||
finally { assigning.value = false; }
|
||||
};
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.difficulty-page { padding: 24px; color: #172033; }
|
||||
.page-head, .filter-panel, .metric-grid { display: flex; gap: 16px; align-items: center; }
|
||||
.page-head { justify-content: space-between; margin-bottom: 18px; }
|
||||
.page-head h1 { margin: 0; font-size: 28px; }
|
||||
.page-head p { margin: 8px 0 0; color: #667085; }
|
||||
.panel { border: 1px solid #e4e7ec; border-radius: 12px; background: #fff; box-shadow: 0 4px 18px rgba(16,24,40,.05); }
|
||||
.filter-panel { padding: 16px; }
|
||||
.filter-panel :deep(.el-select) { width: 200px; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 16px 0; }
|
||||
.metric-card { display: grid; gap: 8px; padding: 18px; border: 1px solid #e4e7ec; border-radius: 12px; background: #fff; }
|
||||
.metric-card span { color: #667085; }
|
||||
.metric-card strong { font-size: 28px; }
|
||||
.table-panel { overflow: hidden; padding: 8px; }
|
||||
.low-score { color: #d92d20; font-weight: 800; }
|
||||
.assign-summary { display: grid; gap: 14px; }
|
||||
.assign-summary > span { color: #667085; }
|
||||
.quick-dates { display: flex; gap: 8px; }
|
||||
@media (max-width: 1100px) {
|
||||
.difficulty-page { padding: 16px; }
|
||||
.filter-panel { flex-wrap: wrap; }
|
||||
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user