Browse Source

feat(图片合并): 新增图片合并组件及功能优化

refactor(患者详情): 重构患者详情页面的日记记录展示逻辑
fix(图片对比): 修复对比日期选择状态同步问题
style(弹窗): 优化弹窗17的样式和布局
perf(图片裁剪): 实现内置裁剪功能替代页面跳转
docs(接口文档): 更新照片上传接口路径变更说明
dev
kola-web 1 week ago
parent
commit
6d53a8b081
  1. 419
      .trae/rules/project_rules.md
  2. 169
      AGENTS.md
  3. 8
      src/components/image-merge/index.json
  4. 7
      src/components/image-merge/index.scss
  5. 186
      src/components/image-merge/index.ts
  6. 1
      src/components/image-merge/index.wxml
  7. 14
      src/components/noteImagePreview/index.ts
  8. 60
      src/components/popup/index.scss
  9. 14
      src/components/popup/index.wxml
  10. 36
      src/pages/d_noteDetail/index.ts
  11. 27
      src/pages/d_noteDetail/index.wxml
  12. 5
      src/pages/d_noteDiff/index.json
  13. 23
      src/pages/d_noteDiff/index.scss
  14. 29
      src/pages/d_noteDiff/index.ts
  15. 12
      src/pages/d_noteDiff/index.wxml
  16. 7
      src/pages/d_noteDiffData/index.json
  17. 71
      src/pages/d_noteDiffData/index.ts
  18. 15
      src/pages/d_noteDiffData/index.wxml
  19. 4
      src/pages/d_noteDiffEdit/index.json
  20. 72
      src/pages/d_noteDiffEdit/index.scss
  21. 112
      src/pages/d_noteDiffEdit/index.ts
  22. 33
      src/pages/d_noteDiffEdit/index.wxml
  23. 5
      src/pages/d_noteList/index.json
  24. 21
      src/pages/d_noteList/index.ts
  25. 15
      src/pages/d_noteList/index.wxml
  26. 7
      src/pages/d_patientDetail/index.ts
  27. 15
      src/pages/d_patientDetail/index.wxml
  28. 2
      src/patient/components/camera/index.ts
  29. 2
      src/patient/pages/note/index.wxml
  30. 46
      src/patient/pages/noteAdd/index.ts
  31. 19
      src/patient/pages/noteAdd/index.wxml
  32. 2
      src/patient/pages/noteDemo/index.wxml
  33. 2
      src/patient/pages/noteDiff/index.json
  34. 23
      src/patient/pages/noteDiff/index.ts
  35. 8
      src/patient/pages/noteDiff/index.wxml
  36. 5
      src/patient/pages/noteDiffEdit/index.json
  37. 60
      src/patient/pages/noteDiffEdit/index.ts
  38. 10
      src/patient/pages/noteDiffEdit/index.wxml
  39. 72
      src/patient/pages/noteHistory/index.ts
  40. 23
      src/patient/pages/noteHistory/index.wxml
  41. 69
      接口文档.md

419
.trae/rules/project_rules.md

@ -0,0 +1,419 @@
# 项目规则 - 信达小程序 (Xinda Mini Program)
WeChat Mini Program for thyroid eye disease patient management. Dual-mode app serving both patients (患者端) and doctors (医生端).
## Project Type
- **Framework**: WeChat Mini Program v3.7.7
- **Language**: TypeScript (strict mode)
- **Styling**: SCSS/Sass via `useCompilerPlugins`
- **UI Library**: Vant Weapp (@vant/weapp)
- **Renderer**: Skyline enabled
- **App ID**: `wxf9ce8010f1ad24aa` (dev), `wx71ac9c27c3c3e3f4` (prod)
## Directory Structure
```
src/
├── pages/ # Doctor-side main pages (医生端)
├── patient/pages/ # Patient-side pages (subpackage)
├── gift/pages/ # DTP pharmacy pages (subpackage)
├── doc/pages/ # Privacy/terms documentation (subpackage)
├── components/ # Shared components
├── utils/ # Request, page/component wrappers, utilities
├── app.ts # App entry with global data
└── config.ts # Environment configs per appId
```
## Key Conventions
### Path Aliases
- `@/*``src/*` (configured in tsconfig.json and app.json `resolveAlias`)
- Use `@/utils/request` not relative paths
### User Types & Routing
Login types enforced in `app.ts`:
- `0`: Not logged in
- `1`: Patient (患者) → routes to `/patient/pages/*`
- `2`: Doctor (医生) → routes to `/pages/*`
Use `app.zdWaitLogin()` to guard pages that require login.
### Page/Component Wrappers
`app.ts` overrides global `Page` and `Component` with wrappers from `utils/page.ts` and `utils/component.ts`:
- Auto-sets `imageUrl` and `Timestamp` on page load
- Auto-handles navbar background on scroll
- Provides default share behavior
### Modal Colors (Required)
All `wx.showModal` must use:
```ts
wx.showModal({
confirmColor: '#8c75d0',
cancelColor: '#141515',
})
```
### App Instance (Required)
`getApp()` should be called at the top of the file, not inside methods:
```ts
// ✅ Correct
const app = getApp<IAppOption>()
Page({
onLoad() {
// Use app directly
console.log(app.globalData)
}
})
// ❌ Incorrect
Page({
onLoad() {
const app = getApp<IAppOption>()
console.log(app.globalData)
}
})
```
## WXML 开发规范
### 1. 不支持在 WXML 中直接调用方法
**错误示例:**
```xml
<!-- ❌ 错误:WXML 中不能直接调用方法 -->
<view>{{getUploadedCount()}}</view>
<view wx:if="{{hasPhotos()}}">内容</view>
```
**正确做法:**
```xml
<!-- ✅ 正确:使用数据绑定 -->
<view>{{uploadedCount}}</view>
<view wx:if="{{hasPhotos}}">内容</view>
```
```typescript
// 在 TS 中计算好数据
this.setData({
uploadedCount: photos.length,
hasPhotos: photos.length > 0
})
```
### 2. 列表渲染时使用 wx:key
```xml
<view wx:for="{{list}}" wx:key="id">{{item.name}}</view>
```
### 3. 条件渲染
```xml
<view wx:if="{{condition}}">显示</view>
<view wx:else>隐藏</view>
```
### 4. WXML 表达式限制
WXML 中的双大括号 `{{}}` 只能使用简单的表达式,**不支持**以下语法:
**❌ 不支持的语法:**
```xml
<!-- 方法调用 -->
<view>{{arr.indexOf(item) > -1}}</view>
<!-- 复杂表达式 -->
<view>{{obj.method().property}}</view>
<!-- 正则表达式 -->
<view>{{str.match(/regex/)}}</view>
<!-- new 操作符 -->
<view>{{new Date().getTime()}}</view>
```
**✅ 支持的语法:**
```xml
<!-- 简单属性访问 -->
<view>{{item.name}}</view>
<!-- 三元表达式 -->
<view class="{{item.isActive ? 'active' : ''}}">内容</view>
<!-- 简单比较 -->
<view wx:if="{{count > 0}}">有数据</view>
<!-- 逻辑运算 -->
<view wx:if="{{isLogin && isVip}}">VIP用户</view>
```
**解决方案:**
```typescript
// 在 TS 中预处理数据,将复杂计算转换为简单布尔值
this.setData({
// ❌ 不要在 WXML 中使用 indexOf
// isSelected: selectedDates.indexOf(item.recordId) > -1
// ✅ 在数据中直接提供 isSelected 字段
list: rawList.map(item => ({
...item,
isSelected: selectedDates.includes(item.recordId)
}))
})
```
## Available Commands
```bash
# Lint and auto-fix (only command available)
npm run lint:fix
# Install dependencies (pnpm preferred based on lockfile)
pnpm install
# Build: Use WeChat Developer Tools
# - Import project with src/ as root
# - project.config.json at repo root
```
## NPM Dependencies
**Critical**: After `npm install`, run **Tools → Build npm** in WeChat Developer Tools to generate `miniprogram_npm/`. This is required for Vant and other packages.
Key dependencies:
- `@vant/weapp`: UI components
- `miniprogram-licia`: Utility library (available as `licia`)
- `dayjs`: Date handling
- `mp-html`: Rich HTML rendering
## Images & Assets
**Images are stored in SVN, not git.**
- SVN URL: `svn://39.106.86.127:28386/projects/xd/proj_src/shop/frontend/web/xd/`
- Local path: `src/images/` (gitignored)
- Excluded from upload via `project.config.json` packOptions
- Only `/images/tabbar/*` is included in uploads
Image URL pattern:
```
{{imageUrl}}/path/to/image.png?t={{Timestamp}}
```
## TypeScript Configuration
- Strict mode enabled
- `noImplicitAny: false` (allows implicit any)
- Paths: `@/*` mapped to `src/*`
- Types: `miniprogram-api-typings` for WX API
Global types in `typings/index.d.ts`:
- `IAppOption`: Global app instance interface
- `pageType`: 0 | 1 | 2 for user types
- `wx.ajax`: Extended request method
## ESLint & Formatting
- Config: `@antfu/eslint-config` (flat config in `eslint.config.js`)
- Prettier: 2-space tabs, no semis, single quotes, trailing commas
- WXML files parsed as HTML, WXSS as CSS
- Globals defined: `wx`, `App`, `Page`, `Component`, `getCurrentPages`, etc.
## Environment Configuration
Selected by App ID in `src/config.ts`:
- `wxf9ce8010f1ad24aa`: Dev/Staging (hbraas.com)
- `wx71ac9c27c3c3e3f4`: Production (hbsaas.com)
App reads `wx.getAccountInfoSync().miniProgram.appId` on launch to select config.
## Testing & Development
- No unit test framework configured
- Manual testing via WeChat Developer Tools
- `project.private.config.json` has hot reload enabled (`compileHotReLoad: true`)
- Predefined test pages in `project.private.config.json` condition list
## Common Gotchas
1. **NPM packages**: Must run "Build npm" in WeChat tools after install
2. **Images**: Will 404 if SVN images not checked out to `src/images/`
3. **Subpackages**: Patient pages are in `patient/` subpackage, not main package
4. **Skyline**: Enabled in rendererOptions, some components may behave differently
5. **Login flow**: App automatically calls `startLogin()` on launch; pages must wait via `app.zdWaitLogin()`
## 自定义导航栏规范
### 统一使用 navbar 组件
所有需要自定义导航的页面统一使用 `/components/navbar/index`。`/components/zd-navBar/navBar` 是历史遗留组件,新页面**不要使用**。
**JSON 配置:**
```json
{
"navigationStyle": "custom",
"usingComponents": {
"navbar": "/components/navbar/index"
}
}
```
**WXML 用法:**
```xml
<navbar fixed title="页面标题" custom-style="background:{{background}}">
<van-icon name="arrow-left" slot="left" size="18px" color="#000" bind:tap="handleBack" />
</navbar>
<view class="page" style="padding-top:{{pageTop+20}}px;">
```
**TS 实现:**
```typescript
handleBack() {
wx.navigateBack()
}
```
### 关键参数
| 属性 | 说明 | 常用值 |
| -------------- | ------------ | --------------------------- |
| `fixed` | 固定导航栏 | `fixed` |
| `title` | 导航栏标题 | 字符串 |
| `custom-style` | 自定义样式 | `background:{{background}}` |
| `leftArrow` | 显示返回箭头 | Boolean |
| `slot="left"` | 左侧插槽 | van-icon 返回按钮 |
| `bind:tap` | 返回按钮点击 | `handleBack` |
### pageTop 变量
`pageTop``utils/page.ts` wrapper 自动注入,表示导航栏高度。页面内容区使用 `padding-top:{{pageTop+20}}px;` 避开导航栏。**不要手动计算 navBarHeight**。
## 返回拦截与弹窗规范
### 使用自定义弹窗替代系统弹窗
**禁止使用** `wx.showModal` 进行操作确认,统一使用项目内的 popup 组件:
```json
{
"usingComponents": {
"popup": "/components/popup/index"
}
}
```
```xml
<popup show="{{popupShow}}" type="{{popupType}}" params="{{popupParams}}" bind:ok="handlePopupOk" bind:cancel="handlePopupCancel" />
```
### 常用 popup 类型
| 类型 | 场景 | 标题 | 确认按钮 | 取消按钮 |
| --------- | -------------- | ------------------ | -------- | -------- |
| `popup15` | 删除确认 | "确认删除记录?" | 确认删除 | 取消 |
| `popup16` | 未保存数据退出 | "您的记录还未保存" | 保存记录 | 退出 |
| `popup17` | 裁剪未保存退出 | "您有裁剪的照片" | 退出 | 取消 |
### popup 数据定义
```typescript
data: {
popupShow: false,
popupType: 'popup16',
popupParams: {
close: false,
position: 'center',
} as any,
}
```
### 返回拦截模式
当页面有未保存数据时,使用自定义导航 + `bind:back` 拦截返回:
```typescript
handleBack() {
if (this.hasUnsavedData()) {
this.setData({ popupShow: true, popupType: 'popup16' })
} else {
wx.navigateBack()
}
}
handlePopupOk() {
this.setData({ popupShow: false })
this.handleSave()
}
handlePopupCancel() {
this.setData({ popupShow: false })
wx.navigateBack()
}
```
**注意**:`navigationStyle: "custom"` 后系统返回手势无法拦截,只有 navBar 的返回按钮可被拦截。如需拦截系统返回手势,需要额外使用 `wx.enableAlertBeforeUnload`
## wx.cropImage 规范
`wx.cropImage``src` 参数**只接受本地文件路径**,不支持网络 URL。裁剪网络图片时必须先用 `wx.getImageInfo` 下载到本地:
```typescript
handleCrop(e: any) {
const index = e.currentTarget.dataset.index
const photo = this.data.photos[index]
wx.showLoading({ title: '加载中...' })
wx.getImageInfo({
src: photo.photoUrl,
success: (imgRes) => {
wx.hideLoading()
wx.cropImage({
src: imgRes.path,
cropScale: '9:16',
success: (cropRes) => {
photos[index].croppedUrl = cropRes.tempFilePath
this.setData({ photos })
},
fail: (err) => {
if (err.errMsg?.includes('cancel')) return
wx.showToast({ title: '裁剪取消', icon: 'none' })
},
})
},
fail: () => {
wx.hideLoading()
wx.showToast({ title: '图片加载失败', icon: 'none' })
},
})
}
```
## 子包组件引用规范
主包页面(`src/pages/`)**不能引用**子包组件(`src/patient/components/`)。如果主包和子包都需要使用某个组件,需要将组件复制到 `src/components/` 目录下。
示例:`image-merge` 组件在 `patient/components/``components/` 各有一份,分别供子包和主包页面使用。

