33 changed files with 1882 additions and 1239 deletions
@ -1,5 +1,5 @@ |
|||||||
{ |
{ |
||||||
"navigationBarTitleText": "拍照", |
"component": true, |
||||||
"usingComponents": { |
"usingComponents": { |
||||||
"van-icon": "@vant/weapp/icon/index", |
"van-icon": "@vant/weapp/icon/index", |
||||||
"van-button": "@vant/weapp/button/index" |
"van-button": "@vant/weapp/button/index" |
||||||
@ -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 }) |
||||||
|
}, |
||||||
|
}, |
||||||
|
}) |
||||||
@ -1,6 +1,5 @@ |
|||||||
{ |
{ |
||||||
"navigationBarTitleText": "图片裁剪", |
"component": true, |
||||||
"disableScroll": true, |
|
||||||
"usingComponents": { |
"usingComponents": { |
||||||
"van-button": "@vant/weapp/button/index", |
"van-button": "@vant/weapp/button/index", |
||||||
"van-icon": "@vant/weapp/icon/index", |
"van-icon": "@vant/weapp/icon/index", |
||||||
@ -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' }) |
||||||
|
} |
||||||
|
}) |
||||||
|
}, |
||||||
|
}, |
||||||
|
}) |
||||||
@ -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" |
||||||
|
} |
||||||
|
} |
||||||
@ -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, |
||||||
|
}) |
||||||
|
}) |
||||||
|
}, |
||||||
|
}, |
||||||
|
}) |
||||||
@ -0,0 +1 @@ |
|||||||
|
<canvas type="2d" id="mergeCanvas-{{id}}" class="merge-canvas"></canvas> |
||||||
@ -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() |
|
||||||
}, |
|
||||||
}) |
|
||||||
@ -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('图片加载失败') |
|
||||||
} |
|
||||||
}) |
|
||||||
}, |
|
||||||
}) |
|
||||||
@ -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" |
|
||||||
} |
|
||||||
} |
|
||||||
@ -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, |
|
||||||
}) |
|
||||||
}, |
|
||||||
}) |
|
||||||
@ -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> |
|
||||||
@ -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" |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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('相机已关闭') |
||||||
|
}, |
||||||
|
}) |
||||||
@ -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> |
||||||
@ -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…
Reference in new issue