ykt-wxapp/pages/case/employee-note-edit.vue
2026-09-20 17:01:42 +08:00

339 lines
13 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="page">
<scroll-view scroll-y class="page-scroll">
<view class="editor-card">
<textarea v-model="content" class="editor" placeholder="请输入备注内容" :maxlength="1000" />
<view class="counter">{{ content.length }}/1000</view>
</view>
<view class="attachment-card">
<text class="section-title">附件</text>
<view class="section-label">图片</view>
<view class="image-list">
<view v-for="{ attachment, index } in imageAttachments" :key="attachment.id" class="image-item">
<image class="attachment-image" mode="aspectFill" :src="attachment.url" @click="previewImage(attachment.url)" />
<view class="remove-icon" @click.stop="removeAttachment(index)">×</view>
</view>
<view v-if="attachments.length < 9" class="add-image" @click="chooseImage">
<uni-icons type="image-filled" size="24" color="#9ca3af" />
<text>添加图片</text>
</view>
</view>
<view class="section-label audio-label">语音</view>
<view v-for="{ attachment, index } in audioAttachments" :key="attachment.id" class="audio-item" @click="playAudio(attachment.url)">
<view class="audio-main"><uni-icons type="sound-filled" size="18" color="#0877F1" /><text>{{ attachment.name || '语音附件' }}</text></view>
<uni-icons type="closeempty" size="18" color="#9ca3af" @click.stop="removeAttachment(index)" />
</view>
<view v-if="attachments.length < 9" class="record-button" @click="recording ? stopRecording() : startRecording()">
<uni-icons :type="recording ? 'stop' : 'sound-filled'" size="19" :color="recording ? '#ef4444' : '#0877F1'" />
<text :class="recording ? 'recording-text' : ''">{{ recording ? `结束录音 ${recordingSeconds}s` : '录音' }}</text>
</view>
</view>
</scroll-view>
<view class="footer">
<button v-if="isEditing" class="delete-button" @click="remove">删除</button>
<button class="cancel-button" @click="cancel">取消</button>
<button class="save-button" :loading="saving" @click="save">保存</button>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue';
import { onLoad, onUnload } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia';
import api from '@/utils/api';
import { uploadFile } from '@/utils/file';
import useAccountStore from '@/store/account';
const EDIT_STORAGE_KEY = 'ykt_case_employee_note_edit';
const accountStore = useAccountStore();
const { account, doctorInfo } = storeToRefs(accountStore);
const { getDoctorInfo } = accountStore;
const customerId = ref('');
const noteId = ref('');
const content = ref('');
const attachments = ref([]);
const saving = ref(false);
const recording = ref(false);
const recordingSeconds = ref(0);
const isEditing = computed(() => Boolean(noteId.value));
const imageAttachments = computed(() =>
attachments.value
.map((attachment, index) => ({ attachment, index }))
.filter(({ attachment }) => attachment.type === 'image')
);
const audioAttachments = computed(() =>
attachments.value
.map((attachment, index) => ({ attachment, index }))
.filter(({ attachment }) => attachment.type === 'audio')
);
let recorderManager = null;
let audioPlayer = null;
let recordTimer = null;
function getUserId() {
const doctor = doctorInfo.value || {};
const currentAccount = account.value || {};
return String(doctor.userid || doctor.userId || doctor.corpUserId || currentAccount.userid || currentAccount.userId || '');
}
function getCorpId() {
const doctor = doctorInfo.value || {};
const currentAccount = account.value || {};
return String(doctor.corpId || currentAccount.corpId || '');
}
function normalizeExistingAttachment(attachment, index) {
return {
id: `existing-${attachment.url}-${index}`,
type: attachment.type,
name: attachment.name || '附件',
url: attachment.url,
mimeType: attachment.mimeType || '',
size: attachment.size || 0,
existing: true,
};
}
function chooseImage() {
const remaining = 9 - attachments.value.length;
if (remaining <= 0) return;
wx.chooseImage({
count: remaining,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const files = Array.isArray(res.tempFilePaths) ? res.tempFilePaths.slice(0, remaining) : [];
attachments.value.push(...files.map((path, index) => ({
id: `image-${Date.now()}-${index}`,
type: 'image',
name: `员工备注图片_${Date.now()}_${index + 1}.jpg`,
url: path,
localPath: path,
})));
},
});
}
function removeAttachment(index) {
attachments.value.splice(index, 1);
}
function previewImage(url) {
wx.previewImage({
current: url,
urls: imageAttachments.value.map(({ attachment }) => attachment.url),
});
}
function playAudio(url) {
if (!url) return;
if (!audioPlayer) audioPlayer = wx.createInnerAudioContext();
audioPlayer.stop();
audioPlayer.src = url;
audioPlayer.play();
}
function clearRecordTimer() {
if (recordTimer) clearInterval(recordTimer);
recordTimer = null;
recordingSeconds.value = 0;
}
function initRecorder() {
if (recorderManager) return;
recorderManager = wx.getRecorderManager();
recorderManager.onStop((res) => {
clearRecordTimer();
recording.value = false;
if (!res?.tempFilePath) return;
attachments.value.push({
id: `audio-${Date.now()}`,
type: 'audio',
name: `员工备注录音_${Date.now()}.aac`,
url: res.tempFilePath,
localPath: res.tempFilePath,
mimeType: 'audio/aac',
});
});
recorderManager.onError(() => {
clearRecordTimer();
recording.value = false;
uni.showToast({ title: '录音失败,请重试', icon: 'none' });
});
}
function startRecording() {
if (attachments.value.length >= 9) return;
initRecorder();
try {
recorderManager.start({ duration: 600000, sampleRate: 44100, numberOfChannels: 1, encodeBitRate: 192000, format: 'aac' });
recording.value = true;
recordingSeconds.value = 0;
recordTimer = setInterval(() => (recordingSeconds.value += 1), 1000);
} catch (e) {
clearRecordTimer();
recording.value = false;
uni.showToast({ title: '录音启动失败', icon: 'none' });
}
}
function stopRecording() {
if (!recorderManager || !recording.value) return;
recorderManager.stop();
}
async function getSubmitAttachments() {
return Promise.all(attachments.value.map(async (attachment) => {
if (attachment.existing) {
return {
type: attachment.type,
name: attachment.name,
url: attachment.url,
mimeType: attachment.mimeType,
size: attachment.size,
};
}
const url = await uploadFile(attachment.localPath);
if (!url) throw new Error('附件上传失败');
return {
type: attachment.type,
name: attachment.name,
url,
mimeType: attachment.mimeType || '',
size: attachment.size || 0,
};
}));
}
async function save() {
if (saving.value) return;
if (!content.value.trim() && !attachments.value.length) {
uni.showToast({ title: '请填写备注内容或添加附件', icon: 'none' });
return;
}
const corpId = getCorpId();
const userId = getUserId();
if (!corpId || !userId || !customerId.value) {
uni.showToast({ title: '员工或档案信息缺失', icon: 'none' });
return;
}
saving.value = true;
try {
const doctor = doctorInfo.value || {};
const payload = {
corpId,
userId,
customerId: customerId.value,
content: content.value.trim(),
attachments: await getSubmitAttachments(),
userName: doctor.anotherName || doctor.name || '当前员工',
departmentName: doctor.departmentName || doctor.deptName || '',
};
const res = await api(isEditing.value ? 'updateEmployeeNote' : 'addEmployeeNote', {
...payload,
...(isEditing.value ? { noteId: noteId.value } : {}),
});
if (!res?.success) throw new Error(res?.message || '保存员工备注失败');
uni.$emit('employee-note-changed');
uni.showToast({ title: isEditing.value ? '员工备注已更新' : '员工备注已保存', icon: 'success' });
setTimeout(() => uni.navigateBack(), 500);
} catch (e) {
uni.showToast({ title: e?.message || '保存员工备注失败', icon: 'none' });
} finally {
saving.value = false;
}
}
async function remove() {
if (saving.value || !noteId.value) return;
const modal = await new Promise((resolve) => {
uni.showModal({
title: '删除备注',
content: '删除后将不再展示,确认删除这条员工备注吗?',
confirmText: '删除',
confirmColor: '#ef4444',
success: resolve,
fail: () => resolve({ confirm: false }),
});
});
if (!modal.confirm) return;
const corpId = getCorpId();
const userId = getUserId();
if (!corpId || !userId) {
uni.showToast({ title: '员工信息缺失', icon: 'none' });
return;
}
saving.value = true;
try {
const res = await api('deleteEmployeeNote', { corpId, userId, noteId: noteId.value });
if (!res?.success) throw new Error(res?.message || '删除员工备注失败');
uni.$emit('employee-note-changed');
uni.showToast({ title: '员工备注已删除', icon: 'success' });
setTimeout(() => uni.navigateBack(), 500);
} catch (e) {
uni.showToast({ title: e?.message || '删除员工备注失败', icon: 'none' });
} finally {
saving.value = false;
}
}
function cancel() {
uni.navigateBack();
}
onLoad(async (options) => {
customerId.value = String(options?.customerId || '');
noteId.value = String(options?.noteId || '');
await getDoctorInfo();
if (!noteId.value) return;
const note = uni.getStorageSync(EDIT_STORAGE_KEY);
if (!note || String(note._id || '') !== noteId.value) {
uni.showToast({ title: '备注数据已失效,请返回后重试', icon: 'none' });
return;
}
content.value = note.content || '';
attachments.value = (Array.isArray(note.attachments) ? note.attachments : []).map(normalizeExistingAttachment);
uni.removeStorageSync(EDIT_STORAGE_KEY);
});
onUnload(() => {
if (recording.value) recorderManager?.stop();
clearRecordTimer();
audioPlayer?.stop();
audioPlayer?.destroy();
audioPlayer = null;
});
</script>
<style lang="scss" scoped>
.page { height: 100vh; display: flex; flex-direction: column; background: #f5f6f8; }
.page-scroll { flex: 1; min-height: 0; }
.editor-card, .attachment-card { padding: 28rpx; background: #fff; }
.attachment-card { margin-top: 20rpx; }
.editor { width: 100%; min-height: 280rpx; font-size: 30rpx; line-height: 44rpx; color: #333; }
.counter { font-size: 24rpx; color: #999; text-align: right; }
.section-title { display: block; font-size: 30rpx; font-weight: 600; color: #333; }
.section-label { margin-top: 24rpx; font-size: 26rpx; color: #666; }
.image-list { display: flex; flex-wrap: wrap; margin-top: 16rpx; }
.image-item { position: relative; width: 140rpx; height: 140rpx; margin: 0 16rpx 16rpx 0; }
.attachment-image { width: 140rpx; height: 140rpx; border-radius: 8rpx; }
.remove-icon { position: absolute; top: -12rpx; right: -12rpx; display: flex; align-items: center; justify-content: center; width: 36rpx; height: 36rpx; color: #fff; font-size: 30rpx; background: rgba(0, 0, 0, .55); border-radius: 50%; }
.add-image { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 136rpx; height: 136rpx; border: 2rpx dashed #cbd5e1; border-radius: 8rpx; color: #999; font-size: 22rpx; }
.audio-label { margin-top: 20rpx; }
.audio-item, .record-button { display: flex; align-items: center; justify-content: space-between; min-height: 76rpx; padding: 0 20rpx; margin-top: 16rpx; background: #f8fafc; border-radius: 8rpx; }
.audio-main { display: flex; align-items: center; min-width: 0; color: #0877F1; }
.audio-main text { max-width: 520rpx; margin-left: 12rpx; overflow: hidden; font-size: 26rpx; text-overflow: ellipsis; white-space: nowrap; }
.record-button { justify-content: center; color: #0877F1; border: 2rpx dashed #93c5fd; }
.record-button text { margin-left: 12rpx; font-size: 26rpx; }
.recording-text { color: #ef4444; }
.footer { display: flex; gap: 20rpx; padding: 22rpx 28rpx calc(22rpx + env(safe-area-inset-bottom)); background: #fff; border-top: 2rpx solid #eee; }
.delete-button, .cancel-button, .save-button { flex: 1; height: 84rpx; line-height: 84rpx; font-size: 30rpx; border-radius: 10rpx; }
.delete-button { color: #ef4444; background: #fff; border: 2rpx solid #ef4444; }
.cancel-button { color: #0877F1; background: #fff; border: 2rpx solid #0877F1; }
.save-button { color: #fff; background: #0877F1; }
</style>