169
AGENTS.md

@ -1,169 +0,0 @@
# AGENTS.md - 信达小程序 (Xinda Mini Program)
WeChat Mini Program for thyroid eye disease patient management. Dual-mode app serving both patients (患者端) and doctors (医生端).
## Project Type
- **Framework**: WeChat Mini Program v3.7.7
- **Language**: TypeScript (strict mode)
- **Styling**: SCSS/Sass via `useCompilerPlugins`
- **UI Library**: Vant Weapp (@vant/weapp)
- **Renderer**: Skyline enabled
- **App ID**: `wxf9ce8010f1ad24aa` (dev), `wx71ac9c27c3c3e3f4` (prod)
## Directory Structure
```
src/
├── pages/ # Doctor-side main pages (医生端)
├── patient/pages/ # Patient-side pages (subpackage)
├── gift/pages/ # DTP pharmacy pages (subpackage)
├── doc/pages/ # Privacy/terms documentation (subpackage)
├── components/ # Shared components
├── utils/ # Request, page/component wrappers, utilities
├── app.ts # App entry with global data
└── config.ts # Environment configs per appId
```
## Key Conventions
### Path Aliases
- `@/*``src/*` (configured in tsconfig.json and app.json `resolveAlias`)
- Use `@/utils/request` not relative paths
### User Types & Routing
Login types enforced in `app.ts`:
- `0`: Not logged in
- `1`: Patient (患者) → routes to `/patient/pages/*`
- `2`: Doctor (医生) → routes to `/pages/*`
Use `app.zdWaitLogin()` to guard pages that require login.
### Page/Component Wrappers
`app.ts` overrides global `Page` and `Component` with wrappers from `utils/page.ts` and `utils/component.ts`:
- Auto-sets `imageUrl` and `Timestamp` on page load
- Auto-handles navbar background on scroll
- Provides default share behavior
### Modal Colors (Required)
All `wx.showModal` must use:
```ts
wx.showModal({
confirmColor: '#8c75d0',
cancelColor: '#141515',
})
```
### App Instance (Required)
`getApp()` should be called at the top of the file, not inside methods:
````ts
// ✅ Correct
const app = getApp<IAppOption>()
Page({
onLoad() {
// Use app directly
console.log(app.globalData)
}
})
// ❌ Incorrect
Page({
onLoad() {
const app = getApp<IAppOption>()
console.log(app.globalData)
}
})```
## Available Commands
```bash
# Lint and auto-fix (only command available)
npm run lint:fix
# Install dependencies (pnpm preferred based on lockfile)
pnpm install
# Build: Use WeChat Developer Tools
# - Import project with src/ as root
# - project.config.json at repo root
````
## NPM Dependencies
**Critical**: After `npm install`, run **Tools → Build npm** in WeChat Developer Tools to generate `miniprogram_npm/`. This is required for Vant and other packages.
Key dependencies:
- `@vant/weapp`: UI components
- `miniprogram-licia`: Utility library (available as `licia`)
- `dayjs`: Date handling
- `mp-html`: Rich HTML rendering
## Images & Assets
**Images are stored in SVN, not git.**
- SVN URL: `svn://39.106.86.127:28386/projects/xd/proj_src/shop/frontend/web/xd/`
- Local path: `src/images/` (gitignored)
- Excluded from upload via `project.config.json` packOptions
- Only `/images/tabbar/*` is included in uploads
Image URL pattern:
```
{{imageUrl}}/path/to/image.png?t={{Timestamp}}
```
## TypeScript Configuration
- Strict mode enabled
- `noImplicitAny: false` (allows implicit any)
- Paths: `@/*` mapped to `src/*`
- Types: `miniprogram-api-typings` for WX API
Global types in `typings/index.d.ts`:
- `IAppOption`: Global app instance interface
- `pageType`: 0 | 1 | 2 for user types
- `wx.ajax`: Extended request method
## ESLint & Formatting
- Config: `@antfu/eslint-config` (flat config in `eslint.config.js`)
- Prettier: 2-space tabs, no semis, single quotes, trailing commas
- WXML files parsed as HTML, WXSS as CSS
- Globals defined: `wx`, `App`, `Page`, `Component`, `getCurrentPages`, etc.
## Environment Configuration
Selected by App ID in `src/config.ts`:
- `wxf9ce8010f1ad24aa`: Dev/Staging (hbraas.com)
- `wx71ac9c27c3c3e3f4`: Production (hbsaas.com)
App reads `wx.getAccountInfoSync().miniProgram.appId` on launch to select config.
## Testing & Development
- No unit test framework configured
- Manual testing via WeChat Developer Tools
- `project.private.config.json` has hot reload enabled (`compileHotReLoad: true`)
- Predefined test pages in `project.private.config.json` condition list
## Common Gotchas
1. **NPM packages**: Must run "Build npm" in WeChat tools after install
2. **Images**: Will 404 if SVN images not checked out to `src/images/`
3. **Subpackages**: Patient pages are in `patient/` subpackage, not main package
4. **Skyline**: Enabled in rendererOptions, some components may behave differently
5. **Login flow**: App automatically calls `startLogin()` on launch; pages must wait via `app.zdWaitLogin()`

8
src/components/image-merge/index.json

@ -0,0 +1,8 @@
{
"component": true,
"usingComponents": {
"van-button": "@vant/weapp/button/index",
"van-icon": "@vant/weapp/icon/index",
"van-toast": "@vant/weapp/toast/index"
}
}

7
src/components/image-merge/index.scss

@ -0,0 +1,7 @@
.merge-canvas {
position: fixed;
left: -9999px;
top: -9999px;
width: 1px;
height: 1px;
}

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

@ -0,0 +1,186 @@
interface ImageItem {
src: string
time?: string
}
Component({
properties: {
id: {
type: String,
value: 'default',
},
},
data: {
imageList: [] as ImageItem[],
mergedImage: '',
isLoading: false,
},
methods: {
mergeImages(imageList: ImageItem[]) {
if (this.data.isLoading) {
return
}
if (!imageList || imageList.length < 2) {
wx.showToast({ title: '至少需要2张图片才能拼接', icon: 'none' })
return
}
wx.showLoading({ title: '拼接中...', mask: true })
this.setData({ isLoading: true, imageList })
this.validateImages(imageList)
.then(() => this.drawImagesOnCanvas())
.then((mergedImage) => {
wx.hideLoading()
this.setData({
mergedImage,
isLoading: false,
})
this.triggerEvent('save', { tempFilePath: mergedImage })
wx.previewImage({
urls: [mergedImage],
current: mergedImage,
showmenu: true,
})
})
.catch((error) => {
wx.hideLoading()
console.error('拼接失败:', error)
this.setData({ isLoading: false })
wx.showToast({ title: error.message || '拼接失败,请重试', icon: 'none' })
this.triggerEvent('error', { reason: error.message })
})
},
validateImages(imageList: ImageItem[]): Promise<void> {
return Promise.all(
imageList.map(
(item, index) =>
new Promise<void>((resolve, reject) => {
wx.getImageInfo({
src: item.src,
success: () => resolve(),
fail: () => reject(new Error(`${index + 1}张图片加载失败`)),
})
}),
),
)
},
formatTime(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
},
drawImagesOnCanvas(): Promise<string> {
return new Promise((resolve, reject) => {
const { imageList, id } = this.data
const query = this.createSelectorQuery()
query
.in(this)
.select(`#mergeCanvas-${id}`)
.fields({ node: true, size: true })
.exec((res) => {
if (!res[0]) {
reject(new Error('Canvas not found'))
return
}
const canvas = res[0].node
const ctx = canvas.getContext('2d')
Promise.all(imageList.map(item => this.getImageInfo(item.src)))
.then((imageInfos) => {
const targetWidth = 750
const pixelRatio = wx.getWindowInfo().pixelRatio
const canvasWidth = Math.floor((targetWidth * pixelRatio) / 2)
let totalHeight = 0
const scaledHeights: number[] = []
imageInfos.forEach((info) => {
const scale = canvasWidth / info.width
const scaledHeight = Math.floor(info.height * scale)
scaledHeights.push(scaledHeight)
totalHeight += scaledHeight
})
canvas.width = canvasWidth
canvas.height = totalHeight
let currentY = 0
let loadedCount = 0
imageInfos.forEach((info, index) => {
const img = canvas.createImage()
img.src = info.path
img.onload = () => {
ctx.drawImage(img, 0, currentY, canvasWidth, scaledHeights[index])
const timeText = imageList[index].time || this.formatTime(new Date())
const padding = 20
const textY = currentY + 40
ctx.fillStyle = '#ffffff'
ctx.font = 'bold 28px sans-serif'
ctx.textAlign = 'left'
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'
ctx.shadowBlur = 4
ctx.shadowOffsetX = 1
ctx.shadowOffsetY = 1
ctx.fillText(timeText, padding, textY)
ctx.shadowColor = 'transparent'
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
if (index === imageInfos.length - 1) {
const lastImageBottom = currentY + scaledHeights[index]
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
ctx.font = '24px sans-serif'
ctx.textAlign = 'right'
ctx.fillText('由-TED关爱小助手-小程序生成', canvasWidth - 20, lastImageBottom - 20)
}
currentY += scaledHeights[index]
loadedCount++
if (loadedCount === imageInfos.length) {
wx.canvasToTempFilePath({
canvas,
success: (result) => {
resolve(result.tempFilePath)
},
fail: reject,
})
}
}
img.onerror = () => {
reject(new Error(`Failed to load image: ${info.path}`))
}
})
})
.catch(reject)
})
})
},
getImageInfo(src: string): Promise<WechatMiniprogram.GetImageInfoSuccessCallbackResult> {
return new Promise((resolve, reject) => {
wx.getImageInfo({
src,
success: resolve,
fail: reject,
})
})
},
},
})

