Files
prop-ai-hr/mobile-uni/src/components/chat/MemoryCandidateCard.vue
T
admin 2488febf19 Merge branch 'codex/personal-assistant-phase2' into codex/multi-tenant-knowledge-platform
# Conflicts:
#	backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java
#	backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java
#	backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java
#	backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrModelSeedServiceTest.java
#	backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java
#	docs/API_INTEGRATION.md
#	docs/个人AI助理阶段二专项TechSpec.md
#	docs/个人AI助理阶段二开发推进计划.md
#	frontend/src/views/knowledge/processing.vue
#	mobile-uni/src/pages/user/sop/index.vue
#	mobile-uni/src/services/api.ts
#	mobile-uni/src/services/personal-assistant.ts
#	mobile-uni/tests/personal-assistant.test.mjs
#	scripts/demo-check.sh
#	scripts/personal-assistant-smoke.sh
2026-07-22 08:54:04 +08:00

615 lines
17 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="memory-card" :class="{ compact }">
<template v-if="completed">
<view class="memory-head">
<text class="memory-title">已确认保存</text>
<text class="memory-tag success">{{ targetLabel }}</text>
</view>
<text class="saved-copy">{{ completedCopy }}</text>
<button v-if="props.candidate.memoryType === 'CASE'" class="contribution-action" @click="goContribution">整理成成果投稿</button>
</template>
<template v-else-if="candidate.status === 'DRAFT' || candidate.status === 'NEEDS_INPUT'">
<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">{{ projectLabel }}</text>
</view>
<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">{{ assistantCandidate ? '由你确认' : targetLabel }}</text>
</view>
<view class="memory-row">
<text class="row-label">发生日期</text>
<text class="row-value">{{ draft.workDate || displayDate(draft.occurredAt).slice(0, 10) }}</text>
</view>
<view class="memory-row">
<text class="row-label">原始来源</text>
<text class="row-value">{{ sourceLabel }}</text>
</view>
<view class="memory-row">
<text class="row-label">处理状态</text>
<text class="row-value">{{ businessStatusLabel }}</text>
</view>
</view>
<text v-if="optionalPrompt" class="optional-copy">{{ optionalPrompt }}</text>
<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">
{{ assistantCandidate ? '仅自己保存不会提交公司;提交公司处理后进入企业流转。' : '确认后才会长期保存;暂不保存不会写入项目记录。' }}
</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
v-if="assistantCandidate"
class="memory-action scope private"
:disabled="submitting"
@click="confirm('PRIVATE')"
>仅自己保存</button>
<button
v-if="assistantCandidate"
class="memory-action scope company"
:disabled="submitting"
@click="confirm('COMPANY')"
>提交公司处理</button>
<button v-else 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;
projectName?: string;
}>(), {
compact: false,
projectName: ''
});
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,
workDate: value.workDate || (value.occurredAt || '').slice(0, 10),
businessStatus: value.businessStatus || 'RECORDED'
});
const draft = ref(cloneDraft(props.candidate.draft));
const editing = ref(false);
const reminderEnabled = ref(false);
const submitting = ref(false);
const completed = ref(false);
const confirmedTarget = ref('');
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 assistantCandidate = computed(() => props.candidate.targetDomain === 'ASSISTANT_CAPTURE');
const projectLabel = computed(() => props.projectName || (draft.value.projectCode ? '当前授权项目' : '个人记录'));
const sourceLabel = computed(() => ({
TEXT: '文字',
VOICE: '原始语音与转写',
IMAGE: '现场照片',
VIDEO: '现场视频'
}[props.candidate.source?.sourceType || 'TEXT'] || '文字'));
const businessStatusLabel = computed(() => ({
RECORDED: '已记录',
PENDING: '待跟进',
IN_PROGRESS: '处理中',
COMPLETED: '已完成',
VOID: '已作废'
}[draft.value.businessStatus || 'RECORDED'] || '已记录'));
const memoryTypeLabel = computed(() => ({
RESIDENT_NEED: '住户需求',
SERVICE_LEAD: '服务线索',
RESIDENT_PROFILE: '住户画像',
CASE: '优秀案例',
PROJECT_NOTE: '工作记录',
SERVICE_FACT: '服务事实',
FOLLOW_UP: '待跟进事项',
ATTENDANCE: '到岗/晨会记录',
INSPECTION: '巡检问题',
SERVICE_RECORD: '业主服务记录',
PERSONAL_NOTE: '个人想法'
}[props.candidate.memoryType] || '工作事项'));
const targetLabel = computed(() => ({
PROJECT_SERVICE: '项目服务记录',
PERSONAL: '仅自己可见',
COMPANY: '公司处理中'
}[confirmedTarget.value || props.candidate.targetDomain] || '我的资料与记忆'));
const completedCopy = computed(() => confirmedTarget.value === 'COMPANY'
? '已进入公司待流转,可在“我的资料与记忆”中查看状态。'
: '这条信息已记录,并会计入对应项目的今日工作成果。');
const terminalStatusTitle = computed(() => ({
CONFIRMED: '这条事项已保存',
DISMISSED: '这条事项已暂不保存',
EXPIRED: '这条确认卡已过期'
}[props.candidate.status] || '这条确认卡已结束'));
const optionalPrompt = 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 (saveScope: '' | 'PRIVATE' | 'COMPANY' = '') => {
if (submitting.value || completed.value) return;
if (!draft.value.category || !draft.value.summary || !draft.value.occurredAt) {
errorText.value = '请先补全事项、摘要和记录时间';
editing.value = true;
return;
}
if (!assistantCandidate.value && 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,
saveScope: saveScope || undefined
});
if (requestAccountKey !== currentAccountKey()) return;
completed.value = true;
confirmedTarget.value = result.targetDomain;
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;
}
};
const goContribution = () => {
uni.setStorageSync('aihr_work_report_prefill', draft.value.summary || draft.value.detail || '');
uni.navigateTo({ url: '/pages/user/report/index' });
};
watch(
() => [props.candidate.id, props.candidate.version] as const,
() => {
draft.value = cloneDraft(props.candidate.draft);
reminderEnabled.value = false;
editing.value = false;
completed.value = false;
confirmedTarget.value = '';
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;
}
.contribution-action {
margin-top: 12px;
padding: 9px 14px;
border: 1px solid #ef3340;
border-radius: 16px;
background: #fff;
color: #d9081c;
font-size: 14px;
}
.contribution-action::after { border: 0; }
.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,
.optional-copy,
.saved-copy {
display: block;
margin-top: 10px;
font-size: 12px;
line-height: 1.55;
}
.memory-note {
color: #8a93a0;
}
.memory-error {
color: #c8001a;
}
.optional-copy,
.saved-copy {
color: #596474;
font-size: 14px;
}
.optional-copy {
padding: 9px 10px;
border-radius: 10px;
background: #f7f8fa;
color: #7a8491;
font-size: 12px;
}
.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;
}
.memory-action.scope {
border: 1px solid #e60012;
}
.memory-action.scope.private {
background: #ffffff;
color: #d70016;
}
.memory-action.scope.company {
background: #e60012;
color: #ffffff;
}
@media (max-width: 360px) {
.memory-card {
padding: 14px;
}
}
</style>