diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md new file mode 100644 index 0000000..1d47c29 --- /dev/null +++ b/.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() + +Page({ + onLoad() { + // Use app directly + console.log(app.globalData) + } +}) + +// ❌ Incorrect +Page({ + onLoad() { + const app = getApp() + console.log(app.globalData) + } +}) +``` + +## WXML 开发规范 + +### 1. 不支持在 WXML 中直接调用方法 + +**错误示例:** + +```xml + +{{getUploadedCount()}} +内容 +``` + +**正确做法:** + +```xml + +{{uploadedCount}} +内容 +``` + +```typescript +// 在 TS 中计算好数据 +this.setData({ + uploadedCount: photos.length, + hasPhotos: photos.length > 0 +}) +``` + +### 2. 列表渲染时使用 wx:key + +```xml +{{item.name}} +``` + +### 3. 条件渲染 + +```xml +显示 +隐藏 +``` + +### 4. WXML 表达式限制 + +WXML 中的双大括号 `{{}}` 只能使用简单的表达式,**不支持**以下语法: + +**❌ 不支持的语法:** + +```xml + +{{arr.indexOf(item) > -1}} + + +{{obj.method().property}} + + +{{str.match(/regex/)}} + + +{{new Date().getTime()}} +``` + +**✅ 支持的语法:** + +```xml + +{{item.name}} + + +内容 + + +有数据 + + +VIP用户 +``` + +**解决方案:** + +```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 + + + + + +``` + +**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 类型 + +| 类型 | 场景 | 标题 | 确认按钮 | 取消按钮 | +| --------- | -------------- | ------------------ | -------- | -------- | +| `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/` 各有一份,分别供子包和主包页面使用。 diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 8bd4ddf..0000000 --- a/AGENTS.md +++ /dev/null @@ -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() - -Page({ - onLoad() { - // Use app directly - console.log(app.globalData) - } -}) - -// ❌ Incorrect -Page({ - onLoad() { - const app = getApp() - 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()` diff --git a/src/components/image-merge/index.json b/src/components/image-merge/index.json new file mode 100644 index 0000000..4d7b384 --- /dev/null +++ b/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" + } +} \ No newline at end of file diff --git a/src/components/image-merge/index.scss b/src/components/image-merge/index.scss new file mode 100644 index 0000000..9b96ac7 --- /dev/null +++ b/src/components/image-merge/index.scss @@ -0,0 +1,7 @@ +.merge-canvas { + position: fixed; + left: -9999px; + top: -9999px; + width: 1px; + height: 1px; +} diff --git a/src/components/image-merge/index.ts b/src/components/image-merge/index.ts new file mode 100644 index 0000000..8568833 --- /dev/null +++ b/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 { + return Promise.all( + imageList.map( + (item, index) => + new Promise((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 { + 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 { + return new Promise((resolve, reject) => { + wx.getImageInfo({ + src, + success: resolve, + fail: reject, + }) + }) + }, + }, +}) diff --git a/src/components/image-merge/index.wxml b/src/components/image-merge/index.wxml new file mode 100644 index 0000000..29fe0c9 --- /dev/null +++ b/src/components/image-merge/index.wxml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/noteImagePreview/index.ts b/src/components/noteImagePreview/index.ts index a5cddab..1235208 100644 --- a/src/components/noteImagePreview/index.ts +++ b/src/components/noteImagePreview/index.ts @@ -1,5 +1,17 @@ Component({ - properties: {}, + properties: { + visible: { + type: Boolean, + value: false, + observer(newVal) { + this.setData({ visible: newVal }) + }, + }, + src: { + type: String, + value: '', + }, + }, data: { visible: false, diff --git a/src/components/popup/index.scss b/src/components/popup/index.scss index 58d5c90..c2e4a4a 100644 --- a/src/components/popup/index.scss +++ b/src/components/popup/index.scss @@ -596,47 +596,59 @@ } .popup17 { + .badge { + position: relative; + z-index: 1; + display: block; + width: 144rpx; + height: 144rpx; + margin: 0 auto -72rpx; + text-align: center; + } .popup-container { - width: 100vw; + width: 670rpx; box-sizing: border-box; - padding: 0 48rpx 132rpx; + padding: 118rpx 48rpx 44rpx; border-radius: 32rpx; background: #fff; - box-sizing: border-box; - background: linear-gradient(360deg, #f1e6ff 0%, #f6f8f9 49.07%, #f6f8f9 100%); .title { - padding: 32rpx 0; - font-size: 36rpx; + font-size: 40rpx; color: #211d2e; font-weight: bold; - display: flex; - justify-content: space-between; - align-items: center; + text-align: center; } - .select { - margin-top: 32rpx; + .p-footer { + margin-top: 52rpx; display: flex; - align-items: center; - gap: 34rpx; - .item { + gap: 30rpx; + .sure, + .cancel { flex: 1; - padding: 64rpx; - text-align: center; - background: #ffffff; - border-radius: 24rpx 24rpx 24rpx 24rpx; - .icon { - width: 116rpx; - height: 116rpx; - } - .name { - font-size: 36rpx; - color: #211d2e; - } + font-size: 36rpx; + 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 { margin: 48rpx auto 0; display: block; diff --git a/src/components/popup/index.wxml b/src/components/popup/index.wxml index c3a040f..b9f17c6 100644 --- a/src/components/popup/index.wxml +++ b/src/components/popup/index.wxml @@ -227,6 +227,20 @@ + + + + + 您有裁剪的照片 + + 现在退出会被清空 + + + 取消 + 继续退出 + + + , + + // 有照片的角度分组 + frontendPhotos: [] as Photo[], + backendPhotos: [] as Photo[], + otherPhotos: [] as Photo[], // 角度分组 angleGroups: { @@ -58,6 +65,7 @@ Page({ onLoad(option: any) { this.setData({ patientId: option.patientId || '', + patientName: option.patientName || '', recordId: option.recordId || '', }) }, @@ -81,8 +89,26 @@ Page({ }, }).then((res: any) => { wx.hideLoading() + const photoMap: Record = {} + 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({ recordDetail: res, + photoMap, + frontendPhotos, + backendPhotos, + otherPhotos, }) }).catch((err) => { 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() { wx.navigateBack() @@ -105,7 +125,7 @@ Page({ // 预览图片 handlePreview(e: any) { const { angle } = e.currentTarget.dataset - const photo = this.getPhotoByAngle(angle) + const photo = this.data.photoMap[angle] if (photo) { wx.previewImage({ urls: [photo.photoUrl], @@ -117,7 +137,7 @@ Page({ // 眼突度对比 handleDiffData() { wx.navigateTo({ - url: `/pages/d_noteDiffData/index?patientId=${this.data.patientId}`, + url: `/pages/d_noteDiffData/index?patientId=${this.data.patientId}&patientName=${this.data.patientName}`, }) }, diff --git a/src/pages/d_noteDetail/index.wxml b/src/pages/d_noteDetail/index.wxml index 76f1ec3..27fb796 100644 --- a/src/pages/d_noteDetail/index.wxml +++ b/src/pages/d_noteDetail/index.wxml @@ -39,33 +39,30 @@ - + 正面 - - - - {{angleNameMap[item]}} + + + {{item.photoAngleName}} - + 侧面 - - - - {{angleNameMap[item]}} + + + {{item.photoAngleName}} - + 眼球运动八个方向 - - - - {{angleNameMap[item]}} + + + {{item.photoAngleName}} diff --git a/src/pages/d_noteDiff/index.json b/src/pages/d_noteDiff/index.json index 4218d82..9847532 100644 --- a/src/pages/d_noteDiff/index.json +++ b/src/pages/d_noteDiff/index.json @@ -1,4 +1,7 @@ { "navigationBarTitleText": "照片对比", - "usingComponents": {} + "usingComponents": { + "navbar": "/components/navbar/index", + "van-icon": "@vant/weapp/icon/index" + } } diff --git a/src/pages/d_noteDiff/index.scss b/src/pages/d_noteDiff/index.scss index 260ce84..14281e2 100644 --- a/src/pages/d_noteDiff/index.scss +++ b/src/pages/d_noteDiff/index.scss @@ -3,7 +3,7 @@ page { } .page { - padding-bottom: 80rpx; + padding-bottom: 160rpx; .page-tip { 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; + } + } } diff --git a/src/pages/d_noteDiff/index.ts b/src/pages/d_noteDiff/index.ts index ff6b902..b307277 100644 --- a/src/pages/d_noteDiff/index.ts +++ b/src/pages/d_noteDiff/index.ts @@ -86,25 +86,17 @@ Page({ getCompareDates() { if (!this.data.photoAngle) return - // 医生端接口文档未提供 compare-dates:用记录列表推导 baseline/nonBaseline wx.ajax({ method: 'GET', - url: '?r=xd/doctor/proptosis/list', + url: '?r=xd/doctor/proptosis/compare-dates', data: { patientId: this.data.patientId, - page: 1, - pageSize: 1000, + photoAngle: this.data.photoAngle, }, }).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({ - baseline: baselineItem ? { recordId: baselineItem.recordId, recordDate: baselineItem.recordDate } : null, - nonBaselineList, + baseline: res.baseline || null, + nonBaselineList: res.nonBaselineList || [], }) }).catch((err) => { console.error('获取对比日期失败:', err) @@ -136,7 +128,12 @@ Page({ else { 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() }, @@ -187,7 +184,7 @@ Page({ }) }) this.setData({ - comparePhotos: merged, + comparePhotos: merged.filter(item => item.photoUrl), }) }).catch((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(',')}`, }) }, + + handleBack() { + wx.navigateBack() + }, }) export {} diff --git a/src/pages/d_noteDiff/index.wxml b/src/pages/d_noteDiff/index.wxml index 3c26dda..f634f1f 100644 --- a/src/pages/d_noteDiff/index.wxml +++ b/src/pages/d_noteDiff/index.wxml @@ -1,4 +1,8 @@ - + + + + + @@ -19,7 +23,7 @@ 选择对比日期(可多选) - - 生成对比图 - + + + 生成对比图 diff --git a/src/pages/d_noteDiffData/index.json b/src/pages/d_noteDiffData/index.json index e5328d3..f189841 100644 --- a/src/pages/d_noteDiffData/index.json +++ b/src/pages/d_noteDiffData/index.json @@ -1,5 +1,6 @@ { - "navigationBarTitleText": "xxx的眼突度对比", - "navigationStyle": "default", - "usingComponents": {} + "navigationBarTitleText": "眼突度对比", + "usingComponents": { + "navbar": "/components/navbar/index" + } } diff --git a/src/pages/d_noteDiffData/index.ts b/src/pages/d_noteDiffData/index.ts index 953799d..0827781 100644 --- a/src/pages/d_noteDiffData/index.ts +++ b/src/pages/d_noteDiffData/index.ts @@ -18,9 +18,13 @@ Page({ }, onLoad(option: any) { + const patientName = option.patientName || '' this.setData({ patientId: option.patientId || '', }) + if (patientName) { + wx.setNavigationBarTitle({ title: `${patientName}的眼突度对比` }) + } }, onShow() { @@ -40,7 +44,7 @@ Page({ data: { patientId: this.data.patientId, page: 1, - pageSize: 1000, // 尽量拉全,避免导出/对比缺数据 + pageSize: 1000, }, }).then((res: any) => { const list: CompareItem[] = (res.list || []) @@ -57,69 +61,8 @@ Page({ }) }, - // 导出Excel - 直接使用下载链接 - handleExport() { - 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' }) - }, - }) + handleBack() { + wx.navigateBack() }, }) diff --git a/src/pages/d_noteDiffData/index.wxml b/src/pages/d_noteDiffData/index.wxml index 8a88872..84e5cd7 100644 --- a/src/pages/d_noteDiffData/index.wxml +++ b/src/pages/d_noteDiffData/index.wxml @@ -1,6 +1,10 @@ - + + + + + - + 日期 @@ -33,8 +37,9 @@ - - - 导出Excel + + + 暂无对比数据 + diff --git a/src/pages/d_noteDiffEdit/index.json b/src/pages/d_noteDiffEdit/index.json index d103120..5b79b53 100644 --- a/src/pages/d_noteDiffEdit/index.json +++ b/src/pages/d_noteDiffEdit/index.json @@ -1,6 +1,8 @@ { "navigationBarTitleText": "对比图编辑", "usingComponents": { - "imageMerge": "/patient/components/image-merge/index" + "navbar": "/components/navbar/index", + "imageMerge": "/components/image-merge/index", + "popup": "/components/popup/index" } } diff --git a/src/pages/d_noteDiffEdit/index.scss b/src/pages/d_noteDiffEdit/index.scss index 64e67aa..52cdf60 100644 --- a/src/pages/d_noteDiffEdit/index.scss +++ b/src/pages/d_noteDiffEdit/index.scss @@ -29,7 +29,7 @@ page { width: 8rpx; height: 32rpx; background: #b982ff; - border-radius: 44rpx 44rpx 44rpx 44rpx; + border-radius: 44rpx; } } @@ -115,6 +115,7 @@ page { .photo-card { margin-top: 24rpx; + position: relative; .photo { border-radius: 32rpx; @@ -122,6 +123,27 @@ page { display: block; 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; box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.05); - .btn1, - .btn2 { + .btn1 { flex: 1; height: 88rpx; - font-size: 32rpx; + font-size: 36rpx; + color: #ffffff; display: flex; align-items: center; justify-content: center; - border-radius: 100rpx; - } - - .btn1 { - color: #ffffff; background: linear-gradient(0deg, #e98ff8 0%, #b073ff 100%); + border-radius: 100rpx; } .btn2 { + flex: 1; + height: 88rpx; + font-size: 36rpx; color: #b073ff; - background: rgba(176, 115, 255, 0.1); + display: flex; + align-items: center; + justify-content: center; + background: #fff; 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; + } + } } diff --git a/src/pages/d_noteDiffEdit/index.ts b/src/pages/d_noteDiffEdit/index.ts index 077204e..84c928a 100644 --- a/src/pages/d_noteDiffEdit/index.ts +++ b/src/pages/d_noteDiffEdit/index.ts @@ -7,16 +7,23 @@ interface PhotoItem { recordDate: string isBaseline: number treatmentCount: number + isCropped?: boolean + croppedUrl?: string } Page({ data: { + popupShow: false, + popupType: 'popup17', + popupParams: {}, + patientId: '', photoAngle: '', photoAngleName: '', recordIds: [] as string[], photos: [] as PhotoItem[], loading: false, + mergedImage: '', }, onLoad(option: any) { @@ -82,6 +89,8 @@ Page({ recordDate: baselineRes.recordDate, isBaseline: 1, treatmentCount: baselineRes.treatmentCount, + isCropped: false, + croppedUrl: '', }) } compareList.forEach((item: any) => { @@ -92,10 +101,12 @@ Page({ recordDate: item.recordDate, isBaseline: 0, treatmentCount: item.treatmentCount, + isCropped: false, + croppedUrl: '', }) }) this.setData({ - photos, + photos: photos.filter(item => item.photoUrl), loading: false, }) }).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() { - const { photos, photoAngleName } = this.data + const { photos } = this.data if (photos.length === 0) { wx.showToast({ title: '暂无照片', icon: 'none' }) return @@ -120,21 +177,24 @@ Page({ ? `基准照片 ${item.recordDate}` : `对比照片 ${item.recordDate} 替妥尤单抗:${item.treatmentCount}次` return { - src: item.photoUrl, + src: item.croppedUrl || item.photoUrl, time: label, } }) - mergeComponent.mergeImages(imageList, { - title: `${photoAngleName}时间线对比`, - }) + mergeComponent.mergeImages(imageList) } }, - // 保存成功回调 - onMergeSave(e: any) { - const { tempFilePath } = e.detail + // 保存到相册 + handleSaveAlbum() { + const { mergedImage } = this.data + if (!mergedImage) { + this.handleMergePreview() + return + } + wx.saveImageToPhotosAlbum({ - filePath: tempFilePath, + filePath: mergedImage, 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) { const { error } = e.detail console.error('合并图片失败:', error) 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 {} diff --git a/src/pages/d_noteDiffEdit/index.wxml b/src/pages/d_noteDiffEdit/index.wxml index 5ef397c..2ba7733 100644 --- a/src/pages/d_noteDiffEdit/index.wxml +++ b/src/pages/d_noteDiffEdit/index.wxml @@ -1,5 +1,9 @@ - - + + + + + + {{photoAngleName}}时间线对比 生成日期:{{photos[0].recordDate}} @@ -18,15 +22,36 @@ 替妥尤单抗:{{item.treatmentCount}} - + + + + 点击裁剪 + + + + 点击还原 + 对比图预览 - 保存到相册 + 保存到相册 + + + + + 加载中... + + + + + 暂无对比照片 + + + diff --git a/src/pages/d_noteList/index.json b/src/pages/d_noteList/index.json index fd686d5..0e080f8 100644 --- a/src/pages/d_noteList/index.json +++ b/src/pages/d_noteList/index.json @@ -1,7 +1,8 @@ { - "navigationStyle": "default", "usingComponents": { - "van-icon": "@vant/weapp/icon/index" + "navbar": "/components/navbar/index", + "van-icon": "@vant/weapp/icon/index", + "pagination": "/components/pagination/index" }, "navigationBarTitleText": "记录" } diff --git a/src/pages/d_noteList/index.ts b/src/pages/d_noteList/index.ts index a55302a..01be786 100644 --- a/src/pages/d_noteList/index.ts +++ b/src/pages/d_noteList/index.ts @@ -16,17 +16,24 @@ interface RecordItem { Page({ data: { patientId: '', + patientName: '', recordList: [] as RecordItem[], total: 0, page: 1, pageSize: 10, loading: false, hasMore: true, + pagination: { + count: 0, + page: 1, + pages: 1, + }, }, onLoad(option: any) { this.setData({ patientId: option.patientId || '', + patientName: option.patientName || '', }) }, @@ -57,6 +64,7 @@ Page({ }).then((res: any) => { const list = res.list || [] const total = res.pagination?.total || 0 + const pages = Math.ceil(total / this.data.pageSize) || 1 this.setData({ recordList: reset ? list : [...this.data.recordList, ...list], @@ -64,6 +72,11 @@ Page({ page: page + 1, hasMore: list.length >= this.data.pageSize, loading: false, + pagination: { + count: total, + page, + pages, + }, }) }).catch((err) => { console.error('获取记录列表失败:', err) @@ -80,14 +93,14 @@ Page({ handleHistory(e: any) { const { recordId } = e.currentTarget.dataset 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() { 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}`, }) }, + + handleBack() { + wx.navigateBack() + }, }) export {} diff --git a/src/pages/d_noteList/index.wxml b/src/pages/d_noteList/index.wxml index 14f98b0..904e9d3 100644 --- a/src/pages/d_noteList/index.wxml +++ b/src/pages/d_noteList/index.wxml @@ -1,5 +1,9 @@ - - 共{{total}}条日记记录 + + + + + + 共{{total}}条日记记录 @@ -13,10 +17,11 @@ + + 暂无日记记录 + - 加载中... - 没有更多了 - + 眼突度 对比 照片 对比 diff --git a/src/pages/d_patientDetail/index.ts b/src/pages/d_patientDetail/index.ts index b58de99..27d56fd 100644 --- a/src/pages/d_patientDetail/index.ts +++ b/src/pages/d_patientDetail/index.ts @@ -913,14 +913,15 @@ Page({ url: `/pages/d_qolDetail/index?id=${this.data.detail.PatientId}`, }) }, - handleNoteDetail() { + handleNoteDetail(e: any) { + const recordId = e.currentTarget.dataset.recordid 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() { 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}`, }) }, }) diff --git a/src/pages/d_patientDetail/index.wxml b/src/pages/d_patientDetail/index.wxml index 441579f..c63197a 100644 --- a/src/pages/d_patientDetail/index.wxml +++ b/src/pages/d_patientDetail/index.wxml @@ -72,28 +72,29 @@ 标识为EDC患者 - + 突眼日记 (可查看患者突眼、眼突度) - + 基准照 - + - 2026-04-01 - 替妥尤单抗:2 - 已上传1个角度 + {{proptosisDetail.recordDate}} + 替妥尤单抗:{{proptosisDetail.treatmentCount}} + 已上传{{proptosisDetail.photoCount}}个角度 - 共3条日记记录,点击查看全部 + 共{{proptosisDetail.totalRecords || 0}}条日记记录,点击查看全部 diff --git a/src/patient/components/camera/index.ts b/src/patient/components/camera/index.ts index 9920fe5..0ec6468 100644 --- a/src/patient/components/camera/index.ts +++ b/src/patient/components/camera/index.ts @@ -372,7 +372,7 @@ Component({ const recordId = this.properties.recordId wx.ajax({ method: 'POST', - url: '?r=xd/proptosis/photo-upload', + url: '?r=xd/proptosis/photo-checker', data: { ...(recordId ? { recordId } : {}), sessionId, diff --git a/src/patient/pages/note/index.wxml b/src/patient/pages/note/index.wxml index 09bcfd9..ee30c52 100644 --- a/src/patient/pages/note/index.wxml +++ b/src/patient/pages/note/index.wxml @@ -2,7 +2,7 @@ - + diff --git a/src/patient/pages/noteAdd/index.ts b/src/patient/pages/noteAdd/index.ts index 327b829..ccf1f3f 100644 --- a/src/patient/pages/noteAdd/index.ts +++ b/src/patient/pages/noteAdd/index.ts @@ -35,7 +35,7 @@ Page({ popupType: 'popup16', popupParams: { close: false, - position: 'bottom', + position: 'center', } as any, imagePreview: false, @@ -62,6 +62,10 @@ Page({ // 替妥尤单抗使用次数选项 (0-9, 9表示大于8) treatmentCountRange: ['0次', '1次', '2次', '3次', '4次', '5次', '6次', '7次', '8次', '大于8次'], + hasUnsavedData: false, + + isCapturing: false, + cameraList: { frontend: [ { name: '睁眼', type: 'front_open', angle: 'front_open' }, @@ -98,6 +102,10 @@ Page({ }, onShow() { + if (this.data.isCapturing) { + this.setData({ isCapturing: false }) + return + } app.waitLogin({ type: [1] }).then(() => { if (this.data.recordId) { this.getRecordDetail() @@ -162,9 +170,8 @@ Page({ // 是否设置为基准照 onBaselineChange(e: any) { - const values: string[] = e?.detail?.value || [] this.setData({ - isBaseline: values.length > 0 ? 1 : 0, + isBaseline: e.detail.value ? 1 : 0, }) }, @@ -194,6 +201,7 @@ Page({ const { angle } = e.currentTarget.dataset this.setData({ currentPhotoAngle: angle, + isCapturing: true, }) // 调用 camera 组件的 handleSelect 方法,传入类型 const cameraComponent = this.selectComponent('#camera-component') @@ -216,6 +224,7 @@ Page({ sequentialShootMode: true, sequentialShootList: shootOrder, sequentialShootIndex: 0, + isCapturing: true, }) // 开始第一个拍摄 this.startSequentialShoot() @@ -338,6 +347,7 @@ Page({ this.setData({ photoMap, sequentialShootIndex: nextIndex, + hasUnsavedData: true, }) // 延迟一下再打开下一个相机,给用户反馈时间 setTimeout(() => { @@ -345,7 +355,7 @@ Page({ }, 500) } else { - this.setData({ photoMap }) + this.setData({ photoMap, hasUnsavedData: true }) } }, @@ -401,8 +411,21 @@ Page({ 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 = { ...(recordId ? { recordId } : {}), @@ -428,6 +451,7 @@ Page({ data, }).then(() => { wx.hideLoading() + this.setData({ hasUnsavedData: false }) wx.showToast({ title: '保存成功', icon: 'success', @@ -452,16 +476,26 @@ Page({ this.setData({ popupShow: false, }) + this.handleSave() }, handlePopupCancel() { this.setData({ popupShow: false, }) + wx.navigateBack() }, handleBack() { - wx.navigateBack() + if (this.data.hasUnsavedData) { + this.setData({ + popupShow: true, + popupType: 'popup16', + }) + } + else { + wx.navigateBack() + } }, }) diff --git a/src/patient/pages/noteAdd/index.wxml b/src/patient/pages/noteAdd/index.wxml index 9f7e16d..f61dc07 100644 --- a/src/patient/pages/noteAdd/index.wxml +++ b/src/patient/pages/noteAdd/index.wxml @@ -4,7 +4,7 @@ - + 设置为基准记录,用于对比 @@ -70,13 +70,13 @@ 正面 - - + + 不符合规范 - 重新上传 + 重新上传 @@ -92,13 +92,13 @@ 侧面 - - + + 不符合规范 - 重新上传 + 重新上传 @@ -114,13 +114,13 @@ 眼球运动八个方向 - - + + 不符合规范 - 重新上传 + 重新上传 @@ -143,6 +143,7 @@ id="note-image-preview" visible="{{imagePreview}}" src="{{previewImageSrc}}" + bind:close="handleImagePreviewClose" bind:delete="handleImageDel" bind:retake="handleImageRetake" > diff --git a/src/patient/pages/noteDemo/index.wxml b/src/patient/pages/noteDemo/index.wxml index 842bb67..e6f57e4 100644 --- a/src/patient/pages/noteDemo/index.wxml +++ b/src/patient/pages/noteDemo/index.wxml @@ -1,10 +1,10 @@ - + 拍摄时,正对镜头,面部居中,露出完整双眼及眼眶;光线均匀无阴影。 diff --git a/src/patient/pages/noteDiff/index.json b/src/patient/pages/noteDiff/index.json index 3f2ebce..fb6bb50 100644 --- a/src/patient/pages/noteDiff/index.json +++ b/src/patient/pages/noteDiff/index.json @@ -1,6 +1,6 @@ { - "navigationStyle": "default", "usingComponents": { + "navbar": "/components/navbar/index", "van-icon": "@vant/weapp/icon/index" }, "navigationBarTitleText": "照片对比分析" diff --git a/src/patient/pages/noteDiff/index.ts b/src/patient/pages/noteDiff/index.ts index bbb3c22..275c57b 100644 --- a/src/patient/pages/noteDiff/index.ts +++ b/src/patient/pages/noteDiff/index.ts @@ -14,6 +14,7 @@ interface ComparePhoto { interface CompareDate { recordId: string recordDate: string + isSelected?: boolean } Page({ @@ -100,9 +101,14 @@ Page({ photoAngle: this.data.photoAngle, }, }).then((res: any) => { + const selectedDates = this.data.selectedDates + const nonBaselineList = (res.nonBaselineList || []).map((item: CompareDate) => ({ + ...item, + isSelected: selectedDates.includes(item.recordId), + })) this.setData({ baseline: res.baseline || null, - nonBaselineList: res.nonBaselineList || [], + nonBaselineList, }) }).catch((err) => { console.error('获取对比日期失败:', err) @@ -133,7 +139,12 @@ Page({ else { 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() }, @@ -155,8 +166,10 @@ Page({ recordIds: recordIds.join(','), }, }).then((res: any) => { + // 过滤掉 photoUrl 为空的数据 + const photos = (res.photos || []).filter((item: any) => item.photoUrl) this.setData({ - comparePhotos: res.photos || [], + comparePhotos: photos, }) }).catch((err) => { console.error('获取对比照片失败:', err) @@ -183,6 +196,10 @@ Page({ url: `/patient/pages/noteDiffEdit/index?photoAngle=${this.data.photoAngle}&recordIds=${recordIds.join(',')}`, }) }, + + handleBack() { + wx.navigateBack() + }, }) export {} diff --git a/src/patient/pages/noteDiff/index.wxml b/src/patient/pages/noteDiff/index.wxml index f413ae4..391ea7a 100644 --- a/src/patient/pages/noteDiff/index.wxml +++ b/src/patient/pages/noteDiff/index.wxml @@ -1,3 +1,7 @@ + + + + 未设置基准照片 @@ -8,7 +12,7 @@ 去设置 - + @@ -28,9 +32,9 @@ 选择对比日期(可多选) - { + 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) 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 {} diff --git a/src/patient/pages/noteDiffEdit/index.wxml b/src/patient/pages/noteDiffEdit/index.wxml index 91326dc..a26e95e 100644 --- a/src/patient/pages/noteDiffEdit/index.wxml +++ b/src/patient/pages/noteDiffEdit/index.wxml @@ -1,4 +1,8 @@ - + + + + + {{photoAngleName}}时间线对比 @@ -18,7 +22,7 @@ 替妥尤单抗:{{item.treatmentCount}} - + 点击裁剪 @@ -50,3 +54,5 @@ + + diff --git a/src/patient/pages/noteHistory/index.ts b/src/patient/pages/noteHistory/index.ts index 9c6b3db..87c0901 100644 --- a/src/patient/pages/noteHistory/index.ts +++ b/src/patient/pages/noteHistory/index.ts @@ -20,6 +20,10 @@ interface RecordDetail { photos: Photo[] } +interface PhotoMap { + [key: string]: Photo +} + Page({ data: { popupShow: false, @@ -31,6 +35,17 @@ Page({ recordId: '', recordDetail: {} as RecordDetail, + // 照片映射表 + photoMap: {} as PhotoMap, + + // 各组是否有照片 + hasFrontend: false, + hasBackend: false, + hasOther: false, + + // 已上传照片数量 + uploadedCount: 0, + // 角度分组 angleGroups: { frontend: ['front_open', 'front_closed', 'front_looking_up'], @@ -83,8 +98,31 @@ Page({ }, }).then((res: any) => { 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({ recordDetail: res, + photoMap, + hasFrontend, + hasBackend, + hasOther, + uploadedCount, }) }).catch((err) => { wx.hideLoading() @@ -105,6 +143,24 @@ Page({ 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() { wx.navigateBack() @@ -119,16 +175,9 @@ Page({ // 删除记录 handleDelete() { - wx.showModal({ - title: '确认删除', - content: '确定要删除这条记录吗?删除后无法恢复。', - confirmColor: '#8c75d0', - cancelColor: '#141515', - success: (res) => { - if (res.confirm) { - this.doDelete() - } - }, + this.setData({ + popupShow: true, + popupType: 'popup15', }) }, @@ -160,7 +209,7 @@ Page({ // 预览图片 handlePreview(e: any) { const { angle } = e.currentTarget.dataset - const photo = this.getPhotoByAngle(angle) + const photo = this.data.photoMap[angle] if (photo) { wx.previewImage({ urls: [photo.photoUrl], @@ -180,6 +229,7 @@ Page({ this.setData({ popupShow: false, }) + this.doDelete() }, handlePopupCancel() { diff --git a/src/patient/pages/noteHistory/index.wxml b/src/patient/pages/noteHistory/index.wxml index 69ebf81..bdbb1fd 100644 --- a/src/patient/pages/noteHistory/index.wxml +++ b/src/patient/pages/noteHistory/index.wxml @@ -30,7 +30,7 @@ 记录未录入完全 - 当前已录入{{getUploadedCount()}}/15个角度。您可以点击右上角的编辑按钮继续补充照片。 + 当前已录入{{uploadedCount}}/15个角度。您可以点击右上角的编辑按钮继续补充照片。 去补充 @@ -64,36 +64,33 @@ - + 正面 - - - + + {{angleNameMap[item]}} - + 侧面 - - - + + {{angleNameMap[item]}} - + 眼球运动八个方向 - - - + + {{angleNameMap[item]}} diff --git a/接口文档.md b/接口文档.md index 4610d0f..a63584b 100644 --- a/接口文档.md +++ b/接口文档.md @@ -156,7 +156,7 @@ #### 业务流程(修订后) -1. 前端:点击保存按钮时,先调用 photo-upload 上传照片(此时没有recordId,返回临时photoId) +1. 前端:点击保存按钮时,先调用 photo-checker 上传照片(此时没有recordId,返回临时photoId) 2. 前端:然后调用 record-save 保存基本信息(含photoIds),将照片关联到记录 3. 这样设计是因为只有一个保存按钮,需要先创建记录,再上传照片 @@ -170,7 +170,7 @@ #### 接口信息 -- **接口路径**: `/?r=xd/proptosis/photo-upload` +- **接口路径**: `/?r=xd/proptosis/photo-checker` - **请求方式**: POST - **代码位置**: `shop/modules/xd_frontend/controllers/ProptosisController.php` - **认证方式**: 内部获取当前登录用户ID @@ -196,7 +196,7 @@ #### 业务流程(调整后) -1. 前端:先调用 photo-upload 上传照片,返回 photoId +1. 前端:先调用 photo-checker 上传照片,返回 photoId 2. 前端:再调用 record-save,传入 photoIds 关联照片和记录 #### 同步保存数据 @@ -371,40 +371,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 | @@ -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 获取图片列表