Browse Source

refactor: 优化眼突度对比页面功能与样式

1.  更新多个页面导航栏标题,统一文案表述
2.  适配iOS安全区域,优化页面底部间距
3.  重构照片详情页面渲染逻辑,优化分组展示
4.  新增基准照选择切换功能,优化日期选择逻辑
5.  添加生成日期统一展示,替换原静态生成日期
6.  新增图片保存相册权限引导功能
7.  重构图片合并组件逻辑,修复绘制顺序问题
8.  优化代码格式与Promise链式调用结构
9.  新增相机/相册权限校验逻辑,完善异常处理
dev
kola-web 6 days ago
parent
commit
65c3ea976e
  1. 16
      .zed/settings.json
  2. 19
      src/components/image-merge/index.ts
  3. 39
      src/pages/d_noteDetail/index.ts
  4. 24
      src/pages/d_noteDetail/index.wxml
  5. 2
      src/pages/d_noteDiff/index.json
  6. 4
      src/pages/d_noteDiff/index.scss
  7. 51
      src/pages/d_noteDiff/index.ts
  8. 4
      src/pages/d_noteDiff/index.wxml
  9. 12
      src/pages/d_noteDiffData/index.ts
  10. 2
      src/pages/d_noteDiffData/index.wxml
  11. 2
      src/pages/d_noteDiffEdit/index.json
  12. 19
      src/pages/d_noteDiffEdit/index.scss
  13. 63
      src/pages/d_noteDiffEdit/index.ts
  14. 8
      src/pages/d_noteDiffEdit/index.wxml
  15. 16
      src/pages/d_noteList/index.ts
  16. 189
      src/patient/components/camera/index.ts
  17. 18
      src/patient/components/image-merge/index.ts
  18. 49
      src/patient/pages/note/index.ts
  19. 56
      src/patient/pages/noteAdd/index.ts
  20. 23
      src/patient/pages/noteDiff/index.scss
  21. 48
      src/patient/pages/noteDiff/index.ts
  22. 6
      src/patient/pages/noteDiff/index.wxml
  23. 2
      src/patient/pages/noteDiffEdit/index.json
  24. 56
      src/patient/pages/noteDiffEdit/index.ts
  25. 2
      src/patient/pages/noteDiffEdit/index.wxml

16
.zed/settings.json

@ -0,0 +1,16 @@
// Folder-specific settings
//
// For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{
"lsp": {
"emmet-language-server": {
"initialization_options": {
"preferences": {
"css.intUnit": "rpx",
"css.floatUnitr": "rpx",
},
},
},
},
}

19
src/components/image-merge/index.ts

