Files
prop-ai-hr/mobile-uni/src/pages/user/assistant/index.vue
T

330 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>