1
src/components/image-merge/index.wxml

@ -0,0 +1 @@
<canvas type="2d" id="mergeCanvas-{{id}}" class="merge-canvas"></canvas>

14
src/components/noteImagePreview/index.ts

@ -1,5 +1,17 @@
Component({ Component({
properties: {}, properties: {
visible: {
type: Boolean,
value: false,
observer(newVal) {
this.setData({ visible: newVal })
},
},
src: {
type: String,
value: '',
},
},
data: { data: {
visible: false, visible: false,

60
src/components/popup/index.scss

@ -596,46 +596,58 @@
} }
.popup17 { .popup17 {
.badge {
position: relative;
z-index: 1;
display: block;
width: 144rpx;
height: 144rpx;
margin: 0 auto -72rpx;
text-align: center;
}
.popup-container { .popup-container {
width: 100vw; width: 670rpx;
box-sizing: border-box; box-sizing: border-box;
padding: 0 48rpx 132rpx; padding: 118rpx 48rpx 44rpx;
border-radius: 32rpx; border-radius: 32rpx;
background: #fff; background: #fff;
box-sizing: border-box;
background: linear-gradient(360deg, #f1e6ff 0%, #f6f8f9 49.07%, #f6f8f9 100%);
.title { .title {
padding: 32rpx 0; font-size: 40rpx;
font-size: 36rpx;
color: #211d2e; color: #211d2e;
font-weight: bold; font-weight: bold;
display: flex; text-align: center;
justify-content: space-between;
align-items: center;
} }
.select { .p-footer {
margin-top: 32rpx; margin-top: 52rpx;
display: flex; display: flex;
align-items: center; gap: 30rpx;
gap: 34rpx; .sure,
.item { .cancel {
flex: 1; flex: 1;
padding: 64rpx;
text-align: center;
background: #ffffff;
border-radius: 24rpx 24rpx 24rpx 24rpx;
.icon {
width: 116rpx;
height: 116rpx;
}
.name {
font-size: 36rpx; font-size: 36rpx;
color: #211d2e; border-radius: 100rpx;
height: 88rpx;
box-sizing: border-box;
} }
.cancel {
color: #b982ff;
border: 1px solid #b982ff;
display: flex;
align-items: center;
justify-content: center;
} }
.sure {
padding: 0;
background: linear-gradient(344deg, #ffbcf9 0%, #b982ff 100%);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
} }
} }
} }
}
.close { .close {
margin: 48rpx auto 0; margin: 48rpx auto 0;

14
src/components/popup/index.wxml

@ -227,6 +227,20 @@
</view> </view>
</view> </view>
</view> </view>
<view class="popup17" wx:if="{{type==='popup17'}}">
<image class="badge" src="{{imageUrl}}icon156.png?t={{Timestamp}}"></image>
<view class="popup-container">
<view class="title">
您有裁剪的照片
<view></view>
现在退出会被清空
</view>
<view class="p-footer">
<view class="cancel" bind:tap="handleCancel">取消</view>
<view class="sure" bind:tap="handleOk">继续退出</view>
</view>
</view>
</view>
<image <image
wx:if="{{params.close}}" wx:if="{{params.close}}"

36
src/pages/d_noteDetail/index.ts

@ -25,8 +25,15 @@ interface RecordDetail {
Page({ Page({
data: { data: {
patientId: '', patientId: '',
patientName: '',
recordId: '', recordId: '',
recordDetail: {} as RecordDetail, recordDetail: {} as RecordDetail,
photoMap: {} as Record<string, Photo>,
// 有照片的角度分组
frontendPhotos: [] as Photo[],
backendPhotos: [] as Photo[],
otherPhotos: [] as Photo[],
// 角度分组 // 角度分组
angleGroups: { angleGroups: {
@ -58,6 +65,7 @@ Page({
onLoad(option: any) { onLoad(option: any) {
this.setData({ this.setData({
patientId: option.patientId || '', patientId: option.patientId || '',
patientName: option.patientName || '',
recordId: option.recordId || '', recordId: option.recordId || '',
}) })
}, },
@ -81,8 +89,26 @@ Page({
}, },
}).then((res: any) => { }).then((res: any) => {
wx.hideLoading() wx.hideLoading()
const photoMap: Record<string, Photo> = {}
const photos = res.photos || []
photos.forEach((p: Photo) => {
if (p.photoUrl) {
photoMap[p.photoAngle] = p
}
})
// 按分组过滤有照片的角度
const { frontend, backend, other } = this.data.angleGroups
const frontendPhotos = frontend.filter(angle => photoMap[angle]).map(angle => photoMap[angle])
const backendPhotos = backend.filter(angle => photoMap[angle]).map(angle => photoMap[angle])
const otherPhotos = other.filter(angle => photoMap[angle]).map(angle => photoMap[angle])
this.setData({ this.setData({
recordDetail: res, recordDetail: res,
photoMap,
frontendPhotos,
backendPhotos,
otherPhotos,
}) })
}).catch((err) => { }).catch((err) => {
wx.hideLoading() wx.hideLoading()
@ -91,12 +117,6 @@ Page({
}) })
}, },
// 获取指定角度的照片
getPhotoByAngle(angle: string): Photo | undefined {
const photos = this.data.recordDetail.photos || []
return photos.find((p: Photo) => p.photoAngle === angle)
},
// 返回上一页 // 返回上一页
handleBack() { handleBack() {
wx.navigateBack() wx.navigateBack()
@ -105,7 +125,7 @@ Page({
// 预览图片 // 预览图片
handlePreview(e: any) { handlePreview(e: any) {
const { angle } = e.currentTarget.dataset const { angle } = e.currentTarget.dataset
const photo = this.getPhotoByAngle(angle) const photo = this.data.photoMap[angle]
if (photo) { if (photo) {
wx.previewImage({ wx.previewImage({
urls: [photo.photoUrl], urls: [photo.photoUrl],
@ -117,7 +137,7 @@ Page({
// 眼突度对比 // 眼突度对比
handleDiffData() { handleDiffData() {
wx.navigateTo({ wx.navigateTo({
url: `/pages/d_noteDiffData/index?patientId=${this.data.patientId}`, url: `/pages/d_noteDiffData/index?patientId=${this.data.patientId}&patientName=${this.data.patientName}`,
}) })
}, },

27
src/pages/d_noteDetail/index.wxml

@ -39,33 +39,30 @@
</view> </view>
</view> </view>
</view> </view>
<view class="card"> <view class="card" wx:if="{{frontendPhotos.length > 0}}">
<view class="card-title">正面</view> <view class="card-title">正面</view>
<view class="card-container"> <view class="card-container">
<view class="card-item" wx:for="{{angleGroups.frontend}}" wx:key="*this" data-angle="{{item}}" bind:tap="handlePreview"> <view class="card-item" wx:for="{{frontendPhotos}}" wx:key="photoAngle" data-angle="{{item.photoAngle}}" bind:tap="handlePreview">
<image wx:if="{{getPhotoByAngle(item)}}" class="photo" mode="aspectFill" src="{{getPhotoByAngle(item).photoUrl}}"></image> <image class="photo" mode="aspectFill" src="{{item.photoUrl}}"></image>
<image wx:else class="photo photo-empty" mode="aspectFill" src="{{imageUrl}}note-demo1.png?t={{Timestamp}}"></image> <view class="name">{{item.photoAngleName}}</view>
<view class="name">{{angleNameMap[item]}}</view>
</view> </view>
</view> </view>
</view> </view>
<view class="card"> <view class="card" wx:if="{{backendPhotos.length > 0}}">
<view class="card-title">侧面</view> <view class="card-title">侧面</view>
<view class="card-container"> <view class="card-container">
<view class="card-item" wx:for="{{angleGroups.backend}}" wx:key="*this" data-angle="{{item}}" bind:tap="handlePreview"> <view class="card-item" wx:for="{{backendPhotos}}" wx:key="photoAngle" data-angle="{{item.photoAngle}}" bind:tap="handlePreview">
<image wx:if="{{getPhotoByAngle(item)}}" class="photo" mode="aspectFill" src="{{getPhotoByAngle(item).photoUrl}}"></image> <image class="photo" mode="aspectFill" src="{{item.photoUrl}}"></image>
<image wx:else class="photo photo-empty" mode="aspectFill" src="{{imageUrl}}note-demo1.png?t={{Timestamp}}"></image> <view class="name">{{item.photoAngleName}}</view>
<view class="name">{{angleNameMap[item]}}</view>
</view> </view>
</view> </view>
</view> </view>
<view class="card"> <view class="card" wx:if="{{otherPhotos.length > 0}}">
<view class="card-title">眼球运动八个方向</view> <view class="card-title">眼球运动八个方向</view>
<view class="card-container"> <view class="card-container">
<view class="card-item" wx:for="{{angleGroups.other}}" wx:key="*this" data-angle="{{item}}" bind:tap="handlePreview"> <view class="card-item" wx:for="{{otherPhotos}}" wx:key="photoAngle" data-angle="{{item.photoAngle}}" bind:tap="handlePreview">
<image wx:if="{{getPhotoByAngle(item)}}" class="photo" mode="aspectFill" src="{{getPhotoByAngle(item).photoUrl}}"></image> <image class="photo" mode="aspectFill" src="{{item.photoUrl}}"></image>
<image wx:else class="photo photo-empty" mode="aspectFill" src="{{imageUrl}}note-demo1.png?t={{Timestamp}}"></image> <view class="name">{{item.photoAngleName}}</view>
<view class="name">{{angleNameMap[item]}}</view>
</view> </view>
</view> </view>
</view> </view>

5
src/pages/d_noteDiff/index.json

@ -1,4 +1,7 @@
{ {
"navigationBarTitleText": "照片对比", "navigationBarTitleText": "照片对比",
"usingComponents": {} "usingComponents": {
"navbar": "/components/navbar/index",
"van-icon": "@vant/weapp/icon/index"
}
} }

23
src/pages/d_noteDiff/index.scss

@ -3,7 +3,7 @@ page {
} }
.page { .page {
padding-bottom: 80rpx; padding-bottom: 160rpx;
.page-tip { .page-tip {
padding: 24rpx 28rpx; padding: 24rpx 28rpx;
@ -272,4 +272,25 @@ page {
} }
} }
} }
.footer-fixed {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 24rpx 40rpx 48rpx;
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;
}
}
} }

29
src/pages/d_noteDiff/index.ts

@ -86,25 +86,17 @@ Page({
getCompareDates() { getCompareDates() {
if (!this.data.photoAngle) if (!this.data.photoAngle)
return return
// 医生端接口文档未提供 compare-dates:用记录列表推导 baseline/nonBaseline
wx.ajax({ wx.ajax({
method: 'GET', method: 'GET',
url: '?r=xd/doctor/proptosis/list', url: '?r=xd/doctor/proptosis/compare-dates',
data: { data: {
patientId: this.data.patientId, patientId: this.data.patientId,
page: 1, photoAngle: this.data.photoAngle,
pageSize: 1000,
}, },
}).then((res: any) => { }).then((res: any) => {
const list: ListItem[] = res.list || []
const baselineItem = list.find(i => i.isBaseline === 1) || null
const nonBaselineList: CompareDate[] = list
.filter(i => i.isBaseline !== 1)
.map(i => ({ recordId: i.recordId, recordDate: i.recordDate }))
this.setData({ this.setData({
baseline: baselineItem ? { recordId: baselineItem.recordId, recordDate: baselineItem.recordDate } : null, baseline: res.baseline || null,
nonBaselineList, nonBaselineList: res.nonBaselineList || [],
}) })
}).catch((err) => { }).catch((err) => {
console.error('获取对比日期失败:', err) console.error('获取对比日期失败:', err)
@ -136,7 +128,12 @@ Page({
else { else {
selectedDates.push(recordId) selectedDates.push(recordId)
} }
this.setData({ selectedDates }) // 更新列表中的选中状态
const nonBaselineList = this.data.nonBaselineList.map((item: CompareDate & { isSelected?: boolean }) => ({
...item,
isSelected: selectedDates.includes(item.recordId),
}))
this.setData({ selectedDates, nonBaselineList })
// 获取对比照片 // 获取对比照片
this.getComparePhotos() this.getComparePhotos()
}, },
@ -187,7 +184,7 @@ Page({
}) })
}) })
this.setData({ this.setData({
comparePhotos: merged, comparePhotos: merged.filter(item => item.photoUrl),
}) })
}).catch((err) => { }).catch((err) => {
console.error('获取对比照片失败:', err) console.error('获取对比照片失败:', err)
@ -207,6 +204,10 @@ Page({
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(',')}`,
}) })
}, },
handleBack() {
wx.navigateBack()
},
}) })
export {} export {}

12
src/pages/d_noteDiff/index.wxml

@ -1,4 +1,8 @@
<view class="page"> <navbar fixed title="照片对比" custom-style="background:{{background}}">
<van-icon name="arrow-left" slot="left" size="18px" color="#000" bind:tap="handleBack" />
</navbar>
<view class="page" style="padding-top:{{pageTop+20}}px;">
<view class="page-tip"> <view class="page-tip">
<image class="icon" src="{{imageUrl}}icon154.png?t={{Timestamp}}"></image> <image class="icon" src="{{imageUrl}}icon154.png?t={{Timestamp}}"></image>
<view class="content"> <view class="content">
@ -19,7 +23,7 @@
<view class="title">选择对比日期(可多选)</view> <view class="title">选择对比日期(可多选)</view>
<view class="multiple"> <view class="multiple">
<view <view
class="item {{selectedDates.indexOf(item.recordId) > -1 ? 'active' : ''}}" class="item {{item.isSelected ? 'active' : ''}}"
wx:for="{{nonBaselineList}}" wx:for="{{nonBaselineList}}"
wx:key="recordId" wx:key="recordId"
data-record-id="{{item.recordId}}" data-record-id="{{item.recordId}}"
@ -74,8 +78,8 @@
</view> </view>
</view> </view>
</view> </view>
<view class="footer">
<view class="btn1" bind:tap="handleEdit">生成对比图</view>
</view> </view>
<view class="footer-fixed" wx:if="{{comparePhotos.length > 0}}">
<view class="btn1" bind:tap="handleEdit">生成对比图</view>
</view> </view>
</view> </view>

7
src/pages/d_noteDiffData/index.json

@ -1,5 +1,6 @@
{ {
"navigationBarTitleText": "xxx的眼突度对比", "navigationBarTitleText": "眼突度对比",
"navigationStyle": "default", "usingComponents": {
"usingComponents": {} "navbar": "/components/navbar/index"
}
} }

71
src/pages/d_noteDiffData/index.ts

@ -18,9 +18,13 @@ Page({
}, },
onLoad(option: any) { onLoad(option: any) {
const patientName = option.patientName || ''
this.setData({ this.setData({
patientId: option.patientId || '', patientId: option.patientId || '',
}) })
if (patientName) {
wx.setNavigationBarTitle({ title: `${patientName}的眼突度对比` })
}
}, },
onShow() { onShow() {
@ -40,7 +44,7 @@ Page({
data: { data: {
patientId: this.data.patientId, patientId: this.data.patientId,
page: 1, page: 1,
pageSize: 1000, // 尽量拉全,避免导出/对比缺数据 pageSize: 1000,
}, },
}).then((res: any) => { }).then((res: any) => {
const list: CompareItem[] = (res.list || []) const list: CompareItem[] = (res.list || [])
@ -57,69 +61,8 @@ Page({
}) })
}, },
// 导出Excel - 直接使用下载链接 handleBack() {
handleExport() { wx.navigateBack()
wx.showLoading({ title: '导出中...' })
// 构建导出URL:该接口为业务接口,优先使用 globalData.url
const baseUrl = String(app.globalData.url || '').replace(/\/$/, '')
const exportUrl = `${baseUrl}/?r=xd/doctor/proptosis/export&patientId=${this.data.patientId}`
wx.downloadFile({
url: exportUrl,
success: (downloadRes) => {
wx.hideLoading()
if (downloadRes.statusCode === 200) {
const filePath = downloadRes.tempFilePath
// 保存到本地文件系统
const fs = wx.getFileSystemManager()
const savedFilePath = `${wx.env.USER_DATA_PATH}/凸眼度对比_${new Date().toISOString().split('T')[0]}.xlsx`
fs.saveFile({
tempFilePath: filePath,
filePath: savedFilePath,
success: () => {
// 打开文档
wx.openDocument({
filePath: savedFilePath,
fileType: 'xlsx',
showMenu: true,
success: () => {
wx.showToast({ title: '导出成功', icon: 'success' })
},
fail: (err) => {
console.error('打开文档失败:', err)
wx.showToast({ title: '打开文件失败', icon: 'none' })
},
})
},
fail: (err) => {
console.error('保存文件失败:', err)
// 直接打开临时文件
wx.openDocument({
filePath,
fileType: 'xlsx',
showMenu: true,
success: () => {
wx.showToast({ title: '导出成功', icon: 'success' })
},
fail: () => {
wx.showToast({ title: '导出失败', icon: 'none' })
},
})
},
})
}
else {
wx.showToast({ title: '导出失败', icon: 'none' })
}
},
fail: (err) => {
wx.hideLoading()
console.error('下载失败:', err)
wx.showToast({ title: '导出失败', icon: 'none' })
},
})
}, },
}) })

15
src/pages/d_noteDiffData/index.wxml

@ -1,6 +1,10 @@
<view class="container"> <navbar fixed title="眼突度对比" custom-style="background:{{background}}">
<van-icon name="arrow-left" slot="left" size="18px" color="#000" bind:tap="handleBack" />
</navbar>
<view class="container" style="padding-top:{{pageTop+20}}px;">
<!-- 表格区域 --> <!-- 表格区域 -->
<view class="table-wrapper"> <view class="table-wrapper" wx:if="{{dataList.length > 0}}">
<!-- 吸顶表头 --> <!-- 吸顶表头 -->
<view class="table-header sticky"> <view class="table-header sticky">
<view class="th th-date">日期</view> <view class="th th-date">日期</view>
@ -33,8 +37,9 @@
</view> </view>
</view> </view>
<!-- 导出按钮 --> <!-- 空状态 -->
<view class="footer"> <view class="empty" wx:if="{{!loading && dataList.length === 0}}">
<view class="export-btn" bind:tap="handleExport">导出Excel</view> <text>暂无对比数据</text>
</view> </view>
</view> </view>

4
src/pages/d_noteDiffEdit/index.json

@ -1,6 +1,8 @@
{ {
"navigationBarTitleText": "对比图编辑", "navigationBarTitleText": "对比图编辑",
"usingComponents": { "usingComponents": {
"imageMerge": "/patient/components/image-merge/index" "navbar": "/components/navbar/index",
"imageMerge": "/components/image-merge/index",
"popup": "/components/popup/index"
} }
} }

72
src/pages/d_noteDiffEdit/index.scss

@ -29,7 +29,7 @@ page {
width: 8rpx; width: 8rpx;
height: 32rpx; height: 32rpx;
background: #b982ff; background: #b982ff;
border-radius: 44rpx 44rpx 44rpx 44rpx; border-radius: 44rpx;
} }
} }
@ -115,6 +115,7 @@ page {
.photo-card { .photo-card {
margin-top: 24rpx; margin-top: 24rpx;
position: relative;
.photo { .photo {
border-radius: 32rpx; border-radius: 32rpx;
@ -122,6 +123,27 @@ page {
display: block; display: block;
width: 100%; width: 100%;
} }
.mask {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 88rpx;
background: rgba(0, 0, 0, 0.5);
border-radius: 0 0 32rpx 32rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #fff;
.icon {
width: 32rpx;
height: 32rpx;
margin-right: 12rpx;
}
}
} }
} }
} }
@ -137,27 +159,57 @@ page {
gap: 24rpx; gap: 24rpx;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.05); box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.05);
.btn1, .btn1 {
.btn2 {
flex: 1; flex: 1;
height: 88rpx; height: 88rpx;
font-size: 32rpx; font-size: 36rpx;
color: #ffffff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 100rpx;
}
.btn1 {
color: #ffffff;
background: linear-gradient(0deg, #e98ff8 0%, #b073ff 100%); background: linear-gradient(0deg, #e98ff8 0%, #b073ff 100%);
border-radius: 100rpx;
} }
.btn2 { .btn2 {
flex: 1;
height: 88rpx;
font-size: 36rpx;
color: #b073ff; color: #b073ff;
background: rgba(176, 115, 255, 0.1); display: flex;
align-items: center;
justify-content: center;
background: #fff;
border: 2rpx solid #b073ff; border: 2rpx solid #b073ff;
border-radius: 100rpx;
} }
} }
} }
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 40rpx;
.loading-text {
font-size: 28rpx;
color: #999;
margin-top: 20rpx;
}
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 40rpx;
.empty-text {
font-size: 28rpx;
color: #999;
}
}
} }

112
src/pages/d_noteDiffEdit/index.ts

@ -7,16 +7,23 @@ interface PhotoItem {
recordDate: string recordDate: string
isBaseline: number isBaseline: number
treatmentCount: number treatmentCount: number
isCropped?: boolean
croppedUrl?: string
} }
Page({ Page({
data: { data: {
popupShow: false,
popupType: 'popup17',
popupParams: {},
patientId: '', patientId: '',
photoAngle: '', photoAngle: '',
photoAngleName: '', photoAngleName: '',
recordIds: [] as string[], recordIds: [] as string[],
photos: [] as PhotoItem[], photos: [] as PhotoItem[],
loading: false, loading: false,
mergedImage: '',
}, },
onLoad(option: any) { onLoad(option: any) {
@ -82,6 +89,8 @@ Page({
recordDate: baselineRes.recordDate, recordDate: baselineRes.recordDate,
isBaseline: 1, isBaseline: 1,
treatmentCount: baselineRes.treatmentCount, treatmentCount: baselineRes.treatmentCount,
isCropped: false,
croppedUrl: '',
}) })
} }
compareList.forEach((item: any) => { compareList.forEach((item: any) => {
@ -92,10 +101,12 @@ Page({
recordDate: item.recordDate, recordDate: item.recordDate,
isBaseline: 0, isBaseline: 0,
treatmentCount: item.treatmentCount, treatmentCount: item.treatmentCount,
isCropped: false,
croppedUrl: '',
}) })
}) })
this.setData({ this.setData({
photos, photos: photos.filter(item => item.photoUrl),
loading: false, loading: false,
}) })
}).catch((err) => { }).catch((err) => {
@ -105,9 +116,55 @@ Page({
}) })
}, },
// 点击裁剪
handleCrop(e: any) {
const index = e.currentTarget.dataset.index
const photos = this.data.photos
const photo = photos[index]
wx.showLoading({ title: '加载中...' })
wx.getImageInfo({
src: photo.photoUrl,
success: (imgRes) => {
wx.hideLoading()
wx.cropImage({
src: imgRes.path,
cropScale: '9:16',
success: (cropRes) => {
photos[index].isCropped = true
photos[index].croppedUrl = cropRes.tempFilePath
this.setData({ photos })
wx.showToast({ title: '裁剪成功', icon: 'success' })
},
fail: (err) => {
console.error('裁剪失败:', err)
if (err.errMsg?.includes('cancel')) {
return
}
wx.showToast({ title: '裁剪取消', icon: 'none' })
},
})
},
fail: (err) => {
wx.hideLoading()
console.error('图片加载失败:', err)
wx.showToast({ title: '图片加载失败', icon: 'none' })
},
})
},
// 点击还原
handleRestore(e: any) {
const index = e.currentTarget.dataset.index
const photos = this.data.photos
photos[index].isCropped = false
photos[index].croppedUrl = ''
this.setData({ photos })
},
// 生成对比图预览 // 生成对比图预览
handleMergePreview() { handleMergePreview() {
const { photos, photoAngleName } = this.data const { photos } = this.data
if (photos.length === 0) { if (photos.length === 0) {
wx.showToast({ title: '暂无照片', icon: 'none' }) wx.showToast({ title: '暂无照片', icon: 'none' })
return return
@ -120,21 +177,24 @@ Page({
? `基准照片 ${item.recordDate}` ? `基准照片 ${item.recordDate}`
: `对比照片 ${item.recordDate} 替妥尤单抗:${item.treatmentCount}` : `对比照片 ${item.recordDate} 替妥尤单抗:${item.treatmentCount}`
return { return {
src: item.photoUrl, src: item.croppedUrl || item.photoUrl,
time: label, time: label,
} }
}) })
mergeComponent.mergeImages(imageList, { mergeComponent.mergeImages(imageList)
title: `${photoAngleName}时间线对比`,
})
} }
}, },
// 保存成功回调 // 保存到相册
onMergeSave(e: any) { handleSaveAlbum() {
const { tempFilePath } = e.detail const { mergedImage } = this.data
if (!mergedImage) {
this.handleMergePreview()
return
}
wx.saveImageToPhotosAlbum({ wx.saveImageToPhotosAlbum({
filePath: tempFilePath, filePath: mergedImage,
success: () => { success: () => {
wx.showToast({ title: '保存成功', icon: 'success' }) wx.showToast({ title: '保存成功', icon: 'success' })
}, },
@ -145,12 +205,44 @@ Page({
}) })
}, },
// 保存成功回调
onMergeSave(e: any) {
const { tempFilePath } = e.detail
this.setData({ mergedImage: tempFilePath })
},
// 合并失败回调 // 合并失败回调
onMergeError(e: any) { onMergeError(e: any) {
const { error } = e.detail const { error } = e.detail
console.error('合并图片失败:', error) console.error('合并图片失败:', error)
wx.showToast({ title: '生成对比图失败', icon: 'none' }) wx.showToast({ title: '生成对比图失败', icon: 'none' })
}, },
// 是否有裁剪过的照片
hasCroppedPhoto(): boolean {
return this.data.photos.some(item => item.isCropped)
},
// 返回上一页
handleBack() {
if (this.hasCroppedPhoto()) {
this.setData({ popupShow: true })
}
else {
wx.navigateBack()
}
},
// popup 确认(继续退出)
handlePopupOk() {
this.setData({ popupShow: false })
wx.navigateBack()
},
// popup 取消
handlePopupCancel() {
this.setData({ popupShow: false })
},
}) })
export {} export {}

33
src/pages/d_noteDiffEdit/index.wxml

@ -1,5 +1,9 @@
<view class="page"> <navbar fixed title="对比图编辑" custom-style="background:{{background}}">
<view class="container"> <van-icon name="arrow-left" slot="left" size="18px" color="#000" bind:tap="handleBack" />
</navbar>
<view class="page" style="padding-top:{{pageTop+20}}px;">
<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">生成日期:{{photos[0].recordDate}}</view>
@ -18,15 +22,36 @@
<view class="tag tag2">替妥尤单抗:{{item.treatmentCount}}</view> <view class="tag tag2">替妥尤单抗:{{item.treatmentCount}}</view>
</view> </view>
<view class="photo-card"> <view class="photo-card">
<image class="photo" src="{{item.photoUrl}}" mode="aspectFill"></image> <image class="photo" src="{{item.croppedUrl || item.photoUrl}}" mode="aspectFill"></image>
<view class="mask" wx:if="{{!item.isCropped}}" bind:tap="handleCrop" data-index="{{index}}">
<image class="icon" src="{{imageUrl}}icon165.png?t={{Timestamp}}"></image>
点击裁剪
</view>
<view class="mask" wx:else bind:tap="handleRestore" data-index="{{index}}">
<image class="icon" src="{{imageUrl}}icon166.png?t={{Timestamp}}"></image>
点击还原
</view>
</view> </view>
</view> </view>
</view> </view>
<view class="footer"> <view class="footer">
<view class="btn1" bind:tap="handleMergePreview">对比图预览</view> <view class="btn1" bind:tap="handleMergePreview">对比图预览</view>
<view class="btn2" bind:tap="handleMergePreview">保存到相册</view> <view class="btn2" bind:tap="handleSaveAlbum">保存到相册</view>
</view>
</view> </view>
<!-- 加载中 -->
<view class="loading" wx:if="{{loading}}">
<van-loading type="spinner" color="#8c75d0"></van-loading>
<text class="loading-text">加载中...</text>
</view>
<!-- 空状态 -->
<view class="empty" wx:if="{{!loading && photos.length === 0}}">
<text>暂无对比照片</text>
</view> </view>
</view> </view>
<imageMerge id="merge" bindsave="onMergeSave" binderror="onMergeError" /> <imageMerge id="merge" bindsave="onMergeSave" binderror="onMergeError" />
<popup id="popup" show="{{popupShow}}" type="{{popupType}}" params="{{popupParams}}" bind:ok="handlePopupOk" bind:cancel="handlePopupCancel" />

5
src/pages/d_noteList/index.json

@ -1,7 +1,8 @@
{ {
"navigationStyle": "default",
"usingComponents": { "usingComponents": {
"van-icon": "@vant/weapp/icon/index" "navbar": "/components/navbar/index",
"van-icon": "@vant/weapp/icon/index",
"pagination": "/components/pagination/index"
}, },
"navigationBarTitleText": "记录" "navigationBarTitleText": "记录"
} }

21
src/pages/d_noteList/index.ts

@ -16,17 +16,24 @@ interface RecordItem {
Page({ Page({
data: { data: {
patientId: '', patientId: '',
patientName: '',
recordList: [] as RecordItem[], recordList: [] as RecordItem[],
total: 0, total: 0,
page: 1, page: 1,
pageSize: 10, pageSize: 10,
loading: false, loading: false,
hasMore: true, hasMore: true,
pagination: {
count: 0,
page: 1,
pages: 1,
},
}, },
onLoad(option: any) { onLoad(option: any) {
this.setData({ this.setData({
patientId: option.patientId || '', patientId: option.patientId || '',
patientName: option.patientName || '',
}) })
}, },
@ -57,6 +64,7 @@ Page({
}).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
this.setData({ this.setData({
recordList: reset ? list : [...this.data.recordList, ...list], recordList: reset ? list : [...this.data.recordList, ...list],
@ -64,6 +72,11 @@ Page({
page: page + 1, page: page + 1,
hasMore: list.length >= this.data.pageSize, hasMore: list.length >= this.data.pageSize,
loading: false, loading: false,
pagination: {
count: total,
page,
pages,
},
}) })
}).catch((err) => { }).catch((err) => {
console.error('获取记录列表失败:', err) console.error('获取记录列表失败:', err)
@ -80,14 +93,14 @@ Page({
handleHistory(e: any) { handleHistory(e: any) {
const { recordId } = e.currentTarget.dataset const { recordId } = e.currentTarget.dataset
wx.navigateTo({ wx.navigateTo({
url: `/pages/d_noteDetail/index?recordId=${recordId}&patientId=${this.data.patientId}`, url: `/pages/d_noteDetail/index?recordId=${recordId}&patientId=${this.data.patientId}&patientName=${this.data.patientName}`,
}) })
}, },
// 眼突度对比 // 眼突度对比
handleDiffData() { handleDiffData() {
wx.navigateTo({ wx.navigateTo({
url: `/pages/d_noteDiffData/index?patientId=${this.data.patientId}`, url: `/pages/d_noteDiffData/index?patientId=${this.data.patientId}&patientName=${this.data.patientName}`,
}) })
}, },
@ -97,6 +110,10 @@ Page({
url: `/pages/d_noteDiff/index?patientId=${this.data.patientId}`, url: `/pages/d_noteDiff/index?patientId=${this.data.patientId}`,
}) })
}, },
handleBack() {
wx.navigateBack()
},
}) })
export {} export {}

15
src/pages/d_noteList/index.wxml

@ -1,5 +1,9 @@
<view class="page"> <navbar fixed title="记录" custom-style="background:{{background}}">
<view class="total">共{{total}}条日记记录</view> <van-icon name="arrow-left" slot="left" size="18px" color="#000" bind:tap="handleBack" />
</navbar>
<view class="page" style="padding-top:{{pageTop+20}}px;">
<view class="total" wx:if="{{recordList.length > 0}}">共{{total}}条日记记录</view>
<view class="history-list"> <view class="history-list">
<view class="list-item" wx:for="{{recordList}}" wx:key="recordId" data-record-id="{{item.recordId}}" bind:tap="handleHistory"> <view class="list-item" wx:for="{{recordList}}" wx:key="recordId" data-record-id="{{item.recordId}}" bind:tap="handleHistory">
<view wx:if="{{item.isBaseline === 1}}" class="benchmark" style="background: url('{{imageUrl}}bg50.png?t={{Timestamp}}') no-repeat top center/100%"> <view wx:if="{{item.isBaseline === 1}}" class="benchmark" style="background: url('{{imageUrl}}bg50.png?t={{Timestamp}}') no-repeat top center/100%">
@ -13,10 +17,11 @@
</view> </view>
<image class="more" src="{{imageUrl}}icon148.png?t={{Timestamp}}"></image> <image class="more" src="{{imageUrl}}icon148.png?t={{Timestamp}}"></image>
</view> </view>
<pagination pagination="{{pagination}}" customEmpty>
<view class="empty">暂无日记记录</view>
</pagination>
</view> </view>
<view class="loading" wx:if="{{loading}}">加载中...</view> <view class="footer" wx:if="{{recordList.length > 0}}">
<view class="no-more" wx:if="{{!hasMore && recordList.length > 0}}">没有更多了</view>
<view class="footer">
<view class="btn1" bind:tap="handleDiffData">眼突度 对比</view> <view class="btn1" bind:tap="handleDiffData">眼突度 对比</view>
<view class="btn2" bind:tap="handlePhotoCompare">照片 对比</view> <view class="btn2" bind:tap="handlePhotoCompare">照片 对比</view>
</view> </view>

7
src/pages/d_patientDetail/index.ts

@ -913,14 +913,15 @@ Page({
url: `/pages/d_qolDetail/index?id=${this.data.detail.PatientId}`, url: `/pages/d_qolDetail/index?id=${this.data.detail.PatientId}`,
}) })
}, },
handleNoteDetail() { handleNoteDetail(e: any) {
const recordId = e.currentTarget.dataset.recordid
wx.navigateTo({ wx.navigateTo({
url: `/pages/d_noteDetail/index?patientId=${this.data.detail.PatientId}`, url: `/pages/d_noteDetail/index?patientId=${this.data.detail.PatientId}&patientName=${this.data.detail.Name}&recordId=${recordId}`,
}) })
}, },
handleNoteList() { handleNoteList() {
wx.navigateTo({ wx.navigateTo({
url: `/pages/d_noteList/index?patientId=${this.data.detail.PatientId}`, url: `/pages/d_noteList/index?patientId=${this.data.detail.PatientId}&patientName=${this.data.detail.Name}`,
}) })
}, },
}) })

15
src/pages/d_patientDetail/index.wxml

@ -72,28 +72,29 @@
<image wx:else class="icon" src="{{imageUrl}}icon66.png?t={{Timestamp}}"></image> <image wx:else class="icon" src="{{imageUrl}}icon66.png?t={{Timestamp}}"></image>
<view class="content">标识为EDC患者</view> <view class="content">标识为EDC患者</view>
</view> </view>
<view class="note"> <view class="note" wx:if="{{proptosisDetail.recordId}}">
<view class="n-title"> <view class="n-title">
突眼日记 突眼日记
<view class="sub">(可查看患者突眼、眼突度)</view> <view class="sub">(可查看患者突眼、眼突度)</view>
</view> </view>
<view class="n-container"> <view class="n-container">
<view class="n-card" bind:tap="handleNoteDetail"> <view class="n-card" bind:tap="handleNoteDetail" data-recordid="{{proptosisDetail.recordId}}">
<view <view
class="benchmark" class="benchmark"
wx:if="{{proptosisDetail.isBaseline === 1}}"
style="background: url('/images/bg56.png') no-repeat top center/100%" style="background: url('/images/bg56.png') no-repeat top center/100%"
> >
基准照 基准照
</view> </view>
<image class="photo" src="{{imageUrl}}icon143.png?t={{Timestamp}}"></image> <image class="photo" src="{{proptosisDetail.firstPhotoUrl || imageUrl + 'icon143.png?t=' + Timestamp}}"></image>
<view class="wrap"> <view class="wrap">
<view class="date">2026-04-01</view> <view class="date">{{proptosisDetail.recordDate}}</view>
<view class="tag">替妥尤单抗:2</view> <view class="tag">替妥尤单抗:{{proptosisDetail.treatmentCount}}</view>
<view class="rotate">已上传1个角度</view> <view class="rotate">已上传{{proptosisDetail.photoCount}}个角度</view>
</view> </view>
<image class="more" src="/images/icon168.png"></image> <image class="more" src="/images/icon168.png"></image>
</view> </view>
<view class="btn" bind:tap="handleNoteList">共3条日记记录,点击查看全部</view> <view class="btn" bind:tap="handleNoteList">共{{proptosisDetail.totalRecords || 0}}条日记记录,点击查看全部</view>
</view> </view>
</view> </view>
<view class="kkd"> <view class="kkd">

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

@ -372,7 +372,7 @@ Component({
const recordId = this.properties.recordId const recordId = this.properties.recordId
wx.ajax({ wx.ajax({
method: 'POST', method: 'POST',
url: '?r=xd/proptosis/photo-upload', url: '?r=xd/proptosis/photo-checker',
data: { data: {
...(recordId ? { recordId } : {}), ...(recordId ? { recordId } : {}),
sessionId, sessionId,

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

@ -2,7 +2,7 @@
<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>
<view class="container" style="padding-top:{{pageTop+20}}px"> <view class="container" style="padding-top:{{pageTop+20}}px;">
<!-- 基准照片设置卡片 - 未设置状态 --> <!-- 基准照片设置卡片 - 未设置状态 -->
<view wx:if="{{!hasBaseline}}" class="setting-card-empty" bindtap="addRecord"> <view wx:if="{{!hasBaseline}}" class="setting-card-empty" bindtap="addRecord">
<view class="setting-header"> <view class="setting-header">

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

@ -35,7 +35,7 @@ Page({
popupType: 'popup16', popupType: 'popup16',
popupParams: { popupParams: {
close: false, close: false,
position: 'bottom', position: 'center',
} as any, } as any,
imagePreview: false, imagePreview: false,
@ -62,6 +62,10 @@ Page({
// 替妥尤单抗使用次数选项 (0-9, 9表示大于8) // 替妥尤单抗使用次数选项 (0-9, 9表示大于8)
treatmentCountRange: ['0次', '1次', '2次', '3次', '4次', '5次', '6次', '7次', '8次', '大于8次'], treatmentCountRange: ['0次', '1次', '2次', '3次', '4次', '5次', '6次', '7次', '8次', '大于8次'],
hasUnsavedData: false,
isCapturing: false,
cameraList: { cameraList: {
frontend: [ frontend: [
{ name: '睁眼', type: 'front_open', angle: 'front_open' }, { name: '睁眼', type: 'front_open', angle: 'front_open' },
@ -98,6 +102,10 @@ Page({
}, },
onShow() { onShow() {
if (this.data.isCapturing) {
this.setData({ isCapturing: false })
return
}
app.waitLogin({ type: [1] }).then(() => { app.waitLogin({ type: [1] }).then(() => {
if (this.data.recordId) { if (this.data.recordId) {
this.getRecordDetail() this.getRecordDetail()
@ -162,9 +170,8 @@ Page({
// 是否设置为基准照 // 是否设置为基准照
onBaselineChange(e: any) { onBaselineChange(e: any) {
const values: string[] = e?.detail?.value || []
this.setData({ this.setData({
isBaseline: values.length > 0 ? 1 : 0, isBaseline: e.detail.value ? 1 : 0,
}) })
}, },
@ -194,6 +201,7 @@ Page({
const { angle } = e.currentTarget.dataset const { angle } = e.currentTarget.dataset
this.setData({ this.setData({
currentPhotoAngle: angle, currentPhotoAngle: angle,
isCapturing: true,
}) })
// 调用 camera 组件的 handleSelect 方法,传入类型 // 调用 camera 组件的 handleSelect 方法,传入类型
const cameraComponent = this.selectComponent('#camera-component') const cameraComponent = this.selectComponent('#camera-component')
@ -216,6 +224,7 @@ Page({
sequentialShootMode: true, sequentialShootMode: true,
sequentialShootList: shootOrder, sequentialShootList: shootOrder,
sequentialShootIndex: 0, sequentialShootIndex: 0,
isCapturing: true,
}) })
// 开始第一个拍摄 // 开始第一个拍摄
this.startSequentialShoot() this.startSequentialShoot()
@ -338,6 +347,7 @@ Page({
this.setData({ this.setData({
photoMap, photoMap,
sequentialShootIndex: nextIndex, sequentialShootIndex: nextIndex,
hasUnsavedData: true,
}) })
// 延迟一下再打开下一个相机,给用户反馈时间 // 延迟一下再打开下一个相机,给用户反馈时间
setTimeout(() => { setTimeout(() => {
@ -345,7 +355,7 @@ Page({
}, 500) }, 500)
} }
else { else {
this.setData({ photoMap }) this.setData({ photoMap, hasUnsavedData: true })
} }
}, },
@ -401,8 +411,21 @@ Page({
return return
} }
// 获取所有已上传的照片ID // 获取所有已上传的照片
const photoIds = Object.values(photoMap).map(photo => photo.photoId).join(',') const photos = Object.values(photoMap)
if (photos.length === 0) {
wx.showToast({ title: '请至少上传一张照片', icon: 'none' })
return
}
// 所有照片全不合规才不允许保存
const hasCompliantPhoto = photos.some(photo => photo.checkStatus === 1)
if (!hasCompliantPhoto) {
wx.showToast({ title: '所有照片不合规,请重新上传', icon: 'none' })
return
}
const photoIds = photos.map(photo => photo.photoId).join(',')
const data: any = { const data: any = {
...(recordId ? { recordId } : {}), ...(recordId ? { recordId } : {}),
@ -428,6 +451,7 @@ Page({
data, data,
}).then(() => { }).then(() => {
wx.hideLoading() wx.hideLoading()
this.setData({ hasUnsavedData: false })
wx.showToast({ wx.showToast({
title: '保存成功', title: '保存成功',
icon: 'success', icon: 'success',
@ -452,16 +476,26 @@ Page({
this.setData({ this.setData({
popupShow: false, popupShow: false,
}) })
this.handleSave()
}, },
handlePopupCancel() { handlePopupCancel() {
this.setData({ this.setData({
popupShow: false, popupShow: false,
}) })
wx.navigateBack()
}, },
handleBack() { handleBack() {
if (this.data.hasUnsavedData) {
this.setData({
popupShow: true,
popupType: 'popup16',
})
}
else {
wx.navigateBack() wx.navigateBack()
}
}, },
}) })

19
src/patient/pages/noteAdd/index.wxml

@ -70,13 +70,13 @@
正面 正面
</view> </view>
<view class="upload-container"> <view class="upload-container">
<view class="upload-item" wx:for="{{cameraList.frontend}}" wx:key="angle" data-angle="{{item.angle}}"> <view class="upload-item" wx:for="{{cameraList.frontend}}" wx:key="angle">
<view class="upload-preview" wx:if="{{photoMap[item.angle]}}" bind:tap="handlePreview"> <view class="upload-preview" wx:if="{{photoMap[item.angle]}}" bind:tap="handlePreview" data-angle="{{item.angle}}">
<image class="photo" src="{{photoMap[item.angle].photoUrl}}"></image> <image class="photo" src="{{photoMap[item.angle].photoUrl}}"></image>
<view class="status" wx:if="{{photoMap[item.angle].checkStatus === 2}}"> <view class="status" wx:if="{{photoMap[item.angle].checkStatus === 2}}">
<image class="icon" src="/images/icon164.png"></image> <image class="icon" src="/images/icon164.png"></image>
<view class="content">不符合规范</view> <view class="content">不符合规范</view>
<view class="guide" catchtap="handleCamera">重新上传</view> <view class="guide" catchtap="handleCamera" data-angle="{{item.angle}}">重新上传</view>
</view> </view>
</view> </view>
<view class="upload-place" wx:else bind:tap="handleCamera" data-angle="{{item.angle}}"> <view class="upload-place" wx:else bind:tap="handleCamera" data-angle="{{item.angle}}">
@ -92,13 +92,13 @@
侧面 侧面
</view> </view>
<view class="upload-container"> <view class="upload-container">
<view class="upload-item" wx:for="{{cameraList.backend}}" wx:key="angle" data-angle="{{item.angle}}"> <view class="upload-item" wx:for="{{cameraList.backend}}" wx:key="angle">
<view class="upload-preview" wx:if="{{photoMap[item.angle]}}" bind:tap="handlePreview"> <view class="upload-preview" wx:if="{{photoMap[item.angle]}}" bind:tap="handlePreview" data-angle="{{item.angle}}">
<image class="photo" src="{{photoMap[item.angle].photoUrl}}"></image> <image class="photo" src="{{photoMap[item.angle].photoUrl}}"></image>
<view class="status" wx:if="{{photoMap[item.angle].checkStatus === 2}}"> <view class="status" wx:if="{{photoMap[item.angle].checkStatus === 2}}">
<image class="icon" src="/images/icon164.png"></image> <image class="icon" src="/images/icon164.png"></image>
<view class="content">不符合规范</view> <view class="content">不符合规范</view>
<view class="guide" catchtap="handleCamera">重新上传</view> <view class="guide" catchtap="handleCamera" data-angle="{{item.angle}}">重新上传</view>
</view> </view>
</view> </view>
<view class="upload-place" wx:else bind:tap="handleCamera" data-angle="{{item.angle}}"> <view class="upload-place" wx:else bind:tap="handleCamera" data-angle="{{item.angle}}">
@ -114,13 +114,13 @@
眼球运动八个方向 眼球运动八个方向
</view> </view>
<view class="upload-container"> <view class="upload-container">
<view class="upload-item" wx:for="{{cameraList.other}}" wx:key="angle" data-angle="{{item.angle}}"> <view class="upload-item" wx:for="{{cameraList.other}}" wx:key="angle">
<view class="upload-preview" wx:if="{{photoMap[item.angle]}}" bind:tap="handlePreview"> <view class="upload-preview" wx:if="{{photoMap[item.angle]}}" bind:tap="handlePreview" data-angle="{{item.angle}}">
<image class="photo" src="{{photoMap[item.angle].photoUrl}}"></image> <image class="photo" src="{{photoMap[item.angle].photoUrl}}"></image>
<view class="status" wx:if="{{photoMap[item.angle].checkStatus === 2}}"> <view class="status" wx:if="{{photoMap[item.angle].checkStatus === 2}}">
<image class="icon" src="/images/icon164.png"></image> <image class="icon" src="/images/icon164.png"></image>
<view class="content">不符合规范</view> <view class="content">不符合规范</view>
<view class="guide" catchtap="handleCamera">重新上传</view> <view class="guide" catchtap="handleCamera" data-angle="{{item.angle}}">重新上传</view>
</view> </view>
</view> </view>
<view class="upload-place" wx:else bind:tap="handleCamera" data-angle="{{item.angle}}"> <view class="upload-place" wx:else bind:tap="handleCamera" data-angle="{{item.angle}}">
@ -143,6 +143,7 @@
id="note-image-preview" id="note-image-preview"
visible="{{imagePreview}}" visible="{{imagePreview}}"
src="{{previewImageSrc}}" src="{{previewImageSrc}}"
bind:close="handleImagePreviewClose"
bind:delete="handleImageDel" bind:delete="handleImageDel"
bind:retake="handleImageRetake" bind:retake="handleImageRetake"
></noteImagePreview> ></noteImagePreview>

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

@ -1,4 +1,4 @@
<navbar fixed title="记录新照片" custom-style="background:{{background}}"> <navbar fixed title="标准拍照示范" 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/patient/pages/noteDiff/index.json

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

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

@ -14,6 +14,7 @@ interface ComparePhoto {
interface CompareDate { interface CompareDate {
recordId: string recordId: string
recordDate: string recordDate: string
isSelected?: boolean
} }
Page({ Page({
@ -100,9 +101,14 @@ Page({
photoAngle: this.data.photoAngle, photoAngle: this.data.photoAngle,
}, },
}).then((res: any) => { }).then((res: any) => {
const selectedDates = this.data.selectedDates
const nonBaselineList = (res.nonBaselineList || []).map((item: CompareDate) => ({
...item,
isSelected: selectedDates.includes(item.recordId),
}))
this.setData({ this.setData({
baseline: res.baseline || null, baseline: res.baseline || null,
nonBaselineList: res.nonBaselineList || [], nonBaselineList,
}) })
}).catch((err) => { }).catch((err) => {
console.error('获取对比日期失败:', err) console.error('获取对比日期失败:', err)
@ -133,7 +139,12 @@ Page({
else { else {
selectedDates.push(recordId) selectedDates.push(recordId)
} }
this.setData({ selectedDates }) // 更新列表中的选中状态
const nonBaselineList = this.data.nonBaselineList.map((item: CompareDate) => ({
...item,
isSelected: selectedDates.includes(item.recordId),
}))
this.setData({ selectedDates, nonBaselineList })
// 获取对比照片 // 获取对比照片
this.getComparePhotos() this.getComparePhotos()
}, },
@ -155,8 +166,10 @@ Page({
recordIds: recordIds.join(','), recordIds: recordIds.join(','),
}, },
}).then((res: any) => { }).then((res: any) => {
// 过滤掉 photoUrl 为空的数据
const photos = (res.photos || []).filter((item: any) => item.photoUrl)
this.setData({ this.setData({
comparePhotos: res.photos || [], comparePhotos: photos,
}) })
}).catch((err) => { }).catch((err) => {
console.error('获取对比照片失败:', err) console.error('获取对比照片失败:', err)
@ -183,6 +196,10 @@ Page({
url: `/patient/pages/noteDiffEdit/index?photoAngle=${this.data.photoAngle}&recordIds=${recordIds.join(',')}`, url: `/patient/pages/noteDiffEdit/index?photoAngle=${this.data.photoAngle}&recordIds=${recordIds.join(',')}`,
}) })
}, },
handleBack() {
wx.navigateBack()
},
}) })
export {} export {}

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

@ -1,3 +1,7 @@
<navbar fixed title="照片对比分析" custom-style="background:{{background}}">
<van-icon name="arrow-left" slot="left" size="18px" color="#000" bind:tap="handleBack" />
</navbar>
<view class="page-none" wx:if="{{!hasBaseline}}"> <view class="page-none" wx:if="{{!hasBaseline}}">
<image class="icon" src="{{imageUrl}}none3.png?t={{Timestamp}}"></image> <image class="icon" src="{{imageUrl}}none3.png?t={{Timestamp}}"></image>
<view class="title">未设置基准照片</view> <view class="title">未设置基准照片</view>
@ -8,7 +12,7 @@
</view> </view>
<view class="btn" bind:tap="goSetBaseline">去设置</view> <view class="btn" bind:tap="goSetBaseline">去设置</view>
</view> </view>
<view class="page" wx:if="{{hasBaseline}}"> <view class="page" wx:if="{{hasBaseline}}" style="padding-top:{{pageTop+20}}px;">
<view class="page-tip"> <view class="page-tip">
<image class="icon" src="{{imageUrl}}icon154.png?t={{Timestamp}}"></image> <image class="icon" src="{{imageUrl}}icon154.png?t={{Timestamp}}"></image>
<view class="content"> <view class="content">
@ -29,7 +33,7 @@
<view class="title">选择对比日期(可多选)</view> <view class="title">选择对比日期(可多选)</view>
<view class="multiple"> <view class="multiple">
<view <view
class="item {{selectedDates.indexOf(item.recordId) > -1 ? 'active' : ''}}" class="item {{item.isSelected ? 'active' : ''}}"
wx:for="{{nonBaselineList}}" wx:for="{{nonBaselineList}}"
wx:key="recordId" wx:key="recordId"
data-record-id="{{item.recordId}}" data-record-id="{{item.recordId}}"

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

@ -1,8 +1,9 @@
{ {
"navigationStyle": "default",
"usingComponents": { "usingComponents": {
"navbar": "/components/navbar/index",
"van-icon": "@vant/weapp/icon/index", "van-icon": "@vant/weapp/icon/index",
"imageMerge": "/patient/components/image-merge/index" "imageMerge": "/patient/components/image-merge/index",
"popup": "/components/popup/index"
}, },
"navigationBarTitleText": "照片对比分析" "navigationBarTitleText": "照片对比分析"
} }

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

@ -15,6 +15,10 @@ interface PhotoItem {
Page({ Page({
data: { data: {
popupShow: false,
popupType: 'popup17',
popupParams: {},
photoAngle: '', photoAngle: '',
photoAngleName: '', photoAngleName: '',
recordIds: [] as string[], recordIds: [] as string[],
@ -100,8 +104,34 @@ Page({
const photos = this.data.photos const photos = this.data.photos
const photo = photos[index] const photo = photos[index]
wx.navigateTo({ wx.showLoading({ title: '加载中...' })
url: `/patient/pages/noteCrop/index?photoId=${photo.photoId}&photoUrl=${photo.photoUrl}&index=${index}`, wx.getImageInfo({
src: photo.photoUrl,
success: (imgRes) => {
wx.hideLoading()
wx.cropImage({
src: imgRes.path,
cropScale: '9:16',
success: (cropRes) => {
photos[index].isCropped = true
photos[index].croppedUrl = cropRes.tempFilePath
this.setData({ photos })
wx.showToast({ title: '裁剪成功', icon: 'success' })
},
fail: (err) => {
console.error('裁剪失败:', err)
if (err.errMsg?.includes('cancel')) {
return
}
wx.showToast({ title: '裁剪取消', icon: 'none' })
},
})
},
fail: (err) => {
wx.hideLoading()
console.error('图片加载失败:', err)
wx.showToast({ title: '图片加载失败', icon: 'none' })
},
}) })
}, },
@ -174,6 +204,32 @@ Page({
console.error('合并图片失败:', reason) console.error('合并图片失败:', reason)
wx.showToast({ title: '生成对比图失败', icon: 'none' }) wx.showToast({ title: '生成对比图失败', icon: 'none' })
}, },
// 是否有裁剪过的照片
hasCroppedPhoto(): boolean {
return this.data.photos.some(item => item.isCropped)
},
// 返回上一页
handleBack() {
if (this.hasCroppedPhoto()) {
this.setData({ popupShow: true })
}
else {
wx.navigateBack()
}
},
// popup 确认(继续退出)
handlePopupOk() {
this.setData({ popupShow: false })
wx.navigateBack()
},
// popup 取消
handlePopupCancel() {
this.setData({ popupShow: false })
},
}) })
export {} export {}

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

@ -1,4 +1,8 @@
<view class="page"> <navbar fixed title="照片对比分析" custom-style="background:{{background}}">
<van-icon name="arrow-left" slot="left" size="18px" color="#000" bind:tap="handleBack" />
</navbar>
<view class="page" style="padding-top:{{pageTop+20}}px;">
<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}}时间线对比
@ -18,7 +22,7 @@
<view class="tag tag2">替妥尤单抗:{{item.treatmentCount}}</view> <view class="tag tag2">替妥尤单抗:{{item.treatmentCount}}</view>
</view> </view>
<view class="photo-card"> <view class="photo-card">
<image class="photo" src="{{item.photoUrl}}" mode="aspectFill"></image> <image class="photo" src="{{item.croppedUrl || item.photoUrl}}" mode="aspectFill"></image>
<view class="mask" wx:if="{{!item.isCropped}}" bind:tap="handleCrop" data-index="{{index}}"> <view class="mask" wx:if="{{!item.isCropped}}" bind:tap="handleCrop" data-index="{{index}}">
<image class="icon" src="{{imageUrl}}icon165.png?t={{Timestamp}}"></image> <image class="icon" src="{{imageUrl}}icon165.png?t={{Timestamp}}"></image>
点击裁剪 点击裁剪
@ -50,3 +54,5 @@
</view> </view>
<imageMerge id="merge" bindsave="onMergeSave" binderror="onMergeError" /> <imageMerge id="merge" bindsave="onMergeSave" binderror="onMergeError" />
<popup id="popup" show="{{popupShow}}" type="{{popupType}}" params="{{popupParams}}" bind:ok="handlePopupOk" bind:cancel="handlePopupCancel" />

72
src/patient/pages/noteHistory/index.ts

@ -20,6 +20,10 @@ interface RecordDetail {
photos: Photo[] photos: Photo[]
} }
interface PhotoMap {
[key: string]: Photo
}
Page({ Page({
data: { data: {
popupShow: false, popupShow: false,
@ -31,6 +35,17 @@ Page({
recordId: '', recordId: '',
recordDetail: {} as RecordDetail, recordDetail: {} as RecordDetail,
// 照片映射表
photoMap: {} as PhotoMap,
// 各组是否有照片
hasFrontend: false,
hasBackend: false,
hasOther: false,
// 已上传照片数量
uploadedCount: 0,
// 角度分组 // 角度分组
angleGroups: { angleGroups: {
frontend: ['front_open', 'front_closed', 'front_looking_up'], frontend: ['front_open', 'front_closed', 'front_looking_up'],
@ -83,8 +98,31 @@ Page({
}, },
}).then((res: any) => { }).then((res: any) => {
wx.hideLoading() wx.hideLoading()
// 构建照片映射表
const photoMap: PhotoMap = {}
const photos = res.photos || []
photos.forEach((p: Photo) => {
if (p.photoUrl) {
photoMap[p.photoAngle] = p
}
})
// 判断各组是否有照片(只计算有 photoUrl 的照片)
const { frontend, backend, other } = this.data.angleGroups
const hasFrontend = frontend.some(angle => photoMap[angle]?.photoUrl)
const hasBackend = backend.some(angle => photoMap[angle]?.photoUrl)
const hasOther = other.some(angle => photoMap[angle]?.photoUrl)
// 计算有 photoUrl 的照片数量
const uploadedCount = photos.filter((p: Photo) => p.photoUrl).length
this.setData({ this.setData({
recordDetail: res, recordDetail: res,
photoMap,
hasFrontend,
hasBackend,
hasOther,
uploadedCount,
}) })
}).catch((err) => { }).catch((err) => {
wx.hideLoading() wx.hideLoading()
@ -105,6 +143,24 @@ Page({
return photos.length return photos.length
}, },
// 判断正面照片组是否有照片
hasFrontendPhotos(): boolean {
const { frontend } = this.data.angleGroups
return frontend.some(angle => this.getPhotoByAngle(angle))
},
// 判断侧面照片组是否有照片
hasBackendPhotos(): boolean {
const { backend } = this.data.angleGroups
return backend.some(angle => this.getPhotoByAngle(angle))
},
// 判断眼球运动组是否有照片
hasOtherPhotos(): boolean {
const { other } = this.data.angleGroups
return other.some(angle => this.getPhotoByAngle(angle))
},
// 返回上一页 // 返回上一页
handleBack() { handleBack() {
wx.navigateBack() wx.navigateBack()
@ -119,16 +175,9 @@ Page({
// 删除记录 // 删除记录
handleDelete() { handleDelete() {
wx.showModal({ this.setData({
title: '确认删除', popupShow: true,
content: '确定要删除这条记录吗?删除后无法恢复。', popupType: 'popup15',
confirmColor: '#8c75d0',
cancelColor: '#141515',
success: (res) => {
if (res.confirm) {
this.doDelete()
}
},
}) })
}, },
@ -160,7 +209,7 @@ Page({
// 预览图片 // 预览图片
handlePreview(e: any) { handlePreview(e: any) {
const { angle } = e.currentTarget.dataset const { angle } = e.currentTarget.dataset
const photo = this.getPhotoByAngle(angle) const photo = this.data.photoMap[angle]
if (photo) { if (photo) {
wx.previewImage({ wx.previewImage({
urls: [photo.photoUrl], urls: [photo.photoUrl],
@ -180,6 +229,7 @@ Page({
this.setData({ this.setData({
popupShow: false, popupShow: false,
}) })
this.doDelete()
}, },
handlePopupCancel() { handlePopupCancel() {

23
src/patient/pages/noteHistory/index.wxml

@ -30,7 +30,7 @@
<image class="tip-icon" src="{{imageUrl}}icon154.png?t={{Timestamp}}"></image> <image class="tip-icon" src="{{imageUrl}}icon154.png?t={{Timestamp}}"></image>
<view class="wrap"> <view class="wrap">
<view class="title">记录未录入完全</view> <view class="title">记录未录入完全</view>
<view class="content">当前已录入{{getUploadedCount()}}/15个角度。您可以点击右上角的编辑按钮继续补充照片。</view> <view class="content">当前已录入{{uploadedCount}}/15个角度。您可以点击右上角的编辑按钮继续补充照片。</view>
</view> </view>
<view class="btn" bind:tap="handleSupplement">去补充</view> <view class="btn" bind:tap="handleSupplement">去补充</view>
</view> </view>
@ -64,36 +64,33 @@
</view> </view>
<!-- 正面照片 --> <!-- 正面照片 -->
<view class="card"> <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="{{angleGroups.frontend}}" wx:key="*this" data-angle="{{item}}" 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 wx:if="{{getPhotoByAngle(item)}}" class="photo" mode="aspectFill" src="{{getPhotoByAngle(item).photoUrl}}"></image> <image class="photo" mode="aspectFill" src="{{photoMap[item].photoUrl}}"></image>
<image wx:else class="photo photo-empty" mode="aspectFill" src="{{imageUrl}}note-demo1.png?t={{Timestamp}}"></image>
<view class="name">{{angleNameMap[item]}}</view> <view class="name">{{angleNameMap[item]}}</view>
</view> </view>
</view> </view>
</view> </view>
<!-- 侧面照片 --> <!-- 侧面照片 -->
<view class="card"> <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="{{angleGroups.backend}}" wx:key="*this" data-angle="{{item}}" 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 wx:if="{{getPhotoByAngle(item)}}" class="photo" mode="aspectFill" src="{{getPhotoByAngle(item).photoUrl}}"></image> <image class="photo" mode="aspectFill" src="{{photoMap[item].photoUrl}}"></image>
<image wx:else class="photo photo-empty" mode="aspectFill" src="{{imageUrl}}note-demo1.png?t={{Timestamp}}"></image>
<view class="name">{{angleNameMap[item]}}</view> <view class="name">{{angleNameMap[item]}}</view>
</view> </view>
</view> </view>
</view> </view>
<!-- 眼球运动八个方向 --> <!-- 眼球运动八个方向 -->
<view class="card"> <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="{{angleGroups.other}}" wx:key="*this" data-angle="{{item}}" 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 wx:if="{{getPhotoByAngle(item)}}" class="photo" mode="aspectFill" src="{{getPhotoByAngle(item).photoUrl}}"></image> <image class="photo" mode="aspectFill" src="{{photoMap[item].photoUrl}}"></image>
<image wx:else class="photo photo-empty" mode="aspectFill" src="{{imageUrl}}note-demo1.png?t={{Timestamp}}"></image>
<view class="name">{{angleNameMap[item]}}</view> <view class="name">{{angleNameMap[item]}}</view>
</view> </view>
</view> </view>

69
接口文档.md

@ -156,7 +156,7 @@
#### 业务流程(修订后) #### 业务流程(修订后)
1. 前端:点击保存按钮时,先调用 photo-upload 上传照片(此时没有recordId,返回临时photoId) 1. 前端:点击保存按钮时,先调用 photo-checker 上传照片(此时没有recordId,返回临时photoId)
2. 前端:然后调用 record-save 保存基本信息(含photoIds),将照片关联到记录 2. 前端:然后调用 record-save 保存基本信息(含photoIds),将照片关联到记录
3. 这样设计是因为只有一个保存按钮,需要先创建记录,再上传照片 3. 这样设计是因为只有一个保存按钮,需要先创建记录,再上传照片
@ -170,7 +170,7 @@
#### 接口信息 #### 接口信息
- **接口路径**: `/?r=xd/proptosis/photo-upload` - **接口路径**: `/?r=xd/proptosis/photo-checker`
- **请求方式**: POST - **请求方式**: POST
- **代码位置**: `shop/modules/xd_frontend/controllers/ProptosisController.php` - **代码位置**: `shop/modules/xd_frontend/controllers/ProptosisController.php`
- **认证方式**: 内部获取当前登录用户ID - **认证方式**: 内部获取当前登录用户ID
@ -196,7 +196,7 @@
#### 业务流程(调整后) #### 业务流程(调整后)
1. 前端:先调用 photo-upload 上传照片,返回 photoId 1. 前端:先调用 photo-checker 上传照片,返回 photoId
2. 前端:再调用 record-save,传入 photoIds 关联照片和记录 2. 前端:再调用 record-save,传入 photoIds 关联照片和记录
#### 同步保存数据 #### 同步保存数据
@ -372,40 +372,6 @@
#### 返回字段 #### 返回字段
| 字段名 | 类型 | 说明 | | 字段名 | 类型 | 说明 |
| ------------------------- | ------ | ------- |
| recordId | string | 记录ID |
| photoAngle.side_right_45 | string | 45°右侧 |
| photoAngle.eye_up_left | string | 左上 |
| photoAngle.eye_up | string | 向上 |
| photoAngle.eye_up_right | string | 右上 |
| photoAngle.eye_left | string | 向左 |
| photoAngle.eye_right | string | 向右 |
| photoAngle.eye_down_left | string | 左下 |
| photoAngle.eye_down | string | 向下 |
| photoAngle.eye_down_right | string | 右下 |
---
## 三、医生端接口
### 3.1 获取患者最近的突眼记录
#### 接口信息
- **接口路径**: `/?r=xd/doctor/proptosis/latest`
- **请求方式**: GET
- **代码位置**: `shop/modules/xd_frontend/controllers/doctor/ProptosisController.php`
- **认证方式**: 内部获取当前登录医生ID
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
| --------- | ------ | ---- | ------ |
| patientId | string | 是 | 患者ID |
#### 返回字段
| 字段名 | 类型 | 说明 |
| -------------------- | ------- | --------------------- | | -------------------- | ------- | --------------------- |
| recordId | string | 记录ID | | recordId | string | 记录ID |
| recordDate | string | 记录日期 | | recordDate | string | 记录日期 |
@ -601,6 +567,35 @@
--- ---
### 3.7 获取对比照片可用日期
#### 接口信息
- **接口路径**: `/?r=xd/doctor/proptosis/compare-dates`
- **请求方式**: GET
- **代码位置**: `shop/modules/xd_frontend/controllers/doctor/ProptosisController.php`
- **认证方式**: 内部获取当前登录医生ID
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
| ---------- | ------ | ---- | -------- |
| patientId | string | 是 | 患者ID |
| photoAngle | string | 是 | 照片角度 |
#### 返回字段
| 字段名 | 类型 | 说明 |
| ---------------------------- | ------ | ---------------- |
| baseline | object | 基准照信息 |
| baseline.recordId | string | 记录ID |
| baseline.recordDate | string | 记录日期 |
| nonBaselineList[] | array | 非基准照日期列表 |
| nonBaselineList[].recordId | string | 记录ID |
| nonBaselineList[].recordDate | string | 记录日期 |
---
## 四、管理后台接口 ## 四、管理后台接口
### 4.1 获取图片列表 ### 4.1 获取图片列表

Loading…
Cancel
Save