339 lines
13 KiB
Vue
339 lines
13 KiB
Vue
|
|
<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>
|