feat: complete governed knowledge lifecycle
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user