Merge branch 'codex/personal-assistant-phase2' into codex/multi-tenant-knowledge-platform

# Conflicts:
#	backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java
#	backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java
#	backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java
#	backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrModelSeedServiceTest.java
#	backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java
#	docs/API_INTEGRATION.md
#	docs/个人AI助理阶段二专项TechSpec.md
#	docs/个人AI助理阶段二开发推进计划.md
#	frontend/src/views/knowledge/processing.vue
#	mobile-uni/src/pages/user/sop/index.vue
#	mobile-uni/src/services/api.ts
#	mobile-uni/src/services/personal-assistant.ts
#	mobile-uni/tests/personal-assistant.test.mjs
#	scripts/demo-check.sh
#	scripts/personal-assistant-smoke.sh
This commit is contained in:
2026-07-22 08:54:04 +08:00
72 changed files with 13883 additions and 61 deletions
@@ -104,7 +104,7 @@
class="memory-action scope private"
:disabled="submitting"
@click="confirm('PRIVATE')"
>确认记录</button>
>仅自己保存</button>
<button
v-if="assistantCandidate"
class="memory-action scope company"
+30
View File
@@ -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,329 @@
<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 }" :disabled="asking" @click="changeScope">
<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" :disabled="!citation.itemId" @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>
<button v-if="answer.citations?.length" class="secondary-button outline-create" :disabled="outlineBusy" @click="generateOutline">
{{ outlineBusy ? '正在生成提纲' : '生成汇报提纲' }}
</button>
</view>
<view v-if="outline" class="panel employee-card outline-panel">
<view class="outline-head"><text class="panel-title">汇报提纲</text><text class="outline-state">{{ outline.confirmed ? '已确认' : '可编辑' }}</text></view>
<input v-model="outline.title" class="outline-input outline-title" :disabled="outline.confirmed" maxlength="120" placeholder="汇报标题" />
<view v-for="(section, sectionIndex) in outline.sections" :key="sectionIndex" class="outline-section">
<input v-model="section.title" class="outline-input section-title" :disabled="outline.confirmed" maxlength="80" placeholder="章节标题" />
<input v-for="(_bullet, bulletIndex) in section.bullets" :key="bulletIndex" v-model="section.bullets[bulletIndex]" class="outline-input bullet-input" :disabled="outline.confirmed" maxlength="240" placeholder="要点" />
<button v-if="!outline.confirmed" class="text-button" @click="addOutlineBullet(sectionIndex)">+ 添加要点</button>
</view>
<button v-if="!outline.confirmed" class="text-button" @click="addOutlineSection">+ 添加章节</button>
<view class="outline-sources">
<text class="citation-heading">资料来源不可编辑</text>
<text v-for="source in outline.sources" :key="`${source.domain}-${source.sourceId}`" class="outline-source">{{ source.domain === 'ENTERPRISE' ? '企业知识' : '我的资料' }} · {{ source.title }}</text>
</view>
<view v-if="!outline.confirmed" class="outline-actions">
<button class="secondary-button" :disabled="outlineBusy" @click="saveOutline">保存修改</button>
<button class="primary-button" :disabled="outlineBusy" @click="confirmOutline">确认大纲</button>
</view>
<view v-else class="outline-actions export-actions">
<button v-if="outline.status === 'CONFIRMED' || outline.status === 'FAILED'" class="primary-button" :disabled="outlineBusy" @click="generatePpt">
{{ outline.status === 'FAILED' ? '重新生成 PPT' : '生成 PPT' }}
</button>
<button v-if="outline.status === 'PENDING' || outline.status === 'RUNNING'" class="secondary-button" disabled>正在生成 PPT</button>
<button v-if="outline.status === 'READY'" class="primary-button" :disabled="outlineBusy" @click="downloadPpt">下载 PPT</button>
</view>
<text v-if="outlineMessage" class="status-message">{{ outlineMessage }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue';
import type { PersonalAskResponse, PersonalCitation, PersonalExportOutline } from '@/types/api';
import { askPersonalAssistant, confirmPersonalOutline, createPersonalOutline, downloadPersonalExport, enqueuePersonalPpt, loadPersonalExport, updatePersonalOutline, type KnowledgeScope } from '@/services/personal-assistant';
import { personalItemRoute } from '@/services/personal-assistant-id';
const scopes = ref<KnowledgeScope[]>(['PERSONAL']);
const queryText = ref('');
const asking = ref(false);
const answer = ref<PersonalAskResponse>();
const message = ref('');
const outline = ref<PersonalExportOutline>();
const outlineBusy = ref(false);
const outlineMessage = ref('');
let requestGeneration = 0;
let exportPollTimer: ReturnType<typeof setTimeout> | undefined;
const enterpriseEnabled = computed(() => scopes.value.includes('ENTERPRISE'));
const scopeKey = (value: KnowledgeScope[]) => value.join(',');
const changeScope = () => {
if (asking.value) return;
requestGeneration += 1;
answer.value = undefined;
outline.value = undefined;
stopExportPolling();
message.value = '';
scopes.value = enterpriseEnabled.value ? ['PERSONAL'] : ['PERSONAL', 'ENTERPRISE'];
};
const submitQuestion = async () => {
const question = queryText.value.trim();
if (!question || asking.value) return;
const generation = ++requestGeneration;
const scopeSnapshot = [...scopes.value];
asking.value = true;
answer.value = undefined;
outline.value = undefined;
stopExportPolling();
message.value = '';
try {
const response = await askPersonalAssistant({ queryText: question, scope: scopeSnapshot, outputFormat: 'ACTION_PLAN' });
if (generation !== requestGeneration || scopeKey(scopes.value) !== scopeKey(scopeSnapshot)) return;
answer.value = response;
} catch (error) {
if (generation !== requestGeneration) return;
const text = error instanceof Error ? error.message : '提问失败,请稍后重试';
if (enterpriseEnabled.value && /ENTERPRISE_SCOPE_FORBIDDEN|企业知识|FORBIDDEN/i.test(text)) {
scopes.value = ['PERSONAL'];
const fallbackScope: KnowledgeScope[] = ['PERSONAL'];
message.value = '当前岗位没有企业知识权限,已仅查询我的资料。';
try {
const personalResponse = await askPersonalAssistant({ queryText: question, scope: fallbackScope, outputFormat: 'ACTION_PLAN' });
if (generation !== requestGeneration || scopeKey(scopes.value) !== scopeKey(fallbackScope)) return;
answer.value = personalResponse;
} catch (personalError) {
if (generation !== requestGeneration) return;
answer.value = undefined;
message.value = personalError instanceof Error ? personalError.message : '我的资料查询失败,请稍后重试';
}
} else {
message.value = text;
}
} finally {
if (generation === requestGeneration) asking.value = false;
}
};
const generateOutline = async () => {
if (!answer.value?.sessionId || !answer.value.citations?.length || outlineBusy.value) return;
outlineBusy.value = true;
outlineMessage.value = '';
try {
outline.value = await createPersonalOutline({ sessionId: answer.value.sessionId, title: queryText.value.trim() });
} catch (error) {
outlineMessage.value = error instanceof Error ? error.message : '提纲生成失败';
} finally {
outlineBusy.value = false;
}
};
const outlinePayload = () => outline.value ? {
title: outline.value.title.trim(),
sections: outline.value.sections.map((section) => ({
title: section.title.trim(),
bullets: section.bullets.map((bullet) => bullet.trim()).filter(Boolean)
})).filter((section) => section.title && section.bullets.length)
} : undefined;
const saveOutline = async () => {
const payload = outlinePayload();
if (!outline.value || !payload || !payload.title || !payload.sections.length || outlineBusy.value) {
outlineMessage.value = '请保留标题和至少一个有效章节';
return;
}
outlineBusy.value = true;
outlineMessage.value = '';
try {
outline.value = await updatePersonalOutline(outline.value.id, payload);
outlineMessage.value = '提纲已保存';
} catch (error) {
outlineMessage.value = error instanceof Error ? error.message : '提纲保存失败';
} finally {
outlineBusy.value = false;
}
};
const confirmOutline = async () => {
await saveOutline();
if (!outline.value || outlineMessage.value !== '提纲已保存') return;
outlineBusy.value = true;
try {
await confirmPersonalOutline(outline.value.id);
outline.value.confirmed = true;
outline.value.status = 'CONFIRMED';
outlineMessage.value = '大纲已确认,内容已冻结';
} catch (error) {
outlineMessage.value = error instanceof Error ? error.message : '大纲确认失败';
} finally {
outlineBusy.value = false;
}
};
const stopExportPolling = () => {
if (exportPollTimer) clearTimeout(exportPollTimer);
exportPollTimer = undefined;
};
const pollExport = async (id: string, attempts = 0) => {
stopExportPolling();
try {
const latest = await loadPersonalExport(id);
if (!outline.value || outline.value.id !== id) return;
outline.value = latest;
if (latest.status === 'READY') {
outlineMessage.value = 'PPT 已生成,可下载 5 分钟短链';
return;
}
if (latest.status === 'FAILED') {
outlineMessage.value = 'PPT 生成失败,可重新生成';
return;
}
if (attempts >= 40) {
outlineMessage.value = 'PPT 仍在后台生成,请稍后重试';
return;
}
exportPollTimer = setTimeout(() => void pollExport(id, attempts + 1), 1500);
} catch (error) {
outlineMessage.value = error instanceof Error ? error.message : 'PPT 状态查询失败';
}
};
const generatePpt = async () => {
if (!outline.value?.confirmed || outlineBusy.value) return;
outlineBusy.value = true;
outlineMessage.value = '';
try {
await enqueuePersonalPpt(outline.value.id);
outline.value.status = 'PENDING';
outlineMessage.value = 'PPT 已进入后台生成队列';
void pollExport(outline.value.id);
} catch (error) {
outlineMessage.value = error instanceof Error ? error.message : 'PPT 生成请求失败';
} finally {
outlineBusy.value = false;
}
};
const downloadPpt = async () => {
if (!outline.value || outline.value.status !== 'READY' || outlineBusy.value) return;
outlineBusy.value = true;
try {
const download = await downloadPersonalExport(outline.value.id);
uni.downloadFile({
url: download.url,
success: (result) => {
if (result.statusCode >= 200 && result.statusCode < 300) {
uni.openDocument({ filePath: result.tempFilePath, showMenu: true });
} else {
outlineMessage.value = 'PPT 下载失败,请重试';
}
},
fail: () => { outlineMessage.value = 'PPT 下载失败,请重试'; }
});
} catch (error) {
outlineMessage.value = error instanceof Error ? error.message : 'PPT 下载失败';
} finally {
outlineBusy.value = false;
}
};
const addOutlineSection = () => outline.value?.sections.push({ title: '', bullets: [''] });
const addOutlineBullet = (sectionIndex: number) => outline.value?.sections[sectionIndex]?.bullets.push('');
const citationLabel = (citation: PersonalCitation) => {
if (citation.domain === 'ENTERPRISE') return '企业 SOP';
return citation.sourceType === 'URL' ? '外部网页' : '我的资料';
};
const openCitation = (citation: PersonalCitation) => {
if (citation.domain !== 'PERSONAL' || !citation.itemId) return;
uni.navigateTo({ url: personalItemRoute(citation.itemId) });
};
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' });
onUnmounted(stopExportPolling);
</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; }
.outline-create { width: 100%; margin-top: 14px; }.outline-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.outline-state { color: var(--brand); font-size: 12px; font-weight: 800; }.outline-input { width: 100%; min-height: 42px; margin-top: 10px; padding: 0 12px; border: 1px solid var(--line); background: #fff; color: var(--ink); }.outline-title { font-size: 17px; font-weight: 900; }.outline-section { margin-top: 14px; padding: 12px; border: 1px solid var(--line); background: #f8fafc; }.section-title { margin-top: 0; font-weight: 800; }.bullet-input { font-size: 13px; }.text-button { margin-top: 8px; padding: 4px 0; border: 0; background: transparent; color: var(--brand); font-size: 13px; }.outline-sources { margin-top: 14px; }.outline-source { display: block; margin-top: 6px; color: var(--muted); font-size: 12px; }.outline-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }.outline-actions button { width: 100%; }
</style>
@@ -0,0 +1,89 @@
<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>
<view v-if="item.ocr?.required" class="ocr-progress">
<view class="ocr-progress-head"><text>{{ ocrProgressText(item.ocr) }}</text><text>{{ ocrProgressPercent(item.ocr) }}%</text></view>
<view class="ocr-progress-track"><view class="ocr-progress-value" :style="{ width: `${ocrProgressPercent(item.ocr)}%` }" /></view>
<text v-if="item.ocr.failedPageNumbers.length" class="ocr-failed-pages">识别失败页:{{ item.ocr.failedPageNumbers.join('、') }}</text>
<button v-if="item.ocr.failedPages > 0 && !isOcrActive(item.ocr)" class="secondary-button full-button" :disabled="busy" @click="retryOcrPages">{{ busy ? '重试中' : '重试失败页' }}</button>
</view>
<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 v-if="item.status === 'READY'" class="panel employee-card publish-card">
<text class="panel-title">沉淀企业知识</text>
<text class="status-copy">提交后由 HR/运营审核;批准时只生成脱敏企业副本,个人资料仍归你所有。</text>
<template v-if="publishRequest">
<text class="publish-status">{{ publishStatusText(publishRequest.status) }}</text>
<text v-if="publishRequest.reviewComment" class="meta">审核意见:{{ publishRequest.reviewComment }}</text>
</template>
<template v-if="!publishRequest || publishRequest.status === 'REJECTED'">
<input v-model="publishReason" class="publish-input" maxlength="500" placeholder="说明为什么值得沉淀为企业知识" />
<button class="primary-button full-button" :disabled="busy || !publishReason.trim()" @click="submitPublishRequest">申请沉淀企业知识</button>
</template>
</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, onUnload } from '@dcloudio/uni-app';
import type { PersonalItem, PersonalItemSourceType, PersonalItemStatus, PersonalPublishRequest } from '@/types/api';
import { deletePersonalItem, downloadPersonalItem, loadPersonalItem, loadPersonalPublishRequests, requestPersonalPublish, retryPersonalItem, retryPersonalOcrPages } from '@/services/personal-assistant';
import { normalizeBackendId } from '@/services/personal-assistant-id';
import { isOcrActive, ocrProgressPercent, ocrProgressText } from '@/services/personal-assistant-ocr';
const itemId = ref(''); const item = ref<PersonalItem>(); const loading = ref(false); const busy = ref(false); const message = ref('');
const publishRequest = ref<PersonalPublishRequest>(); const publishReason = ref('');
let pollTimer: ReturnType<typeof setTimeout> | undefined;
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 stopPolling = () => { if (pollTimer) clearTimeout(pollTimer); pollTimer = undefined; };
const schedulePolling = () => { stopPolling(); if (item.value && (['QUEUED', 'PARSING'].includes(item.value.status) || isOcrActive(item.value.ocr))) pollTimer = setTimeout(() => void load(true), 2000); };
const load = async (silent = false) => { if (!itemId.value) return; if (!silent) loading.value = true; message.value = ''; try { item.value = await loadPersonalItem(itemId.value); const requests = await loadPersonalPublishRequests(); publishRequest.value = requests.find((request) => request.itemId === itemId.value); schedulePolling(); } catch (error) { message.value = error instanceof Error ? error.message : '资料不存在或无权访问'; stopPolling(); } finally { if (!silent) 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 retryOcrPages = async () => { busy.value = true; message.value = ''; try { item.value = await retryPersonalOcrPages(itemId.value); schedulePolling(); } 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 publishStatusText = (status: PersonalPublishRequest['status']) => ({ PENDING: '待审核', APPROVED: '已批准并生成企业知识副本', REJECTED: '已驳回' })[status];
const submitPublishRequest = async () => { const reason = publishReason.value.trim(); if (!reason || busy.value) return; busy.value = true; message.value = ''; try { publishRequest.value = await requestPersonalPublish(itemId.value, reason); publishReason.value = ''; message.value = '申请已提交,等待 HR/运营审核'; } 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) => {
try {
itemId.value = normalizeBackendId(String(options?.id || ''));
void load();
} catch {
message.value = '资料不存在或无权访问';
}
});
onUnload(stopPolling);
</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; }.ocr-progress { margin-top: 14px; }.ocr-progress-head { display: flex; justify-content: space-between; gap: 12px; color: #344054; font-size: 13px; }.ocr-progress-track { height: 8px; margin-top: 8px; overflow: hidden; border-radius: 4px; background: #eef1f5; }.ocr-progress-value { height: 100%; border-radius: 4px; background: #e70014; transition: width 180ms ease; }.ocr-failed-pages { display: block; margin-top: 8px; color: #b54708; font-size: 12px; }.danger-panel { border-color: #f5c2c7; }.danger-button { color: #b42318; }
.publish-input { width: 100%; min-height: 44px; margin-top: 12px; padding: 0 12px; border: 1px solid var(--line); background: #fff; }.publish-status { display: block; margin-top: 12px; color: var(--brand); font-weight: 800; }
</style>
@@ -0,0 +1,146 @@
<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>
<view class="filter-row date-row">
<picker mode="date" :value="dateFrom" @change="changeDateFrom"><view class="filter-select">起始:{{ dateFrom || '不限' }}</view></picker>
<picker mode="date" :value="dateTo" @change="changeDateTo"><view class="filter-select">结束:{{ dateTo || '不限' }}</view></picker>
</view>
<button v-if="dateFrom || dateTo" class="date-clear" @click="clearDates">清除日期</button>
<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">
<text v-if="message" class="status-message">{{ message }}</text>
<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, mergePersonalRows } from '@/services/personal-assistant';
import { personalItemRoute } from '@/services/personal-assistant-id';
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 dateFrom = ref('');
const dateTo = ref('');
const loading = ref(false);
const message = ref('');
let requestGeneration = 0;
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, pageSnapshot = pageNum.value) => {
const generation = ++requestGeneration;
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(),
dateFrom: dateFrom.value,
dateTo: dateTo.value
}),
loadPersonalSpace()
]);
if (generation !== requestGeneration || pageSnapshot !== pageNum.value) return;
items.value = append ? mergePersonalRows(items.value, page.rows) : page.rows;
total.value = page.total;
space.value = latestSpace;
} catch (error) {
if (generation !== requestGeneration) return;
if (append) pageNum.value = Math.max(1, pageSnapshot - 1);
message.value = error instanceof Error ? error.message : '资料加载失败';
} finally {
if (generation === requestGeneration) loading.value = false;
}
};
const refresh = () => {
if (dateFrom.value && dateTo.value && dateFrom.value > dateTo.value) {
message.value = '起始日期不能晚于结束日期';
return;
}
pageNum.value = 1;
void load();
};
const loadMore = () => {
if (loading.value) return;
const nextPage = pageNum.value + 1;
pageNum.value = nextPage;
void load(true, nextPage);
};
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 changeDateFrom = (event: { detail: { value: string } }) => { dateFrom.value = event.detail.value || ''; };
const changeDateTo = (event: { detail: { value: string } }) => { dateTo.value = event.detail.value || ''; };
const clearDates = () => { dateFrom.value = ''; dateTo.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: string) => uni.navigateTo({ url: personalItemRoute(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; }
.date-clear { justify-self: end; min-height: 32px; padding: 0 4px; background: transparent; color: var(--muted); font-size: 13px; }
.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,53 @@
<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.toLowerCase() === 'user' ? '我' : '个人助理' }}</text>
<text class="content">{{ chat.content }}</text>
<view v-if="chat.citations?.length" class="message-citations">
<button v-for="citation in chat.citations" :key="`${citation.domain}-${citation.sourceId}`" class="citation" :disabled="!citation.itemId" @click="openCitation(citation)">
{{ citationLabel(citation) }} · {{ citation.title }}
</button>
</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 { PersonalCitation, PersonalSession, PersonalSessionDetail } from '@/types/api';
import { deletePersonalSession, loadPersonalSession, loadPersonalSessions } from '@/services/personal-assistant';
import { personalItemRoute } from '@/services/personal-assistant-id';
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: string) => { 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: string) => uni.showModal({ title: '删除会话', content: '只删除本次问答记录,不会删除资料库内容。', confirmColor: '#e70014', success: (result) => { if (result.confirm) void remove(id); } });
const remove = async (id: string) => { 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 citationLabel = (citation: PersonalCitation) => citation.domain === 'ENTERPRISE'
? '企业 SOP'
: citation.sourceType === 'URL' ? '外部网页' : '我的资料';
const openCitation = (citation: PersonalCitation) => {
if (citation.itemId) uni.navigateTo({ url: personalItemRoute(citation.itemId) });
};
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 { 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 { min-height: 34px; justify-content: flex-start; padding: 0 8px; background: #eef3f9; color: var(--knowledge); font-size: 12px; text-align: left; }.retry { width: 100%; margin-top: 10px; }
</style>
+18
View File
@@ -22,6 +22,7 @@
<uni-icons v-if="availableProjects.length > 1" type="down" size="15" color="#596474" />
</button>
<text v-if="!projectLoading && !availableProjects.length" class="project-context-note">未绑定项目,可继续查制度;项目工作请联系管理员</text>
<button class="personal-library-entry" @click="openPersonalAssistant">我的资料</button>
</view>
<button v-if="loggedIn && currentProject" class="today-result-entry" @click="openTodayResult">
@@ -1118,6 +1119,10 @@ const openTodayResult = () => {
uni.navigateTo({ url: '/pages/user/work-results/index' });
};
const openPersonalAssistant = () => {
uni.navigateTo({ url: '/pages/user/assistant/index' });
};
const hideMemoryCandidate = (msg: MasterMsg) => {
retireEarlierMemoryCandidate(msg.result.memoryCandidate?.id);
};
@@ -2328,6 +2333,19 @@ onUnload(() => {
line-height: 1.4;
}
.personal-library-entry {
display: block;
width: fit-content;
margin: 7px 0 0 auto;
padding: 2px 0;
border: 0;
color: #c90012;
background: transparent;
font-size: 12px;
}
.personal-library-entry::after { border: 0; }
.project-picker-mask {
position: fixed;
z-index: 1100;
@@ -0,0 +1,14 @@
export const normalizeBackendId = (value: string) => {
const id = value.trim();
if (!/^[1-9]\d*$/.test(id)) throw new Error('PERSONAL_ID_INVALID');
return id;
};
export const personalItemApiPath = (id: string, suffix = '') =>
`/api/aihr/personal-assistant/items/${encodeURIComponent(normalizeBackendId(id))}${suffix}`;
export const personalSessionApiPath = (id: string) =>
`/api/aihr/personal-assistant/sessions/${encodeURIComponent(normalizeBackendId(id))}`;
export const personalItemRoute = (id: string) =>
`/pages/user/assistant/item?id=${encodeURIComponent(normalizeBackendId(id))}`;
@@ -0,0 +1,20 @@
import type { PersonalOcrProgress } from '@/types/api';
export const ocrProgressPercent = (ocr?: PersonalOcrProgress) => {
if (!ocr?.required || ocr.totalPages <= 0) return 0;
return Math.min(100, Math.max(0, Math.round((ocr.processedPages / ocr.totalPages) * 100)));
};
export const ocrProgressText = (ocr?: PersonalOcrProgress) => {
if (!ocr?.required) return '';
if (ocr.processedPages < ocr.totalPages && ['PENDING', 'RUNNING', 'RETRY'].includes(ocr.status)) {
return `正在识别扫描 PDF:${ocr.processedPages}/${ocr.totalPages} 页`;
}
if (ocr.failedPages > 0 && ocr.successPages > 0) return `已收录,${ocr.failedPages} 页识别失败`;
if (ocr.failedPages > 0) return `${ocr.failedPages} 页识别失败`;
if (ocr.totalPages > 0) return `扫描 PDF 已识别:${ocr.successPages}/${ocr.totalPages} 页`;
return '正在准备扫描 PDF 识别';
};
export const isOcrActive = (ocr?: PersonalOcrProgress) =>
Boolean(ocr?.required && ['PENDING', 'RUNNING', 'RETRY'].includes(ocr.status));
+83 -1
View File
@@ -3,10 +3,23 @@ import type {
MemoryCandidate,
MemoryConfirmRequest,
MemoryConfirmResult,
PersonalAskResponse,
PersonalExportOutline,
PersonalExportOutlineSection,
PersonalItem,
PersonalItemCreated,
PersonalItemSourceType,
PersonalKnowledgeScope,
PersonalPage,
PersonalPublishRequest,
PersonalSession,
PersonalSessionDetail,
PersonalSpace,
ServiceMemory
} from '@/types/api';
import type { CaptureStatusHistory } from '@/types/api';
import { apiRequest, apiUrl } from './api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import { personalItemApiPath, personalSessionApiPath } from './personal-assistant-id';
const candidateBase = '/api/aihr/personal-assistant/memory-candidates';
@@ -86,3 +99,72 @@ export const getServiceMemories = async () => {
}));
return rows.map((item) => ({ ...item, version: item.version ?? item.currentVersion ?? 0 }));
};
export type KnowledgeScope = PersonalKnowledgeScope;
export interface PersonalItemQuery {
pageNum?: number;
pageSize?: number;
status?: string;
sourceType?: PersonalItemSourceType | '';
dateFrom?: string;
dateTo?: string;
keyword?: string;
}
const personalQuery = (query: PersonalItemQuery) => {
const entries = Object.entries(query).filter(([, value]) => value !== undefined && value !== '');
return entries.length ? `?${entries.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`).join('&')}` : '';
};
export const askPersonalAssistant = (data: {
sessionId?: string; queryText: string; scope: KnowledgeScope[]; dateFrom?: string; dateTo?: string;
itemIds?: string[]; outputFormat?: 'ANSWER' | 'ACTION_PLAN' | 'OUTLINE';
}) => apiRequest<PersonalAskResponse>({ url: '/api/aihr/personal-assistant/ask', method: 'POST', data, timeout: 45000 });
export const mergePersonalRows = (current: PersonalItem[], incoming: PersonalItem[]) =>
Array.from(new Map([...current, ...incoming].map((item) => [item.id, item])).values());
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${personalQuery(query)}`, method: 'GET' });
export const loadPersonalItem = (id: string) => apiRequest<PersonalItem>({ url: personalItemApiPath(id), method: 'GET' });
export const createTextItem = (data: { title?: string; content: string; capturedAt?: string; tags?: string[] }) =>
apiRequest<PersonalItemCreated>({ url: '/api/aihr/personal-assistant/items/text', method: 'POST', data });
export const createUrlItem = (data: { url: string; title?: string; capturedAt?: string }) =>
apiRequest<PersonalItemCreated>({ url: '/api/aihr/personal-assistant/items/url', method: 'POST', data, 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 file = (Array.isArray(result.tempFiles) ? result.tempFiles[0] : undefined) as Partial<PersonalSelectedFile> | undefined;
const path = file?.path || result.tempFilePaths?.[0] || '';
if (!path) return reject(new Error('未获取到文件路径'));
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: string) => apiRequest<PersonalItem>({ url: personalItemApiPath(id, '/retry'), method: 'POST' });
export const retryPersonalOcrPages = (id: string) => apiRequest<PersonalItem>({ url: personalItemApiPath(id, '/ocr/retry-failed'), method: 'POST' });
export const deletePersonalItem = (id: string) => apiRequest<{ cleanupJobId: string }>({ url: personalItemApiPath(id), method: 'DELETE', timeout: 15000 });
export const downloadPersonalItem = (id: string) => apiRequest<{ url: string; expiresAt?: string }>({ url: personalItemApiPath(id, '/download-url'), method: 'GET' });
export const loadPersonalSessions = () => apiRequest<PersonalSession[]>({ url: '/api/aihr/personal-assistant/sessions', method: 'GET' });
export const loadPersonalSession = (id: string) => apiRequest<PersonalSessionDetail>({ url: personalSessionApiPath(id), method: 'GET' });
export const deletePersonalSession = (id: string) => apiRequest<void>({ url: personalSessionApiPath(id), method: 'DELETE' });
export const createPersonalOutline = (data: { sessionId: string; title?: string }) => apiRequest<PersonalExportOutline>({ url: '/api/aihr/personal-assistant/exports/outline', method: 'POST', data });
export const updatePersonalOutline = (id: string, data: { title: string; sections: PersonalExportOutlineSection[] }) =>
apiRequest<PersonalExportOutline>({ url: `/api/aihr/personal-assistant/exports/${encodeURIComponent(id)}/outline`, method: 'PUT', data });
export const confirmPersonalOutline = (id: string) => apiRequest<void>({ url: `/api/aihr/personal-assistant/exports/${encodeURIComponent(id)}/confirm`, method: 'POST' });
export const loadPersonalExport = (id: string) => apiRequest<PersonalExportOutline>({ url: `/api/aihr/personal-assistant/exports/${encodeURIComponent(id)}`, method: 'GET' });
export const enqueuePersonalPpt = (id: string) => apiRequest<void>({ url: `/api/aihr/personal-assistant/exports/${encodeURIComponent(id)}/pptx`, method: 'POST', data: { templateCode: 'standard' } });
export const downloadPersonalExport = (id: string) => apiRequest<{ url: string; expiresAt?: string }>({ url: `/api/aihr/personal-assistant/exports/${encodeURIComponent(id)}/download-url`, method: 'GET' });
export const requestPersonalPublish = (itemId: string, reason: string) => apiRequest<PersonalPublishRequest>({ url: `${personalItemApiPath(itemId)}/publish-requests`, method: 'POST', data: { targetScope: 'POSITION:生活顾问', reason } });
export const loadPersonalPublishRequests = () => apiRequest<PersonalPublishRequest[]>({ url: '/api/aihr/personal-assistant/publish-requests', method: 'GET' });
+50
View File
@@ -381,6 +381,56 @@ 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;
itemId?: string;
fragmentId?: string;
sourceType?: PersonalItemSourceType | 'ENTERPRISE';
title: string;
excerpt: string;
capturedAt?: string;
}
export interface PersonalAskResponse {
sessionId: string;
answer: string;
citations: PersonalCitation[];
model?: string;
promptVersion?: string;
}
export interface PersonalExportOutlineSection { title: string; bullets: string[]; }
export interface PersonalExportOutlineSource { domain: PersonalKnowledgeScope; sourceId: string; itemId?: string; title: string; excerpt: string; }
export interface PersonalExportOutline {
id: string; sessionId: string; title: string; sections: PersonalExportOutlineSection[];
sources: PersonalExportOutlineSource[]; confirmed: boolean; status: string; ossId?: string;
}
export interface PersonalPublishRequest {
id: string; itemId: string; title: string; sourceType: PersonalItemSourceType; targetScope: string;
reason: string; status: 'PENDING' | 'APPROVED' | 'REJECTED'; reviewerUserId?: string;
reviewComment?: string; publishedKnowledgeId?: string; createdAt?: string;
}
export interface PersonalSpace { spaceId: string; status: string; quotaBytes: number; usedBytes: number; itemCount: number; }
export interface PersonalItem {
id: string; sourceType: PersonalItemSourceType; title: string; originalUrl?: string; mimeType?: string;
sizeBytes: number; status: PersonalItemStatus; errorCode?: string; errorMessage?: string; summary?: string;
tags?: string[]; capturedAt?: string; parsedAt?: string; ocr?: PersonalOcrProgress;
}
export interface PersonalOcrProgress {
required: boolean; status: string; totalPages: number; processedPages: number; successPages: number;
failedPages: number; failedPageNumbers: number[];
}
export interface PersonalPage<T> { rows: T[]; total: number; pageNum: number; pageSize: number; }
export interface PersonalItemCreated { itemId: string; status: PersonalItemStatus; duplicateOf?: string; }
export interface PersonalSession { id: string; title: string; defaultScope: string; updatedAt?: string; }
export interface PersonalChatMessage { id: string; role: string; content: string; citations: PersonalCitation[]; createdAt?: string; }
export interface PersonalSessionDetail { id: string; title: string; messages: PersonalChatMessage[]; }
export type PracticeRole = 'customer' | 'trainee' | 'coach';
export interface PracticeTurn {