feat(mobile): add personal assistant knowledge flow
This commit is contained in:
@@ -25,6 +25,36 @@
|
||||
"navigationBarTitleText": "问师傅"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/assistant/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "个人AI助理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/assistant/library",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的资料库"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/assistant/capture",
|
||||
"style": {
|
||||
"navigationBarTitleText": "收藏资料"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/assistant/item",
|
||||
"style": {
|
||||
"navigationBarTitleText": "资料详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/assistant/sessions",
|
||||
"style": {
|
||||
"navigationBarTitleText": "历史会话"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/profile/index",
|
||||
"style": {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<view class="page employee-page-shell capture-page">
|
||||
<view class="subpage-header"><button class="subpage-back-button" @click="goBack">‹</button><view class="subpage-heading"><text class="page-title">收藏资料</text><text class="page-subtitle">收藏后后台解析,不在页面阻塞等待</text></view></view>
|
||||
<view class="panel employee-card type-tabs">
|
||||
<button v-for="option in typeOptions" :key="option.value" class="type-tab" :class="{ active: captureType === option.value }" @click="captureType = option.value">{{ option.label }}</button>
|
||||
</view>
|
||||
<view class="panel employee-card form-panel">
|
||||
<input v-model="title" class="input" placeholder="标题(可选)" />
|
||||
<template v-if="captureType === 'TEXT'">
|
||||
<textarea v-model="content" class="textarea" maxlength="20000" placeholder="粘贴或输入希望保存的文字" />
|
||||
</template>
|
||||
<template v-else-if="captureType === 'URL'">
|
||||
<input v-model="url" class="input" type="text" placeholder="https://example.com/article" />
|
||||
<text class="muted note">网页内容由服务端安全抓取;内网地址或被拦截地址不会保存。</text>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="secondary-button choose-button" @click="chooseFile">{{ selectedFile ? selectedFile.name : '选择文件' }}</button>
|
||||
<text v-if="selectedFile" class="muted note">{{ Math.ceil(selectedFile.size / 1024) }}KB</text>
|
||||
</template>
|
||||
<button class="primary-button submit-button" :disabled="submitting" @click="submit">{{ submitting ? '正在收藏' : '收藏并返回资料库' }}</button>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { choosePersonalFile, createTextItem, createUrlItem, uploadPersonalFile, type PersonalSelectedFile } from '@/services/personal-assistant';
|
||||
import type { PersonalItemSourceType } from '@/types/api';
|
||||
|
||||
const typeOptions = [{ label: '文字', value: 'TEXT' }, { label: '文件', value: 'FILE' }, { label: '网页', value: 'URL' }] as const;
|
||||
const captureType = ref<PersonalItemSourceType>('TEXT');
|
||||
const title = ref('');
|
||||
const content = ref('');
|
||||
const url = ref('');
|
||||
const selectedFile = ref<PersonalSelectedFile>();
|
||||
const submitting = ref(false);
|
||||
const message = ref('');
|
||||
const chooseFile = async () => { try { selectedFile.value = await choosePersonalFile(); } catch (error) { message.value = error instanceof Error ? error.message : '未选择文件'; } };
|
||||
const submit = async () => {
|
||||
if (submitting.value) return;
|
||||
if (captureType.value === 'TEXT' && !content.value.trim()) { message.value = '请输入需要收藏的文字'; return; }
|
||||
if (captureType.value === 'URL' && !/^https?:\/\//i.test(url.value.trim())) { message.value = '请输入 http 或 https 网页地址'; return; }
|
||||
if (captureType.value === 'FILE' && !selectedFile.value) { message.value = '请先选择文件'; return; }
|
||||
submitting.value = true; message.value = '';
|
||||
try {
|
||||
if (captureType.value === 'TEXT') await createTextItem({ title: title.value.trim(), content: content.value.trim() });
|
||||
else if (captureType.value === 'URL') await createUrlItem({ title: title.value.trim(), url: url.value.trim() });
|
||||
else if (selectedFile.value) await uploadPersonalFile(selectedFile.value, title.value.trim());
|
||||
uni.redirectTo({ url: '/pages/user/assistant/library' });
|
||||
} catch (error) { message.value = error instanceof Error ? error.message : '收藏失败,请重试'; }
|
||||
finally { submitting.value = false; }
|
||||
};
|
||||
const goBack = () => uni.navigateBack();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.capture-page { overflow-x: hidden; }.type-tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }.type-tab { min-width: 0; min-height: 40px; border: 1px solid var(--line); background: var(--surface); color: var(--muted); }.type-tab.active { border-color: var(--brand); background: #fff5f6; color: var(--brand); font-weight: 900; }.form-panel { display: grid; gap: 12px; }.choose-button, .submit-button { width: 100%; }.note { display: block; font-size: 12px; line-height: 1.5; }
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<view class="page assistant-page employee-page-shell">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" @click="goBack">‹</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="page-title">个人 AI 助理</text>
|
||||
<text class="page-subtitle">先查我的资料,需要时再主动加入企业知识</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="panel employee-card domain-panel">
|
||||
<text class="panel-title">回答范围</text>
|
||||
<view class="domain-options">
|
||||
<button class="domain-option active" disabled>
|
||||
<text class="domain-name">我的资料</text>
|
||||
<text class="domain-desc">个人收藏,仅自己可见</text>
|
||||
</button>
|
||||
<button class="domain-option" :class="{ active: enterpriseEnabled }" @click="toggleEnterprise">
|
||||
<text class="domain-name">企业知识</text>
|
||||
<text class="domain-desc">企业知识按岗位权限</text>
|
||||
</button>
|
||||
</view>
|
||||
<text class="scope-note">默认只查询个人资料;企业知识不会自动开启。</text>
|
||||
</view>
|
||||
|
||||
<view class="panel employee-card ask-panel">
|
||||
<textarea v-model="queryText" class="textarea" maxlength="1000" placeholder="例如:帮我汇总最近收藏的投诉处理要点" />
|
||||
<button class="primary-button ask-button" :disabled="asking || !queryText.trim()" @click="submitQuestion">
|
||||
{{ asking ? '正在查找依据' : '提问' }}
|
||||
</button>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
</view>
|
||||
|
||||
<view class="shortcut-grid">
|
||||
<button class="shortcut employee-card" @click="openLibrary">
|
||||
<text class="shortcut-title">我的资料库</text>
|
||||
<text class="shortcut-desc">查看解析状态与来源</text>
|
||||
</button>
|
||||
<button class="shortcut employee-card" @click="openCapture">
|
||||
<text class="shortcut-title">收藏资料</text>
|
||||
<text class="shortcut-desc">文字、文件或网页</text>
|
||||
</button>
|
||||
<button class="shortcut employee-card" @click="openSessions">
|
||||
<text class="shortcut-title">历史会话</text>
|
||||
<text class="shortcut-desc">继续查看过往问答</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="answer" class="panel employee-card answer-panel">
|
||||
<text class="panel-title">回答</text>
|
||||
<text class="answer-text">{{ answer.answer }}</text>
|
||||
<view v-if="answer.citations?.length" class="citation-list">
|
||||
<text class="citation-heading">引用依据</text>
|
||||
<button v-for="citation in answer.citations" :key="`${citation.domain}-${citation.sourceId}`" class="citation-card" @click="openCitation(citation)">
|
||||
<view class="citation-head">
|
||||
<text class="domain-badge" :class="citation.domain.toLowerCase()">{{ citationLabel(citation) }}</text>
|
||||
<text class="citation-title">{{ citation.title }}</text>
|
||||
</view>
|
||||
<text class="citation-excerpt">{{ citation.excerpt }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<view v-else class="empty">本次回答没有可展示的引用,不补造来源。</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { PersonalAskResponse, PersonalCitation } from '@/types/api';
|
||||
import { askPersonalAssistant, loadPersonalItem, type KnowledgeScope } from '@/services/personal-assistant';
|
||||
|
||||
const scopes = ref<KnowledgeScope[]>(['PERSONAL']);
|
||||
const queryText = ref('');
|
||||
const asking = ref(false);
|
||||
const answer = ref<PersonalAskResponse>();
|
||||
const message = ref('');
|
||||
const citationSourceTypes = ref<Record<string, string>>({});
|
||||
const enterpriseEnabled = computed(() => scopes.value.includes('ENTERPRISE'));
|
||||
|
||||
const toggleEnterprise = () => {
|
||||
scopes.value = enterpriseEnabled.value ? ['PERSONAL'] : ['PERSONAL', 'ENTERPRISE'];
|
||||
};
|
||||
|
||||
const submitQuestion = async () => {
|
||||
const question = queryText.value.trim();
|
||||
if (!question || asking.value) return;
|
||||
asking.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
answer.value = await askPersonalAssistant({ queryText: question, scope: scopes.value, outputFormat: 'ACTION_PLAN' });
|
||||
await resolveCitationTypes(answer.value.citations || []);
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? error.message : '提问失败,请稍后重试';
|
||||
if (enterpriseEnabled.value && /ENTERPRISE_SCOPE_FORBIDDEN|企业知识|FORBIDDEN/i.test(text)) {
|
||||
scopes.value = ['PERSONAL'];
|
||||
message.value = '当前岗位没有企业知识权限,已仅查询我的资料。';
|
||||
try {
|
||||
answer.value = await askPersonalAssistant({ queryText: question, scope: ['PERSONAL'], outputFormat: 'ACTION_PLAN' });
|
||||
await resolveCitationTypes(answer.value.citations || []);
|
||||
} catch (personalError) {
|
||||
message.value = personalError instanceof Error ? personalError.message : '我的资料查询失败,请稍后重试';
|
||||
}
|
||||
} else {
|
||||
message.value = text;
|
||||
}
|
||||
} finally {
|
||||
asking.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveCitationTypes = async (citations: PersonalCitation[]) => {
|
||||
const personalIds = Array.from(new Set(citations
|
||||
.filter((citation) => citation.domain === 'PERSONAL' && /^\d+$/.test(citation.sourceId))
|
||||
.map((citation) => citation.sourceId)));
|
||||
const resolved = await Promise.all(personalIds.map(async (id) => {
|
||||
try {
|
||||
const item = await loadPersonalItem(Number(id));
|
||||
return [id, item.sourceType] as const;
|
||||
} catch {
|
||||
return [id, ''] as const;
|
||||
}
|
||||
}));
|
||||
citationSourceTypes.value = Object.fromEntries(resolved);
|
||||
};
|
||||
|
||||
const citationLabel = (citation: PersonalCitation) => {
|
||||
if (citation.domain === 'ENTERPRISE') return '企业 SOP';
|
||||
return /^https?:/i.test(citation.sourceId) || citationSourceTypes.value[citation.sourceId] === 'URL' ? '外部网页' : '我的资料';
|
||||
};
|
||||
|
||||
const openCitation = (citation: PersonalCitation) => {
|
||||
if (citation.domain !== 'PERSONAL' || !/^\d+$/.test(citation.sourceId)) return;
|
||||
uni.navigateTo({ url: `/pages/user/assistant/item?id=${encodeURIComponent(citation.sourceId)}` });
|
||||
};
|
||||
|
||||
const goBack = () => uni.navigateBack();
|
||||
const openLibrary = () => uni.navigateTo({ url: '/pages/user/assistant/library' });
|
||||
const openCapture = () => uni.navigateTo({ url: '/pages/user/assistant/capture' });
|
||||
const openSessions = () => uni.navigateTo({ url: '/pages/user/assistant/sessions' });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.assistant-page { overflow-x: hidden; }
|
||||
.domain-options, .shortcut-grid { display: grid; gap: 10px; }
|
||||
.domain-options { margin-top: 14px; grid-template-columns: 1fr 1fr; }
|
||||
.domain-option { min-width: 0; padding: 14px; border: 1px solid var(--line); background: var(--surface); text-align: left; }
|
||||
.domain-option.active { border-color: var(--brand); background: #fff6f7; }
|
||||
.domain-name, .domain-desc, .shortcut-title, .shortcut-desc, .answer-text, .citation-title, .citation-excerpt, .scope-note { display: block; }
|
||||
.domain-name, .shortcut-title { color: var(--ink); font-weight: 900; }
|
||||
.domain-desc, .shortcut-desc, .scope-note { margin-top: 5px; color: var(--muted); font-size: 12px; line-height: 1.45; }
|
||||
.ask-button { width: 100%; margin-top: 12px; }
|
||||
.shortcut-grid { margin-top: 16px; grid-template-columns: 1fr 1fr; }
|
||||
.shortcut { min-width: 0; padding: 15px; text-align: left; }
|
||||
.shortcut:last-child { grid-column: 1 / -1; }
|
||||
.answer-text { margin-top: 12px; line-height: 1.75; white-space: pre-wrap; }
|
||||
.citation-heading { display: block; margin: 18px 0 8px; font-weight: 900; }
|
||||
.citation-card { width: 100%; min-width: 0; margin-top: 8px; padding: 12px; border: 1px solid var(--line); background: #f8fafc; text-align: left; }
|
||||
.citation-head { display: flex; min-width: 0; gap: 8px; align-items: center; }
|
||||
.domain-badge { flex: 0 0 auto; padding: 3px 7px; border-radius: var(--radius); background: #eef3f9; color: var(--knowledge); font-size: 11px; }
|
||||
.domain-badge.enterprise { background: #fff2e6; color: var(--warning); }
|
||||
.citation-title { min-width: 0; overflow: hidden; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.citation-excerpt { margin-top: 7px; color: var(--muted); font-size: 13px; line-height: 1.55; }
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<view class="page employee-page-shell item-page">
|
||||
<view class="subpage-header"><button class="subpage-back-button" @click="goBack">‹</button><view class="subpage-heading"><text class="page-title">资料详情</text><text class="page-subtitle">解析正文只读,状态来自服务端</text></view></view>
|
||||
<view v-if="loading" class="panel empty">加载中…</view>
|
||||
<view v-else-if="message && !item" class="panel"><text class="status-message">{{ message }}</text><button class="secondary-button full-button" @click="load">重试</button></view>
|
||||
<template v-else-if="item">
|
||||
<view class="panel employee-card detail-card">
|
||||
<view class="detail-head"><text class="source-badge">{{ sourceText(item.sourceType) }}</text><text class="status-badge" :class="item.status.toLowerCase()">{{ statusText(item.status) }}</text></view>
|
||||
<text class="item-title">{{ item.title || '未命名资料' }}</text>
|
||||
<text class="meta">收藏时间:{{ item.capturedAt || '未记录' }}</text>
|
||||
<text v-if="item.originalUrl" class="meta">网页来源:{{ item.originalUrl }}</text>
|
||||
<text v-if="item.tags?.length" class="meta">标签:{{ item.tags.join('、') }}</text>
|
||||
</view>
|
||||
<view class="panel employee-card status-card">
|
||||
<text class="panel-title">处理状态</text>
|
||||
<text class="status-copy">{{ statusDescription(item.status) }}</text>
|
||||
<text v-if="item.errorMessage" class="status-message">{{ item.errorMessage }}</text>
|
||||
<button v-if="item.status === 'FAILED'" class="primary-button full-button" :disabled="busy" @click="retry">{{ busy ? '重试中' : '重新解析' }}</button>
|
||||
<button v-if="item.sourceType === 'FILE' && item.status === 'READY'" class="secondary-button full-button" :disabled="busy" @click="download">下载原附件</button>
|
||||
</view>
|
||||
<view v-if="item.summary" class="panel employee-card"><text class="panel-title">内容摘要</text><text class="summary">{{ item.summary }}</text></view>
|
||||
<view class="panel danger-panel">
|
||||
<text class="panel-title danger-title">删除资料</text>
|
||||
<text class="danger-copy">删除后,附件、解析正文和搜索索引会清除,且无法恢复。</text>
|
||||
<button class="secondary-button danger-button" :disabled="busy || item.status === 'DELETING'" @click="confirmDelete">{{ item.status === 'DELETING' ? '删除中' : '删除资料' }}</button>
|
||||
</view>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onLoad } from '@dcloudio/uni-app';
|
||||
import type { PersonalItem, PersonalItemSourceType, PersonalItemStatus } from '@/types/api';
|
||||
import { deletePersonalItem, downloadPersonalItem, loadPersonalItem, retryPersonalItem } from '@/services/personal-assistant';
|
||||
|
||||
const itemId = ref(0); const item = ref<PersonalItem>(); const loading = ref(false); const busy = ref(false); const message = ref('');
|
||||
const statusText = (status: PersonalItemStatus) => ({ QUEUED: '等待处理', PARSING: '解析中', READY: '可检索', FAILED: '解析失败', DELETING: '删除中', DELETED: '已删除' })[status];
|
||||
const statusDescription = (status: PersonalItemStatus) => ({ QUEUED: '已进入解析队列。', PARSING: '正在提取正文和建立搜索索引。', READY: '已完成解析,可用于个人助理问答。', FAILED: '本次解析失败,可查看公开原因后重试。', DELETING: '正在清理附件、正文和索引。', DELETED: '资料已删除。' })[status];
|
||||
const sourceText = (source: PersonalItemSourceType) => ({ TEXT: '文字', FILE: '文件', URL: '外部网页' })[source];
|
||||
const load = async () => { if (!itemId.value) return; loading.value = true; message.value = ''; try { item.value = await loadPersonalItem(itemId.value); } catch (error) { message.value = error instanceof Error ? error.message : '资料不存在或无权访问'; } finally { loading.value = false; } };
|
||||
const retry = async () => { busy.value = true; message.value = ''; try { item.value = await retryPersonalItem(itemId.value); } catch (error) { message.value = error instanceof Error ? error.message : '重试失败'; } finally { busy.value = false; } };
|
||||
const download = async () => { busy.value = true; try { const result = await downloadPersonalItem(itemId.value); if (!result.url) throw new Error('附件下载地址不可用'); if (typeof window !== 'undefined') window.open(result.url, '_blank', 'noopener,noreferrer'); else uni.downloadFile({ url: result.url }); } catch (error) { message.value = error instanceof Error ? error.message : '下载失败'; } finally { busy.value = false; } };
|
||||
const confirmDelete = () => uni.showModal({ title: '确认删除资料', content: '附件、解析正文和搜索索引会清除,删除后无法恢复。', confirmText: '确认删除', confirmColor: '#e70014', success: (result) => { if (result.confirm) void remove(); } });
|
||||
const remove = async () => { busy.value = true; message.value = ''; try { await deletePersonalItem(itemId.value); uni.redirectTo({ url: '/pages/user/assistant/library' }); } catch (error) { message.value = error instanceof Error ? error.message : '删除失败,请重试'; } finally { busy.value = false; } };
|
||||
const goBack = () => uni.navigateBack();
|
||||
onLoad((options) => { itemId.value = Number(options?.id || 0); if (!Number.isSafeInteger(itemId.value) || itemId.value <= 0) message.value = '资料不存在或无权访问'; else void load(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.item-page { overflow-x: hidden; }.detail-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }.source-badge, .status-badge { padding: 4px 8px; border-radius: var(--radius); background: #eef3f9; color: var(--knowledge); font-size: 12px; }.status-badge.ready { color: var(--success); }.status-badge.failed, .danger-title { color: #b42318; }.item-title, .meta, .summary, .status-copy, .danger-copy { display: block; }.item-title { margin-top: 14px; font-size: 20px; font-weight: 900; }.meta { margin-top: 8px; color: var(--muted); font-size: 13px; overflow-wrap: anywhere; }.status-copy, .summary, .danger-copy { margin-top: 10px; line-height: 1.65; }.summary { white-space: pre-wrap; }.full-button { width: 100%; margin-top: 12px; }.danger-panel { border-color: #f5c2c7; }.danger-button { color: #b42318; }
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<view class="page employee-page-shell library-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" @click="goBack">‹</button>
|
||||
<view class="subpage-heading"><text class="page-title">我的资料库</text><text class="page-subtitle">个人空间,仅展示真实收藏</text></view>
|
||||
<button class="capture-button" @click="openCapture">收藏</button>
|
||||
</view>
|
||||
|
||||
<view v-if="space" class="panel employee-card usage-panel">
|
||||
<view class="usage-row"><text>已用 {{ formatBytes(space.usedBytes) }}</text><text>{{ space.itemCount }} 条</text></view>
|
||||
<view class="usage-track"><view class="usage-fill" :style="{ width: usagePercent + '%' }" /></view>
|
||||
<text class="muted usage-note">空间上限 {{ formatBytes(space.quotaBytes) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="panel employee-card filters">
|
||||
<input v-model="keyword" class="input" placeholder="搜索标题或内容" confirm-type="search" @confirm="refresh" />
|
||||
<view class="filter-row">
|
||||
<picker :range="sourceOptions" range-key="label" @change="changeSource"><view class="filter-select">{{ sourceLabel }}</view></picker>
|
||||
<picker :range="statusOptions" range-key="label" @change="changeStatus"><view class="filter-select">{{ statusLabel }}</view></picker>
|
||||
</view>
|
||||
<button class="secondary-button" @click="refresh">筛选</button>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && !items.length" class="panel empty">加载中…</view>
|
||||
<view v-else-if="message && !items.length" class="panel"><text class="status-message">{{ message }}</text><button class="secondary-button retry" @click="refresh">重试</button></view>
|
||||
<view v-else-if="!items.length" class="panel empty">还没有个人资料,先收藏一条文字、文件或网页。</view>
|
||||
<view v-else class="item-list">
|
||||
<button v-for="item in items" :key="item.id" class="item-card employee-card" @click="openItem(item.id)">
|
||||
<view class="item-head"><text class="source-badge">{{ sourceText(item.sourceType) }}</text><text class="status-badge" :class="item.status.toLowerCase()">{{ statusText(item.status) }}</text></view>
|
||||
<text class="item-title">{{ item.title || '未命名资料' }}</text>
|
||||
<text class="item-meta">{{ item.capturedAt || '未记录时间' }} · {{ formatBytes(item.sizeBytes) }}</text>
|
||||
</button>
|
||||
<button v-if="items.length < total" class="secondary-button load-more" :disabled="loading" @click="loadMore">{{ loading ? '加载中' : '加载更多' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { PersonalItem, PersonalItemSourceType, PersonalItemStatus, PersonalSpace } from '@/types/api';
|
||||
import { loadPersonalItems, loadPersonalSpace } from '@/services/personal-assistant';
|
||||
|
||||
const pageNum = ref(1);
|
||||
const pageSize = 10;
|
||||
const total = ref(0);
|
||||
const items = ref<PersonalItem[]>([]);
|
||||
const space = ref<PersonalSpace>();
|
||||
const sourceType = ref<PersonalItemSourceType | ''>('');
|
||||
const status = ref<PersonalItemStatus | ''>('');
|
||||
const keyword = ref('');
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
const sourceOptions = [{ label: '全部来源', value: '' }, { label: '文字', value: 'TEXT' }, { label: '文件', value: 'FILE' }, { label: '网页', value: 'URL' }] as const;
|
||||
const statusOptions = [{ label: '全部状态', value: '' }, { label: '等待处理', value: 'QUEUED' }, { label: '解析中', value: 'PARSING' }, { label: '可检索', value: 'READY' }, { label: '失败', value: 'FAILED' }] as const;
|
||||
const sourceLabel = computed(() => sourceOptions.find((item) => item.value === sourceType.value)?.label || '全部来源');
|
||||
const statusLabel = computed(() => statusOptions.find((item) => item.value === status.value)?.label || '全部状态');
|
||||
const usagePercent = computed(() => Math.min(100, Math.round(((space.value?.usedBytes || 0) / Math.max(1, space.value?.quotaBytes || 1)) * 100)));
|
||||
|
||||
const load = async (append = false) => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const [page, latestSpace] = await Promise.all([
|
||||
loadPersonalItems({ pageNum: pageNum.value, pageSize, sourceType: sourceType.value, status: status.value, keyword: keyword.value.trim() }),
|
||||
loadPersonalSpace()
|
||||
]);
|
||||
items.value = append ? [...items.value, ...page.rows] : page.rows;
|
||||
total.value = page.total;
|
||||
space.value = latestSpace;
|
||||
} catch (error) {
|
||||
message.value = error instanceof Error ? error.message : '资料加载失败';
|
||||
} finally { loading.value = false; }
|
||||
};
|
||||
const refresh = () => { pageNum.value = 1; void load(); };
|
||||
const loadMore = () => { pageNum.value += 1; void load(true); };
|
||||
const changeSource = (event: { detail: { value: string | number } }) => { sourceType.value = sourceOptions[Number(event.detail.value)]?.value || ''; refresh(); };
|
||||
const changeStatus = (event: { detail: { value: string | number } }) => { status.value = statusOptions[Number(event.detail.value)]?.value || ''; refresh(); };
|
||||
const sourceText = (value: PersonalItemSourceType) => ({ TEXT: '文字', FILE: '文件', URL: '外部网页' })[value];
|
||||
const statusText = (value: PersonalItemStatus) => ({ QUEUED: '等待处理', PARSING: '解析中', READY: '可检索', FAILED: '解析失败', DELETING: '删除中', DELETED: '已删除' })[value];
|
||||
const formatBytes = (bytes: number) => bytes >= 1048576 ? `${(bytes / 1048576).toFixed(1)}MB` : `${Math.ceil(bytes / 1024)}KB`;
|
||||
const openItem = (id: number) => uni.navigateTo({ url: `/pages/user/assistant/item?id=${id}` });
|
||||
const openCapture = () => uni.navigateTo({ url: '/pages/user/assistant/capture' });
|
||||
const goBack = () => uni.navigateBack();
|
||||
onShow(refresh);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.library-page { overflow-x: hidden; }
|
||||
.capture-button { flex: 0 0 auto; min-height: 40px; padding: 0 13px; background: var(--brand); color: #fff; font-weight: 800; }
|
||||
.usage-row, .filter-row, .item-head { display: flex; gap: 10px; align-items: center; justify-content: space-between; }
|
||||
.usage-track { height: 8px; margin-top: 10px; overflow: hidden; border-radius: var(--radius); background: #eef1f5; }
|
||||
.usage-fill { height: 100%; background: var(--brand); }
|
||||
.usage-note { display: block; margin-top: 7px; font-size: 12px; }
|
||||
.filters { display: grid; gap: 10px; }
|
||||
.filter-row > * { flex: 1; min-width: 0; }
|
||||
.filter-select { padding: 12px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); font-size: 14px; }
|
||||
.item-list { display: grid; margin-top: 16px; gap: 10px; }
|
||||
.item-card { width: 100%; min-width: 0; padding: 15px; text-align: left; }
|
||||
.source-badge, .status-badge { padding: 3px 7px; border-radius: var(--radius); background: #eef3f9; color: var(--knowledge); font-size: 11px; }
|
||||
.status-badge.ready { color: var(--success); }.status-badge.failed { color: #b42318; }.status-badge.parsing, .status-badge.queued { color: var(--warning); }
|
||||
.item-title, .item-meta { display: block; }
|
||||
.item-title { margin-top: 10px; overflow: hidden; color: var(--ink); font-weight: 900; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.item-meta { margin-top: 7px; color: var(--muted); font-size: 12px; }
|
||||
.load-more, .retry { width: 100%; margin-top: 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<view class="page employee-page-shell sessions-page">
|
||||
<view class="subpage-header"><button class="subpage-back-button" @click="goBack">‹</button><view class="subpage-heading"><text class="page-title">历史会话</text><text class="page-subtitle">仅展示已保存的真实问答</text></view></view>
|
||||
<view v-if="loading" class="panel empty">加载中…</view>
|
||||
<view v-else-if="message" class="panel"><text class="status-message">{{ message }}</text><button class="secondary-button retry" @click="loadList">重试</button></view>
|
||||
<view v-else-if="detail" class="detail-wrap">
|
||||
<view class="detail-toolbar"><button class="secondary-button" @click="detail = undefined">返回列表</button><button class="secondary-button danger" @click="confirmDelete(detail.id)">删除会话</button></view>
|
||||
<view v-for="chat in detail.messages" :key="chat.id" class="message-card employee-card" :class="chat.role.toLowerCase()">
|
||||
<text class="role">{{ chat.role === 'USER' ? '我' : '个人助理' }}</text>
|
||||
<text class="content">{{ chat.content }}</text>
|
||||
<view v-if="chat.citations?.length" class="message-citations">
|
||||
<text v-for="citation in chat.citations" :key="`${citation.domain}-${citation.sourceId}`" class="citation">{{ citation.domain === 'ENTERPRISE' ? '企业 SOP' : '我的资料' }} · {{ citation.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="!sessions.length" class="panel empty">暂无历史会话</view>
|
||||
<view v-else class="session-list">
|
||||
<button v-for="session in sessions" :key="session.id" class="session-card employee-card" @click="openSession(session.id)">
|
||||
<text class="session-title">{{ session.title || '未命名会话' }}</text>
|
||||
<text class="session-meta">{{ scopeText(session.defaultScope) }} · {{ session.updatedAt || '未记录时间' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { PersonalSession, PersonalSessionDetail } from '@/types/api';
|
||||
import { deletePersonalSession, loadPersonalSession, loadPersonalSessions } from '@/services/personal-assistant';
|
||||
|
||||
const sessions = ref<PersonalSession[]>([]); const detail = ref<PersonalSessionDetail>(); const loading = ref(false); const message = ref('');
|
||||
const loadList = async () => { loading.value = true; message.value = ''; try { sessions.value = await loadPersonalSessions(); } catch (error) { message.value = error instanceof Error ? error.message : '会话加载失败'; } finally { loading.value = false; } };
|
||||
const openSession = async (id: number) => { loading.value = true; message.value = ''; try { detail.value = await loadPersonalSession(id); } catch (error) { message.value = error instanceof Error ? error.message : '会话不存在或无权访问'; } finally { loading.value = false; } };
|
||||
const confirmDelete = (id: number) => uni.showModal({ title: '删除会话', content: '只删除本次问答记录,不会删除资料库内容。', confirmColor: '#e70014', success: (result) => { if (result.confirm) void remove(id); } });
|
||||
const remove = async (id: number) => { loading.value = true; try { await deletePersonalSession(id); detail.value = undefined; await loadList(); } catch (error) { message.value = error instanceof Error ? error.message : '删除会话失败'; } finally { loading.value = false; } };
|
||||
const scopeText = (scope: string) => scope.includes('ENTERPRISE') ? '我的资料 + 企业知识' : '我的资料';
|
||||
const goBack = () => uni.navigateBack();
|
||||
onShow(loadList);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sessions-page { overflow-x: hidden; }.session-list, .detail-wrap { display: grid; margin-top: 16px; gap: 10px; }.session-card, .message-card { width: 100%; min-width: 0; padding: 15px; text-align: left; }.session-title, .session-meta, .role, .content, .citation { display: block; }.session-title { color: var(--ink); font-weight: 900; }.session-meta { margin-top: 7px; color: var(--muted); font-size: 12px; }.detail-toolbar { display: flex; justify-content: space-between; gap: 10px; }.danger { color: #b42318; }.message-card.user { margin-left: 28px; width: calc(100% - 28px); background: #fff7f8; }.role { color: var(--muted); font-size: 12px; font-weight: 800; }.content { margin-top: 8px; line-height: 1.65; white-space: pre-wrap; }.message-citations { display: grid; margin-top: 10px; gap: 5px; }.citation { color: var(--knowledge); font-size: 12px; }.retry { width: 100%; margin-top: 10px; }
|
||||
</style>
|
||||
@@ -8,6 +8,18 @@
|
||||
<text class="toolbar-tip">先把问题问清楚,再按需生成总结卡,不伪造分类能力。</text>
|
||||
</view>
|
||||
|
||||
<view class="assistant-entry employee-card">
|
||||
<view class="assistant-entry-copy">
|
||||
<text class="assistant-entry-title">个人助理 · 我的资料</text>
|
||||
<text class="assistant-entry-desc">收藏个人文字、文件和网页后提问;与当前企业 SOP 问答分开管理。</text>
|
||||
</view>
|
||||
<view class="assistant-entry-actions">
|
||||
<button class="secondary-button" @click="openPersonalAssistant">进入个人助理</button>
|
||||
<button class="secondary-button" @click="openPersonalCapture">收藏资料</button>
|
||||
</view>
|
||||
<text class="assistant-domain-note">当前页面:企业 SOP · 个人助理默认:我的资料</text>
|
||||
</view>
|
||||
|
||||
<view class="panel query-panel employee-card">
|
||||
<view class="query-hero">
|
||||
<view class="query-copy">
|
||||
@@ -340,6 +352,14 @@ const closeSummary = () => {
|
||||
summaryCard.value = null;
|
||||
};
|
||||
|
||||
const openPersonalAssistant = () => {
|
||||
uni.navigateTo({ url: '/pages/user/assistant/index' });
|
||||
};
|
||||
|
||||
const openPersonalCapture = () => {
|
||||
uni.navigateTo({ url: '/pages/user/assistant/capture' });
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect('/pages/user/sop/index');
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
@@ -404,6 +424,43 @@ onShow(() => {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.assistant-entry {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.assistant-entry-title,
|
||||
.assistant-entry-desc,
|
||||
.assistant-domain-note {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.assistant-entry-title {
|
||||
color: var(--ink);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.assistant-entry-desc,
|
||||
.assistant-domain-note {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.assistant-entry-actions {
|
||||
display: grid;
|
||||
margin-top: 12px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assistant-entry-actions button {
|
||||
min-width: 0;
|
||||
padding: 0 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sop-head .page-title {
|
||||
color: #1f2b3d;
|
||||
font-size: 31px;
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import type {
|
||||
PersonalAskResponse,
|
||||
PersonalItem,
|
||||
PersonalItemCreated,
|
||||
PersonalItemSourceType,
|
||||
PersonalKnowledgeScope,
|
||||
PersonalPage,
|
||||
PersonalSession,
|
||||
PersonalSessionDetail,
|
||||
PersonalSpace
|
||||
} from '@/types/api';
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
|
||||
export type KnowledgeScope = PersonalKnowledgeScope;
|
||||
|
||||
export interface PersonalItemQuery {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
sourceType?: PersonalItemSourceType | '';
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
const queryString = (query: PersonalItemQuery) => {
|
||||
const params = Object.entries(query)
|
||||
.filter(([, value]) => value !== undefined && value !== '')
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
||||
return params.length ? `?${params.join('&')}` : '';
|
||||
};
|
||||
|
||||
export const askPersonalAssistant = (payload: {
|
||||
sessionId?: number;
|
||||
queryText: string;
|
||||
scope: KnowledgeScope[];
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
itemIds?: number[];
|
||||
outputFormat?: 'ANSWER' | 'ACTION_PLAN' | 'OUTLINE';
|
||||
}) => apiRequest<PersonalAskResponse>({
|
||||
url: '/api/aihr/personal-assistant/ask',
|
||||
method: 'POST',
|
||||
data: payload,
|
||||
timeout: 45000,
|
||||
clearAuthOnUnauthorized: false
|
||||
});
|
||||
|
||||
export const loadPersonalSpace = () => apiRequest<PersonalSpace>({
|
||||
url: '/api/aihr/personal-assistant/space',
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
export const loadPersonalItems = (query: PersonalItemQuery = {}) => apiRequest<PersonalPage<PersonalItem>>({
|
||||
url: `/api/aihr/personal-assistant/items${queryString(query)}`,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
export const loadPersonalItem = (id: number) => apiRequest<PersonalItem>({
|
||||
url: `/api/aihr/personal-assistant/items/${id}`,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
export const createTextItem = (payload: { title?: string; content: string; capturedAt?: string; tags?: string[] }) =>
|
||||
apiRequest<PersonalItemCreated>({
|
||||
url: '/api/aihr/personal-assistant/items/text',
|
||||
method: 'POST',
|
||||
data: payload
|
||||
});
|
||||
|
||||
export const createUrlItem = (payload: { url: string; title?: string; capturedAt?: string }) =>
|
||||
apiRequest<PersonalItemCreated>({
|
||||
url: '/api/aihr/personal-assistant/items/url',
|
||||
method: 'POST',
|
||||
data: payload,
|
||||
timeout: 45000
|
||||
});
|
||||
|
||||
export interface PersonalSelectedFile {
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const choosePersonalFile = () => new Promise<PersonalSelectedFile>((resolve, reject) => {
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
type: 'all',
|
||||
success: (result) => {
|
||||
const files = Array.isArray(result.tempFiles) ? result.tempFiles : [];
|
||||
const file = (files[0] || {}) as Partial<PersonalSelectedFile>;
|
||||
const path = file.path || result.tempFilePaths?.[0] || '';
|
||||
if (!path) {
|
||||
reject(new Error('未获取到文件路径'));
|
||||
return;
|
||||
}
|
||||
resolve({ path, name: file.name || path.split('/').pop() || 'personal-file', size: Number(file.size || 0) });
|
||||
},
|
||||
fail: () => reject(new Error('未选择文件'))
|
||||
});
|
||||
});
|
||||
|
||||
export const uploadPersonalFile = (file: PersonalSelectedFile, title?: string, capturedAt?: string) =>
|
||||
new Promise<PersonalItemCreated>((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: apiUrl('/api/aihr/personal-assistant/items/file'),
|
||||
filePath: file.path,
|
||||
name: 'file',
|
||||
header: authHeaders(false),
|
||||
formData: {
|
||||
...(title?.trim() ? { title: title.trim() } : {}),
|
||||
...(capturedAt ? { capturedAt } : {})
|
||||
},
|
||||
timeout: 45000,
|
||||
success: (response) => {
|
||||
try {
|
||||
resolve(readTextPayload<PersonalItemCreated>(response.statusCode, response.data));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: () => reject(new Error('文件收藏失败'))
|
||||
});
|
||||
});
|
||||
|
||||
export const retryPersonalItem = (id: number) => apiRequest<PersonalItem>({
|
||||
url: `/api/aihr/personal-assistant/items/${id}/retry`,
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
export const deletePersonalItem = (id: number) => apiRequest<{ cleanupJobId: number }>({
|
||||
url: `/api/aihr/personal-assistant/items/${id}`,
|
||||
method: 'DELETE',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const downloadPersonalItem = (id: number) => apiRequest<{ url: string; expiresAt?: string }>({
|
||||
url: `/api/aihr/personal-assistant/items/${id}/download-url`,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
export const loadPersonalSessions = () => apiRequest<PersonalSession[]>({
|
||||
url: '/api/aihr/personal-assistant/sessions',
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
export const loadPersonalSession = (id: number) => apiRequest<PersonalSessionDetail>({
|
||||
url: `/api/aihr/personal-assistant/sessions/${id}`,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
export const deletePersonalSession = (id: number) => apiRequest<void>({
|
||||
url: `/api/aihr/personal-assistant/sessions/${id}`,
|
||||
method: 'DELETE'
|
||||
});
|
||||
@@ -116,6 +116,84 @@ export interface SummaryCardResponse {
|
||||
reminders: string[];
|
||||
}
|
||||
|
||||
export type PersonalKnowledgeScope = 'PERSONAL' | 'ENTERPRISE';
|
||||
export type PersonalItemSourceType = 'TEXT' | 'FILE' | 'URL';
|
||||
export type PersonalItemStatus = 'QUEUED' | 'PARSING' | 'READY' | 'FAILED' | 'DELETING' | 'DELETED';
|
||||
|
||||
export interface PersonalCitation {
|
||||
domain: PersonalKnowledgeScope;
|
||||
sourceId: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
capturedAt?: string;
|
||||
}
|
||||
|
||||
export interface PersonalAskResponse {
|
||||
sessionId: number;
|
||||
answer: string;
|
||||
citations: PersonalCitation[];
|
||||
model?: string;
|
||||
promptVersion?: string;
|
||||
}
|
||||
|
||||
export interface PersonalSpace {
|
||||
spaceId: number;
|
||||
status: string;
|
||||
quotaBytes: number;
|
||||
usedBytes: number;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
export interface PersonalItem {
|
||||
id: number;
|
||||
sourceType: PersonalItemSourceType;
|
||||
title: string;
|
||||
originalUrl?: string;
|
||||
mimeType?: string;
|
||||
sizeBytes: number;
|
||||
status: PersonalItemStatus;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
summary?: string;
|
||||
tags?: string[];
|
||||
capturedAt?: string;
|
||||
parsedAt?: string;
|
||||
}
|
||||
|
||||
export interface PersonalPage<T> {
|
||||
rows: T[];
|
||||
total: number;
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface PersonalItemCreated {
|
||||
itemId: number;
|
||||
status: PersonalItemStatus;
|
||||
duplicateOf?: number;
|
||||
}
|
||||
|
||||
export interface PersonalSession {
|
||||
id: number;
|
||||
title: string;
|
||||
defaultScope: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface PersonalChatMessage {
|
||||
id: number;
|
||||
role: string;
|
||||
content: string;
|
||||
citations: PersonalCitation[];
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface PersonalSessionDetail {
|
||||
id: number;
|
||||
title: string;
|
||||
messages: PersonalChatMessage[];
|
||||
}
|
||||
|
||||
export type PracticeRole = 'customer' | 'trainee' | 'coach';
|
||||
|
||||
export interface PracticeTurn {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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('个人助理 API 固定使用登录态且不接收 owner 参数', async () => {
|
||||
const client = await source('../src/services/personal-assistant.ts');
|
||||
assert.match(client, /apiRequest/);
|
||||
assert.match(client, /\/api\/aihr\/personal-assistant\/ask/);
|
||||
assert.match(client, /\/api\/aihr\/personal-assistant\/items\/file/);
|
||||
assert.match(client, /uni\.uploadFile/);
|
||||
assert.doesNotMatch(client, /ownerUserId|tenantId/);
|
||||
});
|
||||
|
||||
test('问答首页默认只查询个人资料并展示真实引用域', async () => {
|
||||
const page = await source('../src/pages/user/assistant/index.vue');
|
||||
assert.match(page, /ref<KnowledgeScope\[\]>\(\['PERSONAL'\]\)/);
|
||||
assert.match(page, /我的资料/);
|
||||
assert.match(page, /企业知识按岗位权限/);
|
||||
assert.match(page, /企业 SOP/);
|
||||
assert.match(page, /外部网页/);
|
||||
assert.match(page, /citation\.domain/);
|
||||
assert.match(page, /收藏资料/);
|
||||
assert.doesNotMatch(page, /ownerUserId|tenantId/);
|
||||
});
|
||||
|
||||
test('资料库和收藏页覆盖分页筛选以及三种真实收藏方式', async () => {
|
||||
const [library, capture] = await Promise.all([
|
||||
source('../src/pages/user/assistant/library.vue'),
|
||||
source('../src/pages/user/assistant/capture.vue')
|
||||
]);
|
||||
assert.match(library, /pageNum/);
|
||||
assert.match(library, /sourceType/);
|
||||
assert.match(library, /status/);
|
||||
assert.match(library, /loadPersonalItems/);
|
||||
assert.match(capture, /TEXT/);
|
||||
assert.match(capture, /FILE/);
|
||||
assert.match(capture, /URL/);
|
||||
assert.match(capture, /createTextItem/);
|
||||
assert.match(capture, /uploadPersonalFile/);
|
||||
assert.match(capture, /createUrlItem/);
|
||||
});
|
||||
|
||||
test('个人资料详情包含全部状态、重试、下载和明确删除提示', async () => {
|
||||
const page = await source('../src/pages/user/assistant/item.vue');
|
||||
assert.match(page, /QUEUED/);
|
||||
assert.match(page, /PARSING/);
|
||||
assert.match(page, /READY/);
|
||||
assert.match(page, /FAILED/);
|
||||
assert.match(page, /DELETING/);
|
||||
assert.match(page, /DELETED/);
|
||||
assert.match(page, /retryPersonalItem/);
|
||||
assert.match(page, /deletePersonalItem/);
|
||||
assert.match(page, /downloadPersonalItem/);
|
||||
assert.match(page, /附件、解析正文和搜索索引/);
|
||||
});
|
||||
|
||||
test('会话页覆盖列表、详情和删除且不伪造会话', async () => {
|
||||
const page = await source('../src/pages/user/assistant/sessions.vue');
|
||||
assert.match(page, /loadPersonalSessions/);
|
||||
assert.match(page, /loadPersonalSession/);
|
||||
assert.match(page, /deletePersonalSession/);
|
||||
assert.match(page, /暂无历史会话/);
|
||||
});
|
||||
|
||||
test('个人助理路由已注册且企业 SOP 问答保留独立入口', async () => {
|
||||
const [pages, sop] = await Promise.all([
|
||||
source('../src/pages.json'),
|
||||
source('../src/pages/user/sop/index.vue')
|
||||
]);
|
||||
for (const route of ['index', 'library', 'capture', 'item', 'sessions']) {
|
||||
assert.match(pages, new RegExp(`pages/user/assistant/${route}`));
|
||||
}
|
||||
assert.match(sop, /个人助理/);
|
||||
assert.match(sop, /我的资料/);
|
||||
assert.match(sop, /企业 SOP/);
|
||||
assert.match(sop, /\/pages\/user\/assistant\/index/);
|
||||
});
|
||||
Reference in New Issue
Block a user