Browse Source

image stash

dev
kola-web 5 days ago
parent
commit
322c3c490e
  1. 2
      project.config.json
  2. 18
      project.private.config.json
  3. 4
      src/app.json
  4. 12
      src/app.ts
  5. 2
      src/patient/components/camera/index.json
  6. 35
      src/patient/components/camera/index.scss
  7. 97
      src/patient/components/camera/index.ts
  8. 6
      src/patient/components/camera/index.wxml
  9. 3
      src/patient/components/image-crop/index.json
  10. 19
      src/patient/components/image-crop/index.scss
  11. 278
      src/patient/components/image-crop/index.ts
  12. 7
      src/patient/components/image-crop/index.wxml
  13. 8
      src/patient/components/image-merge/index.json
  14. 77
      src/patient/components/image-merge/index.scss
  15. 177
      src/patient/components/image-merge/index.ts
  16. 1
      src/patient/components/image-merge/index.wxml
  17. 81
      src/patient/pages/camera/index.ts
  18. 246
      src/patient/pages/imageCrop/index.ts
  19. 10
      src/patient/pages/imageMerge/index.json
  20. 266
      src/patient/pages/imageMerge/index.ts
  21. 108
      src/patient/pages/imageMerge/index.wxml
  22. 11
      src/patient/pages/imageProcessing/index.json
  23. 111
      src/patient/pages/imageProcessing/index.scss
  24. 89
      src/patient/pages/imageProcessing/index.ts
  25. 55
      src/patient/pages/imageProcessing/index.wxml
  26. 528
      接口文档.md

2
project.config.json

@ -71,5 +71,5 @@ @@ -71,5 +71,5 @@
}
]
},
"appid": "wxf9ce8010f1ad24aa"
"appid": "wx71ac9c27c3c3e3f4"
}

18
project.private.config.json

