# 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
615 lines
17 KiB
Vue
615 lines
17 KiB
Vue
<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>
|