feat: govern knowledge assets and source citations
This commit is contained in:
Vendored
+1
@@ -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 {
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
"navigationBarTitleText": "工作助手"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/sop/citation-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "来源详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/broadcast/index",
|
||||
"style": {
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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\)/);
|
||||
|
||||
Reference in New Issue
Block a user