feat: govern knowledge assets and source citations

This commit is contained in:
key
2026-08-02 01:43:43 +08:00
parent cafb836cda
commit 699cc08050
144 changed files with 17205 additions and 453 deletions
+1
View File
@@ -7,6 +7,7 @@ interface ImportMetaEnv {
readonly VITE_APP_PRIVACY_URL?: string;
readonly VITE_DEV_SMS_HINT_ENABLED?: string;
readonly VITE_DEV_SMS_HINT_VALUE?: string;
readonly VITE_SMS_VERIFICATION_ENABLED?: string;
}
interface ImportMeta {
+6
View File
@@ -25,6 +25,12 @@
"navigationBarTitleText": "工作助手"
}
},
{
"path": "pages/user/sop/citation-detail",
"style": {
"navigationBarTitleText": "来源详情"
}
},
{
"path": "pages/user/broadcast/index",
"style": {
+6 -4
View File
@@ -45,7 +45,7 @@
/>
</view>
</uni-forms-item>
<uni-forms-item label="验证码" name="smsCode">
<uni-forms-item v-if="smsVerificationEnabled" label="验证码" name="smsCode">
<view class="code-row">
<view class="field-shell code-shell">
<uni-icons class="field-icon" type="locked" size="22" color="#a7adb8" />
@@ -74,7 +74,7 @@
</uni-forms-item>
</uni-forms>
<view v-if="showDevSmsHint" class="dev-hint-pill" @click="fillDevCredentials">
<view v-if="smsVerificationEnabled && showDevSmsHint" class="dev-hint-pill" @click="fillDevCredentials">
<uni-icons type="info-filled" size="14" color="#d97706" />
<text class="dev-hint-text">测试验证码: {{ devSmsHintValue }}</text>
<text class="dev-fill-btn">快捷填入</text>
@@ -132,6 +132,8 @@ import { goToMobileTarget, nextUserRouteAfterLogin } from '@/services/position';
const stored = getAuth();
const loginForm = ref();
const smsVerificationEnabled = !import.meta.env.DEV
|| import.meta.env.VITE_SMS_VERIFICATION_ENABLED === 'true';
const formData = reactive({
phone: stored.phone || uni.getStorageSync(phoneKey) || '',
smsCode: ''
@@ -144,10 +146,10 @@ const formRules = {
]
},
smsCode: {
rules: [
rules: smsVerificationEnabled ? [
{ required: true, errorMessage: '请输入短信验证码' },
{ pattern: '^\\d{4,6}$', errorMessage: '验证码应为 4 至 6 位数字' }
]
] : []
}
};
const sending = ref(false);
@@ -0,0 +1,139 @@
<template>
<view class="citation-page">
<view v-if="state === 'loading'" class="state-panel" role="status">正在确认来源…</view>
<view v-else-if="state === 'forbidden' || state === 'not-found'" class="state-panel error-panel">
<text class="state-title">暂时无法查看这条来源</text>
<text class="state-copy">来源可能已撤回、过期,或当前账号没有访问权限。</text>
<button class="action-button" @click="goBack">返回问答</button>
</view>
<view v-else-if="state === 'error'" class="state-panel error-panel">
<text class="state-title">来源加载失败</text>
<text class="state-copy">网络连接可能不稳定,请稍后重试。</text>
<button class="action-button" @click="load">重试</button>
</view>
<template v-else-if="detail">
<view class="detail-header">
<text class="detail-kind">{{ kindLabel(detail.mediaType) }}</text>
<text class="detail-title">{{ detail.title }}</text>
<text class="detail-location">{{ locationLabel(detail) }}</text>
</view>
<scroll-view v-if="detail.mediaType !== 'IMAGE' && detail.mediaType !== 'VIDEO'" scroll-y class="text-content" :scroll-into-view="targetView">
<view v-for="segment in detail.segments" :id="segment.target ? 'target-segment' : undefined" :key="segment.index" class="text-segment" :class="{ target: segment.target }">
<text>{{ segment.text }}</text>
</view>
</scroll-view>
<view v-else-if="detail.mediaType === 'IMAGE'" class="media-content">
<image v-if="mediaUrl" class="source-image" :src="mediaUrl" mode="widthFix" @click="previewImage" />
<text v-else class="media-loading">正在加载原图…</text>
</view>
<view v-else class="media-content video-content">
<video v-if="mediaUrl" class="source-video" :src="mediaUrl" controls :initial-time="initialSeconds" @loadedmetadata="seekVideo" @canplay="seekVideo" />
<text v-else class="media-loading">正在加载视频…</text>
<text class="video-note">{{ locationLabel(detail) }},播放器将在可用时定位</text>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
import type { KnowledgeCitationDetail } from '@/types/api';
import { ApiError, authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
import { isLoggedIn } from '@/services/auth';
import { loadKnowledgeCitationDetail, loadKnowledgeCitationMediaUrl } from '@/services/knowledge';
type ViewState = 'loading' | 'ready' | 'forbidden' | 'not-found' | 'error';
const state = ref<ViewState>('loading');
const detail = ref<KnowledgeCitationDetail | null>(null);
const mediaUrl = ref('');
const targetView = ref('');
const initialSeconds = ref(0);
const detailRef = ref('');
const kindLabel = (kind: string) => ({ TEXT: '文本', PDF: 'PDF', DOCX: '文档', PPTX: '演示文稿', XLSX: '表格', IMAGE: '图片', VIDEO: '视频' }[kind] || '来源');
const formatTime = (ms?: number | null) => typeof ms === 'number' && ms >= 0
? `${String(Math.floor(ms / 60000)).padStart(2, '0')}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}` : '';
const locationLabel = (value: KnowledgeCitationDetail) => {
const locator = value.locator;
if (value.mediaType === 'VIDEO') return `引用位置 ${formatTime(locator?.frameMs ?? locator?.startMs) || '未知'}`;
if (locator?.pageNumber) return `第 ${locator.pageNumber} 页`;
if (locator?.slideNumber) return `第 ${locator.slideNumber} 页`;
if (locator?.sheetName && locator.rowStart) return `${locator.sheetName} · 第 ${locator.rowStart} 行`;
if (locator?.paragraphStart) return `第 ${locator.paragraphStart} 段`;
return '来源全文';
};
const releaseMedia = () => {
if (mediaUrl.value) releaseAuthenticatedFileUrl(mediaUrl.value);
mediaUrl.value = '';
};
const load = async () => {
if (!detailRef.value) { state.value = 'not-found'; return; }
releaseMedia();
detail.value = null;
targetView.value = '';
state.value = 'loading';
try {
const loaded = await loadKnowledgeCitationDetail(detailRef.value);
detail.value = loaded;
const target = loaded.locator?.startMs ?? loaded.locator?.frameMs;
initialSeconds.value = typeof target === 'number' ? target / 1000 : 0;
if (loaded.mediaType === 'VIDEO') {
mediaUrl.value = await loadKnowledgeCitationMediaUrl(detailRef.value);
} else if (loaded.mediaType === 'IMAGE') {
if (!loaded.contentUrl) throw new Error('来源媒体地址缺失');
mediaUrl.value = await authenticatedFileUrl(loaded.contentUrl);
}
state.value = 'ready';
await nextTick();
if (loaded.segments.some((segment) => segment.target)) targetView.value = 'target-segment';
} catch (error) {
if (!isLoggedIn()) state.value = 'forbidden';
else if (error instanceof ApiError && error.statusCode === 404) state.value = 'not-found';
else if (error instanceof ApiError && (error.statusCode === 401 || error.statusCode === 403)) state.value = 'forbidden';
else state.value = 'error';
}
};
const seekVideo = (event: any) => {
const target = initialSeconds.value;
if (target <= 0 || !event?.target) return;
try { event.target.currentTime = target; } catch { /* platform may delay seek until canplay */ }
};
const previewImage = () => mediaUrl.value && uni.previewImage({ urls: [mediaUrl.value], current: mediaUrl.value });
const goBack = () => uni.navigateBack();
onMounted(() => {
const pages = getCurrentPages();
const current = pages[pages.length - 1] as { options?: Record<string, string> } | undefined;
detailRef.value = current?.options?.ref || '';
void load();
});
onUnmounted(releaseMedia);
</script>
<style scoped>
.citation-page { min-height: 100vh; box-sizing: border-box; padding: 16px 14px calc(24px + env(safe-area-inset-bottom)); background: #f5f7fa; color: #202733; }
.detail-header { padding: 16px; border: 1px solid #e5e8ed; border-radius: 12px; background: #fff; }
.detail-kind { display: block; color: #c90018; font-size: 12px; font-weight: 700; }
.detail-title { display: block; margin-top: 6px; font-size: 18px; font-weight: 800; line-height: 1.4; word-break: break-word; }
.detail-location { display: block; margin-top: 8px; color: #697586; font-size: 12px; }
.text-content { height: calc(100vh - 156px); margin-top: 12px; padding: 4px 2px; box-sizing: border-box; }
.text-segment { margin-bottom: 10px; padding: 12px; border-radius: 8px; background: #fff; font-size: 15px; line-height: 1.7; word-break: break-word; }
.text-segment.target { border: 1px solid #f2aab2; background: #fff3f4; box-shadow: 0 0 0 2px rgba(201, 0, 24, .08); }
.media-content { margin-top: 12px; padding: 10px; border-radius: 12px; background: #111; text-align: center; }
.source-image { width: 100%; max-height: 72vh; border-radius: 8px; background: #fff; }
.source-video { width: 100%; height: 230px; border-radius: 8px; background: #000; }
.video-note { display: block; margin-top: 9px; color: #d6dbe2; font-size: 12px; text-align: left; }
.media-loading { color: #d6dbe2; font-size: 13px; }
.state-panel { min-height: 60vh; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
.error-panel { padding: 24px; }
.state-title { font-size: 18px; font-weight: 800; }
.state-copy { max-width: 280px; margin-top: 9px; color: #7a8492; font-size: 13px; line-height: 1.6; }
.action-button { min-width: 112px; margin-top: 20px; border: 0; border-radius: 8px; background: #c90018; color: #fff; font-size: 14px; }
.action-button::after { border: 0; }
</style>
+61 -3
View File
@@ -217,7 +217,14 @@
</view>
<view v-if="msg.citationsOpen && msg.result.snippets?.length" class="bubble-citations">
<view v-for="(snippet, index) in msg.result.snippets" :key="`${snippet.docTitle}-${index}`" class="citation">
<component
:is="snippet.detailRef && snippet.sourceType === 'DOCUMENT' ? 'button' : 'view'"
v-for="(snippet, index) in msg.result.snippets"
:key="`${snippet.docTitle}-${index}`"
class="citation"
:class="{ 'citation-action': Boolean(snippet.detailRef && snippet.sourceType === 'DOCUMENT') }"
@click="openCitationDetail(snippet)"
>
<text class="citation-domain" :class="{ project: snippet.domain === 'PROJECT_SERVICE' }">
{{ citationDomainLabel(snippet) }}
</text>
@@ -227,8 +234,11 @@
</text>
<text v-else-if="snippet.spaceCode" class="citation-meta">知识空间:{{ snippet.spaceCode }}</text>
<text class="citation-content">{{ snippetExcerpt(snippet.content) }}</text>
<text v-if="snippet.fragmentId" class="citation-meta">片段 #{{ snippet.fragmentId }}</text>
</view>
<view v-if="snippet.detailRef && snippet.sourceType === 'DOCUMENT'" class="citation-open-row">
<text class="citation-location">{{ citationLocationLabel(snippet) }}</text>
<text class="citation-open">查看出处 ›</text>
</view>
</component>
</view>
<view v-if="msg.voiceStatus !== 'unavailable'" class="transcript-collapse" @click="collapseTranscript(msg)">
@@ -1304,6 +1314,28 @@ const serviceMemoryStatusLabel = (status?: string | null) => ({
const displayCitationDate = (value?: string | null) => value ? value.replace('T', ' ').slice(0, 16) : '未记录';
const formatCitationTime = (milliseconds?: number | null) => {
if (typeof milliseconds !== 'number' || milliseconds < 0) return '';
const seconds = Math.floor(milliseconds / 1000);
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
};
const citationLocationLabel = (snippet: KnowledgeSearchResponse['snippets'][number]) => {
const locator = snippet.locatorSummary;
const at = locator?.frameMs ?? locator?.startMs;
if (snippet.mediaType === 'VIDEO' && typeof at === 'number') return `视频 ${formatCitationTime(at)}`;
if (locator?.pageNumber) return `第 ${locator.pageNumber} 页`;
if (locator?.slideNumber) return `第 ${locator.slideNumber} 页`;
if (locator?.sheetName && locator?.rowStart) return `${locator.sheetName} · 第 ${locator.rowStart} 行`;
if (locator?.paragraphStart) return `第 ${locator.paragraphStart} 段`;
return snippet.mediaType === 'IMAGE' ? '原图' : '来源全文';
};
const openCitationDetail = (snippet: KnowledgeSearchResponse['snippets'][number]) => {
if (!snippet.detailRef || snippet.sourceType !== 'DOCUMENT') return;
uni.navigateTo({ url: `/pages/user/sop/citation-detail?ref=${encodeURIComponent(snippet.detailRef)}` });
};
const noEvidenceText = (result: KnowledgeSearchResponse) => {
if (result.agentIntent === 'WEB_RESEARCH') return '公开网络中没有找到可核验来源。';
if (result.agentIntent === 'MEDIA_UNDERSTANDING') return '已分析附件,但当前资料中没有补充依据。';
@@ -1354,6 +1386,7 @@ const feedback = async (verdict: AnswerFeedbackVerdict, msg: MasterMsg) => {
try {
await submitAnswerFeedback({
queryText: msg.result.queryText || '',
requestId: msg.result.requestId,
fragmentIds,
verdict,
reviewId: msg.result.reviewId,
@@ -3253,6 +3286,31 @@ onUnload(() => {
background: #fafbfc;
}
.citation-action {
box-sizing: border-box;
width: 100%;
margin: 0;
border: 0;
border-left: 3px solid #d3132a;
text-align: left;
line-height: normal;
}
.citation-action::after { border: 0; }
.citation-open-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 8px;
padding-top: 7px;
border-top: 1px solid #e8ebef;
}
.citation-location { color: #697586; font-size: 12px; }
.citation-open { color: #c90018; font-size: 12px; font-weight: 700; }
.citation-title {
display: block;
color: #232a33;
+4 -1
View File
@@ -89,7 +89,10 @@ export const normalizeAgentResponse = (
domain: citation.domain,
status: citation.status,
occurredAt: citation.occurredAt,
updatedAt: citation.updatedAt
updatedAt: citation.updatedAt,
mediaType: citation.mediaType,
detailRef: citation.detailRef,
locatorSummary: citation.locatorSummary
}));
return {
requestId: response.runId,
+31 -2
View File
@@ -6,7 +6,8 @@ import type {
UnifiedKnowledgeResponse,
KnowledgeSnippet,
PositionSopSummary,
SummaryCardResponse
SummaryCardResponse,
KnowledgeCitationDetail
} from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import { getSelectedPosition } from './auth';
@@ -243,6 +244,29 @@ export const requestKnowledgeResourceDownloadLink = async (attachmentId: number)
return apiUrl(response.url);
};
export const loadKnowledgeCitationDetail = (detailRef: string) => {
const safeRef = String(detailRef || '').trim();
if (!/^[A-Za-z0-9_-]{43}$/.test(safeRef)) return Promise.reject(new Error('引用地址无效'));
return apiRequest<KnowledgeCitationDetail>({
url: `/api/knowledge/citations/${encodeURIComponent(safeRef)}`,
method: 'GET',
timeout: 15000,
clearAuthOnUnauthorized: false
});
};
export const loadKnowledgeCitationMediaUrl = async (detailRef: string) => {
const safeRef = String(detailRef || '').trim();
if (!/^[A-Za-z0-9_-]{43}$/.test(safeRef)) throw new Error('引用地址无效');
const response = await apiRequest<{ url: string }>({
url: `/api/knowledge/citations/${encodeURIComponent(safeRef)}/media-link`,
method: 'POST',
clearAuthOnUnauthorized: false
});
if (!response.url?.startsWith('/api/knowledge/resources/')) throw new Error('视频地址无效');
return apiUrl(response.url);
};
const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): KnowledgeSearchResponse => {
const legacy = response.legacy || ({} as KnowledgeSearchResponse);
const citationSnippets = (response.citations || []).map((item) => normalizeSnippet({
@@ -255,7 +279,10 @@ const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): Knowledge
domain: item.domain,
status: item.status,
occurredAt: item.occurredAt,
updatedAt: item.updatedAt
updatedAt: item.updatedAt,
mediaType: item.mediaType,
detailRef: item.detailRef,
locatorSummary: item.locatorSummary
}));
return {
...legacy,
@@ -292,6 +319,7 @@ export const getPositionSopSummary = (position = getSelectedPosition()) =>
export const submitAnswerFeedback = (params: {
queryText: string;
requestId?: string;
fragmentIds: number[];
verdict: AnswerFeedbackVerdict;
reviewId?: number | null;
@@ -303,6 +331,7 @@ export const submitAnswerFeedback = (params: {
method: 'POST',
data: {
queryText: params.queryText,
requestId: params.requestId || '',
fragmentIds: params.fragmentIds,
verdict: params.verdict,
category: 'sop',
+33
View File
@@ -119,6 +119,9 @@ export interface KnowledgeSnippet {
status?: string | null;
occurredAt?: string | null;
updatedAt?: string | null;
mediaType?: KnowledgeCitation['mediaType'];
detailRef?: string | null;
locatorSummary?: KnowledgeCitation['locatorSummary'];
}
export interface KnowledgeDoc {
@@ -176,6 +179,36 @@ export interface KnowledgeCitation {
status?: string | null;
occurredAt?: string | null;
updatedAt?: string | null;
mediaType?: 'TEXT' | 'PDF' | 'DOCX' | 'PPTX' | 'XLSX' | 'IMAGE' | 'VIDEO' | string | null;
detailRef?: string | null;
locatorSummary?: {
pageNumber?: number | null;
slideNumber?: number | null;
paragraphStart?: number | null;
paragraphEnd?: number | null;
sheetName?: string | null;
rowStart?: number | null;
rowEnd?: number | null;
startMs?: number | null;
endMs?: number | null;
frameMs?: number | null;
} | null;
}
export interface KnowledgeCitationTextSegment {
index: number;
text: string;
target: boolean;
}
export interface KnowledgeCitationDetail {
mediaType: 'TEXT' | 'PDF' | 'DOCX' | 'PPTX' | 'XLSX' | 'IMAGE' | 'VIDEO' | string;
title: string;
locator?: KnowledgeCitation['locatorSummary'];
snippet: string;
segments: KnowledgeCitationTextSegment[];
contentUrl?: string | null;
originalUrl?: string | null;
}
export interface KnowledgeResource {
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const source = (path) => readFile(new URL(path, import.meta.url), 'utf8');
test('引用详情使用不透明 ref,并覆盖文本、图片和视频状态', async () => {
const [types, service, page, detail] = await Promise.all([
source('../src/types/api.ts'),
source('../src/services/knowledge.ts'),
source('../src/pages/user/sop/index.vue'),
source('../src/pages/user/sop/citation-detail.vue')
]);
assert.match(types, /detailRef\?: string \| null/);
assert.match(types, /KnowledgeCitationDetail/);
assert.match(service, /\/api\/knowledge\/citations/);
assert.match(page, /openCitationDetail/);
assert.match(page, /snippet\.detailRef/);
assert.doesNotMatch(page, /attachmentId.*navigateTo|navigateTo.*attachmentId/);
assert.match(detail, /state === 'forbidden'|state === 'not-found'/);
assert.match(detail, /authenticatedFileUrl/);
assert.match(detail, /loaded\.locator/);
assert.match(detail, /currentTime/);
});
test('引用详情页释放媒体临时 URL', async () => {
const detail = await source('../src/pages/user/sop/citation-detail.vue');
assert.match(detail, /onUnmounted\(releaseMedia\)/);
assert.match(detail, /releaseAuthenticatedFileUrl/);
});
+6 -2
View File
@@ -142,12 +142,16 @@ test('员工端 tabBar 继续保留四个固定入口', async () => {
assert.match(source, /pages\/user\/profile\/index/);
});
test('登录页按高保真原型保留品牌结构和紧凑验证码行', async () => {
test('登录页保留品牌结构并按开发开关控制验证码行', async () => {
const source = await pageSource('../src/pages/auth/login/index.vue');
assert.match(source, /手机号登录,立刻上手/);
assert.match(source, /<uni-icons[^>]+type="phone"/);
assert.match(source, /<uni-icons[^>]+type="locked"/);
assert.match(source, /v-if="smsVerificationEnabled" label="验证码"/);
assert.match(source, /const smsVerificationEnabled = !import\.meta\.env\.DEV/);
assert.match(source, /VITE_SMS_VERIFICATION_ENABLED === 'true'/);
assert.match(source, /rules: smsVerificationEnabled \? \[/);
assert.doesNotMatch(source, /class="logo-badge"/);
assert.doesNotMatch(source, /grid-template-columns:\s*1fr;[\s\S]*?\.code-button/);
});
@@ -176,7 +180,7 @@ test('登录前协议默认不勾选且协议未配置时禁止发送验证码
test('测试验证码提示必须由开发环境显式开关启用', async () => {
const source = await pageSource('../src/pages/auth/login/index.vue');
assert.match(source, /v-if="showDevSmsHint"/);
assert.match(source, /v-if="smsVerificationEnabled && showDevSmsHint"/);
assert.match(source, /import\.meta\.env\.VITE_DEV_SMS_HINT_VALUE/);
assert.match(source, /import\.meta\.env\.VITE_DEV_SMS_HINT_ENABLED === 'true'/);
assert.match(source, /\/\^\\d\{4,6\}\$\/\.test\(devSmsHintValue\)/);