feat: complete governed knowledge lifecycle

This commit is contained in:
key
2026-08-03 14:34:54 +08:00
parent a275f98fad
commit df77998e0c
41 changed files with 4921 additions and 225 deletions
+228
View File
@@ -187,6 +187,129 @@ export type LegacyStagePreview = {
moreAvailable: boolean;
};
export type KnowledgeRolloutMode = 'LEGACY' | 'SHADOW' | 'ENFORCED';
export type KnowledgeRolloutScope = {
knowledgeId: number;
spaceCode: string;
spaceName: string;
mode: KnowledgeRolloutMode;
governedCollection: string;
activeGeneration: number;
candidateGeneration?: number;
updatedBy?: number;
reason?: string;
updatedTime?: string;
readyAttachments: number;
enforcementBlockers: number;
};
export type KnowledgeVersion = {
id: number;
versionNo: number;
versionStatus: string;
revisionOfVersionId?: number;
revisionReason?: string;
contentSha256: string;
parserName?: string;
parserVersion?: string;
semanticAnalysisStatus: string;
privacyStatus: string;
published: boolean;
candidate: boolean;
chunkCount: number;
openIssueCount: number;
createTime: string;
};
export type ReviewPackage = {
assetId: number;
versionId: number;
versionNo: number;
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
batchEligible: boolean;
summary: {
sourceName: string;
contentPreview: string;
headings: string[];
chunkCount: number;
issueCount: number;
duplicateCount: number;
conflictCount: number;
};
suggestion: { decision: string; reasonCodes: string[]; recommendedActions: string[]; disclaimer: string };
};
export type DeletionImpact = {
assetId: number;
lifecycleStatus: string;
retentionClass: string;
legalHold: boolean;
chunks: number;
publishedFragments: number;
queryEvidence: number;
learningTasks: number;
broadcasts: number;
generations: number;
canRequestPurge: boolean;
blockers: string[];
};
export type PurgeRequest = {
id: number;
requestKey: string;
assetId: number;
retentionClass: string;
reason: string;
status: string;
requesterId: number;
requestedTime: string;
approverId?: number;
approvalReason?: string;
executeAfter: string;
executedTime?: string;
lastError?: string;
};
export type MigrationRun = {
id: number;
runKey: string;
knowledgeId?: number;
riskScope: string;
dryRun: boolean;
batchSize: number;
cursorAttachmentId: number;
status: string;
discoveredCount: number;
stagedCount: number;
quarantinedCount: number;
failedCount: number;
manifestSha256: string;
verifiedDryRunId?: number;
};
export type GenerationBuild = {
knowledgeId: number;
generation: number;
collection: string;
status: string;
expectedVersions: number;
includedVersions: number;
outstandingJobs: number;
};
export type ActivationGate = {
gateId: number;
knowledgeId: number;
candidateGeneration: number;
status: 'PASSED' | 'BLOCKED';
sampleCount: number;
top1AgreementRate: number;
overlapAt5Avg: number;
governedEmptyRate: number;
evidence: { blockingAssets: number; openCriticalIssues: number; deadLetters: number; generationStatus: string };
};
export type KnowledgeGlossaryTerm = {
id: number;
term: string;
@@ -601,6 +724,111 @@ export function previewLegacyQualityAssets(limit = 50, afterAttachmentId = 0): P
});
}
export function listKnowledgeRolloutScopes(): Promise<ApiResult<KnowledgeRolloutScope[]>> {
return request({
url: '/api/knowledge/quality/rollout/scopes',
method: 'get'
});
}
export function transitionKnowledgeRollout(
knowledgeId: number,
targetMode: KnowledgeRolloutMode,
reason: string,
activeGeneration?: number
): Promise<ApiResult<KnowledgeRolloutScope>> {
return request({
url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/transition`,
method: 'post',
data: { targetMode, reason, activeGeneration },
headers: { repeatSubmit: false }
});
}
export function listAssetVersions(assetId: number): Promise<ApiResult<KnowledgeVersion[]>> {
return request({ url: `/api/knowledge/quality/assets/${assetId}/versions`, method: 'get' });
}
export function getAssetReviewPackage(assetId: number): Promise<ApiResult<ReviewPackage>> {
return request({ url: `/api/knowledge/quality/assets/${assetId}/review-package`, method: 'get' });
}
export function getDeletionImpact(assetId: number): Promise<ApiResult<DeletionImpact>> {
return request({ url: `/api/knowledge/quality/assets/${assetId}/impact`, method: 'get' });
}
export function createPurgeRequest(assetId: number, reason: string): Promise<ApiResult<PurgeRequest>> {
return request({
url: `/api/knowledge/quality/assets/${assetId}/purge-requests`,
method: 'post',
data: { requestKey: `purge:${assetId}:${Date.now()}`, reason },
headers: { repeatSubmit: false }
});
}
export function listPurgeRequests(status = '', limit = 100): Promise<ApiResult<PurgeRequest[]>> {
return request({ url: '/api/knowledge/quality/purge-requests', method: 'get', params: { status, limit } });
}
export function decidePurgeRequest(id: number, decision: 'APPROVE' | 'REJECT', reason: string): Promise<ApiResult<PurgeRequest>> {
return request({ url: `/api/knowledge/quality/purge-requests/${id}/decision`, method: 'post', data: { decision, reason } });
}
export function executePurgeRequest(id: number): Promise<ApiResult<PurgeRequest>> {
return request({ url: `/api/knowledge/quality/purge-requests/${id}/execute`, method: 'post' });
}
export function runKnowledgeReconciliation(): Promise<ApiResult<{ runId: number; status: string; issues: unknown[] }>> {
return request({ url: '/api/knowledge/quality/reconciliation-runs', method: 'post' });
}
export function listMigrationRuns(limit = 50): Promise<ApiResult<MigrationRun[]>> {
return request({ url: '/api/knowledge/quality/migration-runs', method: 'get', params: { limit } });
}
export function createMigrationRun(data: {
runKey: string;
knowledgeId?: number;
riskScope: string;
dryRun: boolean;
batchSize: number;
verifiedDryRunId?: number;
}): Promise<ApiResult<MigrationRun>> {
return request({ url: '/api/knowledge/quality/migration-runs', method: 'post', data, headers: { repeatSubmit: false } });
}
export function runNextMigrationBatch(id: number): Promise<ApiResult<{ moreAvailable: boolean; failed: number }>> {
return request({ url: `/api/knowledge/quality/migration-runs/${id}/next`, method: 'post', headers: { repeatSubmit: false } });
}
export function startGenerationBuild(knowledgeId: number, reason: string): Promise<ApiResult<GenerationBuild>> {
return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/generation-builds`, method: 'post', data: { reason } });
}
export function refreshGenerationBuild(knowledgeId: number): Promise<ApiResult<GenerationBuild>> {
return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/generation-builds/refresh`, method: 'post' });
}
export function compareShadowQuery(knowledgeId: number, queryText: string): Promise<ApiResult<unknown>> {
return request({
url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/shadow-comparisons`,
method: 'post',
data: { queryText, source: 'ADMIN_WORKBENCH' }
});
}
export function evaluateActivationGate(knowledgeId: number): Promise<ApiResult<ActivationGate>> {
return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/activation-gates`, method: 'post' });
}
export function activateGeneration(knowledgeId: number, gateId: number, reason: string): Promise<ApiResult<unknown>> {
return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/activate`, method: 'post', data: { gateId, reason } });
}
export function rollbackGeneration(knowledgeId: number, reason: string): Promise<ApiResult<unknown>> {
return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/generation-rollback`, method: 'post', data: { reason } });
}
export function listKnowledgeGlossary(status: KnowledgeGlossaryTerm['status'] = 'ACTIVE', limit = 200): Promise<ApiResult<KnowledgeGlossaryTerm[]>> {
return request({
url: '/api/knowledge/quality/glossary',
+613 -4
View File
@@ -256,6 +256,57 @@
<el-button :icon="Refresh" :loading="qualityLoading" @click="loadQualityAssets">刷新</el-button>
</div>
</div>
<div class="rollout-workbench">
<div class="rollout-head">
<div>
<strong>空间放量</strong>
<span>旧链路、影子验证和治理链路按空间独立切换</span>
</div>
<el-button :icon="Refresh" :loading="rolloutLoading" circle title="刷新空间放量状态" @click="loadRolloutScopes" />
</div>
<el-table v-loading="rolloutLoading" :data="rolloutScopes" size="small" empty-text="暂无可管理知识空间">
<el-table-column prop="spaceName" label="知识空间" min-width="180" show-overflow-tooltip />
<el-table-column label="模式" width="104">
<template #default="{ row }">
<el-tag :type="rolloutModeType(row.mode)" size="small">{{ rolloutModeLabel(row.mode) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="资料覆盖" min-width="170">
<template #default="{ row }">
<span>{{ row.readyAttachments - row.enforcementBlockers }} / {{ row.readyAttachments }}</span>
<el-tag v-if="row.enforcementBlockers" class="rollout-blocker" size="small" type="warning"> 阻断 {{ row.enforcementBlockers }} </el-tag>
</template>
</el-table-column>
<el-table-column label="向量代次" width="100">
<template #default="{ row }">
G{{ row.activeGeneration }}
<el-tag v-if="row.candidateGeneration" size="small" type="warning">候选 G{{ row.candidateGeneration }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="reason" label="最近变更原因" min-width="210" show-overflow-tooltip />
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.mode === 'LEGACY'"
link
type="primary"
:loading="rolloutTransitioningId === row.knowledgeId"
@click="transitionRollout(row, 'SHADOW')"
>进入影子</el-button
>
<template v-else-if="row.mode === 'SHADOW'">
<el-button link :loading="rolloutTransitioningId === row.knowledgeId" @click="transitionRollout(row, 'LEGACY')">退回旧链路</el-button>
<el-button link type="success" :loading="rolloutTransitioningId === row.knowledgeId" @click="openRolloutAssistant(row)"
>验证与启用</el-button
>
</template>
<el-button v-else link type="warning" :loading="rolloutTransitioningId === row.knowledgeId" @click="rollbackActiveGeneration(row)"
>回退上一代</el-button
>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="indexHealth" class="index-health" :class="{ unhealthy: !indexHealth.healthy }">
<div>
<strong>{{ indexHealth.healthy ? '索引一致' : '索引需处理' }}</strong>
@@ -340,6 +391,74 @@
</template>
</el-table-column>
</el-table>
<div class="governance-operations">
<div class="rollout-head">
<div>
<strong>迁移与清理任务</strong>
<span>历史补链默认先演练;物理清理必须经过异人审批和保留期。</span>
</div>
<div class="quality-actions">
<el-button :loading="governanceLoading" @click="createMigrationPlan">新建迁移演练</el-button>
<el-button :loading="governanceLoading" @click="reconcileKnowledgeStores">运行存储对账</el-button>
<el-button :icon="Refresh" circle title="刷新治理任务" @click="loadGovernanceOperations" />
</div>
</div>
<el-tabs>
<el-tab-pane label="历史迁移">
<el-table :data="migrationRuns" size="small" empty-text="暂无迁移任务">
<el-table-column prop="runKey" label="任务" min-width="180" show-overflow-tooltip />
<el-table-column label="模式" width="90"
><template #default="{ row }">{{ row.dryRun ? '演练' : '执行' }}</template></el-table-column
>
<el-table-column prop="status" label="状态" width="150" />
<el-table-column label="进度" min-width="190">
<template #default="{ row }"
>发现 {{ row.discoveredCount }} / 纳管 {{ row.stagedCount }} / 隔离 {{ row.quarantinedCount }} / 失败
{{ row.failedCount }}</template
>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button
v-if="!['COMPLETED', 'COMPLETED_WITH_ERRORS', 'CANCELLED'].includes(row.status)"
link
type="primary"
@click="advanceMigration(row)"
>执行下一批</el-button
>
<el-button
v-if="row.dryRun && row.status === 'COMPLETED' && row.failedCount === 0"
link
type="success"
@click="createFormalMigration(row)"
>生成正式任务</el-button
>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="清理审批">
<el-table :data="purgeRequests" size="small" empty-text="暂无清理申请">
<el-table-column prop="assetId" label="资产" width="90" />
<el-table-column prop="retentionClass" label="保留策略" width="110" />
<el-table-column prop="status" label="状态" width="120" />
<el-table-column prop="reason" label="申请原因" min-width="220" show-overflow-tooltip />
<el-table-column prop="executeAfter" label="最早执行时间" width="170" />
<el-table-column prop="lastError" label="异常" min-width="180" show-overflow-tooltip />
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<template v-if="row.status === 'PENDING'">
<el-button link type="success" @click="reviewPurge(row, 'APPROVE')">批准</el-button>
<el-button link type="danger" @click="reviewPurge(row, 'REJECT')">驳回</el-button>
</template>
<el-button v-if="['APPROVED', 'FAILED'].includes(row.status)" link type="danger" @click="executePurge(row)">执行清理</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</div>
</section>
<section class="panel event-panel">
@@ -781,6 +900,51 @@
<el-descriptions-item label="重试次数">{{ selectedQualityAsset.semanticRetryCount }}</el-descriptions-item>
</el-descriptions>
<section class="asset-governance-summary">
<div class="privacy-preview-head">
<h3>版本与审核辅助</h3>
<el-tag
v-if="reviewPackage"
:type="reviewPackage.riskLevel === 'LOW' ? 'success' : reviewPackage.riskLevel === 'MEDIUM' ? 'warning' : 'danger'"
>
{{ reviewPackage.riskLevel }} 风险
</el-tag>
</div>
<div v-if="reviewPackage" class="review-assistant-grid">
<span>候选 v{{ reviewPackage.versionNo }}</span>
<span>{{ reviewPackage.summary.chunkCount }} 个片段</span>
<span>{{ reviewPackage.summary.duplicateCount }} 组重复</span>
<span>{{ reviewPackage.summary.conflictCount }} 项冲突</span>
</div>
<el-table :data="assetVersions" size="small" max-height="190" empty-text="暂无版本记录">
<el-table-column label="版本" width="86"
><template #default="{ row }">v{{ row.versionNo }}</template></el-table-column
>
<el-table-column prop="versionStatus" label="状态" width="130" />
<el-table-column label="指针" width="150">
<template #default="{ row }">
<el-tag v-if="row.published" size="small" type="success">生效版</el-tag>
<el-tag v-if="row.candidate" size="small" type="warning">候选版</el-tag>
</template>
</el-table-column>
<el-table-column prop="chunkCount" label="片段" width="80" />
<el-table-column prop="createTime" label="创建时间" min-width="170" />
</el-table>
<el-alert
v-if="deletionImpact"
:title="`清理影响:${deletionImpact.publishedFragments} 个生产片段、${deletionImpact.queryEvidence} 条问答证据、${deletionImpact.generations} 个向量代次`"
:type="deletionImpact.canRequestPurge ? 'warning' : 'info'"
:closable="false"
/>
<el-button
v-if="selectedQualityAsset.lifecycleStatus === 'DEPRECATED' && deletionImpact?.canRequestPurge"
type="danger"
plain
@click="requestAssetPurge(selectedQualityAsset)"
>申请物理清理</el-button
>
</section>
<el-alert
v-if="selectedQualityAsset.semanticAnalysisStatus === 'FAILED' || selectedQualityAsset.semanticAnalysisStatus === 'DEAD_LETTER'"
:title="selectedQualityAsset.semanticLastError || '语义分析失败,当前版本不能发布'"
@@ -970,6 +1134,43 @@
</template>
</el-dialog>
<el-dialog v-model="rolloutDialogVisible" title="影子验证与代次启用" width="min(760px, 94vw)" destroy-on-close>
<template v-if="selectedRolloutScope">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="知识空间">{{ selectedRolloutScope.spaceName }}</el-descriptions-item>
<el-descriptions-item label="当前代次">G{{ selectedRolloutScope.activeGeneration }}</el-descriptions-item>
<el-descriptions-item label="候选代次">{{
selectedRolloutScope.candidateGeneration ? `G${selectedRolloutScope.candidateGeneration}` : '尚未构建'
}}</el-descriptions-item>
<el-descriptions-item label="阻断资料">{{ selectedRolloutScope.enforcementBlockers }}</el-descriptions-item>
</el-descriptions>
<div class="rollout-assistant-actions">
<el-button :loading="rolloutAssisting" @click="buildCandidateGeneration">构建候选代次</el-button>
<el-button :loading="rolloutAssisting" @click="refreshCandidateGeneration">刷新构建状态</el-button>
</div>
<el-form label-position="top">
<el-form-item label="影子验证问题">
<el-input v-model="rolloutQuery" maxlength="1000" placeholder="输入一条真实业务问题进行旧链路与治理链路对照" />
</el-form-item>
<el-button :disabled="!rolloutQuery.trim()" :loading="rolloutAssisting" @click="runShadowComparison">记录对照样本</el-button>
<el-button :loading="rolloutAssisting" @click="checkActivationGate">评估启用门禁</el-button>
</el-form>
<el-alert
v-if="rolloutGate"
:title="`门禁 ${rolloutGate.status}:样本 ${rolloutGate.sampleCount},Top1 一致率 ${formatPercent(rolloutGate.top1AgreementRate)},Top5 重合率 ${formatPercent(rolloutGate.overlapAt5Avg)}`"
:type="rolloutGate.status === 'PASSED' ? 'success' : 'warning'"
:closable="false"
show-icon
/>
</template>
<template #footer>
<el-button @click="rolloutDialogVisible = false">关闭</el-button>
<el-button v-if="rolloutGate?.status === 'PASSED'" type="success" :loading="rolloutAssisting" @click="activateCandidateGeneration"
>确认启用候选代次</el-button
>
</template>
</el-dialog>
<el-dialog
v-model="glossaryDialogVisible"
:title="glossaryRevisionSourceId ? '创建术语新修订' : '新建业务术语'"
@@ -1016,15 +1217,24 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
addGoldenSample,
approveKnowledgeGlossary,
activateGeneration,
compareShadowQuery,
createAcceptanceProfile,
createMigrationRun,
createPurgeRequest,
decidePurgeRequest,
executePurgeRequest,
createGoldenDatasetDraft,
createKnowledgeGlossaryDraft,
createProcessingRuleDraft,
deprecateKnowledgeGlossary,
freezeAcceptanceProfile,
freezeGoldenDataset,
getAssetReviewPackage,
getDeletionImpact,
getProcessingRuleMetrics,
getRuleReadiness,
evaluateActivationGate,
getQualityMetrics,
getQualityIndexHealth,
getQualityPrivacyPreview,
@@ -1034,10 +1244,14 @@ import {
listQualityIssues,
listQualityAlerts,
listKnowledgeGlossary,
listAssetVersions,
listAcceptanceProfiles,
listGoldenDatasets,
listGoldenSamples,
listPipelineRuns,
listKnowledgeRolloutScopes,
listMigrationRuns,
listPurgeRequests,
listProcessingRules,
listRuleReviewSamples,
previewLegacyQualityAssets,
@@ -1045,10 +1259,16 @@ import {
rejectKnowledgeGlossary,
reviewRuleSample,
reviewQualityConflict,
rollbackGeneration,
runKnowledgeReconciliation,
runNextMigrationBatch,
retryProcessingTask,
stageLegacyQualityAssets,
scheduleRuleReviewSamples,
transitionProcessingRule,
transitionKnowledgeRollout,
startGenerationBuild,
refreshGenerationBuild,
withdrawQualityAsset,
type ProcessingOverview,
type ProcessingTask,
@@ -1060,7 +1280,15 @@ import {
type QualityIssue,
type QualityPrivacyPreview,
type QualityReviewRequest,
type ReviewPackage,
type KnowledgeVersion,
type DeletionImpact,
type PurgeRequest,
type MigrationRun,
type ActivationGate,
type KnowledgeGlossaryTerm,
type KnowledgeRolloutMode,
type KnowledgeRolloutScope,
type AcceptanceProfile,
type AcceptanceProfileDraft,
type GoldenDataset,
@@ -1078,7 +1306,8 @@ import { uploadKnowledgeDocAsync, listUploadItems, retryUploadItem, type UploadI
import { listKnowledgeSpaces, type KnowledgeSpace } from '@/api/aihr/space';
import { listPersonalPublishRequests, reviewPersonalPublishRequest, type PersonalPublishRequestRow } from '@/api/aihr/personal';
const acceptTypes = '.txt,.md,.markdown,.rtf,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v,.zip';
const acceptTypes =
'.txt,.md,.markdown,.rtf,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v,.zip';
const supportedPattern = /\.(txt|md|markdown|rtf|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|webp|bmp|mp4|mov|avi|mkv|webm|m4v|zip)$/i;
const maxFileSize = 500 * 1024 * 1024;
const browserBatchWarnCount = 20;
@@ -1103,6 +1332,20 @@ const indexHealth = ref<QualityIndexHealth | null>(null);
const indexHealthFailed = ref(false);
const qualityMetrics = ref<QualitySnapshot | null>(null);
const qualityAlerts = ref<QualityAlert[]>([]);
const rolloutLoading = ref(false);
const rolloutTransitioningId = ref<number | null>(null);
const rolloutScopes = ref<KnowledgeRolloutScope[]>([]);
const rolloutDialogVisible = ref(false);
const selectedRolloutScope = ref<KnowledgeRolloutScope | null>(null);
const rolloutGate = ref<ActivationGate | null>(null);
const rolloutQuery = ref('');
const rolloutAssisting = ref(false);
const governanceLoading = ref(false);
const migrationRuns = ref<MigrationRun[]>([]);
const purgeRequests = ref<PurgeRequest[]>([]);
const assetVersions = ref<KnowledgeVersion[]>([]);
const reviewPackage = ref<ReviewPackage | null>(null);
const deletionImpact = ref<DeletionImpact | null>(null);
const glossaryLoading = ref(false);
const glossarySaving = ref(false);
const glossaryStatus = ref<KnowledgeGlossaryTerm['status']>('ACTIVE');
@@ -1424,6 +1667,8 @@ onMounted(async () => {
loadQualityAssets(),
loadIndexHealth(),
loadQualityMonitoring(),
loadRolloutScopes(),
loadGovernanceOperations(),
loadGlossary(),
loadRuleWorkbench(),
loadGoldenDatasets()
@@ -1462,6 +1707,284 @@ async function loadQualityAssets() {
}
}
async function loadRolloutScopes() {
rolloutLoading.value = true;
try {
rolloutScopes.value = (await listKnowledgeRolloutScopes()).data || [];
} catch {
rolloutScopes.value = [];
} finally {
rolloutLoading.value = false;
}
}
async function transitionRollout(scope: KnowledgeRolloutScope, targetMode: KnowledgeRolloutMode) {
const targetLabel = rolloutModeLabel(targetMode);
try {
const { value } = await ElMessageBox.prompt(
targetMode === 'ENFORCED'
? '启用后该空间只召回已审核发布且索引就绪的治理版本。'
: targetMode === 'LEGACY'
? '回退后该空间立即继续使用原有资料与旧向量。'
: '影子模式继续服务旧结果,同时允许生成治理链路对照数据。',
`切换到${targetLabel}`,
{
confirmButtonText: '确认切换',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '填写切换原因',
inputValidator: (input) => Boolean(input?.trim()) || '请填写切换原因'
}
);
rolloutTransitioningId.value = scope.knowledgeId;
await transitionKnowledgeRollout(scope.knowledgeId, targetMode, value.trim(), scope.activeGeneration);
await loadRolloutScopes();
ElMessage.success(`${scope.spaceName}已切换到${targetLabel}`);
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('空间模式切换失败,请检查覆盖率与索引状态');
} finally {
rolloutTransitioningId.value = null;
}
}
async function loadGovernanceOperations() {
governanceLoading.value = true;
try {
const [migrations, purges] = await Promise.all([listMigrationRuns(), listPurgeRequests()]);
migrationRuns.value = migrations.data || [];
purgeRequests.value = purges.data || [];
} catch {
migrationRuns.value = [];
purgeRequests.value = [];
} finally {
governanceLoading.value = false;
}
}
async function createMigrationPlan() {
try {
const { value } = await ElMessageBox.prompt('可选:输入知识空间 ID;留空则覆盖当前租户全部空间。', '新建历史迁移演练', {
confirmButtonText: '创建演练',
cancelButtonText: '取消',
inputPlaceholder: '知识空间 ID(可留空)',
inputValidator: (input) => !input?.trim() || /^\d+$/.test(input.trim()) || '请输入数字 ID'
});
const knowledgeId = value?.trim() ? Number(value.trim()) : undefined;
await createMigrationRun({
runKey: `admin-dry-run:${knowledgeId || 'all'}:${Date.now()}`,
knowledgeId,
riskScope: 'ALL',
dryRun: true,
batchSize: 50
});
await loadGovernanceOperations();
ElMessage.success('迁移演练清单已创建,尚未修改任何资料');
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('迁移演练创建失败');
}
}
async function advanceMigration(run: MigrationRun) {
governanceLoading.value = true;
try {
const result = (await runNextMigrationBatch(run.id)).data;
await loadGovernanceOperations();
ElMessage.success(result.moreAvailable ? '本批完成,可继续下一批' : '迁移任务已完成');
} catch {
ElMessage.error('迁移批次执行失败,任务游标未丢失');
} finally {
governanceLoading.value = false;
}
}
async function createFormalMigration(dryRun: MigrationRun) {
try {
await ElMessageBox.confirm(`将依据演练 #${dryRun.id} 生成同范围正式迁移任务。创建后不会立即改动资料,仍需人工逐批执行。`, '确认生成正式任务', {
confirmButtonText: '生成正式任务',
cancelButtonText: '取消',
type: 'warning'
});
await createMigrationRun({
runKey: `admin-formal:${dryRun.knowledgeId || 'all'}:${Date.now()}`,
knowledgeId: dryRun.knowledgeId,
riskScope: dryRun.riskScope,
dryRun: false,
batchSize: dryRun.batchSize,
verifiedDryRunId: dryRun.id
});
await loadGovernanceOperations();
ElMessage.success('正式迁移任务已创建,尚未执行任何批次');
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('正式迁移任务创建失败');
}
}
async function reconcileKnowledgeStores() {
governanceLoading.value = true;
try {
const result = (await runKnowledgeReconciliation()).data;
ElMessage[result.status === 'PASSED' ? 'success' : 'warning'](
result.status === 'PASSED' ? 'MySQL、OSS 与向量血缘对账通过' : `对账发现 ${result.issues.length} 项待处理问题`
);
} catch {
ElMessage.error('存储对账执行失败');
} finally {
governanceLoading.value = false;
}
}
async function reviewPurge(request: PurgeRequest, decision: 'APPROVE' | 'REJECT') {
try {
const { value } = await ElMessageBox.prompt(
decision === 'APPROVE' ? '批准人必须与申请人不同;批准后仍需等待保留期。' : '请说明驳回依据。',
decision === 'APPROVE' ? '批准清理申请' : '驳回清理申请',
{
confirmButtonText: decision === 'APPROVE' ? '确认批准' : '确认驳回',
cancelButtonText: '取消',
inputType: 'textarea',
inputValidator: (input) => Boolean(input?.trim()) || '请填写审核依据'
}
);
await decidePurgeRequest(request.id, decision, value.trim());
await loadGovernanceOperations();
ElMessage.success(decision === 'APPROVE' ? '清理申请已批准' : '清理申请已驳回');
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('清理申请审核失败');
}
}
async function executePurge(request: PurgeRequest) {
try {
await ElMessageBox.confirm(
`将不可恢复地清理资产 #${request.assetId} 的 MySQL、向量与独占 OSS 数据。系统仍会校验保留期和异人控制。`,
'执行物理清理',
{ confirmButtonText: '确认执行', cancelButtonText: '取消', type: 'error' }
);
await executePurgeRequest(request.id);
await Promise.all([loadGovernanceOperations(), loadQualityAssets(), loadIndexHealth()]);
ElMessage.success('物理清理已完成并保留审计墓碑');
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('物理清理未执行,请检查保留期、审批人和存储状态');
}
}
function openRolloutAssistant(scope: KnowledgeRolloutScope) {
selectedRolloutScope.value = scope;
rolloutGate.value = null;
rolloutQuery.value = '';
rolloutDialogVisible.value = true;
}
async function buildCandidateGeneration() {
const scope = selectedRolloutScope.value;
if (!scope) return;
rolloutAssisting.value = true;
try {
await startGenerationBuild(scope.knowledgeId, '管理端启动候选代次全量构建');
await loadRolloutScopes();
selectedRolloutScope.value = rolloutScopes.value.find((item) => item.knowledgeId === scope.knowledgeId) || scope;
ElMessage.success('候选代次已排队构建');
} catch {
ElMessage.error('候选代次构建启动失败');
} finally {
rolloutAssisting.value = false;
}
}
async function refreshCandidateGeneration() {
const scope = selectedRolloutScope.value;
if (!scope) return;
rolloutAssisting.value = true;
try {
const result = (await refreshGenerationBuild(scope.knowledgeId)).data;
ElMessage.info(`候选 G${result.generation}:${result.status},已纳入 ${result.includedVersions}/${result.expectedVersions}`);
} catch {
ElMessage.error('候选代次状态刷新失败');
} finally {
rolloutAssisting.value = false;
}
}
async function runShadowComparison() {
const scope = selectedRolloutScope.value;
if (!scope || !rolloutQuery.value.trim()) return;
rolloutAssisting.value = true;
try {
await compareShadowQuery(scope.knowledgeId, rolloutQuery.value.trim());
rolloutQuery.value = '';
ElMessage.success('影子对照样本已记录,不影响当前在线结果');
} catch {
ElMessage.error('影子对照失败,请确认候选代次已创建');
} finally {
rolloutAssisting.value = false;
}
}
async function checkActivationGate() {
const scope = selectedRolloutScope.value;
if (!scope) return;
rolloutAssisting.value = true;
try {
rolloutGate.value = (await evaluateActivationGate(scope.knowledgeId)).data;
} catch {
ElMessage.error('启用门禁评估失败');
} finally {
rolloutAssisting.value = false;
}
}
async function activateCandidateGeneration() {
const scope = selectedRolloutScope.value;
const gate = rolloutGate.value;
if (!scope || !gate || gate.status !== 'PASSED') return;
try {
const { value } = await ElMessageBox.prompt('启用后员工检索将切换到候选代次,七天内可回退。', '确认启用治理链路', {
confirmButtonText: '确认启用',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '填写启用原因',
inputValidator: (input) => Boolean(input?.trim()) || '请填写启用原因'
});
rolloutAssisting.value = true;
await activateGeneration(scope.knowledgeId, gate.gateId, value.trim());
rolloutDialogVisible.value = false;
await loadRolloutScopes();
ElMessage.success('候选代次已启用,回退窗口为七天');
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('候选代次启用失败');
} finally {
rolloutAssisting.value = false;
}
}
async function rollbackActiveGeneration(scope: KnowledgeRolloutScope) {
try {
const { value } = await ElMessageBox.prompt('仅能在最近一次启用后的七天窗口内回退。', '回退向量代次', {
confirmButtonText: '确认回退',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '填写回退原因',
inputValidator: (input) => Boolean(input?.trim()) || '请填写回退原因'
});
rolloutTransitioningId.value = scope.knowledgeId;
await rollbackGeneration(scope.knowledgeId, value.trim());
await loadRolloutScopes();
ElMessage.success('已回退上一代并返回影子模式');
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('代次回退失败或已超过回退窗口');
} finally {
rolloutTransitioningId.value = null;
}
}
function rolloutModeLabel(mode: KnowledgeRolloutMode) {
return { LEGACY: '旧链路', SHADOW: '影子', ENFORCED: '治理链路' }[mode];
}
function rolloutModeType(mode: KnowledgeRolloutMode) {
return ({ LEGACY: 'info', SHADOW: 'warning', ENFORCED: 'success' } as const)[mode];
}
async function loadGlossary() {
glossaryLoading.value = true;
try {
@@ -2059,6 +2582,9 @@ async function openQualityReview(asset: QualityAsset) {
qualityPrivacyPreview.value = null;
qualityConflicts.value = [];
publishedQualityAssets.value = [];
assetVersions.value = [];
reviewPackage.value = null;
deletionImpact.value = null;
qualityDetailLoading.value = true;
qualityDialogVisible.value = true;
qualityReviewForm.value = {
@@ -2075,23 +2601,32 @@ async function openQualityReview(asset: QualityAsset) {
supersedesAssetId: undefined
};
try {
const [issues, privacyPreview, conflicts, published] = await Promise.all([
const [issues, privacyPreview, conflicts, published, versions, impact, review] = await Promise.all([
listQualityIssues(asset.id),
getQualityPrivacyPreview(asset.id),
listQualityConflicts(asset.id),
listQualityAssets('PUBLISHED', 200)
listQualityAssets('PUBLISHED', 200),
listAssetVersions(asset.id),
getDeletionImpact(asset.id),
asset.lifecycleStatus === 'REVIEW_PENDING' ? getAssetReviewPackage(asset.id) : Promise.resolve({ data: null })
]);
if (selectedQualityAsset.value?.id !== asset.id) return;
qualityIssues.value = issues.data || [];
qualityPrivacyPreview.value = privacyPreview.data || null;
qualityConflicts.value = conflicts.data || [];
publishedQualityAssets.value = (published.data || []).filter((item) => item.id !== asset.id);
assetVersions.value = versions.data || [];
deletionImpact.value = impact.data || null;
reviewPackage.value = review.data || null;
} catch {
if (selectedQualityAsset.value?.id !== asset.id) return;
qualityIssues.value = [];
qualityPrivacyPreview.value = null;
qualityConflicts.value = [];
publishedQualityAssets.value = [];
assetVersions.value = [];
reviewPackage.value = null;
deletionImpact.value = null;
ElMessage.error('质量原因加载失败');
} finally {
if (selectedQualityAsset.value?.id === asset.id) qualityDetailLoading.value = false;
@@ -2578,6 +3113,23 @@ function fileBadgeClass(type: string) {
if (type === '视频') return 'ppt';
return 'text';
}
async function requestAssetPurge(asset: QualityAsset) {
try {
const { value } = await ElMessageBox.prompt('申请不会立即删除。系统将在保留期结束后,要求另一名管理员审批并执行。', '申请物理清理', {
confirmButtonText: '提交申请',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '填写必须物理清理的原因',
inputValidator: (input) => Boolean(input?.trim()) || '请填写清理原因'
});
await createPurgeRequest(asset.id, value.trim());
qualityDialogVisible.value = false;
await loadGovernanceOperations();
ElMessage.success('清理申请已提交,等待异人审批和保留期');
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('清理申请提交失败');
}
}
</script>
<style scoped lang="scss">
@@ -3082,6 +3634,34 @@ function fileBadgeClass(type: string) {
}
}
.rollout-workbench {
margin-bottom: 16px;
border-top: 1px solid #e5e7eb;
}
.rollout-head {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 52px;
gap: 16px;
}
.rollout-head > div {
display: flex;
align-items: baseline;
gap: 12px;
}
.rollout-head span {
color: #6b7280;
font-size: 13px;
}
.rollout-blocker {
margin-left: 8px;
}
.index-health.unhealthy {
border-left-color: #d64545;
background: #fff4f4;
@@ -3225,6 +3805,34 @@ function fileBadgeClass(type: string) {
}
}
.governance-operations,
.asset-governance-summary {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid #e4e7ed;
}
.review-assistant-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 12px;
span {
padding: 8px 10px;
border: 1px solid #e4e7ed;
background: #f8fafc;
font-size: 13px;
}
}
.rollout-assistant-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 16px 0;
}
.quality-issue-list {
margin: 18px 0;
@@ -3421,7 +4029,8 @@ function fileBadgeClass(type: string) {
.quality-form-grid,
.reason-checks,
.conflict-sources {
.conflict-sources,
.review-assistant-grid {
grid-template-columns: 1fr;
}