@ -23,27 +23,13 @@ @@ -23,27 +23,13 @@
"miniprogram": {
"list": [
{
"name": "拍照",
"pathName": "patient/pages/camera/index",
"name": "patient/pages/imageProcessing/index",
"pathName": "patient/pages/imageProcessing/index",
"query": "",
"scene": null,
"launchMode": "default"
},
{
"name": "图片裁剪",
"pathName": "patient/pages/imageCrop/index",
"query": "",
"launchMode": "default",
"scene": null
},
{
"name": "图片拼接",
"pathName": "patient/pages/imageMerge/index",
"query": "",
"launchMode": "default",
"scene": null
},
{
"name": "患者-qol",
"pathName": "patient/pages/qol/index",
"query": "pushId=81",

4
src/app.json

@ -72,9 +72,7 @@ @@ -72,9 +72,7 @@
"pages/hormonesResult/index",
"pages/medical/index",
"pages/medicalDetail/index",
"pages/imageMerge/index",
"pages/imageCrop/index",
"pages/camera/index"
"pages/imageProcessing/index"
]
},
{

12
src/app.ts

@ -18,15 +18,15 @@ App<IAppOption>({ @@ -18,15 +18,15 @@ App<IAppOption>({
// 测试号 wx2b0bb13edf717c1d
// dev
// appid:wxf9ce8010f1ad24aa
url: 'https://m.xd.hbraas.com',
upFileUrl: 'https://m.xd.hbraas.com/',
imageUrl: 'https://m.xd.hbraas.com/xd/',
// url: 'https://m.xd.hbraas.com',
// upFileUrl: 'https://m.xd.hbraas.com/',
// imageUrl: 'https://m.xd.hbraas.com/xd/',
// pro
// appid:wx71ac9c27c3c3e3f4
// url: 'https://m.xd.hbsaas.com',
// upFileUrl: 'https://m.xd.hbsaas.com/',
// imageUrl: 'https://m.xd.hbsaas.com/api/xd/',
url: 'https://m.xd.hbsaas.com',
upFileUrl: 'https://m.xd.hbsaas.com/',
imageUrl: 'https://m.xd.hbsaas.com/api/xd/',
loginState: '',
isLogin: 0,

2
src/patient/pages/camera/index.json → src/patient/components/camera/index.json

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
{
"navigationBarTitleText": "拍照",
"component": true,
"usingComponents": {
"van-icon": "@vant/weapp/icon/index",
"van-button": "@vant/weapp/button/index"

35
src/patient/pages/camera/index.scss → src/patient/components/camera/index.scss

@ -1,13 +1,14 @@ @@ -1,13 +1,14 @@
page {
background-color: #000;
height: 100vh;
overflow: hidden;
}
.container {
.camera-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
height: 100vh;
display: flex;
flex-direction: column;
background-color: #000;
z-index: 1000;
}
.camera {
@ -62,6 +63,20 @@ page { @@ -62,6 +63,20 @@ page {
border-top: none;
}
.close-btn {
position: fixed;
top: 40rpx;
right: 40rpx;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
z-index: 10;
}
.controls {
position: fixed;
bottom: 80rpx;
@ -107,8 +122,7 @@ page { @@ -107,8 +122,7 @@ page {
background-color: #000;
display: flex;
flex-direction: column;
z-index: 100;
}
z-index: 10;
.preview-image {
flex: 1;
@ -119,5 +133,6 @@ page { @@ -119,5 +133,6 @@ page {
display: flex;
justify-content: center;
padding: 40rpx;
background-color: #1a1a1a;
background-color: #000;
}
}

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

@ -0,0 +1,97 @@ @@ -0,0 +1,97 @@
Component({
properties: {
defaultPosition: {
type: String,
value: 'back',
},
},
data: {
visible: false,
devicePosition: 'back',
flash: 'off' as 'off' | 'auto' | 'on',
previewImage: '',
},
lifetimes: {
attached() {
this.setData({
devicePosition: this.properties.defaultPosition,
})
},
},
methods: {
openCamera() {
this.setData({
visible: true,
previewImage: '',
})
},
closeCamera() {
this.setData({ visible: false })
this.triggerEvent('cancel')
},
switchCamera() {
const newPosition = this.data.devicePosition === 'back' ? 'front' : 'back'
this.setData({
devicePosition: newPosition,
})
this.triggerEvent('positionchange', { position: newPosition })
},
toggleFlash() {
const flashModes: Array<'off' | 'auto' | 'on'> = ['off', 'auto', 'on']
const currentIndex = flashModes.indexOf(this.data.flash)
const nextIndex = (currentIndex + 1) % flashModes.length
this.setData({
flash: flashModes[nextIndex],
})
},
takePhoto() {
const ctx = wx.createCameraContext()
ctx.takePhoto({
quality: 'high',
success: (res) => {
this.setData({
previewImage: res.tempImagePath,
})
this.triggerEvent('capture', { tempFilePath: res.tempImagePath })
},
fail: (err) => {
console.error('拍照失败:', err)
wx.showToast({
title: '拍照失败',
icon: 'none',
})
this.triggerEvent('error', { reason: 'capture_failed' })
},
})
},
onCameraError(e: WechatMiniprogram.CustomEvent) {
console.error('相机错误:', e.detail)
wx.showToast({
title: '相机权限未开启',
icon: 'none',
})
this.triggerEvent('error', { reason: 'permission_denied' })
},
retake() {
this.setData({
previewImage: '',
})
this.triggerEvent('retake')
},
usePhoto() {
this.triggerEvent('use', { tempFilePath: this.data.previewImage })
this.setData({ visible: false })
},
},
})

6
src/patient/pages/camera/index.wxml → src/patient/components/camera/index.wxml

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
<view class="container">
<view class="camera-container" wx:if="{{visible}}">
<camera
class="camera"
device-position="{{devicePosition}}"
@ -13,6 +13,10 @@ @@ -13,6 +13,10 @@
</view>
</camera>
<view class="close-btn" bindtap="closeCamera">
<van-icon name="cross" size="20px" color="#fff" />
</view>
<view class="controls">
<view class="control-btn switch-btn" bindtap="switchCamera">
<van-icon name="reload" size="24px" color="#fff" />

3
src/patient/pages/imageCrop/index.json → src/patient/components/image-crop/index.json

@ -1,6 +1,5 @@ @@ -1,6 +1,5 @@
{
"navigationBarTitleText": "图片裁剪",
"disableScroll": true,
"component": true,
"usingComponents": {
"van-button": "@vant/weapp/button/index",
"van-icon": "@vant/weapp/icon/index",

19
src/patient/pages/imageCrop/index.scss → src/patient/components/image-crop/index.scss

@ -1,14 +1,15 @@ @@ -1,14 +1,15 @@
page {
background-color: #1a1a1a;
height: 100vh;
overflow: hidden;
}
.container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
height: 100vh;
display: flex;
flex-direction: column;
padding: 30rpx;
background-color: #1a1a1a;
overflow: hidden;
z-index: 1000;
}
.crop-wrapper {
@ -16,6 +17,7 @@ page { @@ -16,6 +17,7 @@ page {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.crop-inner {
@ -86,7 +88,7 @@ page { @@ -86,7 +88,7 @@ page {
}
.action-buttons {
padding: 20rpx 0;
padding: 20rpx 30rpx;
}
.crop-canvas {
@ -94,4 +96,3 @@ page { @@ -94,4 +96,3 @@ page {
left: -9999px;
top: -9999px;
}

278
src/patient/components/image-crop/index.ts

@ -0,0 +1,278 @@ @@ -0,0 +1,278 @@
interface TouchPoint {
x: number
y: number
}
Component({
properties: {
id: {
type: String,
value: 'default',
},
},
data: {
visible: false,
imageSrc: '',
imgWidth: 0,
imgHeight: 0,
imgX: 0,
imgY: 0,
scale: 1,
minScale: 1,
maxScale: 4,
cropSize: 280,
originScale: 1,
lastTouches: [] as TouchPoint[],
lastDist: 0,
lastScale: 1,
},
lifetimes: {
attached() {
const systemInfo = wx.getSystemInfoSync()
const cropSize = Math.min(systemInfo.windowWidth - 60, 350)
this.setData({ cropSize })
},
},
methods: {
cropImage(imageSrc: string) {
this.setData({ visible: true })
this.initImage(imageSrc)
},
cancelCrop() {
this.setData({ visible: false })
this.triggerEvent('cancel')
},
chooseImage() {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: (res) => {
if (res.tempFiles.length > 0) {
this.initImage(res.tempFiles[0].tempFilePath)
}
},
fail: () => {
this.triggerEvent('cancel')
},
})
},
initImage(src: string) {
wx.getImageInfo({
src,
success: (res) => {
const { width, height } = res
const { cropSize } = this.data
const scaleX = cropSize / width
const scaleY = cropSize / height
const scale = Math.max(scaleX, scaleY)
const imgWidth = width * scale
const imgHeight = height * scale
const imgX = 0
const imgY = (cropSize - imgHeight) / 2
this.setData({
imageSrc: src,
imgWidth,
imgHeight,
imgX,
imgY,
scale: 1,
originScale: scale,
})
this.triggerEvent('imagechange', { src })
},
fail: () => {
wx.showToast({ title: '图片加载失败', icon: 'none' })
this.triggerEvent('error', { reason: 'load_failed' })
},
})
},
clampImgPosition() {
const { imgWidth, imgHeight, imgX, imgY, scale, cropSize } = this.data
const scaledWidth = imgWidth * scale
const scaledHeight = imgHeight * scale
let newImgX = imgX
let newImgY = imgY
if (scaledWidth <= cropSize) {
newImgX = (cropSize - scaledWidth) / 2
}
else {
const minX = cropSize - scaledWidth
const maxX = 0
newImgX = Math.max(minX, Math.min(maxX, imgX))
}
if (scaledHeight <= cropSize) {
newImgY = (cropSize - scaledHeight) / 2
}
else {
const minY = cropSize - scaledHeight
const maxY = 0
newImgY = Math.max(minY, Math.min(maxY, imgY))
}
if (newImgX !== imgX || newImgY !== imgY) {
this.setData({ imgX: newImgX, imgY: newImgY })
}
},
onTouchStart(e: WechatMiniprogram.TouchEvent) {
const touches = e.touches
if (touches.length === 1) {
this.setData({
lastTouches: [{ x: touches[0].clientX, y: touches[0].clientY }],
})
}
else if (touches.length === 2) {
const currentDist = Math.hypot(
touches[0].clientX - touches[1].clientX,
touches[0].clientY - touches[1].clientY,
)
this.setData({
lastTouches: [
{ x: touches[0].clientX, y: touches[0].clientY },
{ x: touches[1].clientX, y: touches[1].clientY },
],
lastDist: currentDist,
lastScale: this.data.scale,
})
}
},
onTouchMove(e: WechatMiniprogram.TouchEvent) {
const touches = e.touches
if (touches.length === 1) {
const lastTouches = this.data.lastTouches
if (lastTouches.length === 1) {
const dx = touches[0].clientX - lastTouches[0].x
const dy = touches[0].clientY - lastTouches[0].y
this.setData({
imgX: this.data.imgX + dx,
imgY: this.data.imgY + dy,
lastTouches: [{ x: touches[0].clientX, y: touches[0].clientY }],
})
this.clampImgPosition()
}
}
else if (touches.length === 2) {
const currentDist = Math.hypot(
touches[0].clientX - touches[1].clientX,
touches[0].clientY - touches[1].clientY,
)
const lastTouches = this.data.lastTouches
if (lastTouches.length === 2 && this.data.lastDist > 0) {
const ratio = currentDist / this.data.lastDist
let newScale = this.data.lastScale * ratio
newScale = Math.max(this.data.minScale, Math.min(this.data.maxScale, newScale))
const { imgWidth, imgHeight, cropSize } = this.data
const scaledWidth = imgWidth * newScale
const scaledHeight = imgHeight * newScale
if (scaledWidth < cropSize || scaledHeight < cropSize) {
newScale = Math.max(cropSize / imgWidth, cropSize / imgHeight)
}
this.setData({ scale: newScale })
this.clampImgPosition()
}
}
},
onTouchEnd() {
this.setData({ lastTouches: [], lastDist: 0 })
},
chooseNewImage() {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: (res) => {
if (res.tempFiles.length > 0) {
this.initImage(res.tempFiles[0].tempFilePath)
}
},
})
},
saveCroppedImage() {
wx.showLoading({ title: '裁剪中...', mask: true })
const { imageSrc, imgWidth, imgHeight, imgX, imgY, scale, cropSize, id, originScale } = this.data
const query = this.createSelectorQuery()
query
.in(this)
.select(`#cropCanvas-${id}`)
.fields({ node: true, size: true })
.exec((res) => {
if (!res[0]) {
wx.hideLoading()
wx.showToast({ title: '裁剪失败', icon: 'none' })
this.triggerEvent('error', { reason: 'canvas_not_found' })
return
}
const canvas = res[0].node
const ctx = canvas.getContext('2d')
const pixelRatio = wx.getWindowInfo().pixelRatio
canvas.width = cropSize * pixelRatio
canvas.height = cropSize * pixelRatio
const img = canvas.createImage()
img.src = imageSrc
img.onload = () => {
const scaledWidth = imgWidth * scale
const scaledHeight = imgHeight * scale
const sx = (-imgX / scale) * pixelRatio
const sy = (-imgY / scale) * pixelRatio
const sWidth = (cropSize / scale) * pixelRatio
const sHeight = (cropSize / scale) * pixelRatio
ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, 0, canvas.width, canvas.height)
wx.canvasToTempFilePath({
canvas,
success: (result) => {
wx.hideLoading()
this.setData({ visible: false })
this.triggerEvent('save', { tempFilePath: result.tempFilePath })
},
fail: () => {
wx.hideLoading()
wx.showToast({ title: '裁剪失败', icon: 'none' })
this.triggerEvent('error', { reason: 'canvas_to_temp_failed' })
},
})
}
img.onerror = () => {
wx.hideLoading()
wx.showToast({ title: '图片加载失败', icon: 'none' })
this.triggerEvent('error', { reason: 'image_load_failed' })
}
})
},
},
})

7
src/patient/pages/imageCrop/index.wxml → src/patient/components/image-crop/index.wxml

@ -1,6 +1,4 @@ @@ -1,6 +1,4 @@
<van-toast id="van-toast" />
<view class="container">
<view class="container" wx:if="{{visible}}">
<view class="crop-wrapper">
<view class="crop-inner">
<view class="crop-area" style="width: {{cropSize}}px; height: {{cropSize}}px;">
@ -38,5 +36,4 @@ @@ -38,5 +36,4 @@
</view>
</view>
<canvas type="2d" id="cropCanvas" class="crop-canvas" style="width: {{cropSize}}px; height: {{cropSize}}px;"></canvas>
<canvas type="2d" id="cropCanvas-{{id}}" class="crop-canvas" style="width: {{cropSize}}px; height: {{cropSize}}px;" wx:if="{{visible}}"></canvas>

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

@ -0,0 +1,8 @@ @@ -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"
}
}

77
src/patient/pages/imageMerge/index.scss → src/patient/components/image-merge/index.scss

@ -1,13 +1,14 @@ @@ -1,13 +1,14 @@
page {
background-color: #f5f5f5;
min-height: 100vh;
}
.container {
height: 100vh;
display: flex;
flex-direction: column;
padding: 30rpx;
overflow: hidden;
box-sizing: border-box;
}
.header {
flex-shrink: 0;
text-align: center;
margin-bottom: 40rpx;
@ -16,17 +17,11 @@ page { @@ -16,17 +17,11 @@ page {
font-size: 40rpx;
font-weight: bold;
color: #333;
margin-bottom: 10rpx;
}
.subtitle {
display: block;
font-size: 26rpx;
color: #999;
}
}
.upload-section {
flex-shrink: 0;
background-color: #fff;
border-radius: 16rpx;
padding: 30rpx;
@ -40,6 +35,12 @@ page { @@ -40,6 +35,12 @@ page {
margin-bottom: 20rpx;
}
.scroll-area {
flex: 1;
overflow-y: auto;
margin-bottom: 20rpx;
}
.image-grid {
display: flex;
flex-wrap: wrap;
@ -111,36 +112,18 @@ page { @@ -111,36 +112,18 @@ page {
width: 100%;
display: block;
}
.preview-hint {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 20rpx;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.5));
color: #fff;
font-size: 24rpx;
text-align: center;
}
}
.tips {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: center;
padding: 40rpx;
text-align: center;
text {
font-size: 24rpx;
color: #999;
margin-left: 10rpx;
line-height: 1.5;
}
}
// 隐藏的Canvas
.merge-canvas {
position: fixed;
left: -9999px;
@ -149,7 +132,6 @@ page { @@ -149,7 +132,6 @@ page {
height: 1px;
}
// 预览弹窗
.preview-modal {
position: fixed;
top: 0;
@ -157,19 +139,19 @@ page { @@ -157,19 +139,19 @@ page {
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.9);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
.preview-content {
width: 90%;
max-height: 80%;
overflow: auto;
width: 100%;
max-height: 100%;
}
.full-img {
width: 100%;
}
display: block;
}
.close-btn {
@ -178,10 +160,27 @@ page { @@ -178,10 +160,27 @@ page {
right: 40rpx;
width: 60rpx;
height: 60rpx;
background-color: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.save-btn {
position: absolute;
bottom: 60rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8rpx;
padding: 20rpx 40rpx;
background-color: rgba(255, 255, 255, 0.2);
border-radius: 60rpx;
backdrop-filter: blur(10px);
text {
color: #fff;
font-size: 28rpx;
}
}
}

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

@ -0,0 +1,177 @@ @@ -0,0 +1,177 @@
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,
})
})
.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')
ctx.fillStyle = '#ffffff'
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)
const timeAreaHeight = 60
const watermarkHeight = 80
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 + timeAreaHeight
})
totalHeight += watermarkHeight
canvas.width = canvasWidth
canvas.height = totalHeight
ctx.fillRect(0, 0, canvasWidth, 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 timeY = currentY + scaledHeights[index] + 40
ctx.fillStyle = '#666666'
ctx.font = '24px sans-serif'
ctx.textAlign = 'center'
const timeText = imageList[index].time || this.formatTime(new Date())
ctx.fillText(timeText, canvasWidth / 2, timeY)
currentY += scaledHeights[index] + timeAreaHeight
loadedCount++
if (loadedCount === imageInfos.length) {
ctx.fillStyle = '#999999'
ctx.font = '28px sans-serif'
ctx.textAlign = 'right'
ctx.fillText('TED关爱小助手', canvasWidth - 30, totalHeight - 30)
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/patient/components/image-merge/index.wxml

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

81
src/patient/pages/camera/index.ts

@ -1,81 +0,0 @@ @@ -1,81 +0,0 @@
Page({
data: {
devicePosition: 'back',
flash: 'off',
previewImage: '',
},
onLoad() {
const app = getApp<IAppOption>()
this.setData({
devicePosition: app.globalData?.cameraPosition || 'back',
})
},
switchCamera() {
const newPosition = this.data.devicePosition === 'back' ? 'front' : 'back'
const app = getApp<IAppOption>()
if (app.globalData) {
app.globalData.cameraPosition = newPosition
}
this.setData({
devicePosition: newPosition,
})
},
toggleFlash() {
const flashModes = ['off', 'auto', 'on']
const currentIndex = flashModes.indexOf(this.data.flash)
const nextIndex = (currentIndex + 1) % flashModes.length
this.setData({
flash: flashModes[nextIndex],
})
},
takePhoto() {
const ctx = wx.createCameraContext()
ctx.takePhoto({
quality: 'high',
success: (res) => {
this.setData({
previewImage: res.tempImagePath,
})
},
fail: (err) => {
console.error('拍照失败:', err)
wx.showToast({
title: '拍照失败',
icon: 'none',
})
},
})
},
onCameraError(e: WechatMiniprogram.CustomEvent) {
console.error('相机错误:', e.detail)
wx.showToast({
title: '相机权限未开启',
icon: 'none',
})
},
retake() {
this.setData({
previewImage: '',
})
},
usePhoto() {
const pages = getCurrentPages()
const prevPage = pages[pages.length - 2]
if (prevPage) {
prevPage.setData({
photoPath: this.data.previewImage,
})
}
wx.navigateBack()
},
})

246
src/patient/pages/imageCrop/index.ts

@ -1,246 +0,0 @@ @@ -1,246 +0,0 @@
import Toast from '@vant/weapp/toast/toast'
interface TouchPoint {
x: number
y: number
}
Page({
data: {
imageSrc: '',
imgWidth: 0,
imgHeight: 0,
imgX: 0,
imgY: 0,
scale: 1,
minScale: 0.5,
maxScale: 4,
cropSize: 280,
originScale: 1,
lastTouches: [] as TouchPoint[],
},
onLoad(options: { imageSrc?: string }) {
const systemInfo = wx.getSystemInfoSync()
const cropSize = Math.min(systemInfo.windowWidth - 60, 350)
this.setData({ cropSize })
if (options.imageSrc) {
this.setData({ imageSrc: decodeURIComponent(options.imageSrc) })
}
else {
this.chooseImage()
}
},
chooseImage() {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: (res) => {
if (res.tempFiles.length > 0) {
this.initImage(res.tempFiles[0].tempFilePath)
}
},
fail: () => {
Toast('请先选择图片')
wx.navigateBack()
},
})
},
initImage(src: string) {
wx.getImageInfo({
src,
success: (res) => {
const { width, height } = res
const { cropSize } = this.data
const scaleX = cropSize / width
const scaleY = cropSize / height
const scale = Math.max(scaleX, scaleY)
const imgWidth = width * scale
const imgHeight = height * scale
const imgX = (cropSize - imgWidth) / 2
const imgY = (cropSize - imgHeight) / 2
this.setData({
imageSrc: src,
imgWidth,
imgHeight,
imgX,
imgY,
scale: 1,
originScale: 1,
})
},
fail: () => {
Toast('图片加载失败')
},
})
},
onTouchStart(e: WechatMiniprogram.TouchEvent) {
const touches = e.touches
if (touches.length === 1) {
this.setData({
lastTouches: [{ x: touches[0].clientX, y: touches[0].clientY }],
})
}
else if (touches.length === 2) {
const lastTouches = this.data.lastTouches
if (lastTouches.length < 2) {
this.setData({
lastTouches: [
{ x: touches[0].clientX, y: touches[0].clientY },
{ x: touches[1].clientX, y: touches[1].clientY },
],
})
}
}
},
onTouchMove(e: WechatMiniprogram.TouchEvent) {
const touches = e.touches
if (touches.length === 1) {
const lastTouches = this.data.lastTouches
if (lastTouches.length === 1) {
const dx = touches[0].clientX - lastTouches[0].x
const dy = touches[0].clientY - lastTouches[0].y
this.setData({
imgX: this.data.imgX + dx,
imgY: this.data.imgY + dy,
lastTouches: [{ x: touches[0].clientX, y: touches[0].clientY }],
})
}
}
else if (touches.length === 2) {
const lastTouches = this.data.lastTouches
if (lastTouches.length === 2) {
const currentDist = Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY)
const lastDist = Math.hypot(lastTouches[0].x - lastTouches[1].x, lastTouches[0].y - lastTouches[1].y)
let newScale = this.data.scale * (currentDist / lastDist)
newScale = Math.max(this.data.minScale, Math.min(this.data.maxScale, newScale))
const centerX = (touches[0].clientX + touches[1].clientX) / 2
const centerY = (touches[0].clientY + touches[1].clientY) / 2
const lastCenterX = (lastTouches[0].x + lastTouches[1].x) / 2
const lastCenterY = (lastTouches[0].y + lastTouches[1].y) / 2
const dx = centerX - lastCenterX
const dy = centerY - lastCenterY
const imgCenterX = this.data.imgX + (this.data.imgWidth * this.data.scale) / 2
const imgCenterY = this.data.imgY + (this.data.imgHeight * this.data.scale) / 2
const newImgX = imgCenterX - (this.data.imgWidth * newScale) / 2
const newImgY = imgCenterY - (this.data.imgHeight * newScale) / 2
this.setData({
scale: newScale,
imgX: newImgX + dx,
imgY: newImgY + dy,
lastTouches: [
{ x: touches[0].clientX, y: touches[0].clientY },
{ x: touches[1].clientX, y: touches[1].clientY },
],
})
}
}
},
onTouchEnd() {
this.setData({ lastTouches: [] })
},
chooseNewImage() {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: (res) => {
if (res.tempFiles.length > 0) {
this.initImage(res.tempFiles[0].tempFilePath)
}
},
})
},
saveCroppedImage() {
Toast.loading({ message: '裁剪中...', forbidClick: true })
const { imageSrc, imgWidth, imgHeight, imgX, imgY, scale, cropSize } = this.data
const query = wx.createSelectorQuery()
query
.select('#cropCanvas')
.fields({ node: true, size: true })
.exec((res) => {
if (!res[0]) {
Toast.clear()
Toast('裁剪失败')
return
}
const canvas = res[0].node
const ctx = canvas.getContext('2d')
const pixelRatio = wx.getWindowInfo().pixelRatio
canvas.width = cropSize * pixelRatio
canvas.height = cropSize * pixelRatio
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
const img = canvas.createImage()
img.src = imageSrc
img.onload = () => {
const sx = (-imgX / scale) * pixelRatio
const sy = (-imgY / scale) * pixelRatio
const sWidth = (imgWidth / scale) * pixelRatio
const sHeight = (imgHeight / scale) * pixelRatio
ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, 0, canvas.width, canvas.height)
wx.canvasToTempFilePath({
canvas,
x: 0,
y: 0,
width: canvas.width,
height: canvas.height,
destWidth: cropSize,
destHeight: cropSize,
fileType: 'png',
quality: 1,
success: (result) => {
Toast.clear()
wx.saveImageToPhotosAlbum({
filePath: result.tempFilePath,
success: () => {
Toast.success('保存成功')
},
fail: () => {
Toast.fail('保存失败,请检查权限')
},
})
},
fail: () => {
Toast.clear()
Toast('裁剪失败')
},
})
}
img.onerror = () => {
Toast.clear()
Toast('图片加载失败')
}
})
},
})

10
src/patient/pages/imageMerge/index.json

@ -1,10 +0,0 @@ @@ -1,10 +0,0 @@
{
"navigationBarTitleText": "图片拼接",
"usingComponents": {
"van-button": "@vant/weapp/button/index",
"van-icon": "@vant/weapp/icon/index",
"van-toast": "@vant/weapp/toast/index",
"van-dialog": "@vant/weapp/dialog/index",
"van-loading": "@vant/weapp/loading/index"
}
}

266
src/patient/pages/imageMerge/index.ts

@ -1,266 +0,0 @@ @@ -1,266 +0,0 @@
import Toast from '@vant/weapp/toast/toast'
const app = getApp<IAppOption>()
Page({
data: {
imageList: [] as string[],
imageTimes: [] as string[], // 存储每张图片的拍摄时间
mergedImage: '',
canvasWidth: 0,
canvasHeight: 0,
isLoading: false,
previewShow: false,
},
onLoad() {
// 页面加载
},
// 选择图片
chooseImage() {
const remainingCount = 9 - this.data.imageList.length
if (remainingCount <= 0) {
Toast('最多只能选择9张图片')
return
}
wx.chooseMedia({
count: remainingCount,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: (res) => {
const newImages = res.tempFiles.map((file) => file.tempFilePath)
// 获取当前时间作为图片时间
const now = new Date()
const timeStr = this.formatTime(now)
const newTimes = res.tempFiles.map(() => timeStr)
this.setData({
imageList: [...this.data.imageList, ...newImages],
imageTimes: [...this.data.imageTimes, ...newTimes],
})
},
})
},
// 格式化时间
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}`
},
// 删除图片
deleteImage(e: WechatMiniprogram.TouchEvent) {
const index = e.currentTarget.dataset.index
const newList = [...this.data.imageList]
const newTimes = [...this.data.imageTimes]
newList.splice(index, 1)
newTimes.splice(index, 1)
this.setData({
imageList: newList,
imageTimes: newTimes,
})
},
// 预览单张图片
previewImage(e: WechatMiniprogram.TouchEvent) {
const index = e.currentTarget.dataset.index
wx.previewImage({
current: this.data.imageList[index],
urls: this.data.imageList,
})
},
// 拼接图片
async mergeImages() {
if (this.data.imageList.length < 2) {
Toast('至少需要2张图片才能拼接')
return
}
this.setData({ isLoading: true })
try {
const mergedImage = await this.drawImagesOnCanvas()
this.setData({
mergedImage,
isLoading: false,
})
Toast('拼接成功')
} catch (error) {
console.error('拼接失败:', error)
this.setData({ isLoading: false })
Toast('拼接失败,请重试')
}
},
// 在Canvas上绘制图片(纵向拼接)
drawImagesOnCanvas(): Promise<string> {
return new Promise((resolve, reject) => {
const { imageList, imageTimes } = this.data
const query = wx.createSelectorQuery()
query
.select('#mergeCanvas')
.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')
// 设置画布背景为白色
ctx.fillStyle = '#ffffff'
// 加载所有图片并获取尺寸
Promise.all(
imageList.map((src) => this.getImageInfo(src))
).then((imageInfos) => {
// 计算目标宽度(取最大宽度或固定宽度)
const targetWidth = 750 // 固定宽度为750rpx对应的像素
const pixelRatio = wx.getWindowInfo().pixelRatio
const canvasWidth = Math.floor(targetWidth * pixelRatio / 2) // 转换为实际像素
// 底部时间文字区域高度
const timeAreaHeight = 60
// 右下角水印区域高度
const watermarkHeight = 80
// 计算每张图片缩放后的高度
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 + timeAreaHeight
})
// 添加水印区域高度
totalHeight += watermarkHeight
// 设置画布尺寸
canvas.width = canvasWidth
canvas.height = totalHeight
// 填充白色背景
ctx.fillRect(0, 0, canvasWidth, 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 timeY = currentY + scaledHeights[index] + 40
ctx.fillStyle = '#666666'
ctx.font = '24px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(imageTimes[index] || this.formatTime(new Date()), canvasWidth / 2, timeY)
currentY += scaledHeights[index] + timeAreaHeight
loadedCount++
if (loadedCount === imageInfos.length) {
// 所有图片绘制完成,绘制右下角水印
ctx.fillStyle = '#999999'
ctx.font = '28px sans-serif'
ctx.textAlign = 'right'
ctx.fillText('TED关爱小助手', canvasWidth - 30, totalHeight - 30)
// 导出图片
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,
})
})
},
// 预览拼接结果
previewMergedImage() {
if (!this.data.mergedImage) return
this.setData({ previewShow: true })
},
// 关闭预览
closePreview() {
this.setData({ previewShow: false })
},
// 保存图片到相册
saveImage() {
if (!this.data.mergedImage) {
Toast('请先拼接图片')
return
}
wx.saveImageToPhotosAlbum({
filePath: this.data.mergedImage,
success: () => {
Toast('保存成功')
},
fail: (err) => {
console.error('保存失败:', err)
if (err.errMsg.includes('auth')) {
wx.showModal({
title: '提示',
content: '需要授权保存到相册权限',
success: (res) => {
if (res.confirm) {
wx.openSetting()
}
},
})
} else {
Toast('保存失败')
}
},
})
},
// 重新选择
reset() {
this.setData({
imageList: [],
imageTimes: [],
mergedImage: '',
previewShow: false,
})
},
})

108
src/patient/pages/imageMerge/index.wxml

@ -1,108 +0,0 @@ @@ -1,108 +0,0 @@
<van-toast id="van-toast" />
<view class="container">
<!-- 标题 -->
<view class="header">
<text class="title">图片拼接工具</text>
<text class="subtitle">选择多张图片,自动拼接成长图</text>
</view>
<!-- 图片上传区域 -->
<view class="upload-section">
<view class="section-title">
<text>选择图片({{imageList.length}}/9)</text>
</view>
<view class="image-grid">
<!-- 已选择的图片 -->
<view
class="image-item"
wx:for="{{imageList}}"
wx:key="index"
data-index="{{index}}"
bindtap="previewImage"
>
<image class="preview-img" src="{{item}}" mode="aspectFill" />
<view class="delete-btn" catchtap="deleteImage" data-index="{{index}}">
<van-icon name="cross" size="12px" color="#fff" />
</view>
</view>
<!-- 添加按钮 -->
<view class="add-btn" bindtap="chooseImage" wx:if="{{imageList.length < 9}}">
<van-icon name="plus" size="32px" color="#999" />
<text class="add-text">添加图片</text>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="action-section" wx:if="{{imageList.length > 0}}">
<van-button
type="primary"
block
round
loading="{{isLoading}}"
loading-text="拼接中..."
disabled="{{imageList.length < 2}}"
bindtap="mergeImages"
>
{{imageList.length < 2 ? '至少选择2张图片' : '开始拼接'}}
</van-button>
<van-button
type="default"
block
round
custom-style="margin-top: 20rpx;"
bindtap="reset"
>
重新选择
</van-button>
</view>
<!-- 拼接结果预览 -->
<view class="result-section" wx:if="{{mergedImage}}">
<view class="section-title">
<text>拼接结果</text>
</view>
<view class="result-preview" bindtap="previewMergedImage">
<image class="merged-img" src="{{mergedImage}}" mode="widthFix" />
<view class="preview-hint">点击预览大图</view>
</view>
<van-button
type="primary"
block
round
custom-style="margin-top: 30rpx;"
bindtap="saveImage"
>
保存到相册
</van-button>
</view>
<!-- 提示信息 -->
<view class="tips" wx:if="{{imageList.length === 0}}">
<van-icon name="info-o" size="16px" color="#999" />
<text>支持上传2-9张图片,建议图片宽度相近以获得更好的拼接效果</text>
</view>
</view>
<!-- Canvas(隐藏)用于图片拼接 -->
<canvas
type="2d"
id="mergeCanvas"
class="merge-canvas"
></canvas>
<!-- 全屏预览弹窗 -->
<view class="preview-modal" wx:if="{{previewShow}}" bindtap="closePreview">
<view class="preview-content">
<image class="full-img" src="{{mergedImage}}" mode="widthFix" />
</view>
<view class="close-btn">
<van-icon name="cross" size="24px" color="#fff" />
</view>
</view>

11
src/patient/pages/imageProcessing/index.json

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
{
"navigationBarTitleText": "图片处理",
"navigationStyle": "default",
"usingComponents": {
"van-icon": "@vant/weapp/icon/index",
"van-button": "@vant/weapp/button/index",
"image-crop": "/patient/components/image-crop/index",
"image-merge": "/patient/components/image-merge/index",
"camera": "/patient/components/camera/index"
}
}

111
src/patient/pages/imageProcessing/index.scss

@ -0,0 +1,111 @@ @@ -0,0 +1,111 @@
page {
background-color: #f5f5f5;
min-height: 100vh;
}
.container {
padding: 30rpx;
}
.header {
text-align: center;
margin-bottom: 60rpx;
.title {
display: block;
font-size: 44rpx;
font-weight: bold;
color: #333;
margin-bottom: 10rpx;
}
.subtitle {
display: block;
font-size: 26rpx;
color: #999;
}
}
.tools-list {
background-color: #fff;
border-radius: 16rpx;
overflow: hidden;
}
.tool-card {
display: flex;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.tool-icon {
width: 80rpx;
height: 80rpx;
border-radius: 16rpx;
background-color: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
}
.tool-info {
flex: 1;
.tool-name {
display: block;
font-size: 32rpx;
font-weight: 500;
color: #333;
margin-bottom: 8rpx;
}
.tool-desc {
display: block;
font-size: 24rpx;
color: #999;
}
}
.demo-section {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #fff;
z-index: 100;
display: flex;
flex-direction: column;
}
.demo-content {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
image-crop {
flex: 1;
min-height: 0;
}
.merge-demo {
flex: 1;
display: flex;
flex-direction: column;
padding: 30rpx;
.demo-tip {
text-align: center;
color: #999;
font-size: 26rpx;
margin-bottom: 30rpx;
}
}
}

89
src/patient/pages/imageProcessing/index.ts

@ -0,0 +1,89 @@ @@ -0,0 +1,89 @@
Page({
data: {
showCrop: false,
showMerge: false,
isMerging: false,
},
openCrop() {
const cropComponent = this.selectComponent('#crop')
if (cropComponent) {
this.setData({ showCrop: true })
cropComponent.cropImage('https://picsum.photos/400/400')
}
},
openMerge() {
this.setData({
showCrop: false,
showMerge: true,
isMerging: false,
})
},
openCamera() {
const cameraComponent = this.selectComponent('#camera-component')
if (cameraComponent) {
cameraComponent.openCamera()
}
},
closeDemo() {
this.setData({
showCrop: false,
showMerge: false,
})
},
onCropSave(e) {
wx.previewImage({
urls: [e.detail.tempFilePath],
})
this.setData({ showCrop: false })
},
onCropCancel() {
this.setData({ showCrop: false })
},
onCropError(e: WechatMiniprogram.CustomEvent) {
console.error('裁剪失败:', e.detail.reason)
this.setData({ showCrop: false })
},
onMergeSave() {
this.setData({ isMerging: false })
},
onMergeError() {
this.setData({ isMerging: false })
},
demoMerge() {
if (this.data.isMerging) {
return
}
this.setData({ isMerging: true })
const mergeComponent = this.selectComponent('#merge')
if (mergeComponent) {
mergeComponent.mergeImages([
{ src: 'https://picsum.photos/400/300', time: '2024-01-01 10:00' },
{ src: 'https://picsum.photos/400/301', time: '2024-01-01 12:00' },
{ src: 'https://picsum.photos/400/302' },
])
}
},
onCameraUse(e: WechatMiniprogram.CustomEvent) {
console.log('拍照成功:', e.detail.tempFilePath)
wx.previewImage({
urls: [e.detail.tempFilePath],
})
},
onCameraCancel() {
console.log('相机已关闭')
},
})

55
src/patient/pages/imageProcessing/index.wxml

@ -0,0 +1,55 @@ @@ -0,0 +1,55 @@
<view class="container">
<view class="header">
<text class="title">图片处理工具</text>
<text class="subtitle">选择不同的图片处理功能</text>
</view>
<view class="tools-list">
<view class="tool-card" bindtap="openCrop">
<view class="tool-icon">
<van-icon name="crop" size="32px" color="#07c160" />
</view>
<view class="tool-info">
<text class="tool-name">图片裁剪</text>
<text class="tool-desc">调整图片大小和比例</text>
</view>
<van-icon name="arrow" size="16px" color="#999" />
</view>
<view class="tool-card" bindtap="openMerge">
<view class="tool-icon">
<van-icon name="coupon" size="32px" color="#1989fa" />
</view>
<view class="tool-info">
<text class="tool-name">图片拼接</text>
<text class="tool-desc">将多张图片拼接成一张</text>
</view>
<van-icon name="arrow" size="16px" color="#999" />
</view>
<view class="tool-card" bindtap="openCamera">
<view class="tool-icon">
<van-icon name="photograph" size="32px" color="#ff976a" />
</view>
<view class="tool-info">
<text class="tool-name">拍照</text>
<text class="tool-desc">拍摄照片</text>
</view>
<van-icon name="arrow" size="16px" color="#999" />
</view>
</view>
<view class="demo-section" wx:if="{{showMerge}}">
<view class="demo-content">
<view wx:if="{{showMerge}}" class="merge-demo">
<view class="demo-tip">点击下方按钮使用示例图片演示拼接功能</view>
<van-button type="primary" block round loading="{{isMerging}}" disabled="{{isMerging}}" bindtap="demoMerge">演示图片拼接</van-button>
<image-merge id="merge" bindsave="onMergeSave" binderror="onMergeError" />
</view>
</view>
</view>
<image-crop id="crop" bindsave="onCropSave" bindcancel="onCropCancel" binderror="onCropError" />
<camera id="camera-component" binduse="onCameraUse" bindcancel="onCameraCancel" />
</view>

528
接口文档.md

@ -0,0 +1,528 @@ @@ -0,0 +1,528 @@
# 突眼日记接口文档
## 一、接口概览
### 1.1 基本信息
- **模块名称**: 突眼日记
- **控制器路径**: modules\xd_frontend\controllers\ProtrusionController
- **接口前缀**: ?r=xd/protrusion
- **请求方式**: 需要登录(Header中传入loginState)
### 1.2 响应格式
```json
// 成功响应
{
"code": 0,
"data": {
// 业务数据
}
}
// 失败响应
{
"code": 1,
"msg": "错误信息"
}
```
---
## 二、接口详情
### 2.1 获取基准照状态
| 项目 | 内容 |
| ---------------- | -------------------------------- |
| **接口路径** | ?r=xd/protrusion/baseline-status |
| **请求方式** | GET |
| **是否需要登录** | 是 |
#### 响应示例
```json
{
"code": 0,
"data": {
"hasBaseline": true,
"baselineRecordId": 1
}
}
```
#### 返回字段说明
| 字段名 | 类型 | 说明 |
| ---------------- | ---- | --------------------- |
| hasBaseline | bool | 是否已设置基准照 |
| baselineRecordId | int | 基准照记录ID(无则为0) |
---
### 2.2 获取记录列表
| 项目 | 内容 |
| ---------------- | ---------------------------- |
| **接口路径** | ?r=xd/protrusion/record-list |
| **请求方式** | GET |
| **是否需要登录** | 是 |
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
| -------- | ---- | ---- | ---------------- |
| page | int | 否 | 页码,默认1 |
| pageSize | int | 否 | 每页数量,默认10 |
#### 响应示例
```json
{
"code": 0,
"data": {
"list": [
{
"recordId": 1,
"recordDate": "2026-04-10",
"isBaseline": 1,
"treatmentCount": 3,
"leftEye": 18.5,
"rightEye": 19.0,
"interorbitalDistance": 95.0,
"photoCount": 8,
"firstPhoto": "http://example.com/photo1.jpg",
"uploadCompleted": 0
}
],
"pages": 2,
"count": 15,
"page": 1
}
}
```
#### 返回字段说明
| 字段名 | 类型 | 说明 |
| -------------------- | ------ | ----------------------------- |
| recordId | int | 记录ID |
| recordDate | string | 记录日期 |
| isBaseline | int | 是否为基准照(0-否 1-是) |
| treatmentCount | int | 替妥尤单抗使用次数 |
| leftEye | float | 左眼凸眼度(mm) |
| rightEye | float | 右眼凸眼度(mm) |
| interorbitalDistance | float | 框间距(mm) |
| photoCount | int | 已上传照片数量 |
| firstPhoto | string | 第一张图片URL |
| uploadCompleted | int | 记录完整性(0-未完成 1-已完成) |
| pages | int | 总页数 |
| count | int | 总记录数 |
| page | int | 当前页码 |
---
### 2.3 新增记录
| 项目 | 内容 |
| ---------------- | --------------------------- |
| **接口路径** | ?r=xd/protrusion/record-add |
| **请求方式** | POST |
| **是否需要登录** | 是 |
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
| -------------------- | ------ | ---- | ------------------------------ |
| recordDate | string | 是 | 记录日期,格式YYYY-MM-DD |
| isBaseline | int | 否 | 是否为基准照(0-否 1-是),默认0 |
| treatmentCount | int | 否 | 替妥尤单抗使用次数(0-9),默认0 |
| leftEye | float | 否 | 左眼凸眼度(mm) |
| rightEye | float | 否 | 右眼凸眼度(mm) |
| interorbitalDistance | float | 否 | 框间距(mm) |
| frontOpen | string | 否 | 正面睁眼照片URL |
| frontClose | string | 否 | 正面闭眼照片URL |
| frontUp | string | 否 | 正面仰头照片URL |
| sideLeft90 | string | 否 | 90°左侧照片URL |
| sideRight90 | string | 否 | 90°右侧照片URL |
| sideLeft45 | string | 否 | 45°左侧照片URL |
| sideRight45 | string | 否 | 45°右侧照片URL |
| eyeUpLeft | string | 否 | 左上眼球照片URL |
| eyeUp | string | 否 | 向上眼球照片URL |
| eyeUpRight | string | 否 | 右上眼球照片URL |
| eyeLeft | string | 否 | 向左眼球照片URL |
| eyeRight | string | 否 | 向右眼球照片URL |
| eyeDownLeft | string | 否 | 左下眼球照片URL |
| eyeDown | string | 否 | 向下眼球照片URL |
| eyeDownRight | string | 否 | 右下眼球照片URL |
#### 响应示例
```json
{
"code": 0,
"data": {
"recordId": 1
}
}
```
#### 返回字段说明
| 字段名 | 类型 | 说明 |
| -------- | ---- | ------------ |
| recordId | int | 新增记录的ID |
---
### 2.4 更新记录
| 项目 | 内容 |
| ---------------- | ------------------------------ |
| **接口路径** | ?r=xd/protrusion/record-update |
| **请求方式** | POST |
| **是否需要登录** | 是 |
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
| -------------------- | ------ | ---- | ------------------------ |
| recordId | int | 是 | 记录ID |
| recordDate | string | 否 | 记录日期,格式YYYY-MM-DD |
| isBaseline | int | 否 | 是否为基准照(0-否 1-是) |
| treatmentCount | int | 否 | 替妥尤单抗使用次数(0-9) |
| leftEye | float | 否 | 左眼凸眼度(mm) |
| rightEye | float | 否 | 右眼凸眼度(mm) |
| interorbitalDistance | float | 否 | 框间距(mm) |
| frontOpen | string | 否 | 正面睁眼照片URL |
| frontClose | string | 否 | 正面闭眼照片URL |
| frontUp | string | 否 | 正面仰头照片URL |
| sideLeft90 | string | 否 | 90°左侧照片URL |
| sideRight90 | string | 否 | 90°右侧照片URL |
| sideLeft45 | string | 否 | 45°左侧照片URL |
| sideRight45 | string | 否 | 45°右侧照片URL |
| eyeUpLeft | string | 否 | 左上眼球照片URL |
| eyeUp | string | 否 | 向上眼球照片URL |
| eyeUpRight | string | 否 | 右上眼球照片URL |
| eyeLeft | string | 否 | 向左眼球照片URL |
| eyeRight | string | 否 | 向右眼球照片URL |
| eyeDownLeft | string | 否 | 左下眼球照片URL |
| eyeDown | string | 否 | 向下眼球照片URL |
| eyeDownRight | string | 否 | 右下眼球照片URL |
#### 响应示例
```json
{
"code": 0,
"data": {
"success": true
}
}
```
---
### 2.5 获取记录详情
| 项目 | 内容 |
| ---------------- | ------------------------------ |
| **接口路径** | ?r=xd/protrusion/record-detail |
| **请求方式** | GET |
| **是否需要登录** | 是 |
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
| -------- | ---- | ---- | ------ |
| recordId | int | 是 | 记录ID |
#### 响应示例
```json
{
"code": 0,
"data": {
"recordId": 1,
"recordDate": "2026-04-10",
"isBaseline": 1,
"treatmentCount": 3,
"leftEye": 18.5,
"rightEye": 19.0,
"interorbitalDistance": 95.0,
"photoCount": 15,
"uploadCompleted": 1,
"frontOpen": "http://example.com/photo1.jpg",
"frontClose": "http://example.com/photo2.jpg",
"frontUp": "http://example.com/photo3.jpg",
"sideLeft90": "http://example.com/photo4.jpg",
"sideRight90": "http://example.com/photo5.jpg",
"sideLeft45": "http://example.com/photo6.jpg",
"sideRight45": "http://example.com/photo7.jpg",
"eyeUpLeft": "http://example.com/photo8.jpg",
"eyeUp": "http://example.com/photo9.jpg",
"eyeUpRight": "http://example.com/photo10.jpg",
"eyeLeft": "http://example.com/photo11.jpg",
"eyeRight": "http://example.com/photo12.jpg",
"eyeDownLeft": "http://example.com/photo13.jpg",
"eyeDown": "http://example.com/photo14.jpg",
"eyeDownRight": "http://example.com/photo15.jpg",
"frontOpenName": "正面睁眼",
"frontCloseName": "正面闭眼",
"frontUpName": "正面仰头",
"sideLeft90Name": "90°左侧",
"sideRight90Name": "90°右侧",
"sideLeft45Name": "45°左侧",
"sideRight45Name": "45°右侧",
"eyeUpLeftName": "左上",
"eyeUpName": "向上",
"eyeUpRightName": "右上",
"eyeLeftName": "向左",
"eyeRightName": "向右",
"eyeDownLeftName": "左下",
"eyeDownName": "向下",
"eyeDownRightName": "右下",
"createdAt": "2026-04-10 10:00:00"
}
}
```
#### 返回字段说明
| 字段名 | 类型 | 说明 |
| -------------------- | ------ | ------------------ |
| recordId | int | 记录ID |
| recordDate | string | 记录日期 |
| isBaseline | int | 是否为基准照 |
| treatmentCount | int | 替妥尤单抗使用次数 |
| leftEye | float | 左眼凸眼度 |
| rightEye | float | 右眼凸眼度 |
| interorbitalDistance | float | 框间距 |
| photoCount | int | 已上传照片数量 |
| uploadCompleted | int | 记录完整性 |
| frontOpen | string | 正面睁眼照片URL |
| frontClose | string | 正面闭眼照片URL |
| frontUp | string | 正面仰头照片URL |
| sideLeft90 | string | 90°左侧照片URL |
| sideRight90 | string | 90°右侧照片URL |
| sideLeft45 | string | 45°左侧照片URL |
| sideRight45 | string | 45°右侧照片URL |
| eyeUpLeft | string | 左上眼球照片URL |
| eyeUp | string | 向上眼球照片URL |
| eyeUpRight | string | 右上眼球照片URL |
| eyeLeft | string | 向左眼球照片URL |
| eyeRight | string | 向右眼球照片URL |
| eyeDownLeft | string | 左下眼球照片URL |
| eyeDown | string | 向下眼球照片URL |
| eyeDownRight | string | 右下眼球照片URL |
| frontOpenName | string | 正面睁眼名称 |
| frontCloseName | string | 正面闭眼名称 |
| frontUpName | string | 正面仰头名称 |
| sideLeft90Name | string | 90°左侧名称 |
| sideRight90Name | string | 90°右侧名称 |
| sideLeft45Name | string | 45°左侧名称 |
| sideRight45Name | string | 45°右侧名称 |
| eyeUpLeftName | string | 左上名称 |
| eyeUpName | string | 向上名称 |
| eyeUpRightName | string | 右上名称 |
| eyeLeftName | string | 向左名称 |
| eyeRightName | string | 向右名称 |
| eyeDownLeftName | string | 左下名称 |
| eyeDownName | string | 向下名称 |
| eyeDownRightName | string | 右下名称 |
| createdAt | string | 创建时间 |
---
### 2.6 删除记录
| 项目 | 内容 |
| ---------------- | ------------------------------ |
| **接口路径** | ?r=xd/protrusion/record-delete |
| **请求方式** | POST |
| **是否需要登录** | 是 |
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
| -------- | ---- | ---- | ------ |
| recordId | int | 是 | 记录ID |
#### 响应示例
```json
{
"code": 0,
"data": {
"success": true
}
}
```
> **注意**: 删除为软删除,仅更新状态,不实际删除数据
---
### 2.7 获取可对比记录列表
| 项目 | 内容 |
| ---------------- | ---------------------------------- |
| **接口路径** | ?r=xd/protrusion/compare-date-list |
| **请求方式** | GET |
| **是否需要登录** | 是 |
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
| ------ | ------ | ---- | ------------ |
| angle | string | 是 | 照片角度标识 |
#### 响应示例
```json
{
"code": 0,
"data": {
"angleName": "正面睁眼",
"recordList": [
{
"recordId": 5,
"recordDate": "2026-04-10"
},
{
"recordId": 8,
"recordDate": "2026-05-15"
}
]
}
}
```
#### 返回字段说明
| 字段名 | 类型 | 说明 |
| ---------- | ------ | ------------------------ |
| angleName | string | 当前角度中文名称 |
| recordList | array | 记录列表(不包含基准照) |
| recordId | int | 记录ID |
| recordDate | string | 记录日期 |
---
### 2.8 获取对比详情
| 项目 | 内容 |
| ---------------- | ------------------------------- |
| **接口路径** | ?r=xd/protrusion/compare-detail |
| **请求方式** | GET |
| **是否需要登录** | 是 |
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
| --------- | ------ | ---- | ---------------------- |
| angle | string | 是 | 照片角度标识 |
| recordIds | string | 是 | 记录ID,多个用逗号分隔 |
#### 响应示例
```json
{
"code": 0,
"data": {
"angleName": "正面睁眼",
"baselineRecord": {
"recordId": 1,
"recordDate": "2026-01-01",
"treatmentCount": 0,
"leftEye": 20.0,
"rightEye": 20.5,
"interorbitalDistance": 100.0,
"photoUrl": "http://example.com/baseline.jpg"
},
"compareRecords": [
{
"recordId": 5,
"recordDate": "2026-04-10",
"treatmentCount": 3,
"leftEye": 18.5,
"rightEye": 19.0,
"interorbitalDistance": 95.0,
"photoUrl": "http://example.com/compare1.jpg"
},
{
"recordId": 8,
"recordDate": "2026-05-15",
"treatmentCount": 5,
"leftEye": 17.5,
"rightEye": 18.0,
"interorbitalDistance": 92.0,
"photoUrl": "http://example.com/compare2.jpg"
}
]
}
}
```
#### 返回字段说明
| 字段名 | 类型 | 说明 |
| -------------- | ------ | ---------------- |
| angleName | string | 当前角度中文名称 |
| baselineRecord | object | 基准照记录 |
| compareRecords | array | 对比记录列表 |
---
### 2.9 获取角度选项
| 项目 | 内容 |
| ---------------- | ------------------------------ |
| **接口路径** | ?r=xd/protrusion/angle-options |
| **请求方式** | GET |
| **是否需要登录** | 否 |
#### 响应示例
```json
{
"code": 0,
"data": {
"frontOpen": "正面睁眼",
"frontClose": "正面闭眼",
"frontUp": "正面仰头",
"sideLeft90": "90°左侧",
"sideRight90": "90°右侧",
"sideLeft45": "45°左侧",
"sideRight45": "45°右侧",
"eyeUpLeft": "左上",
"eyeUp": "向上",
"eyeUpRight": "右上",
"eyeLeft": "向左",
"eyeRight": "向右",
"eyeDownLeft": "左下",
"eyeDown": "向下",
"eyeDownRight": "右下"
}
}
```
---
## 三、错误码说明
| 错误码 | 说明 |
| ------ | -------- |
| 0 | 成功 |
| 1 | 通用错误 |
| 403 | 需要登录 |
---
## 四、注意事项
1. **UserId获取**: 用户ID从登录上下文中自动获取,无需传入
2. **基准照唯一性**: 设置新基准照时,系统会自动取消之前的基准照
3. **照片上传**: 照片为URL链接,物理文件存储在其他服务器
4. **软删除**: 删除记录采用软删除方式
5. **记录完整性**: 当photoCount达到15时,uploadCompleted自动变为1
Loading…
Cancel
Save