feat(personal-assistant): add confirmed project memory
This commit is contained in:
@@ -0,0 +1,518 @@
|
||||
<template>
|
||||
<view class="memory-card" :class="{ compact }">
|
||||
<template v-if="candidate.status === 'NEEDS_INPUT'">
|
||||
<view class="memory-head">
|
||||
<text class="memory-title">还需要补充信息</text>
|
||||
<text class="memory-tag muted">暂不保存</text>
|
||||
</view>
|
||||
<text class="needs-input-copy">{{ missingPrompt }}</text>
|
||||
</template>
|
||||
|
||||
<template v-else-if="completed">
|
||||
<view class="memory-head">
|
||||
<text class="memory-title">已确认保存</text>
|
||||
<text class="memory-tag success">{{ targetLabel }}</text>
|
||||
</view>
|
||||
<text class="saved-copy">这条事项已保存,后续工作助手可按权限查询。</text>
|
||||
</template>
|
||||
|
||||
<template v-else-if="candidate.status === 'DRAFT'">
|
||||
<view class="memory-head">
|
||||
<text class="memory-title">发现一条可保存的信息</text>
|
||||
<text class="memory-tag">{{ memoryTypeLabel }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="editing" class="edit-form">
|
||||
<view class="location-grid">
|
||||
<input v-model.trim="draft.buildingName" class="field-input" maxlength="30" placeholder="楼栋" />
|
||||
<input v-model.trim="draft.unitName" class="field-input" maxlength="30" placeholder="单元" />
|
||||
<input v-model.trim="draft.roomNo" class="field-input" maxlength="30" placeholder="房号" />
|
||||
</view>
|
||||
<label class="field-row">
|
||||
<text class="field-label">事项</text>
|
||||
<input v-model.trim="draft.category" class="field-input wide" maxlength="60" placeholder="事项类别" />
|
||||
</label>
|
||||
<label class="field-row vertical">
|
||||
<text class="field-label">摘要</text>
|
||||
<textarea v-model.trim="draft.summary" class="field-textarea" maxlength="300" auto-height placeholder="简要说明需要记住的内容" />
|
||||
</label>
|
||||
<picker mode="date" :value="occurredDate" @change="changeOccurredDate">
|
||||
<view class="field-row picker-row">
|
||||
<text class="field-label">记录时间</text>
|
||||
<text class="picker-value">{{ occurredDate || '请选择' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view v-else class="memory-rows">
|
||||
<view class="memory-row">
|
||||
<text class="row-label">对象</text>
|
||||
<text class="row-value">{{ subjectLabel || '待补充' }}</text>
|
||||
</view>
|
||||
<view class="memory-row">
|
||||
<text class="row-label">事项</text>
|
||||
<text class="row-value">{{ draft.category || memoryTypeLabel }}</text>
|
||||
</view>
|
||||
<view class="memory-row">
|
||||
<text class="row-label">摘要</text>
|
||||
<text class="row-value">{{ draft.summary || '待补充' }}</text>
|
||||
</view>
|
||||
<view class="memory-row">
|
||||
<text class="row-label">保存到</text>
|
||||
<text class="row-value">{{ targetLabel }}</text>
|
||||
</view>
|
||||
<view class="memory-row">
|
||||
<text class="row-label">记录时间</text>
|
||||
<text class="row-value">{{ displayDate(draft.occurredAt) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button
|
||||
class="reminder-switch"
|
||||
role="switch"
|
||||
:aria-checked="reminderEnabled"
|
||||
@click="reminderEnabled = !reminderEnabled"
|
||||
>
|
||||
<text class="switch-mark" :class="{ active: reminderEnabled }">{{ reminderEnabled ? '✓' : '' }}</text>
|
||||
<text>加入跟进提醒(默认关闭)</text>
|
||||
</button>
|
||||
<picker v-if="reminderEnabled" mode="date" :value="followUpDate" @change="changeFollowUpDate">
|
||||
<view class="followup-picker">
|
||||
<text>跟进日期</text>
|
||||
<text>{{ followUpDate || '请选择' }} ›</text>
|
||||
</view>
|
||||
</picker>
|
||||
|
||||
<text class="memory-note">确认后才会长期保存;暂不保存不会写入个人资料或项目记录。</text>
|
||||
<text v-if="errorText" class="memory-error">{{ errorText }}</text>
|
||||
|
||||
<view class="memory-actions">
|
||||
<button v-if="!editing" class="memory-action plain" :disabled="submitting" @click="editing = true">修改</button>
|
||||
<button v-else class="memory-action plain" :disabled="submitting" @click="editing = false">完成修改</button>
|
||||
<button class="memory-action outline" :disabled="submitting" @click="dismiss">暂不保存</button>
|
||||
<button class="memory-action primary" :disabled="submitting" @click="confirm">确认保存</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<view class="memory-head">
|
||||
<text class="memory-title">{{ terminalStatusTitle }}</text>
|
||||
<text class="memory-tag muted">{{ memoryTypeLabel }}</text>
|
||||
</view>
|
||||
<text class="saved-copy">这条确认卡已不可操作,请返回工作助手重新识别或刷新列表。</text>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type { MemoryCandidate, MemoryCandidateDraft, MemoryConfirmResult } from '@/types/api';
|
||||
import {
|
||||
confirmMemoryCandidate,
|
||||
createMemoryIdempotencyKey,
|
||||
dismissMemoryCandidate
|
||||
} from '@/services/personal-assistant';
|
||||
import { currentAccountKey } from '@/services/request-scope';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
candidate: MemoryCandidate;
|
||||
compact?: boolean;
|
||||
}>(), {
|
||||
compact: false
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirmed: [result: MemoryConfirmResult];
|
||||
dismissed: [candidateId: number];
|
||||
refresh: [candidateId: number];
|
||||
}>();
|
||||
|
||||
const cloneDraft = (value: MemoryCandidateDraft): MemoryCandidateDraft => ({
|
||||
...value,
|
||||
projectCode: value.projectCode || null,
|
||||
buildingName: value.buildingName || '',
|
||||
unitName: value.unitName || '',
|
||||
roomNo: value.roomNo || '',
|
||||
category: value.category || '',
|
||||
title: value.title || '',
|
||||
summary: value.summary || '',
|
||||
detail: value.detail || '',
|
||||
status: value.status || 'OPEN',
|
||||
occurredAt: value.occurredAt || null,
|
||||
followUpAt: value.followUpAt || null
|
||||
});
|
||||
|
||||
const draft = ref(cloneDraft(props.candidate.draft));
|
||||
const editing = ref(false);
|
||||
const reminderEnabled = ref(false);
|
||||
const submitting = ref(false);
|
||||
const completed = ref(false);
|
||||
const errorText = ref('');
|
||||
const idempotencyKey = ref(createMemoryIdempotencyKey(props.candidate.id));
|
||||
|
||||
const occurredDate = computed(() => (draft.value.occurredAt || '').slice(0, 10));
|
||||
const followUpDate = computed(() => (draft.value.followUpAt || '').slice(0, 10));
|
||||
const subjectLabel = computed(() => [draft.value.buildingName, draft.value.unitName, draft.value.roomNo].filter(Boolean).join(''));
|
||||
const missingProjectLocationLabels = computed(() => [
|
||||
{ label: '楼栋', value: draft.value.buildingName },
|
||||
{ label: '单元', value: draft.value.unitName },
|
||||
{ label: '房号', value: draft.value.roomNo }
|
||||
].filter((item) => !item.value?.trim()).map((item) => item.label));
|
||||
|
||||
const memoryTypeLabel = computed(() => ({
|
||||
RESIDENT_NEED: '住户需求',
|
||||
SERVICE_FACT: '服务事实',
|
||||
FOLLOW_UP: '待跟进事项',
|
||||
PERSONAL_NOTE: '个人事项'
|
||||
}[props.candidate.memoryType] || '工作事项'));
|
||||
|
||||
const targetLabel = computed(() =>
|
||||
props.candidate.targetDomain === 'PROJECT_SERVICE' ? '项目服务记录' : '我的资料与记忆'
|
||||
);
|
||||
|
||||
const terminalStatusTitle = computed(() => ({
|
||||
CONFIRMED: '这条事项已保存',
|
||||
DISMISSED: '这条事项已暂不保存',
|
||||
EXPIRED: '这条确认卡已过期'
|
||||
}[props.candidate.status] || '这条确认卡已结束'));
|
||||
|
||||
const missingPrompt = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
projectCode: '所属项目',
|
||||
buildingName: '楼栋',
|
||||
unitName: '单元',
|
||||
roomNo: '房号',
|
||||
category: '事项类别',
|
||||
summary: '事项说明',
|
||||
occurredAt: '发生时间'
|
||||
};
|
||||
const fields = (props.candidate.missingFields || []).map((item) => labels[item] || item);
|
||||
return fields.length ? `请继续告诉我:${fields.join('、')}。信息补齐后再请你确认保存。` : '请继续补充对象或事项信息,信息补齐后再请你确认保存。';
|
||||
});
|
||||
|
||||
const displayDate = (value: string | null) => value ? value.replace('T', ' ').slice(0, 16) : '未填写';
|
||||
|
||||
const dateFromEvent = (event: { detail?: { value?: string } }) => String(event.detail?.value || '');
|
||||
|
||||
const changeOccurredDate = (event: { detail?: { value?: string } }) => {
|
||||
const value = dateFromEvent(event);
|
||||
draft.value.occurredAt = value ? `${value}T00:00:00+08:00` : null;
|
||||
};
|
||||
|
||||
const changeFollowUpDate = (event: { detail?: { value?: string } }) => {
|
||||
const value = dateFromEvent(event);
|
||||
draft.value.followUpAt = value ? `${value}T09:00:00+08:00` : null;
|
||||
};
|
||||
|
||||
const confirm = async () => {
|
||||
if (submitting.value || completed.value) return;
|
||||
if (!draft.value.category || !draft.value.summary || !draft.value.occurredAt) {
|
||||
errorText.value = '请先补全事项、摘要和记录时间';
|
||||
editing.value = true;
|
||||
return;
|
||||
}
|
||||
if (props.candidate.targetDomain === 'PROJECT_SERVICE' && missingProjectLocationLabels.value.length) {
|
||||
errorText.value = `项目服务记录还需补充:${missingProjectLocationLabels.value.join('、')}`;
|
||||
editing.value = true;
|
||||
return;
|
||||
}
|
||||
if (reminderEnabled.value && !draft.value.followUpAt) {
|
||||
errorText.value = '请先选择跟进日期';
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
errorText.value = '';
|
||||
const requestAccountKey = currentAccountKey();
|
||||
try {
|
||||
const result = await confirmMemoryCandidate(props.candidate.id, {
|
||||
expectedVersion: props.candidate.version,
|
||||
idempotencyKey: idempotencyKey.value,
|
||||
draft: {
|
||||
...draft.value,
|
||||
followUpAt: reminderEnabled.value ? draft.value.followUpAt : null
|
||||
},
|
||||
enableReminder: reminderEnabled.value
|
||||
});
|
||||
if (requestAccountKey !== currentAccountKey()) return;
|
||||
completed.value = true;
|
||||
emit('confirmed', result);
|
||||
} catch (error) {
|
||||
errorText.value = error instanceof Error ? error.message : '保存失败,请稍后重试';
|
||||
if (/冲突|版本|更新/.test(errorText.value)) emit('refresh', props.candidate.id);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const dismiss = async () => {
|
||||
if (submitting.value || completed.value) return;
|
||||
submitting.value = true;
|
||||
errorText.value = '';
|
||||
const requestAccountKey = currentAccountKey();
|
||||
try {
|
||||
await dismissMemoryCandidate(props.candidate.id);
|
||||
if (requestAccountKey !== currentAccountKey()) return;
|
||||
emit('dismissed', props.candidate.id);
|
||||
} catch (error) {
|
||||
errorText.value = error instanceof Error ? error.message : '操作失败,请稍后重试';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.candidate.id, props.candidate.version] as const,
|
||||
() => {
|
||||
draft.value = cloneDraft(props.candidate.draft);
|
||||
reminderEnabled.value = false;
|
||||
editing.value = false;
|
||||
completed.value = false;
|
||||
errorText.value = '';
|
||||
idempotencyKey.value = createMemoryIdempotencyKey(props.candidate.id);
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.memory-card {
|
||||
box-sizing: border-box;
|
||||
width: min(100%, 360px);
|
||||
padding: 16px;
|
||||
border: 1px solid #e7e9ed;
|
||||
border-radius: 18px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 24px rgba(28, 36, 48, 0.07);
|
||||
}
|
||||
|
||||
.memory-card.compact {
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.memory-head,
|
||||
.memory-row,
|
||||
.field-row,
|
||||
.followup-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.memory-title {
|
||||
color: #18202b;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.memory-tag {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 9px;
|
||||
border-radius: 8px;
|
||||
background: #fff0f2;
|
||||
color: #d70016;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.memory-tag.muted {
|
||||
background: #f2f3f5;
|
||||
color: #737c89;
|
||||
}
|
||||
|
||||
.memory-tag.success {
|
||||
background: #edf8f1;
|
||||
color: #168243;
|
||||
}
|
||||
|
||||
.memory-rows {
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid #edf0f3;
|
||||
}
|
||||
|
||||
.memory-row {
|
||||
align-items: flex-start;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid #edf0f3;
|
||||
}
|
||||
|
||||
.row-label,
|
||||
.field-label {
|
||||
flex: 0 0 64px;
|
||||
color: #8a93a0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.row-value {
|
||||
min-width: 0;
|
||||
color: #29313c;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.location-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-row.vertical {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.field-input,
|
||||
.field-textarea {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
border: 1px solid #dde2e8;
|
||||
border-radius: 10px;
|
||||
background: #f8f9fb;
|
||||
color: #29313c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
height: 42px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.field-input.wide,
|
||||
.field-textarea {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-textarea {
|
||||
min-height: 76px;
|
||||
padding: 9px 10px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.picker-row,
|
||||
.followup-picker {
|
||||
min-height: 44px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #dde2e8;
|
||||
border-radius: 10px;
|
||||
background: #f8f9fb;
|
||||
}
|
||||
|
||||
.picker-value,
|
||||
.followup-picker {
|
||||
color: #596474;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.reminder-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #38424e;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.reminder-switch::after,
|
||||
.memory-action::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.switch-mark {
|
||||
display: flex;
|
||||
flex: 0 0 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #c8ced6;
|
||||
border-radius: 50%;
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.switch-mark.active {
|
||||
border-color: #e60012;
|
||||
background: #e60012;
|
||||
}
|
||||
|
||||
.memory-note,
|
||||
.memory-error,
|
||||
.needs-input-copy,
|
||||
.saved-copy {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.memory-note {
|
||||
color: #8a93a0;
|
||||
}
|
||||
|
||||
.memory-error {
|
||||
color: #c8001a;
|
||||
}
|
||||
|
||||
.needs-input-copy,
|
||||
.saved-copy {
|
||||
color: #596474;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.memory-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.memory-action {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.memory-action.plain {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #29313c;
|
||||
}
|
||||
|
||||
.memory-action.outline {
|
||||
border: 1px solid #9ba5b3;
|
||||
background: #ffffff;
|
||||
color: #29313c;
|
||||
}
|
||||
|
||||
.memory-action.primary {
|
||||
grid-column: 1 / -1;
|
||||
border: 1px solid #e60012;
|
||||
background: #e60012;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.memory-card {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -22,7 +22,7 @@
|
||||
{
|
||||
"path": "pages/user/sop/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "问师傅"
|
||||
"navigationBarTitleText": "工作助手"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -52,7 +52,13 @@
|
||||
{
|
||||
"path": "pages/user/web-ai/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "问 AI(全网)"
|
||||
"navigationBarTitleText": "查全网"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/assistant/memories",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的资料与记忆"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
<template>
|
||||
<view class="page employee-page-shell memories-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回" @click="goBack">←</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="employee-kicker">工作助手</text>
|
||||
<text class="page-title">我的资料与记忆</text>
|
||||
<text class="page-subtitle">首版仅展示待确认事项和项目跟进;完整个人资料库将在后续开放。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!loggedIn" class="panel employee-card login-panel">
|
||||
<text class="panel-title">登录后查看</text>
|
||||
<text class="muted">这些内容只按你的账号和当前项目权限读取。</text>
|
||||
<button class="primary-button" @click="goLogin">去登录</button>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
|
||||
<view class="section-head">
|
||||
<view>
|
||||
<text class="section-title">待确认</text>
|
||||
<text class="section-note">只有你点“确认保存”后才会成为长期记录</text>
|
||||
</view>
|
||||
<button class="refresh-button" :disabled="loading" @click="load">刷新</button>
|
||||
</view>
|
||||
<view v-if="candidates.length" class="candidate-list">
|
||||
<MemoryCandidateCard
|
||||
v-for="candidate in candidates"
|
||||
:key="`${candidate.id}-${candidate.version}`"
|
||||
:candidate="candidate"
|
||||
compact
|
||||
@confirmed="removeCandidate(candidate.id, '已确认保存')"
|
||||
@dismissed="removeCandidate(candidate.id, '已暂不保存')"
|
||||
@refresh="load"
|
||||
/>
|
||||
</view>
|
||||
<view v-else class="empty-card employee-card">
|
||||
<text class="empty-title">暂无待确认事项</text>
|
||||
<text class="empty-copy">在工作助手里说“记一下”,识别到完整信息后会先让你确认。</text>
|
||||
</view>
|
||||
|
||||
<view class="section-head followup-head">
|
||||
<view>
|
||||
<text class="section-title">项目跟进</text>
|
||||
<text class="section-note">仅展示当前账号有权限查看的项目服务记录</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="serviceMemories.length" class="service-list">
|
||||
<view v-for="item in serviceMemories" :key="`${item.id}-${item.version}`" class="service-card employee-card">
|
||||
<view class="service-head">
|
||||
<text class="service-category">{{ item.category || memoryTypeLabel(item.memoryType) }}</text>
|
||||
<text class="service-status" :class="statusClass(item.status)">{{ statusLabel(item.status) }}</text>
|
||||
</view>
|
||||
<text v-if="item.projectCode" class="service-project">项目:{{ item.projectCode }}</text>
|
||||
<text class="service-subject">{{ serviceSubject(item) || '项目服务事项' }}</text>
|
||||
<text class="service-summary">{{ item.summary }}</text>
|
||||
<view class="service-meta">
|
||||
<text>记录 {{ displayDate(item.occurredAt) }}</text>
|
||||
<text v-if="item.followUpAt">跟进 {{ displayDate(item.followUpAt) }}</text>
|
||||
<text v-if="item.updatedAt">更新 {{ displayDate(item.updatedAt) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-card employee-card">
|
||||
<text class="empty-title">暂无项目跟进</text>
|
||||
<text class="empty-copy">经你确认的住户需求、服务事实和待跟进事项会显示在这里。</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type { MemoryCandidate, ServiceMemory } from '@/types/api';
|
||||
import MemoryCandidateCard from '@/components/chat/MemoryCandidateCard.vue';
|
||||
import { getMemoryCandidates, getServiceMemories } from '@/services/personal-assistant';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { createPageRequestScope } from '@/services/request-scope';
|
||||
|
||||
const path = '/pages/user/assistant/memories';
|
||||
const loggedIn = ref(false);
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
const candidates = ref<MemoryCandidate[]>([]);
|
||||
const serviceMemories = ref<ServiceMemory[]>([]);
|
||||
const requests = createPageRequestScope();
|
||||
|
||||
const goBack = () => uni.navigateBack({ delta: 1 });
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect(path);
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const displayDate = (value?: string | null) => value ? value.replace('T', ' ').slice(0, 16) : '未填写';
|
||||
|
||||
const memoryTypeLabel = (type = '') => ({
|
||||
RESIDENT_NEED: '住户需求',
|
||||
SERVICE_FACT: '服务事实',
|
||||
FOLLOW_UP: '待跟进事项'
|
||||
}[type] || '工作事项');
|
||||
|
||||
const statusLabel = (status = '') => ({
|
||||
OPEN: '待跟进',
|
||||
IN_PROGRESS: '跟进中',
|
||||
RESOLVED: '已完成',
|
||||
VOID: '已作废'
|
||||
}[status] || status || '已记录');
|
||||
|
||||
const statusClass = (status = '') => status === 'VOID' ? 'void' : status === 'RESOLVED' ? 'done' : '';
|
||||
|
||||
const serviceSubject = (item: ServiceMemory) => {
|
||||
return [
|
||||
item.buildingName,
|
||||
item.unitName,
|
||||
item.roomNo
|
||||
].filter(Boolean).join('');
|
||||
};
|
||||
|
||||
const removeCandidate = (candidateId: number, toast: string) => {
|
||||
candidates.value = candidates.value.filter((item) => item.id !== candidateId);
|
||||
uni.showToast({ title: toast, icon: 'none' });
|
||||
void loadServiceMemories();
|
||||
};
|
||||
|
||||
const loadServiceMemories = async () => {
|
||||
const request = requests.begin('service-memories');
|
||||
try {
|
||||
const result = await getServiceMemories();
|
||||
if (requests.isCurrent(request)) serviceMemories.value = result;
|
||||
} catch (error) {
|
||||
if (requests.isCurrent(request)) {
|
||||
message.value = error instanceof Error ? error.message : '项目跟进读取失败';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
candidates.value = [];
|
||||
serviceMemories.value = [];
|
||||
message.value = '';
|
||||
if (!loggedIn.value) return;
|
||||
if (!(await ensureEmployeePosition(path))) return;
|
||||
const request = requests.begin('memory-page');
|
||||
loading.value = true;
|
||||
const [candidateResult, serviceResult] = await Promise.allSettled([
|
||||
getMemoryCandidates('DRAFT'),
|
||||
getServiceMemories()
|
||||
]);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
if (candidateResult.status === 'fulfilled') candidates.value = candidateResult.value;
|
||||
if (serviceResult.status === 'fulfilled') serviceMemories.value = serviceResult.value;
|
||||
const errors = [candidateResult, serviceResult]
|
||||
.filter((item): item is PromiseRejectedResult => item.status === 'rejected')
|
||||
.map((item) => item.reason instanceof Error ? item.reason.message : '读取失败');
|
||||
message.value = errors.join(';');
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
requests.activate();
|
||||
resetPageScroll();
|
||||
void load();
|
||||
});
|
||||
|
||||
onHide(() => requests.invalidate());
|
||||
onUnload(() => requests.invalidate());
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.memories-page {
|
||||
max-width: var(--mobile-shell-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: calc(32px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.status-message {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
color: #a9000d;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.login-panel,
|
||||
.empty-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.login-panel .primary-button {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.section-head,
|
||||
.service-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.section-head.followup-head {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.section-title,
|
||||
.section-note,
|
||||
.empty-title,
|
||||
.empty-copy,
|
||||
.service-subject,
|
||||
.service-summary {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: #18202b;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.section-note {
|
||||
margin-top: 5px;
|
||||
color: #8a93a0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
flex-shrink: 0;
|
||||
min-height: 38px;
|
||||
margin: 0;
|
||||
padding: 0 13px;
|
||||
border: 1px solid #dde2e8;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: #596474;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.candidate-list,
|
||||
.service-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
color: #29313c;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.empty-copy {
|
||||
color: #8a93a0;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
padding: 17px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.service-category {
|
||||
color: #29313c;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.service-status {
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
background: #fff0f2;
|
||||
color: #d70016;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.service-status.done {
|
||||
background: #edf8f1;
|
||||
color: #168243;
|
||||
}
|
||||
|
||||
.service-status.void {
|
||||
background: #f2f3f5;
|
||||
color: #7d8590;
|
||||
}
|
||||
|
||||
.service-subject {
|
||||
margin-top: 12px;
|
||||
color: #18202b;
|
||||
font-size: 17px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.service-project {
|
||||
display: block;
|
||||
margin-top: 11px;
|
||||
color: #727c89;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.service-project + .service-subject {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.service-summary {
|
||||
margin-top: 8px;
|
||||
color: #4f5967;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.service-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #edf0f3;
|
||||
color: #8a93a0;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -125,6 +125,14 @@
|
||||
|
||||
<view v-if="loggedIn" class="panel feature-entries">
|
||||
<text class="panel-title">常用功能</text>
|
||||
<button class="feature-entry" @click="openMemories">
|
||||
<view class="feature-entry-icon memory">记</view>
|
||||
<view class="feature-entry-copy">
|
||||
<text class="feature-entry-title">我的资料与记忆</text>
|
||||
<text class="feature-entry-desc">确认待保存事项 · 查看项目跟进</text>
|
||||
</view>
|
||||
<text class="points-entry-arrow">›</text>
|
||||
</button>
|
||||
<button class="feature-entry" @click="openLearning">
|
||||
<view class="feature-entry-icon learn">学</view>
|
||||
<view class="feature-entry-copy">
|
||||
@@ -292,6 +300,10 @@ const openLearning = () => {
|
||||
uni.navigateTo({ url: '/pages/user/learning/index' });
|
||||
};
|
||||
|
||||
const openMemories = () => {
|
||||
uni.navigateTo({ url: '/pages/user/assistant/memories' });
|
||||
};
|
||||
|
||||
const openPracticeSummary = () => {
|
||||
uni.setStorageSync('aihr_sop_pending_tool', 'MY_PRACTICE_SUMMARY');
|
||||
uni.switchTab({ url: '/pages/user/sop/index' });
|
||||
@@ -868,6 +880,10 @@ onShow(() => {
|
||||
background: #1f5c9d;
|
||||
}
|
||||
|
||||
.feature-entry-icon.memory {
|
||||
background: #5d6878;
|
||||
}
|
||||
|
||||
.feature-entry-icon.summary {
|
||||
background: #e70014;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="source-switch chat-source-switch" aria-label="问答来源">
|
||||
<button class="source-option active" aria-current="page">企业问师傅</button>
|
||||
<button class="source-option" @click="openWebAi">问 AI(全网)</button>
|
||||
<button class="source-option active" aria-current="page">工作助手</button>
|
||||
<button class="source-option" @click="openWebAi">查全网</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<view class="bubble-row left">
|
||||
<image class="avatar master-avatar" src="/static/mentor-female-avatar.png" mode="aspectFill" />
|
||||
<view class="bubble master">
|
||||
<text class="bubble-text">你好,我是老师傅。工作里的 SOP、话术、流程直接问我,答案都带引用依据;也可以按住下面的按钮说话。</text>
|
||||
<text class="bubble-text">工作里的问题直接问我,我会查制度、SOP、项目记录和经你确认保存的事项;也可以按住下面的按钮说话。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
<button v-if="msg.toolCode === ''" class="bubble-action" :disabled="summaryLoading" @click="buildSummaryCard(msg)">
|
||||
{{ summaryLoading ? '生成中' : '总结卡' }}
|
||||
</button>
|
||||
<button v-if="msg.result.noEvidence" class="bubble-action primary" @click="openWebAi">去查全网</button>
|
||||
</view>
|
||||
<view v-if="msg.toolCode === ''" class="bubble-actions feedback-row">
|
||||
<button
|
||||
@@ -112,8 +113,14 @@
|
||||
|
||||
<view v-if="msg.citationsOpen && msg.result.snippets?.length" class="bubble-citations">
|
||||
<view v-for="(snippet, index) in msg.result.snippets" :key="`${snippet.docTitle}-${index}`" class="citation">
|
||||
<text class="citation-domain" :class="{ project: snippet.domain === 'PROJECT_SERVICE' }">
|
||||
{{ citationDomainLabel(snippet) }}
|
||||
</text>
|
||||
<text class="citation-title">{{ snippet.docTitle }}</text>
|
||||
<text v-if="snippet.spaceCode" class="citation-meta">知识空间:{{ snippet.spaceCode }}</text>
|
||||
<text v-if="snippet.domain === 'PROJECT_SERVICE'" class="citation-meta">
|
||||
{{ serviceMemoryStatusLabel(snippet.status) }} · 发生 {{ displayCitationDate(snippet.occurredAt) }} · 更新 {{ displayCitationDate(snippet.updatedAt) }}
|
||||
</text>
|
||||
<text v-else-if="snippet.spaceCode" class="citation-meta">知识空间:{{ snippet.spaceCode }}</text>
|
||||
<text class="citation-content">{{ snippetExcerpt(snippet.content) }}</text>
|
||||
<text v-if="snippet.fragmentId" class="citation-meta">片段 #{{ snippet.fragmentId }}</text>
|
||||
</view>
|
||||
@@ -149,6 +156,13 @@
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<MemoryCandidateCard
|
||||
v-if="msg.result.memoryCandidate"
|
||||
:candidate="msg.result.memoryCandidate"
|
||||
@dismissed="hideMemoryCandidate(msg)"
|
||||
@refresh="refreshMemoryCandidate(msg, $event)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -156,7 +170,7 @@
|
||||
<view v-if="!loggedIn" class="bubble-row left">
|
||||
<image class="avatar master-avatar" src="/static/mentor-female-avatar.png" mode="aspectFill" />
|
||||
<view class="bubble master">
|
||||
<text class="bubble-text">登录后才能查询 SOP、生成总结卡并反馈答案。</text>
|
||||
<text class="bubble-text">登录后才能使用工作助手、确认保存事项并查询内部资料。</text>
|
||||
<view class="bubble-actions">
|
||||
<button class="bubble-action primary" @click="goLogin">去登录</button>
|
||||
</view>
|
||||
@@ -167,6 +181,8 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="scope-note">资料范围:自动 · 仅查询当前有权限的内部资料</view>
|
||||
|
||||
<ChatComposer
|
||||
ref="composerRef"
|
||||
:disabled="!loggedIn"
|
||||
@@ -278,6 +294,7 @@ import type {
|
||||
SummaryCardResponse
|
||||
} from '@/types/api';
|
||||
import ChatComposer from '@/components/chat/ChatComposer.vue';
|
||||
import MemoryCandidateCard from '@/components/chat/MemoryCandidateCard.vue';
|
||||
import {
|
||||
chooseKnowledgeMedia,
|
||||
generateSummaryCard,
|
||||
@@ -299,6 +316,8 @@ import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/ap
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||
import { downloadSummaryCardImage } from '@/services/summary-card-image';
|
||||
import { getMemoryCandidates } from '@/services/personal-assistant';
|
||||
import { currentAccountKey } from '@/services/request-scope';
|
||||
import { computed } from 'vue';
|
||||
|
||||
type ToolCode = '' | 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY';
|
||||
@@ -372,6 +391,9 @@ let voiceTranscriptionGeneration = 0;
|
||||
let abortVoiceTranscription: (() => void) | null = null;
|
||||
let knowledgeSearchGeneration = 0;
|
||||
let activeMediaUploadTask: ReturnType<typeof uni.uploadFile> | null = null;
|
||||
let candidateRefreshGeneration = 0;
|
||||
let showGeneration = 0;
|
||||
let visibleAccountKey = '';
|
||||
|
||||
const defaultToolCode = computed<'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY'>(() =>
|
||||
currentRole.value === 'supervisor' && !supervisorLearner.value
|
||||
@@ -418,6 +440,15 @@ const applyConversationState = (result: KnowledgeSearchResponse) => {
|
||||
}
|
||||
};
|
||||
|
||||
const retireEarlierMemoryCandidate = (candidateId?: number) => {
|
||||
if (!candidateId) return;
|
||||
messages.value.forEach((item) => {
|
||||
if (item.role === 'master' && item.result.memoryCandidate?.id === candidateId) {
|
||||
item.result.memoryCandidate = null;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const releaseMessageResources = () => {
|
||||
messages.value.forEach((msg) => {
|
||||
if (msg.role !== 'master') return;
|
||||
@@ -430,6 +461,7 @@ const releaseMessageResources = () => {
|
||||
|
||||
const resetConversation = (notify: boolean) => {
|
||||
cancelPendingMediaSearch();
|
||||
candidateRefreshGeneration += 1;
|
||||
releaseMessageResources();
|
||||
speechPlayback.stop();
|
||||
messages.value = [];
|
||||
@@ -474,16 +506,21 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
|
||||
uni.showToast({ title: '请先输入问题', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const requestShowGeneration = showGeneration;
|
||||
const requestAccountKey = currentAccountKey();
|
||||
const isCurrentQuestion = () => requestShowGeneration === showGeneration
|
||||
&& requestAccountKey === currentAccountKey();
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
uni.setStorageSync(pendingSopQueryKey, text);
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
if (!(await ensureEmployeePosition('/pages/user/sop/index'))) {
|
||||
if (!(await ensureEmployeePosition('/pages/user/sop/index')) || !isCurrentQuestion()) {
|
||||
return;
|
||||
}
|
||||
refreshAuthState();
|
||||
if (!isCurrentQuestion()) return;
|
||||
pushMsg<UserMsg>({
|
||||
role: 'user',
|
||||
text,
|
||||
@@ -503,6 +540,7 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
|
||||
: await searchKnowledge(text, getSelectedPosition(), conversationState());
|
||||
if (generation !== knowledgeSearchGeneration) return;
|
||||
applyConversationState(nextResult);
|
||||
retireEarlierMemoryCandidate(nextResult.memoryCandidate?.id);
|
||||
const spoken = (nextResult.answer || '').trim();
|
||||
replaceMsg(pending.id, {
|
||||
id: pending.id,
|
||||
@@ -518,7 +556,7 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
|
||||
transcriptOpen: false
|
||||
});
|
||||
if (spoken) void prepareVoice(pending.id, spoken);
|
||||
if (!nextResult.answer && !nextResult.snippets?.length) {
|
||||
if (!nextResult.answer && !nextResult.snippets?.length && !nextResult.memoryCandidate) {
|
||||
pushSystem('未检索到可引用内容,换个问法试试');
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -748,6 +786,22 @@ const cleanOrderedText = (text = '') =>
|
||||
.replace(/^(?:[一二三四五六七八九十]+[、..]|[0-9]+[.)、..])\s*/, '')
|
||||
.trim();
|
||||
|
||||
const citationDomainLabel = (snippet: KnowledgeSearchResponse['snippets'][number]) => {
|
||||
if (snippet.domain === 'PROJECT_SERVICE') return '项目记录';
|
||||
if (snippet.domain === 'PERSONAL') return '我的资料';
|
||||
if (snippet.sourceType === 'DATA_TOOL') return '数据工具';
|
||||
return '企业 SOP';
|
||||
};
|
||||
|
||||
const serviceMemoryStatusLabel = (status?: string | null) => ({
|
||||
OPEN: '待跟进',
|
||||
IN_PROGRESS: '跟进中',
|
||||
RESOLVED: '已完成',
|
||||
VOID: '已作废'
|
||||
}[status || ''] || '状态未知');
|
||||
|
||||
const displayCitationDate = (value?: string | null) => value ? value.replace('T', ' ').slice(0, 16) : '未记录';
|
||||
|
||||
const feedback = async (verdict: AnswerFeedbackVerdict, msg: MasterMsg) => {
|
||||
if (msg.feedbackSubmitting || msg.feedbackVerdict) return;
|
||||
const fragmentIds = Array.from(
|
||||
@@ -858,30 +912,60 @@ const openWebAi = () => {
|
||||
uni.navigateTo({ url: '/pages/user/web-ai/index' });
|
||||
};
|
||||
|
||||
const hideMemoryCandidate = (msg: MasterMsg) => {
|
||||
retireEarlierMemoryCandidate(msg.result.memoryCandidate?.id);
|
||||
};
|
||||
|
||||
const refreshMemoryCandidate = async (msg: MasterMsg, candidateId: number) => {
|
||||
const generation = ++candidateRefreshGeneration;
|
||||
const accountKey = currentAccountKey();
|
||||
try {
|
||||
const candidates = await getMemoryCandidates('DRAFT');
|
||||
if (generation !== candidateRefreshGeneration || accountKey !== currentAccountKey()) return;
|
||||
msg.result.memoryCandidate = candidates.find((item) => item.id === candidateId) || null;
|
||||
} catch (error) {
|
||||
if (generation !== candidateRefreshGeneration || accountKey !== currentAccountKey()) return;
|
||||
uni.showToast({ title: requestErrorText(error, '确认卡刷新失败'), icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleShow = async () => {
|
||||
const generation = ++showGeneration;
|
||||
resetPageScroll();
|
||||
const accountKey = currentAccountKey();
|
||||
const isCurrentShow = () => generation === showGeneration && accountKey === currentAccountKey();
|
||||
if (visibleAccountKey && visibleAccountKey !== accountKey) resetConversation(false);
|
||||
visibleAccountKey = accountKey;
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value && messages.value.length) {
|
||||
resetConversation(false);
|
||||
}
|
||||
if (loggedIn.value && !(await ensureEmployeePosition('/pages/user/sop/index'))) {
|
||||
return;
|
||||
if (!isCurrentShow()) return;
|
||||
if (loggedIn.value) {
|
||||
const hasEmployeePosition = await ensureEmployeePosition('/pages/user/sop/index');
|
||||
if (!isCurrentShow() || !hasEmployeePosition) return;
|
||||
}
|
||||
positionProfile.value = getSelectedPositionProfile();
|
||||
scrollToBottom();
|
||||
if (!isCurrentShow()) return;
|
||||
const pending = uni.getStorageSync(pendingSopQueryKey);
|
||||
if (!isCurrentShow()) return;
|
||||
const pendingTool = uni.getStorageSync(pendingToolKey);
|
||||
if (pendingTool === 'MY_PRACTICE_SUMMARY' || pendingTool === 'TEAM_PRACTICE_SUMMARY') {
|
||||
if (!loggedIn.value) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
if (!isCurrentShow()) return;
|
||||
uni.removeStorageSync(pendingToolKey);
|
||||
if (!isCurrentShow()) return;
|
||||
void runDataTool(pendingTool);
|
||||
return;
|
||||
}
|
||||
if (pending && loggedIn.value) {
|
||||
if (!isCurrentShow()) return;
|
||||
uni.removeStorageSync(pendingSopQueryKey);
|
||||
if (!isCurrentShow()) return;
|
||||
void sendQuestion(String(pending));
|
||||
}
|
||||
};
|
||||
@@ -891,6 +975,8 @@ onShow(() => {
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
showGeneration += 1;
|
||||
candidateRefreshGeneration += 1;
|
||||
speechPlayback.stop();
|
||||
composerRef.value?.teardown();
|
||||
cancelVoiceTranscription();
|
||||
@@ -898,6 +984,7 @@ onHide(() => {
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
showGeneration += 1;
|
||||
releaseMessageResources();
|
||||
speechPlayback.destroy();
|
||||
composerRef.value?.teardown();
|
||||
@@ -2295,6 +2382,22 @@ onUnload(() => {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.citation-domain {
|
||||
display: inline-flex;
|
||||
margin-bottom: 5px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: #eef3f8;
|
||||
color: #526176;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.citation-domain.project {
|
||||
background: #fff0f2;
|
||||
color: #c8001a;
|
||||
}
|
||||
|
||||
.citation-meta {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
@@ -2423,4 +2526,18 @@ onUnload(() => {
|
||||
color: #98a1ad;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scope-note {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 14px;
|
||||
border-top: 1px solid #eceef1;
|
||||
background: #ffffff;
|
||||
color: #8a93a0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.master-col :deep(.memory-card) {
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
<view class="chat-head">
|
||||
<view class="chat-head-row">
|
||||
<view class="chat-head-left">
|
||||
<button class="chat-back" aria-label="返回问师傅" @click="goBack">←</button>
|
||||
<text class="chat-title">问 AI(全网)</text>
|
||||
<button class="chat-back" aria-label="返回工作助手" @click="goBack">←</button>
|
||||
<text class="chat-title">查全网</text>
|
||||
</view>
|
||||
<text class="chat-context">互联网信息 · 独立来源</text>
|
||||
</view>
|
||||
<view class="source-switch chat-source-switch" aria-label="问答来源">
|
||||
<button class="source-option" @click="goBack">企业问师傅</button>
|
||||
<button class="source-option active" aria-current="page">问 AI(全网)</button>
|
||||
<button class="source-option" @click="goBack">工作助手</button>
|
||||
<button class="source-option active" aria-current="page">查全网</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<view class="bubble-row left">
|
||||
<view class="avatar ai-avatar">AI</view>
|
||||
<view class="bubble master">
|
||||
<text class="bubble-text">我是全网 AI,检索公开网页并保留可核验引用,不与企业 SOP 混用。问题会先脱敏再发送到外部服务;答案来自互联网,不代表公司制度,涉及作业、消防、电梯或法律事项请以公司 SOP 为准。</text>
|
||||
<text class="bubble-text">这里单独查询公开网页并保留可核验引用,不与工作助手的内部资料混用。问题会先脱敏再发送到外部服务;答案来自互联网,不代表公司制度,涉及作业、消防、电梯或法律事项请以公司 SOP 为准。</text>
|
||||
<text class="bubble-subnote">不要输入业主姓名、手机号、房号、身份证、门禁或公司未公开资料。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -54,6 +54,30 @@ const normalizeSnippet = (snippet: RawKnowledgeSnippet): KnowledgeSnippet => ({
|
||||
content: snippet.content || snippet.text || ''
|
||||
});
|
||||
|
||||
export const mergeKnowledgeSnippets = (...groups: RawKnowledgeSnippet[][]): KnowledgeSnippet[] => {
|
||||
const rows: KnowledgeSnippet[] = [];
|
||||
const indexes = new Map<string, number>();
|
||||
groups.flat().forEach((item) => {
|
||||
const snippet = normalizeSnippet(item);
|
||||
const key = snippet.fragmentId
|
||||
? `fragment:${snippet.fragmentId}`
|
||||
: snippet.docId
|
||||
? `${snippet.domain || snippet.sourceType || ''}:doc:${snippet.docId}`
|
||||
: `${snippet.domain || snippet.sourceType || snippet.spaceCode || ''}|${snippet.docTitle}|${snippet.content}`;
|
||||
const existingIndex = indexes.get(key);
|
||||
if (existingIndex === undefined) {
|
||||
indexes.set(key, rows.length);
|
||||
rows.push(snippet);
|
||||
return;
|
||||
}
|
||||
const present = Object.fromEntries(
|
||||
Object.entries(snippet).filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
);
|
||||
rows[existingIndex] = { ...rows[existingIndex], ...present };
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
|
||||
export const searchKnowledge = async (
|
||||
queryText: string,
|
||||
position = getSelectedPosition(),
|
||||
@@ -136,9 +160,14 @@ const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): Knowledge
|
||||
const citationSnippets = (response.citations || []).map((item) => normalizeSnippet({
|
||||
docTitle: item.title,
|
||||
content: item.snippet,
|
||||
docId: item.docId,
|
||||
fragmentId: item.fragmentId,
|
||||
spaceCode: item.spaceCode,
|
||||
sourceType: item.sourceType
|
||||
sourceType: item.sourceType,
|
||||
domain: item.domain,
|
||||
status: item.status,
|
||||
occurredAt: item.occurredAt,
|
||||
updatedAt: item.updatedAt
|
||||
}));
|
||||
return {
|
||||
...legacy,
|
||||
@@ -148,7 +177,7 @@ const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): Knowledge
|
||||
answer: response.answer,
|
||||
reference: legacy.reference || (response.citations || []).map((item) => item.title).join('、'),
|
||||
docs: legacy.docs || [],
|
||||
snippets: (legacy.snippets?.length ? legacy.snippets : citationSnippets).map((item) => normalizeSnippet(item as RawKnowledgeSnippet)),
|
||||
snippets: mergeKnowledgeSnippets(citationSnippets, legacy.snippets || []),
|
||||
training: legacy.training || [],
|
||||
records: legacy.records || [],
|
||||
citations: response.citations || [],
|
||||
@@ -160,7 +189,8 @@ const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): Knowledge
|
||||
contextVersion: response.contextVersion,
|
||||
intent: response.intent,
|
||||
rewrittenQuery: response.rewrittenQuery,
|
||||
resources: response.resources || []
|
||||
resources: response.resources || [],
|
||||
memoryCandidate: response.memoryCandidate || legacy.memoryCandidate
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
MemoryCandidate,
|
||||
MemoryConfirmRequest,
|
||||
MemoryConfirmResult,
|
||||
ServiceMemory
|
||||
} from '@/types/api';
|
||||
import { apiRequest } from './api';
|
||||
|
||||
const candidateBase = '/api/aihr/personal-assistant/memory-candidates';
|
||||
|
||||
type RawServiceMemory = Omit<ServiceMemory, 'version'> & { version?: number; currentVersion?: number };
|
||||
|
||||
const rowsOf = <T>(value: T[] | { rows?: T[]; records?: T[] } | null | undefined): T[] => {
|
||||
if (Array.isArray(value)) return value;
|
||||
return value?.rows || value?.records || [];
|
||||
};
|
||||
|
||||
export const createMemoryIdempotencyKey = (candidateId: number) => {
|
||||
const suffix = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
|
||||
return `memory-confirm-${candidateId}-${suffix}`;
|
||||
};
|
||||
|
||||
export const getMemoryCandidates = async (status = 'DRAFT') =>
|
||||
rowsOf(await apiRequest<MemoryCandidate[] | { rows?: MemoryCandidate[]; records?: MemoryCandidate[] }>({
|
||||
url: `${candidateBase}?status=${encodeURIComponent(status)}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
}));
|
||||
|
||||
export const confirmMemoryCandidate = (candidateId: number, request: MemoryConfirmRequest) =>
|
||||
apiRequest<MemoryConfirmResult>({
|
||||
url: `${candidateBase}/${candidateId}/confirm`,
|
||||
method: 'POST',
|
||||
data: request,
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const dismissMemoryCandidate = (candidateId: number) =>
|
||||
apiRequest<{ id: number; status: string }>({
|
||||
url: `${candidateBase}/${candidateId}/dismiss`,
|
||||
method: 'POST',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const getServiceMemories = async () => {
|
||||
const rows = rowsOf(await apiRequest<RawServiceMemory[] | { rows?: RawServiceMemory[]; records?: RawServiceMemory[] }>({
|
||||
url: '/api/aihr/service-memories',
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
}));
|
||||
return rows.map((item) => ({ ...item, version: item.version ?? item.currentVersion ?? 0 }));
|
||||
};
|
||||
@@ -97,9 +97,14 @@ export interface KnowledgeSnippet {
|
||||
docTitle: string;
|
||||
content: string;
|
||||
source?: string;
|
||||
docId?: string | null;
|
||||
fragmentId?: number | null;
|
||||
spaceCode?: string | null;
|
||||
sourceType?: 'DOCUMENT' | 'DATA_TOOL' | string;
|
||||
domain?: 'PERSONAL' | 'PROJECT_SERVICE' | 'ENTERPRISE' | string | null;
|
||||
status?: string | null;
|
||||
occurredAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface KnowledgeDoc {
|
||||
@@ -131,6 +136,7 @@ export interface KnowledgeSearchResponse {
|
||||
intent?: 'QA' | 'FILE' | 'VIDEO' | 'DATA_TOOL' | string;
|
||||
rewrittenQuery?: string;
|
||||
resources?: KnowledgeResource[];
|
||||
memoryCandidate?: MemoryCandidate | null;
|
||||
}
|
||||
|
||||
export interface KnowledgeMediaSelectedFile {
|
||||
@@ -147,6 +153,10 @@ export interface KnowledgeCitation {
|
||||
title: string;
|
||||
snippet: string;
|
||||
fragmentId?: number | null;
|
||||
domain?: 'PERSONAL' | 'PROJECT_SERVICE' | 'ENTERPRISE' | string | null;
|
||||
status?: string | null;
|
||||
occurredAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface KnowledgeResource {
|
||||
@@ -178,6 +188,70 @@ export interface UnifiedKnowledgeResponse {
|
||||
intent?: 'QA' | 'FILE' | 'VIDEO' | 'DATA_TOOL' | string;
|
||||
rewrittenQuery?: string;
|
||||
resources?: KnowledgeResource[];
|
||||
memoryCandidate?: MemoryCandidate | null;
|
||||
}
|
||||
|
||||
export type MemoryCandidateStatus = 'DRAFT' | 'NEEDS_INPUT' | 'CONFIRMED' | 'DISMISSED' | 'EXPIRED' | string;
|
||||
|
||||
export type MemoryTargetDomain = 'PERSONAL' | 'PROJECT_SERVICE' | string;
|
||||
|
||||
export interface MemoryCandidateDraft {
|
||||
projectCode: string | null;
|
||||
occurredAt: string | null;
|
||||
followUpAt: string | null;
|
||||
buildingName: string;
|
||||
unitName: string;
|
||||
roomNo: string;
|
||||
category: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
detail: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface MemoryCandidate {
|
||||
id: number;
|
||||
version: number;
|
||||
status: MemoryCandidateStatus;
|
||||
memoryType: string;
|
||||
targetDomain: MemoryTargetDomain;
|
||||
draft: MemoryCandidateDraft;
|
||||
missingFields: string[];
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface MemoryConfirmRequest {
|
||||
expectedVersion: number;
|
||||
idempotencyKey: string;
|
||||
draft: MemoryCandidateDraft;
|
||||
enableReminder: boolean;
|
||||
}
|
||||
|
||||
export interface MemoryConfirmResult {
|
||||
targetDomain: MemoryTargetDomain;
|
||||
targetId: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface ServiceMemory {
|
||||
id: number;
|
||||
version: number;
|
||||
currentVersion?: number;
|
||||
projectCode?: string | null;
|
||||
memoryType: string;
|
||||
subjectType?: string;
|
||||
category: string;
|
||||
title?: string;
|
||||
summary: string;
|
||||
detail?: string;
|
||||
status: string;
|
||||
occurredAt: string | null;
|
||||
followUpAt: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
buildingName?: string;
|
||||
unitName?: string;
|
||||
roomNo?: string;
|
||||
}
|
||||
|
||||
export interface CaseCapabilities {
|
||||
|
||||
Reference in New Issue
Block a user