330 lines
16 KiB
Vue
330 lines
16 KiB
Vue
<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>
|