feat(personal-assistant): add confirmed project memory

This commit is contained in:
2026-07-19 08:19:24 +08:00
parent 151cc26caf
commit 24da833fa6
31 changed files with 4037 additions and 60 deletions
@@ -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>