@ -96,7 +96,7 @@ Component({
const canvas = res[0].node const canvas = res[0].node
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
Promise.all(imageList.map(item => this.getImageInfo(item.src))) Promise.all(imageList.map((item) => this.getImageInfo(item.src)))
.then((imageInfos) => { .then((imageInfos) => {
const targetWidth = 750 const targetWidth = 750
const pixelRatio = wx.getWindowInfo().pixelRatio const pixelRatio = wx.getWindowInfo().pixelRatio
@ -118,15 +118,25 @@ Component({
let currentY = 0 let currentY = 0
let loadedCount = 0 let loadedCount = 0
// 预计算每张图片的 Y 偏移,确保顺序正确
const yPositions: number[] = []
scaledHeights.forEach((h, i) => {
yPositions.push(currentY)
currentY += h
})
loadedCount = 0
currentY = 0
imageInfos.forEach((info, index) => { imageInfos.forEach((info, index) => {
const img = canvas.createImage() const img = canvas.createImage()
img.src = info.path img.src = info.path
img.onload = () => { img.onload = () => {
ctx.drawImage(img, 0, currentY, canvasWidth, scaledHeights[index]) ctx.drawImage(img, 0, yPositions[index], canvasWidth, scaledHeights[index])
const timeText = imageList[index].time || this.formatTime(new Date()) const timeText = imageList[index].time || this.formatTime(new Date())
const padding = 20 const padding = 20
const textY = currentY + 40 const textY = yPositions[index] + 40
ctx.fillStyle = '#ffffff' ctx.fillStyle = '#ffffff'
ctx.font = 'bold 28px sans-serif' ctx.font = 'bold 28px sans-serif'
@ -143,14 +153,13 @@ Component({
ctx.shadowOffsetY = 0 ctx.shadowOffsetY = 0
if (index === imageInfos.length - 1) { if (index === imageInfos.length - 1) {
const lastImageBottom = currentY + scaledHeights[index] const lastImageBottom = yPositions[index] + scaledHeights[index]
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)' ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
ctx.font = '24px sans-serif' ctx.font = '24px sans-serif'
ctx.textAlign = 'right' ctx.textAlign = 'right'
ctx.fillText('由-TED关爱小助手-小程序生成', canvasWidth - 20, lastImageBottom - 20) ctx.fillText('由-TED关爱小助手-小程序生成', canvasWidth - 20, lastImageBottom - 20)
} }
currentY += scaledHeights[index]
loadedCount++ loadedCount++
if (loadedCount === imageInfos.length) { if (loadedCount === imageInfos.length) {

39
src/pages/d_noteDetail/index.ts

@ -30,16 +30,25 @@ Page({
recordDetail: {} as RecordDetail, recordDetail: {} as RecordDetail,
photoMap: {} as Record<string, Photo>, photoMap: {} as Record<string, Photo>,
// 有照片的角度分组 // 各组是否有照片
frontendPhotos: [] as Photo[], hasFrontend: false,
backendPhotos: [] as Photo[], hasBackend: false,
otherPhotos: [] as Photo[], hasOther: false,
// 角度分组 // 角度分组
angleGroups: { angleGroups: {
frontend: ['front_open', 'front_closed', 'front_looking_up'], frontend: ['front_open', 'front_closed', 'front_looking_up'],
backend: ['side_left_90', 'side_right_90', 'side_left_45', 'side_right_45'], backend: ['side_left_90', 'side_right_90', 'side_left_45', 'side_right_45'],
other: ['eye_up_left', 'eye_up', 'eye_up_right', 'eye_left', 'eye_right', 'eye_down_left', 'eye_down', 'eye_down_right'], other: [
'eye_up_left',
'eye_up',
'eye_up_right',
'eye_left',
'eye_right',
'eye_down_left',
'eye_down',
'eye_down_right',
],
}, },
// 角度名称映射 // 角度名称映射
@ -87,7 +96,8 @@ Page({
data: { data: {
recordId: this.data.recordId, recordId: this.data.recordId,
}, },
}).then((res: any) => { })
.then((res: any) => {
wx.hideLoading() wx.hideLoading()
const photoMap: Record<string, Photo> = {} const photoMap: Record<string, Photo> = {}
const photos = res.photos || [] const photos = res.photos || []
@ -97,20 +107,21 @@ Page({
} }
}) })
// 按分组过滤有照片的角度 // 判断各组是否有照片
const { frontend, backend, other } = this.data.angleGroups const { frontend, backend, other } = this.data.angleGroups
const frontendPhotos = frontend.filter(angle => photoMap[angle]).map(angle => photoMap[angle]) const hasFrontend = frontend.some((angle) => photoMap[angle]?.photoUrl)
const backendPhotos = backend.filter(angle => photoMap[angle]).map(angle => photoMap[angle]) const hasBackend = backend.some((angle) => photoMap[angle]?.photoUrl)
const otherPhotos = other.filter(angle => photoMap[angle]).map(angle => photoMap[angle]) const hasOther = other.some((angle) => photoMap[angle]?.photoUrl)
this.setData({ this.setData({
recordDetail: res, recordDetail: res,
photoMap, photoMap,
frontendPhotos, hasFrontend,
backendPhotos, hasBackend,
otherPhotos, hasOther,
})
}) })
}).catch((err) => { .catch((err) => {
wx.hideLoading() wx.hideLoading()
console.error('获取记录详情失败:', err) console.error('获取记录详情失败:', err)
wx.showToast({ title: '加载失败', icon: 'none' }) wx.showToast({ title: '加载失败', icon: 'none' })

24
src/pages/d_noteDetail/index.wxml

@ -39,30 +39,30 @@
</view> </view>
</view> </view>
</view> </view>
<view class="card" wx:if="{{frontendPhotos.length > 0}}"> <view class="card" wx:if="{{hasFrontend}}">
<view class="card-title">正面</view> <view class="card-title">正面</view>
<view class="card-container"> <view class="card-container">
<view class="card-item" wx:for="{{frontendPhotos}}" wx:key="photoAngle" data-angle="{{item.photoAngle}}" bind:tap="handlePreview"> <view class="card-item" wx:for="{{angleGroups.frontend}}" wx:key="*this" wx:if="{{photoMap[item]}}" data-angle="{{item}}" bind:tap="handlePreview">
<image class="photo" mode="aspectFill" src="{{item.photoUrl}}"></image> <image class="photo" mode="aspectFill" src="{{photoMap[item].photoUrl}}"></image>
<view class="name">{{item.photoAngleName}}</view> <view class="name">{{angleNameMap[item]}}</view>
</view> </view>
</view> </view>
</view> </view>
<view class="card" wx:if="{{backendPhotos.length > 0}}"> <view class="card" wx:if="{{hasBackend}}">
<view class="card-title">侧面</view> <view class="card-title">侧面</view>
<view class="card-container"> <view class="card-container">
<view class="card-item" wx:for="{{backendPhotos}}" wx:key="photoAngle" data-angle="{{item.photoAngle}}" bind:tap="handlePreview"> <view class="card-item" wx:for="{{angleGroups.backend}}" wx:key="*this" wx:if="{{photoMap[item]}}" data-angle="{{item}}" bind:tap="handlePreview">
<image class="photo" mode="aspectFill" src="{{item.photoUrl}}"></image> <image class="photo" mode="aspectFill" src="{{photoMap[item].photoUrl}}"></image>
<view class="name">{{item.photoAngleName}}</view> <view class="name">{{angleNameMap[item]}}</view>
</view> </view>
</view> </view>
</view> </view>
<view class="card" wx:if="{{otherPhotos.length > 0}}"> <view class="card" wx:if="{{hasOther}}">
<view class="card-title">眼球运动八个方向</view> <view class="card-title">眼球运动八个方向</view>
<view class="card-container"> <view class="card-container">
<view class="card-item" wx:for="{{otherPhotos}}" wx:key="photoAngle" data-angle="{{item.photoAngle}}" bind:tap="handlePreview"> <view class="card-item" wx:for="{{angleGroups.other}}" wx:key="*this" wx:if="{{photoMap[item]}}" data-angle="{{item}}" bind:tap="handlePreview">
<image class="photo" mode="aspectFill" src="{{item.photoUrl}}"></image> <image class="photo" mode="aspectFill" src="{{photoMap[item].photoUrl}}"></image>
<view class="name">{{item.photoAngleName}}</view> <view class="name">{{angleNameMap[item]}}</view>
</view> </view>
</view> </view>
</view> </view>

2
src/pages/d_noteDiff/index.json

@ -1,5 +1,5 @@
{ {
"navigationBarTitleText": "照片对比", "navigationBarTitleText": "照片对比分析",
"usingComponents": { "usingComponents": {
"navbar": "/components/navbar/index", "navbar": "/components/navbar/index",
"van-icon": "@vant/weapp/icon/index" "van-icon": "@vant/weapp/icon/index"

4
src/pages/d_noteDiff/index.scss

@ -3,7 +3,7 @@ page {
} }
.page { .page {
padding-bottom: 160rpx; padding-bottom: calc(env(safe-area-inset-bottom) + 160rpx);
.page-tip { .page-tip {
padding: 24rpx 28rpx; padding: 24rpx 28rpx;
@ -288,7 +288,7 @@ page {
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
padding: 24rpx 40rpx 48rpx; padding: 24rpx 40rpx calc(env(safe-area-inset-bottom) + 24rpx);
background: #fff; background: #fff;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.05); box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.05);

51
src/pages/d_noteDiff/index.ts

@ -1,4 +1,5 @@
const app = getApp<IAppOption>() const app = getApp<IAppOption>()
const licia = require('miniprogram-licia')
interface ComparePhoto { interface ComparePhoto {
recordId: string recordId: string
@ -14,11 +15,10 @@ interface ComparePhoto {
interface CompareDate { interface CompareDate {
recordId: string recordId: string
recordDate: string recordDate: string
isSelected?: boolean
} }
interface ListItem extends CompareDate { type BaselineItem = CompareDate | null
isBaseline: number
}
Page({ Page({
data: { data: {
@ -31,17 +31,23 @@ Page({
angleMap: {} as Record<string, string>, angleMap: {} as Record<string, string>,
// 日期选择 // 日期选择
baseline: null as CompareDate | null, baseline: null as unknown as BaselineItem,
nonBaselineList: [] as CompareDate[], nonBaselineList: [] as CompareDate[],
selectedDates: [] as string[], selectedDates: [] as string[],
baselineSelected: true,
// 对比照片 // 对比照片
comparePhotos: [] as ComparePhoto[], comparePhotos: [] as ComparePhoto[],
generateDate: '',
}, },
onLoad(option: any) { onLoad(option: any) {
const now = new Date()
const generateDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
this.setData({ this.setData({
patientId: option.patientId || '', patientId: option.patientId || '',
generateDate,
}) })
app.waitLogin({ type: [2] }).then(() => { app.waitLogin({ type: [2] }).then(() => {
this.getCompareAngle() this.getCompareAngle()
@ -101,16 +107,17 @@ Page({
newRecordIds.push(newBaseline.recordId) newRecordIds.push(newBaseline.recordId)
} }
const selectedDates = this.data.selectedDates.filter((id: string) => newRecordIds.includes(id)) const selectedDates = this.data.selectedDates.filter((id: string) => newRecordIds.includes(id))
const baselineSelected = this.data.baselineSelected
const nonBaselineList = newList.map((item: CompareDate & { isSelected?: boolean }) => ({ const nonBaselineList = newList.map((item: CompareDate & { isSelected?: boolean }) => ({
...item, ...item,
isSelected: selectedDates.includes(item.recordId), isSelected: selectedDates.includes(item.recordId),
})) }))
this.setData({ this.setData({
baseline: newBaseline, baseline: newBaseline ? { ...newBaseline, isSelected: baselineSelected } : null,
nonBaselineList, nonBaselineList,
selectedDates, selectedDates,
}) })
if (selectedDates.length > 0 || newBaseline) { if (selectedDates.length > 0 || (newBaseline && baselineSelected)) {
this.getComparePhotos() this.getComparePhotos()
} else { } else {
this.setData({ comparePhotos: [] }) this.setData({ comparePhotos: [] })
@ -129,11 +136,25 @@ Page({
this.setData({ this.setData({
photoAngle: angle.key, photoAngle: angle.key,
photoAngleName: angle.name, photoAngleName: angle.name,
selectedDates: [],
baselineSelected: true,
comparePhotos: [], comparePhotos: [],
}) })
this.getCompareDates() this.getCompareDates()
}, },
// 选择基准照日期
onBaselineSelect() {
const baselineSelected = !this.data.baselineSelected
const baseline = this.data.baseline
this.setData({
baselineSelected,
baseline: baseline ? { ...baseline, isSelected: baselineSelected } : null,
})
// 获取对比照片
this.getComparePhotos()
},
// 选择日期 // 选择日期
onDateSelect(e: any) { onDateSelect(e: any) {
const { recordId } = e.currentTarget.dataset const { recordId } = e.currentTarget.dataset
@ -142,7 +163,7 @@ Page({
if (index > -1) { if (index > -1) {
selectedDates.splice(index, 1) selectedDates.splice(index, 1)
} else { } else {
const maxSelect = this.data.baseline ? 5 : 6 const maxSelect = this.data.baselineSelected ? 5 : 6
if (selectedDates.length >= maxSelect) { if (selectedDates.length >= maxSelect) {
wx.showToast({ title: '最多选择6张对比图', icon: 'none' }) wx.showToast({ title: '最多选择6张对比图', icon: 'none' })
return return
@ -160,13 +181,13 @@ Page({
}, },
// 获取对比照片 // 获取对比照片
getComparePhotos() { getComparePhotos: licia.debounce(function (this: any) {
const { photoAngle, selectedDates, baseline } = this.data const { photoAngle, selectedDates, baseline } = this.data
if (!photoAngle) { if (!photoAngle) {
this.setData({ comparePhotos: [] }) this.setData({ comparePhotos: [] })
return return
} }
const recordIds = baseline ? [baseline.recordId, ...selectedDates] : selectedDates const recordIds = baseline && this.data.baselineSelected ? [baseline.recordId, ...selectedDates] : selectedDates
if (recordIds.length === 0) { if (recordIds.length === 0) {
this.setData({ comparePhotos: [] }) this.setData({ comparePhotos: [] })
return return
@ -216,16 +237,18 @@ Page({
console.error('获取对比照片失败:', err) console.error('获取对比照片失败:', err)
wx.showToast({ title: '获取对比照片失败', icon: 'none' }) wx.showToast({ title: '获取对比照片失败', icon: 'none' })
}) })
}, }, 300),
// 生成对比图 // 生成对比图
handleEdit() { handleEdit() {
if (this.data.selectedDates.length === 0) { const { baseline, baselineSelected, selectedDates } = this.data
wx.showToast({ title: '请选择对比日期', icon: 'none' }) const hasBaseline = baseline && baselineSelected
const totalCount = selectedDates.length + (hasBaseline ? 1 : 0)
if (totalCount < 2) {
wx.showToast({ title: '请至少选择两个日期', icon: 'none' })
return return
} }
const { baseline, selectedDates } = this.data const recordIds = hasBaseline ? [baseline.recordId, ...selectedDates] : selectedDates
const recordIds = baseline ? [baseline.recordId, ...selectedDates] : selectedDates
wx.navigateTo({ wx.navigateTo({
url: `/pages/d_noteDiffEdit/index?patientId=${this.data.patientId}&photoAngle=${this.data.photoAngle}&recordIds=${recordIds.join(',')}`, url: `/pages/d_noteDiffEdit/index?patientId=${this.data.patientId}&photoAngle=${this.data.photoAngle}&recordIds=${recordIds.join(',')}`,
}) })

4
src/pages/d_noteDiff/index.wxml

@ -22,7 +22,7 @@
<view class="form-item"> <view class="form-item">
<view class="title">选择对比日期(可多选)</view> <view class="title">选择对比日期(可多选)</view>
<view class="multiple"> <view class="multiple">
<view class="item active baseline" wx:if="{{baseline}}"> <view class="item {{baseline.isSelected ? 'active' : ''}} baseline" wx:if="{{baseline}}" bind:tap="onBaselineSelect">
{{baseline.recordDate}} {{baseline.recordDate}}
<image class="icon" src="{{imageUrl}}icon169.png?t={{Timestamp}}"></image> <image class="icon" src="{{imageUrl}}icon169.png?t={{Timestamp}}"></image>
</view> </view>
@ -39,7 +39,7 @@
<view class="container" wx:if="{{comparePhotos.length > 0}}"> <view class="container" wx:if="{{comparePhotos.length > 0}}">
<view class="title"> <view class="title">
{{photoAngleName}}时间线对比 {{photoAngleName}}时间线对比
<view class="date">生成日期:{{comparePhotos[0].recordDate}}</view> <view class="date">生成日期:{{generateDate}}</view>
</view> </view>
<view class="card" wx:for="{{comparePhotos}}" wx:key="recordId"> <view class="card" wx:for="{{comparePhotos}}" wx:key="recordId">
<view class="aside"> <view class="aside">

12
src/pages/d_noteDiffData/index.ts

@ -13,6 +13,7 @@ interface CompareItem {
Page({ Page({
data: { data: {
patientId: '', patientId: '',
patientName: '',
dataList: [] as CompareItem[], dataList: [] as CompareItem[],
loading: false, loading: false,
}, },
@ -21,6 +22,7 @@ Page({
const patientName = option.patientName || '' const patientName = option.patientName || ''
this.setData({ this.setData({
patientId: option.patientId || '', patientId: option.patientId || '',
patientName,
}) })
if (patientName) { if (patientName) {
wx.setNavigationBarTitle({ title: `${patientName}的眼突度对比` }) wx.setNavigationBarTitle({ title: `${patientName}的眼突度对比` })
@ -46,15 +48,15 @@ Page({
page: 1, page: 1,
pageSize: 1000, pageSize: 1000,
}, },
}).then((res: any) => { })
const list: CompareItem[] = (res.list || []) .then((res: any) => {
.slice() const list: CompareItem[] = (res.list || []).slice()
.sort((a, b) => String(a.recordDate).localeCompare(String(b.recordDate)))
this.setData({ this.setData({
dataList: list, dataList: list,
loading: false, loading: false,
}) })
}).catch((err) => { })
.catch((err) => {
console.error('获取对比数据失败:', err) console.error('获取对比数据失败:', err)
this.setData({ loading: false }) this.setData({ loading: false })
wx.showToast({ title: '获取对比数据失败', icon: 'none' }) wx.showToast({ title: '获取对比数据失败', icon: 'none' })

2
src/pages/d_noteDiffData/index.wxml

@ -1,4 +1,4 @@
<navbar fixed title="眼突度对比" custom-style="background:{{background}}"> <navbar fixed title="{{patientName ? patientName + '的眼突度对比' : '眼突度对比'}}" custom-style="background:{{background}}">
<van-icon name="arrow-left" slot="left" size="18px" color="#000" bind:tap="handleBack" /> <van-icon name="arrow-left" slot="left" size="18px" color="#000" bind:tap="handleBack" />
</navbar> </navbar>

2
src/pages/d_noteDiffEdit/index.json

@ -1,5 +1,5 @@
{ {
"navigationBarTitleText": "对比编辑", "navigationBarTitleText": "照片对比编辑",
"usingComponents": { "usingComponents": {
"navbar": "/components/navbar/index", "navbar": "/components/navbar/index",
"imageMerge": "/components/image-merge/index", "imageMerge": "/components/image-merge/index",

19
src/pages/d_noteDiffEdit/index.scss

@ -4,6 +4,25 @@ page {
.page { .page {
padding-bottom: 160rpx; padding-bottom: 160rpx;
.page-tip {
padding: 24rpx 28rpx;
background-color: #fff7e9;
display: flex;
.icon {
margin-top: 6rpx;
flex-shrink: 0;
width: 32rpx;
height: 32rpx;
}
.content {
margin-left: 16rpx;
font-size: 28rpx;
color: #ffa300;
line-height: 44rpx;
}
}
.container { .container {
margin: 40rpx 40rpx 0; margin: 40rpx 40rpx 0;

63
src/pages/d_noteDiffEdit/index.ts

@ -20,6 +20,7 @@ Page({
patientId: '', patientId: '',
photoAngle: '', photoAngle: '',
photoAngleName: '', photoAngleName: '',
generateDate: '',
recordIds: [] as string[], recordIds: [] as string[],
photos: [] as PhotoItem[], photos: [] as PhotoItem[],
loading: false, loading: false,
@ -27,10 +28,13 @@ Page({
}, },
onLoad(option: any) { onLoad(option: any) {
const now = new Date()
const generateDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
this.setData({ this.setData({
patientId: option.patientId || '', patientId: option.patientId || '',
photoAngle: option.photoAngle || '', photoAngle: option.photoAngle || '',
recordIds: option.recordIds ? option.recordIds.split(',') : [], recordIds: option.recordIds ? option.recordIds.split(',') : [],
generateDate,
}) })
// 获取角度名称 // 获取角度名称
this.getAngleName() this.getAngleName()
@ -137,7 +141,7 @@ Page({
success: (cropRes) => { success: (cropRes) => {
photos[index].isCropped = true photos[index].isCropped = true
photos[index].croppedUrl = cropRes.tempFilePath photos[index].croppedUrl = cropRes.tempFilePath
this.setData({ photos }) this.setData({ photos, mergedImage: '' })
wx.showToast({ title: '裁剪成功', icon: 'success' }) wx.showToast({ title: '裁剪成功', icon: 'success' })
}, },
fail: (err) => { fail: (err) => {
@ -163,7 +167,7 @@ Page({
const photos = this.data.photos const photos = this.data.photos
photos[index].isCropped = false photos[index].isCropped = false
photos[index].croppedUrl = '' photos[index].croppedUrl = ''
this.setData({ photos }) this.setData({ photos, mergedImage: '' })
}, },
// 生成对比图预览 // 生成对比图预览
@ -176,7 +180,9 @@ Page({
const mergeComponent = this.selectComponent('#merge') const mergeComponent = this.selectComponent('#merge')
if (mergeComponent) { if (mergeComponent) {
const imageList = photos.map((item) => { // 确保基准照片排在第一位
const sorted = [...photos].sort((a, b) => b.isBaseline - a.isBaseline)
const imageList = sorted.map((item) => {
const label = const label =
item.isBaseline === 1 item.isBaseline === 1
? `基准照片 ${item.recordDate} 替妥尤单抗:${item.treatmentCount >= 9 ? '>8' : item.treatmentCount}` ? `基准照片 ${item.recordDate} 替妥尤单抗:${item.treatmentCount >= 9 ? '>8' : item.treatmentCount}`
@ -194,12 +200,54 @@ Page({
handleSaveAlbum() { handleSaveAlbum() {
const { mergedImage } = this.data const { mergedImage } = this.data
if (!mergedImage) { if (!mergedImage) {
this.handleMergePreview() wx.showToast({ title: '请先生成对比图', icon: 'none' })
return return
} }
this.saveImageToAlbum(mergedImage)
},
// 保存图片到相册(带权限引导)
saveImageToAlbum(filePath: string) {
wx.getSetting({
success: (res) => {
if (
res.authSetting['scope.writePhotosAlbum'] != undefined &&
res.authSetting['scope.writePhotosAlbum'] == true
) {
this.doSaveImage(filePath)
} else if (res.authSetting['scope.writePhotosAlbum'] == undefined) {
this.doSaveImage(filePath)
} else {
wx.showModal({
title: '请求授权相册权限',
content: '需要保存对比图到相册,请确认授权',
confirmColor: '#8c75d0',
success: (res) => {
if (res.cancel) {
wx.showToast({ title: '拒绝授权', icon: 'none' })
} else if (res.confirm) {
wx.openSetting({
success: (res) => {
if (res.authSetting['scope.writePhotosAlbum'] == true) {
this.doSaveImage(filePath)
} else {
wx.showToast({ title: '授权失败', icon: 'none' })
}
},
})
}
},
})
}
},
})
},
// 执行保存图片
doSaveImage(filePath: string) {
wx.saveImageToPhotosAlbum({ wx.saveImageToPhotosAlbum({
filePath: mergedImage, filePath,
success: () => { success: () => {
wx.showToast({ title: '保存成功', icon: 'success' }) wx.showToast({ title: '保存成功', icon: 'success' })
}, },
@ -214,6 +262,11 @@ Page({
onMergeSave(e: any) { onMergeSave(e: any) {
const { tempFilePath } = e.detail const { tempFilePath } = e.detail
this.setData({ mergedImage: tempFilePath }) this.setData({ mergedImage: tempFilePath })
wx.previewImage({
urls: [tempFilePath],
current: tempFilePath,
showmenu: true,
})
}, },
// 合并失败回调 // 合并失败回调

8
src/pages/d_noteDiffEdit/index.wxml

@ -3,10 +3,16 @@
</navbar> </navbar>
<view class="page" style="padding-top:{{pageTop+20}}px;"> <view class="page" style="padding-top:{{pageTop+20}}px;">
<view class="page-tip">
<image class="icon" src="{{imageUrl}}icon154.png?t={{Timestamp}}"></image>
<view class="content">
本页面仅作为生成对比图的工具使用。裁剪后照片不会覆盖原图、也不会保存。
</view>
</view>
<view class="container" wx:if="{{photos.length > 0}}"> <view class="container" wx:if="{{photos.length > 0}}">
<view class="title"> <view class="title">
{{photoAngleName}}时间线对比 {{photoAngleName}}时间线对比
<view class="date">生成日期:{{photos[0].recordDate}}</view> <view class="date">生成日期:{{generateDate}}</view>
</view> </view>
<view class="card" wx:for="{{photos}}" wx:key="recordId"> <view class="card" wx:for="{{photos}}" wx:key="recordId">
<view class="aside"> <view class="aside">

16
src/pages/d_noteList/index.ts

@ -45,10 +45,8 @@ Page({
// 获取患者突眼记录列表 // 获取患者突眼记录列表
getRecordList(reset = false) { getRecordList(reset = false) {
if (this.data.loading) if (this.data.loading) return
return if (!reset && !this.data.hasMore) return
if (!reset && !this.data.hasMore)
return
const page = reset ? 1 : this.data.page const page = reset ? 1 : this.data.page
this.setData({ loading: true }) this.setData({ loading: true })
@ -61,7 +59,8 @@ Page({
page, page,
pageSize: this.data.pageSize, pageSize: this.data.pageSize,
}, },
}).then((res: any) => { })
.then((res: any) => {
const list = res.list || [] const list = res.list || []
const total = res.pagination?.total || 0 const total = res.pagination?.total || 0
const pages = Math.ceil(total / this.data.pageSize) || 1 const pages = Math.ceil(total / this.data.pageSize) || 1
@ -78,7 +77,8 @@ Page({
pages, pages,
}, },
}) })
}).catch((err) => { })
.catch((err) => {
console.error('获取记录列表失败:', err) console.error('获取记录列表失败:', err)
this.setData({ loading: false }) this.setData({ loading: false })
}) })
@ -89,6 +89,10 @@ Page({
this.getRecordList() this.getRecordList()
}, },
onReachBottom() {
this.loadMore()
},
// 查看记录详情 // 查看记录详情
handleHistory(e: any) { handleHistory(e: any) {
const { recordId } = e.currentTarget.dataset const { recordId } = e.currentTarget.dataset

189
src/patient/components/camera/index.ts

@ -37,11 +37,41 @@ Component({
type: 1, type: 1,
// 拍摄位置名称映射 // 拍摄位置名称映射
typeNameMap: { typeNameMap: {
1: { name: '正面睁眼', group: '正面', index: 1, total: 3, tip: '平视,目光看向镜头方向,自然睁眼,不眯眼、不瞪眼。' }, 1: {
2: { name: '正面闭眼', group: '正面', index: 2, total: 3, tip: '正对镜头,面部居中,自然放松,双眼轻轻闭合,不皱眉、不挤眼。' }, name: '正面睁眼',
3: { name: '正面仰头', group: '正面', index: 3, total: 3, tip: '拍摄时,正对镜头,面部居中,头部自然向上仰至约45°,双眼同步平视,保持自然睁眼、不眯眼。' }, group: '正面',
4: { name: '左侧-90°', group: '侧面', index: 1, total: 4, tip: '身体与头部完全转向右侧,呈标准90°侧面,仅可见左侧眼睛。' }, index: 1,
5: { name: '右侧-90°', group: '侧面', index: 2, total: 4, tip: '身体与头部完全转向左侧,呈标准90°侧面,仅可见右侧眼睛。' }, total: 3,
tip: '平视,目光看向镜头方向,自然睁眼,不眯眼、不瞪眼。',
},
2: {
name: '正面闭眼',
group: '正面',
index: 2,
total: 3,
tip: '正对镜头,面部居中,自然放松,双眼轻轻闭合,不皱眉、不挤眼。',
},
3: {
name: '正面仰头',
group: '正面',
index: 3,
total: 3,
tip: '拍摄时,正对镜头,面部居中,头部自然向上仰至约45°,双眼同步平视,保持自然睁眼、不眯眼。',
},
4: {
name: '左侧-90°',
group: '侧面',
index: 1,
total: 4,
tip: '身体与头部完全转向右侧,呈标准90°侧面,仅可见左侧眼睛。',
},
5: {
name: '右侧-90°',
group: '侧面',
index: 2,
total: 4,
tip: '身体与头部完全转向左侧,呈标准90°侧面,仅可见右侧眼睛。',
},
6: { name: '左侧-45°', group: '侧面', index: 3, total: 4, tip: '身体与头部转向右前方45°。' }, 6: { name: '左侧-45°', group: '侧面', index: 3, total: 4, tip: '身体与头部转向右前方45°。' },
7: { name: '右侧-45°', group: '侧面', index: 4, total: 4, tip: '身体与头部转向左前方45°' }, 7: { name: '右侧-45°', group: '侧面', index: 4, total: 4, tip: '身体与头部转向左前方45°' },
8: { name: '左上', group: '眼球运动', index: 1, total: 8, tip: '正对镜头,双眼向左上方看。' }, 8: { name: '左上', group: '眼球运动', index: 1, total: 8, tip: '正对镜头,双眼向左上方看。' },
@ -133,11 +163,12 @@ Component({
}) })
// 如果设置了只使用相机,直接打开相机 // 如果设置了只使用相机,直接打开相机
if (this.properties.onlyCamera) { if (this.properties.onlyCamera) {
this.checkCameraPermission(() => {
this.setData({ this.setData({
visible: true, visible: true,
}) })
} })
else { } else {
this.setData({ this.setData({
selectShow: true, selectShow: true,
}) })
@ -149,10 +180,47 @@ Component({
}) })
}, },
handleCamera() { handleCamera() {
// 先检查相机权限
this.checkCameraPermission(() => {
this.setData({ this.setData({
selectShow: false, selectShow: false,
visible: true, visible: true,
}) })
})
},
// 检查相机权限
checkCameraPermission(callback: () => void) {
wx.getSetting({
success: (res) => {
if (res.authSetting['scope.camera'] != undefined && res.authSetting['scope.camera'] == true) {
callback()
} else if (res.authSetting['scope.camera'] == undefined) {
callback()
} else {
wx.showModal({
title: '请求授权相机权限',
content: '需要使用相机进行拍摄,请确认授权',
confirmColor: '#8c75d0',
success: (res) => {
if (res.cancel) {
wx.showToast({ title: '拒绝授权', icon: 'none' })
} else if (res.confirm) {
wx.openSetting({
success: (res) => {
if (res.authSetting['scope.camera'] == true) {
callback()
} else {
wx.showToast({ title: '授权失败', icon: 'none' })
}
},
})
}
},
})
}
},
})
}, },
handleHideCamera() { handleHideCamera() {
this.triggerEvent('close') this.triggerEvent('close')
@ -160,8 +228,7 @@ Component({
this.setData({ this.setData({
visible: false, visible: false,
}) })
} } else {
else {
this.setData({ this.setData({
visible: false, visible: false,
selectShow: true, selectShow: true,
@ -170,6 +237,8 @@ Component({
}, },
handlePicture() { handlePicture() {
this.handleCancel() this.handleCancel()
// 检查相册权限
this.checkAlbumPermission(() => {
wx.chooseMedia({ wx.chooseMedia({
count: 1, count: 1,
mediaType: ['image'], mediaType: ['image'],
@ -180,7 +249,7 @@ Component({
const maxSize = 10 * 1024 * 1024 const maxSize = 10 * 1024 * 1024
if (tempFile.size > maxSize) { if (tempFile.size > maxSize) {
wx.showToast({ wx.showToast({
title: '图片大小不能超过10M', title: '图片大小不能超过10MB',
icon: 'none', icon: 'none',
}) })
this.triggerEvent('uploaderror', { reason: 'file_too_large', size: tempFile.size, maxSize }) this.triggerEvent('uploaderror', { reason: 'file_too_large', size: tempFile.size, maxSize })
@ -196,6 +265,44 @@ Component({
} }
}, },
}) })
})
},
// 检查相册权限
checkAlbumPermission(callback: () => void) {
wx.getSetting({
success: (res) => {
if (
res.authSetting['scope.writePhotosAlbum'] != undefined &&
res.authSetting['scope.writePhotosAlbum'] == true
) {
callback()
} else if (res.authSetting['scope.writePhotosAlbum'] == undefined) {
callback()
} else {
wx.showModal({
title: '请求授权相册权限',
content: '需要访问相册选择照片,请确认授权',
confirmColor: '#8c75d0',
success: (res) => {
if (res.cancel) {
wx.showToast({ title: '拒绝授权', icon: 'none' })
} else if (res.confirm) {
wx.openSetting({
success: (res) => {
if (res.authSetting['scope.writePhotosAlbum'] == true) {
callback()
} else {
wx.showToast({ title: '授权失败', icon: 'none' })
}
},
})
}
},
})
}
},
})
}, },
openCamera() { openCamera() {
this.setData({ this.setData({
@ -258,9 +365,27 @@ Component({
onCameraError(e: WechatMiniprogram.CustomEvent) { onCameraError(e: WechatMiniprogram.CustomEvent) {
console.error('相机错误:', e.detail) console.error('相机错误:', e.detail)
wx.showToast({ // 相机出错时引导用户去设置页开启权限
title: '相机权限未开启', wx.showModal({
icon: 'none', title: '请求授权相机权限',
content: '需要使用相机进行拍摄,请确认授权',
confirmColor: '#8c75d0',
success: (res) => {
if (res.cancel) {
this.setData({ visible: false, selectShow: true })
} else if (res.confirm) {
wx.openSetting({
success: (res) => {
if (res.authSetting['scope.camera'] == true) {
wx.showToast({ title: '授权成功', icon: 'success' })
} else {
this.setData({ visible: false, selectShow: true })
wx.showToast({ title: '授权失败', icon: 'none' })
}
},
})
}
},
}) })
this.triggerEvent('error', { reason: 'permission_denied' }) this.triggerEvent('error', { reason: 'permission_denied' })
}, },
@ -275,13 +400,12 @@ Component({
const fileSize = (stats as WechatMiniprogram.Stats).size const fileSize = (stats as WechatMiniprogram.Stats).size
if (fileSize > maxSize) { if (fileSize > maxSize) {
wx.showToast({ wx.showToast({
title: '图片大小不能超过10M', title: '图片大小不能超过10MB',
icon: 'none', icon: 'none',
}) })
this.triggerEvent('uploaderror', { reason: 'file_too_large', size: fileSize, maxSize }) this.triggerEvent('uploaderror', { reason: 'file_too_large', size: fileSize, maxSize })
resolve(false) resolve(false)
} } else {
else {
resolve(true) resolve(true)
} }
}, },
@ -294,8 +418,7 @@ Component({
uploadImage(tempFilePath: string) { uploadImage(tempFilePath: string) {
this.checkImageSize(tempFilePath).then((isValid) => { this.checkImageSize(tempFilePath).then((isValid) => {
if (!isValid) if (!isValid) return
return
wx.showLoading({ wx.showLoading({
title: '正在上传', title: '正在上传',
@ -313,8 +436,7 @@ Component({
const photoUrl = data.data.Url const photoUrl = data.data.Url
// 第二步:调用 photo-upload 接口保存照片信息 // 第二步:调用 photo-upload 接口保存照片信息
this.savePhotoInfo(photoUrl) this.savePhotoInfo(photoUrl)
} } else {
else {
wx.hideLoading() wx.hideLoading()
wx.showToast({ wx.showToast({
title: '上传失败', title: '上传失败',
@ -322,8 +444,7 @@ Component({
}) })
this.triggerEvent('uploaderror', { reason: 'upload_failed', data }) this.triggerEvent('uploaderror', { reason: 'upload_failed', data })
} }
} } catch (e) {
catch (e) {
wx.hideLoading() wx.hideLoading()
wx.showToast({ wx.showToast({
title: '解析响应失败', title: '解析响应失败',
@ -355,15 +476,16 @@ Component({
method: 'GET', method: 'GET',
url: '?r=xd/proptosis/get-session-id', url: '?r=xd/proptosis/get-session-id',
data: {}, data: {},
}).then((res: any) => { })
.then((res: any) => {
if (res.sessionId) { if (res.sessionId) {
this.setData({ sessionIdCache: res.sessionId }) this.setData({ sessionIdCache: res.sessionId })
resolve(res.sessionId) resolve(res.sessionId)
} } else {
else {
reject(new Error('sessionId not found')) reject(new Error('sessionId not found'))
} }
}).catch((err) => { })
.catch((err) => {
reject(err) reject(err)
}) })
}) })
@ -383,7 +505,8 @@ Component({
} }
// 先获取 sessionId,再上传照片 // 先获取 sessionId,再上传照片
this.getSessionId().then((sessionId) => { this.getSessionId()
.then((sessionId) => {
const recordId = this.properties.recordId const recordId = this.properties.recordId
wx.ajax({ wx.ajax({
method: 'POST', method: 'POST',
@ -394,7 +517,8 @@ Component({
photoAngle, photoAngle,
photoUrl, photoUrl,
}, },
}).then((res: any) => { })
.then((res: any) => {
wx.hideLoading() wx.hideLoading()
const { photoId, checkStatus, isContinue, message } = res const { photoId, checkStatus, isContinue, message } = res
@ -423,15 +547,15 @@ Component({
confirmColor: '#8c75d0', confirmColor: '#8c75d0',
cancelColor: '#141515', cancelColor: '#141515',
}) })
} } else if (checkStatus === 2 && isContinue) {
else if (checkStatus === 2 && isContinue) {
// 机审不通过但允许继续,提示用户 // 机审不通过但允许继续,提示用户
wx.showToast({ wx.showToast({
title: message || '图片可能不合规', title: message || '图片可能不合规',
icon: 'none', icon: 'none',
}) })
} }
}).catch((err) => { })
.catch((err) => {
wx.hideLoading() wx.hideLoading()
wx.showToast({ wx.showToast({
title: '保存照片信息失败', title: '保存照片信息失败',
@ -439,7 +563,8 @@ Component({
}) })
this.triggerEvent('uploaderror', { reason: 'save_photo_failed', error: err }) this.triggerEvent('uploaderror', { reason: 'save_photo_failed', error: err })
}) })
}).catch((err) => { })
.catch((err) => {
wx.hideLoading() wx.hideLoading()
wx.showToast({ wx.showToast({
title: '获取会话ID失败', title: '获取会话ID失败',

18
src/patient/components/image-merge/index.ts

@ -96,7 +96,7 @@ Component({
const canvas = res[0].node const canvas = res[0].node
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
Promise.all(imageList.map(item => this.getImageInfo(item.src))) Promise.all(imageList.map((item) => this.getImageInfo(item.src)))
.then((imageInfos) => { .then((imageInfos) => {
const targetWidth = 750 const targetWidth = 750
const pixelRatio = wx.getWindowInfo().pixelRatio const pixelRatio = wx.getWindowInfo().pixelRatio
@ -118,16 +118,25 @@ Component({
let currentY = 0 let currentY = 0
let loadedCount = 0 let loadedCount = 0
// 预计算每张图片的 Y 偏移,确保顺序正确
const yPositions: number[] = []
scaledHeights.forEach((h, i) => {
yPositions.push(currentY)
currentY += h
})
loadedCount = 0
imageInfos.forEach((info, index) => { imageInfos.forEach((info, index) => {
const img = canvas.createImage() const img = canvas.createImage()
img.src = info.path img.src = info.path
img.onload = () => { img.onload = () => {
ctx.drawImage(img, 0, currentY, canvasWidth, scaledHeights[index]) ctx.drawImage(img, 0, yPositions[index], canvasWidth, scaledHeights[index])
// 在每张图片左上角绘制时间,白色字体 // 在每张图片左上角绘制时间,白色字体
const timeText = imageList[index].time || this.formatTime(new Date()) const timeText = imageList[index].time || this.formatTime(new Date())
const padding = 20 const padding = 20
const textY = currentY + 40 const textY = yPositions[index] + 40
// 设置白色字体和阴影以增强可读性 // 设置白色字体和阴影以增强可读性
ctx.fillStyle = '#ffffff' ctx.fillStyle = '#ffffff'
@ -147,14 +156,13 @@ Component({
// 如果是最后一张图片,在其右下角绘制水印 // 如果是最后一张图片,在其右下角绘制水印
if (index === imageInfos.length - 1) { if (index === imageInfos.length - 1) {
const lastImageBottom = currentY + scaledHeights[index] const lastImageBottom = yPositions[index] + scaledHeights[index]
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)' ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
ctx.font = '24px sans-serif' ctx.font = '24px sans-serif'
ctx.textAlign = 'right' ctx.textAlign = 'right'
ctx.fillText('由-TED关爱小助手-小程序生成', canvasWidth - 20, lastImageBottom - 20) ctx.fillText('由-TED关爱小助手-小程序生成', canvasWidth - 20, lastImageBottom - 20)
} }
currentY += scaledHeights[index]
loadedCount++ loadedCount++
if (loadedCount === imageInfos.length) { if (loadedCount === imageInfos.length) {

49
src/patient/pages/note/index.ts

@ -46,22 +46,45 @@ Page({
method: 'GET', method: 'GET',
url: '?r=xd/proptosis/baseline-status', url: '?r=xd/proptosis/baseline-status',
data: {}, data: {},
}).then((res: any) => { })
.then((res: any) => {
this.setData({ this.setData({
hasBaseline: res.hasBaseline, hasBaseline: res.hasBaseline,
baselineRecordId: res.baselineRecordId || '', baselineRecordId: res.baselineRecordId || '',
}) })
}).catch((err) => { // 有基准照时,通过 record-detail 获取基准照详情
if (res.baselineRecordId) {
this.getBaselineDetail(res.baselineRecordId)
}
})
.catch((err) => {
console.error('获取基准照状态失败:', err) console.error('获取基准照状态失败:', err)
}) })
}, },
// 通过 record-detail 获取基准照详情
getBaselineDetail(recordId: string) {
wx.ajax({
method: 'GET',
url: '?r=xd/proptosis/record-detail',
data: { recordId },
})
.then((res: any) => {
const firstPhoto = (res.photos || [])[0]
this.setData({
baselineDate: res.recordDate || '',
baselinePhotoUrl: firstPhoto?.photoUrl || '',
})
})
.catch((err) => {
console.error('获取基准照详情失败:', err)
})
},
// 获取突眼记录列表 // 获取突眼记录列表
getRecordList(reset = false) { getRecordList(reset = false) {
if (this.data.loading) if (this.data.loading) return
return if (!reset && !this.data.hasMore) return
if (!reset && !this.data.hasMore)
return
const page = reset ? 1 : this.data.page const page = reset ? 1 : this.data.page
@ -76,20 +99,13 @@ Page({
page, page,
pageSize: this.data.pageSize, pageSize: this.data.pageSize,
}, },
}).then((res: any) => { })
.then((res: any) => {
const list = res.list || [] const list = res.list || []
const total = res.pagination?.total || 0 const total = res.pagination?.total || 0
const nextList = reset ? list : [...this.data.recordList, ...list] const nextList = reset ? list : [...this.data.recordList, ...list]
// 从已加载数据中找到基准照信息(避免后续分页把基准照“翻走”)
const baselineItem = nextList.find((item: RecordItem) => item.isBaseline === 1)
if (baselineItem) {
this.setData({
baselineDate: baselineItem.recordDate,
baselinePhotoUrl: baselineItem.firstPhotoUrl,
})
}
this.setData({ this.setData({
recordList: nextList, recordList: nextList,
loading: false, loading: false,
@ -99,7 +115,8 @@ Page({
hasMore: nextList.length < total && list.length >= this.data.pageSize, hasMore: nextList.length < total && list.length >= this.data.pageSize,
}) })
wx.stopPullDownRefresh() wx.stopPullDownRefresh()
}).catch((err) => { })
.catch((err) => {
console.error('获取记录列表失败:', err) console.error('获取记录列表失败:', err)
this.setData({ loading: false, refreshing: false }) this.setData({ loading: false, refreshing: false })
wx.stopPullDownRefresh() wx.stopPullDownRefresh()

56
src/patient/pages/noteAdd/index.ts

@ -56,7 +56,7 @@ Page({
// 顺序拍摄模式 // 顺序拍摄模式
sequentialShootMode: false, sequentialShootMode: false,
sequentialShootList: [] as { name: string, type: string, angle: string }[], sequentialShootList: [] as { name: string; type: string; angle: string }[],
sequentialShootIndex: 0, sequentialShootIndex: 0,
// 替妥尤单抗使用次数选项 (0-9, 9表示大于8) // 替妥尤单抗使用次数选项 (0-9, 9表示大于8)
@ -125,7 +125,8 @@ Page({
data: { data: {
recordId: this.data.recordId, recordId: this.data.recordId,
}, },
}).then((res: RecordDetail) => { })
.then((res: RecordDetail) => {
wx.hideLoading() wx.hideLoading()
const photoMap: Record<string, PhotoItem> = {} const photoMap: Record<string, PhotoItem> = {}
;(res.photos || []).forEach((p) => { ;(res.photos || []).forEach((p) => {
@ -149,7 +150,8 @@ Page({
interorbitalDistance: res.interorbitalDistance != null ? String(res.interorbitalDistance) : '', interorbitalDistance: res.interorbitalDistance != null ? String(res.interorbitalDistance) : '',
photoMap, photoMap,
}) })
}).catch((err) => { })
.catch((err) => {
wx.hideLoading() wx.hideLoading()
console.error('获取记录详情失败:', err) console.error('获取记录详情失败:', err)
wx.showToast({ title: '加载失败', icon: 'none' }) wx.showToast({ title: '加载失败', icon: 'none' })
@ -177,12 +179,14 @@ Page({
method: 'GET', method: 'GET',
url: '?r=xd/proptosis/baseline-status', url: '?r=xd/proptosis/baseline-status',
data: {}, data: {},
}).then((res: any) => { })
.then((res: any) => {
this.setData({ this.setData({
hasBaseline: res.hasBaseline, hasBaseline: res.hasBaseline,
isBaseline: res.hasBaseline ? this.data.isBaseline : 1, isBaseline: res.hasBaseline ? this.data.isBaseline : 1,
}) })
}).catch(() => {}) })
.catch(() => {})
}, },
// 是否设置为基准照 // 是否设置为基准照
@ -203,8 +207,7 @@ Page({
// 左眼度数输入 // 左眼度数输入
onLeftEyeInput(e: any) { onLeftEyeInput(e: any) {
let val = e.detail.value let val = e.detail.value
if (Number(val) > 999.9) if (Number(val) > 999.9) val = '999.9'
val = '999.9'
this.setData({ this.setData({
leftEye: val, leftEye: val,
}) })
@ -212,8 +215,7 @@ Page({
onRightEyeInput(e: any) { onRightEyeInput(e: any) {
let val = e.detail.value let val = e.detail.value
if (Number(val) > 999.9) if (Number(val) > 999.9) val = '999.9'
val = '999.9'
this.setData({ this.setData({
rightEye: val, rightEye: val,
}) })
@ -221,8 +223,7 @@ Page({
onInterorbitalDistanceInput(e: any) { onInterorbitalDistanceInput(e: any) {
let val = e.detail.value let val = e.detail.value
if (Number(val) > 999.9) if (Number(val) > 999.9) val = '999.9'
val = '999.9'
this.setData({ this.setData({
interorbitalDistance: val, interorbitalDistance: val,
}) })
@ -361,7 +362,7 @@ Page({
// camera 组件上传失败回调 // camera 组件上传失败回调
onUploadError(e: any) { onUploadError(e: any) {
const { errMsg } = e.detail const { errMsg } = e.detail
wx.showToast({ title: errMsg || '上传失败', icon: 'none' }) console.error('上传失败:', errMsg)
}, },
// 关闭相机 // 关闭相机
@ -412,7 +413,8 @@ Page({
// 保存记录 // 保存记录
handleSave() { handleSave() {
const { recordId, recordDate, treatmentCount, isBaseline, leftEye, rightEye, interorbitalDistance, photoMap } = this.data const { recordId, recordDate, treatmentCount, isBaseline, leftEye, rightEye, interorbitalDistance, photoMap } =
this.data
// 表单验证 // 表单验证
if (!recordDate) { if (!recordDate) {
@ -428,13 +430,13 @@ Page({
} }
// 所有照片全不合规才不允许保存 // 所有照片全不合规才不允许保存
const hasCompliantPhoto = photos.some(photo => photo.checkStatus === 1) const hasCompliantPhoto = photos.some((photo) => photo.checkStatus === 1)
if (!hasCompliantPhoto) { if (!hasCompliantPhoto) {
wx.showToast({ title: '所有照片不合规,请重新上传', icon: 'none' }) wx.showToast({ title: '所有照片不合规,请重新上传', icon: 'none' })
return return
} }
const photoIds = photos.map(photo => photo.photoId).join(',') const photoIds = photos.map((photo) => photo.photoId).join(',')
const data: any = { const data: any = {
...(recordId ? { recordId } : {}), ...(recordId ? { recordId } : {}),
@ -445,12 +447,9 @@ Page({
} }
// 可选字段 // 可选字段
if (leftEye) if (leftEye) data.leftEye = leftEye
data.leftEye = leftEye if (rightEye) data.rightEye = rightEye
if (rightEye) if (interorbitalDistance) data.interorbitalDistance = interorbitalDistance
data.rightEye = rightEye
if (interorbitalDistance)
data.interorbitalDistance = interorbitalDistance
wx.showLoading({ title: '保存中...' }) wx.showLoading({ title: '保存中...' })
@ -458,7 +457,8 @@ Page({
method: 'POST', method: 'POST',
url: '?r=xd/proptosis/record-save', url: '?r=xd/proptosis/record-save',
data, data,
}).then(() => { })
.then(() => {
wx.hideLoading() wx.hideLoading()
this.setData({ hasUnsavedData: false }) this.setData({ hasUnsavedData: false })
wx.showToast({ wx.showToast({
@ -468,7 +468,8 @@ Page({
setTimeout(() => { setTimeout(() => {
wx.navigateBack() wx.navigateBack()
}, 1500) }, 1500)
}).catch((err: any) => { })
.catch((err: any) => {
wx.hideLoading() wx.hideLoading()
const msg = err?.data?.msg || '保存失败' const msg = err?.data?.msg || '保存失败'
wx.showToast({ title: msg, icon: 'none' }) wx.showToast({ title: msg, icon: 'none' })
@ -488,8 +489,7 @@ Page({
}) })
if (popupType === 'popup18') { if (popupType === 'popup18') {
this.setData({ isBaseline: 1 }) this.setData({ isBaseline: 1 })
} } else {
else {
this.handleSave() this.handleSave()
} }
}, },
@ -501,8 +501,7 @@ Page({
}) })
if (popupType === 'popup18') { if (popupType === 'popup18') {
this.setData({ isBaseline: 0 }) this.setData({ isBaseline: 0 })
} } else {
else {
wx.navigateBack() wx.navigateBack()
} }
}, },
@ -513,8 +512,7 @@ Page({
popupShow: true, popupShow: true,
popupType: 'popup16', popupType: 'popup16',
}) })
} } else {
else {
wx.navigateBack() wx.navigateBack()
} }
}, },

23
src/patient/pages/noteDiff/index.scss

@ -36,7 +36,7 @@ page {
} }
} }
.page { .page {
padding-bottom: 80rpx; padding-bottom: calc(env(safe-area-inset-bottom) + 160rpx);
.page-tip { .page-tip {
padding: 24rpx 28rpx; padding: 24rpx 28rpx;
background-color: #fff7e9; background-color: #fff7e9;
@ -288,5 +288,26 @@ page {
border-radius: 100rpx 100rpx 100rpx 100rpx; border-radius: 100rpx 100rpx 100rpx 100rpx;
} }
} }
.footer-fixed {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 24rpx 40rpx calc(env(safe-area-inset-bottom) + 24rpx);
background: #fff;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.05);
.btn1 {
height: 88rpx;
font-size: 36rpx;
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(0deg, #e98ff8 0%, #b073ff 100%);
border-radius: 100rpx;
}
}
} }
} }

48
src/patient/pages/noteDiff/index.ts

@ -1,4 +1,5 @@
const app = getApp<IAppOption>() const app = getApp<IAppOption>()
const licia = require('miniprogram-licia')
interface ComparePhoto { interface ComparePhoto {
recordId: string recordId: string
@ -17,6 +18,8 @@ interface CompareDate {
isSelected?: boolean isSelected?: boolean
} }
type BaselineItem = CompareDate | null
Page({ Page({
data: { data: {
// 对比角度 // 对比角度
@ -26,18 +29,24 @@ Page({
angleMap: {} as Record<string, string>, angleMap: {} as Record<string, string>,
// 日期选择 // 日期选择
baseline: null as CompareDate | null, baseline: null as unknown as BaselineItem,
nonBaselineList: [] as CompareDate[], nonBaselineList: [] as CompareDate[],
selectedDates: [] as string[], selectedDates: [] as string[],
baselineSelected: true,
// 对比照片 // 对比照片
comparePhotos: [] as ComparePhoto[], comparePhotos: [] as ComparePhoto[],
// 空状态 // 空状态
hasBaseline: true, hasBaseline: true,
generateDate: '',
}, },
onLoad() { onLoad() {
const now = new Date()
const generateDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
this.setData({ generateDate })
this.getCompareAngle() this.getCompareAngle()
}, },
@ -112,16 +121,17 @@ Page({
newRecordIds.push(newBaseline.recordId) newRecordIds.push(newBaseline.recordId)
} }
const selectedDates = this.data.selectedDates.filter((id: string) => newRecordIds.includes(id)) const selectedDates = this.data.selectedDates.filter((id: string) => newRecordIds.includes(id))
const baselineSelected = this.data.baselineSelected
const nonBaselineList = newList.map((item: CompareDate) => ({ const nonBaselineList = newList.map((item: CompareDate) => ({
...item, ...item,
isSelected: selectedDates.includes(item.recordId), isSelected: selectedDates.includes(item.recordId),
})) }))
this.setData({ this.setData({
baseline: newBaseline, baseline: newBaseline ? { ...newBaseline, isSelected: baselineSelected } : null,
nonBaselineList, nonBaselineList,
selectedDates, selectedDates,
}) })
if (selectedDates.length > 0 || newBaseline) { if (selectedDates.length > 0 || (newBaseline && baselineSelected)) {
this.getComparePhotos() this.getComparePhotos()
} else { } else {
this.setData({ comparePhotos: [] }) this.setData({ comparePhotos: [] })
@ -139,11 +149,25 @@ Page({
this.setData({ this.setData({
photoAngle: angle.key, photoAngle: angle.key,
photoAngleName: angle.name, photoAngleName: angle.name,
selectedDates: [],
baselineSelected: true,
comparePhotos: [], comparePhotos: [],
}) })
this.getCompareDates() this.getCompareDates()
}, },
// 选择基准照日期
onBaselineSelect() {
const baselineSelected = !this.data.baselineSelected
const baseline = this.data.baseline
this.setData({
baselineSelected,
baseline: baseline ? { ...baseline, isSelected: baselineSelected } : null,
})
// 获取对比照片
this.getComparePhotos()
},
// 选择日期 // 选择日期
onDateSelect(e: any) { onDateSelect(e: any) {
const { recordId } = e.currentTarget.dataset const { recordId } = e.currentTarget.dataset
@ -152,7 +176,7 @@ Page({
if (index > -1) { if (index > -1) {
selectedDates.splice(index, 1) selectedDates.splice(index, 1)
} else { } else {
const maxSelect = this.data.baseline ? 5 : 6 const maxSelect = this.data.baselineSelected ? 5 : 6
if (selectedDates.length >= maxSelect) { if (selectedDates.length >= maxSelect) {
wx.showToast({ title: '最多选择6张对比图', icon: 'none' }) wx.showToast({ title: '最多选择6张对比图', icon: 'none' })
return return
@ -170,13 +194,13 @@ Page({
}, },
// 获取对比照片 // 获取对比照片
getComparePhotos() { getComparePhotos: licia.debounce(function (this: any) {
const { photoAngle, selectedDates, baseline } = this.data const { photoAngle, selectedDates, baseline } = this.data
if (!photoAngle) { if (!photoAngle) {
this.setData({ comparePhotos: [] }) this.setData({ comparePhotos: [] })
return return
} }
const recordIds = baseline ? [baseline.recordId, ...selectedDates] : selectedDates const recordIds = baseline && this.data.baselineSelected ? [baseline.recordId, ...selectedDates] : selectedDates
if (recordIds.length === 0) { if (recordIds.length === 0) {
this.setData({ comparePhotos: [] }) this.setData({ comparePhotos: [] })
return return
@ -201,7 +225,7 @@ Page({
.catch((err) => { .catch((err) => {
console.error('获取对比照片失败:', err) console.error('获取对比照片失败:', err)
}) })
}, }, 300),
// 去设置基准照 // 去设置基准照
goSetBaseline() { goSetBaseline() {
@ -212,13 +236,15 @@ Page({
// 生成对比图 // 生成对比图
handleEdit() { handleEdit() {
if (this.data.selectedDates.length === 0) { const { baseline, baselineSelected, selectedDates } = this.data
wx.showToast({ title: '请选择对比日期', icon: 'none' }) const hasBaseline = baseline && baselineSelected
const totalCount = selectedDates.length + (hasBaseline ? 1 : 0)
if (totalCount < 2) {
wx.showToast({ title: '请至少选择两个日期', icon: 'none' })
return return
} }
const { baseline, selectedDates } = this.data
// 生成长图页也应包含基准照(与预览一致) // 生成长图页也应包含基准照(与预览一致)
const recordIds = baseline ? [baseline.recordId, ...selectedDates] : selectedDates const recordIds = hasBaseline ? [baseline.recordId, ...selectedDates] : selectedDates
wx.navigateTo({ wx.navigateTo({
url: `/patient/pages/noteDiffEdit/index?photoAngle=${this.data.photoAngle}&recordIds=${recordIds.join(',')}`, url: `/patient/pages/noteDiffEdit/index?photoAngle=${this.data.photoAngle}&recordIds=${recordIds.join(',')}`,
}) })

6
src/patient/pages/noteDiff/index.wxml

@ -32,7 +32,7 @@
<view class="form-item"> <view class="form-item">
<view class="title">选择对比日期(可多选)</view> <view class="title">选择对比日期(可多选)</view>
<view class="multiple"> <view class="multiple">
<view class="item active baseline" wx:if="{{baseline}}"> <view class="item {{baseline.isSelected ? 'active' : ''}} baseline" wx:if="{{baseline}}" bind:tap="onBaselineSelect">
{{baseline.recordDate}} {{baseline.recordDate}}
<image class="icon" src="{{imageUrl}}icon169.png?t={{Timestamp}}"></image> <image class="icon" src="{{imageUrl}}icon169.png?t={{Timestamp}}"></image>
</view> </view>
@ -51,7 +51,7 @@
<view class="container" wx:if="{{comparePhotos.length > 0}}"> <view class="container" wx:if="{{comparePhotos.length > 0}}">
<view class="title"> <view class="title">
{{photoAngleName}}时间线对比 {{photoAngleName}}时间线对比
<view class="date">生成日期:{{comparePhotos[0].recordDate}}</view> <view class="date">生成日期:{{generateDate}}</view>
</view> </view>
<view class="card" wx:for="{{comparePhotos}}" wx:key="recordId"> <view class="card" wx:for="{{comparePhotos}}" wx:key="recordId">
<view class="aside"> <view class="aside">
@ -99,7 +99,7 @@
</view> </view>
</view> </view>
</view> </view>
<view class="footer"> <view class="footer-fixed" wx:if="{{comparePhotos.length > 0}}">
<view class="btn1" bind:tap="handleEdit">生成对比图</view> <view class="btn1" bind:tap="handleEdit">生成对比图</view>
</view> </view>
</view> </view>

2
src/patient/pages/noteDiffEdit/index.json

@ -5,5 +5,5 @@
"imageMerge": "/patient/components/image-merge/index", "imageMerge": "/patient/components/image-merge/index",
"popup": "/components/popup/index" "popup": "/components/popup/index"
}, },
"navigationBarTitleText": "照片对比分析" "navigationBarTitleText": "照片对比编辑"
} }

56
src/patient/pages/noteDiffEdit/index.ts

@ -21,6 +21,7 @@ Page({
photoAngle: '', photoAngle: '',
photoAngleName: '', photoAngleName: '',
generateDate: '',
recordIds: [] as string[], recordIds: [] as string[],
photos: [] as PhotoItem[], photos: [] as PhotoItem[],
loading: false, loading: false,
@ -28,7 +29,10 @@ Page({
}, },
onLoad(option: any) { onLoad(option: any) {
const now = new Date()
const generateDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
this.setData({ this.setData({
generateDate,
photoAngle: option.photoAngle || '', photoAngle: option.photoAngle || '',
recordIds: option.recordIds ? option.recordIds.split(',') : [], recordIds: option.recordIds ? option.recordIds.split(',') : [],
}) })
@ -121,7 +125,7 @@ Page({
success: (cropRes) => { success: (cropRes) => {
photos[index].isCropped = true photos[index].isCropped = true
photos[index].croppedUrl = cropRes.tempFilePath photos[index].croppedUrl = cropRes.tempFilePath
this.setData({ photos }) this.setData({ photos, mergedImage: '' })
wx.showToast({ title: '裁剪成功', icon: 'success' }) wx.showToast({ title: '裁剪成功', icon: 'success' })
}, },
fail: (err) => { fail: (err) => {
@ -147,7 +151,7 @@ Page({
const photos = this.data.photos const photos = this.data.photos
photos[index].isCropped = false photos[index].isCropped = false
photos[index].croppedUrl = '' photos[index].croppedUrl = ''
this.setData({ photos }) this.setData({ photos, mergedImage: '' })
}, },
// 生成对比图预览 // 生成对比图预览
@ -160,7 +164,9 @@ Page({
const mergeComponent = this.selectComponent('#merge') const mergeComponent = this.selectComponent('#merge')
if (mergeComponent) { if (mergeComponent) {
const imageList = photos.map((item) => { // 确保基准照片排在第一位
const sorted = [...photos].sort((a, b) => b.isBaseline - a.isBaseline)
const imageList = sorted.map((item) => {
const label = const label =
item.isBaseline === 1 item.isBaseline === 1
? `基准照片 ${item.recordDate} 替妥尤单抗:${item.treatmentCount >= 9 ? '>8' : item.treatmentCount}` ? `基准照片 ${item.recordDate} 替妥尤单抗:${item.treatmentCount >= 9 ? '>8' : item.treatmentCount}`
@ -182,8 +188,50 @@ Page({
return return
} }
this.saveImageToAlbum(mergedImage)
},
// 保存图片到相册(带权限引导)
saveImageToAlbum(filePath: string) {
wx.getSetting({
success: (res) => {
if (
res.authSetting['scope.writePhotosAlbum'] != undefined &&
res.authSetting['scope.writePhotosAlbum'] == true
) {
this.doSaveImage(filePath)
} else if (res.authSetting['scope.writePhotosAlbum'] == undefined) {
this.doSaveImage(filePath)
} else {
wx.showModal({
title: '请求授权相册权限',
content: '需要保存对比图到相册,请确认授权',
confirmColor: '#8c75d0',
success: (res) => {
if (res.cancel) {
wx.showToast({ title: '拒绝授权', icon: 'none' })
} else if (res.confirm) {
wx.openSetting({
success: (res) => {
if (res.authSetting['scope.writePhotosAlbum'] == true) {
this.doSaveImage(filePath)
} else {
wx.showToast({ title: '授权失败', icon: 'none' })
}
},
})
}
},
})
}
},
})
},
// 执行保存图片
doSaveImage(filePath: string) {
wx.saveImageToPhotosAlbum({ wx.saveImageToPhotosAlbum({
filePath: mergedImage, filePath,
success: () => { success: () => {
wx.showToast({ title: '保存成功', icon: 'success' }) wx.showToast({ title: '保存成功', icon: 'success' })
}, },

2
src/patient/pages/noteDiffEdit/index.wxml

@ -6,7 +6,7 @@
<view class="container" wx:if="{{!loading && photos.length > 0}}"> <view class="container" wx:if="{{!loading && photos.length > 0}}">
<view class="title"> <view class="title">
{{photoAngleName}}时间线对比 {{photoAngleName}}时间线对比
<view class="date">生成日期:{{photos[0].recordDate}}</view> <view class="date">生成日期:{{generateDate}}</view>
</view> </view>
<view class="card" wx:for="{{photos}}" wx:key="photoId" data-index="{{index}}"> <view class="card" wx:for="{{photos}}" wx:key="photoId" data-index="{{index}}">
<view class="aside"> <view class="aside">

Loading…
Cancel
Save