新增用户评价组件及文档 2026-04-18 13:54

This commit is contained in:
MaDaLei 2026-04-18 13:54:57 +08:00
parent d632e895e8
commit d93ed027c7
5 changed files with 362 additions and 173 deletions

View File

@ -107,6 +107,10 @@ export const unsubscribeReportReminder = (unsubscribeToken) =>
export const getReportLeads = (storeId, status = 'pending') => export const getReportLeads = (storeId, status = 'pending') =>
get('/report/leads', { storeId, status }) get('/report/leads', { storeId, status })
// 报告页:宠主寄语(生成海报前提交,供口碑墙)
export const submitReportTestimonial = (token, content, isPublic = true) =>
post(`/report/${encodeURIComponent(token)}/testimonial`, { content, isPublic })
// 服务类型列表(不传 storeId 时仅返回系统默认,供未写入门店会话的 C 端展示) // 服务类型列表(不传 storeId 时仅返回系统默认,供未写入门店会话的 C 端展示)
export const getServiceTypeList = (storeId) => { export const getServiceTypeList = (storeId) => {
const params = {} const params = {}

View File

@ -0,0 +1,150 @@
<template>
<view v-if="modelValue" class="tm-mask" @click="onMask">
<view class="tm-card" @click.stop>
<text class="tm-title">{{ titleText }}</text>
<textarea
class="tm-input"
v-model="localText"
maxlength="200"
:placeholder="placeholderText"
:disabled="submitting"
:show-confirm-bar="false"
/>
<text class="tm-hint">选填最多 200 跳过则直接生成不含寄语的版本</text>
<view class="tm-actions">
<button class="tm-btn tm-btn-ghost" :disabled="submitting" @click="emitSkip">跳过</button>
<button class="tm-btn tm-btn-primary" :disabled="submitting" @click="emitConfirm">
{{ submitting ? '请稍候…' : '生成海报' }}
</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
const props = defineProps({
modelValue: { type: Boolean, default: false },
petName: { type: String, default: '' },
submitting: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue', 'skip', 'confirm'])
const localText = ref('')
const titleText = computed(() => {
const n = (props.petName || '').trim()
return n ? `想对「${n}」说点什么?` : '想对宝贝说点什么?'
})
const placeholderText = computed(() => {
const n = (props.petName || '').trim()
return n ? `例如:${n}洗完又香又软,今天超可爱` : '例如:洗完香香的,精神又可爱'
})
watch(
() => props.modelValue,
(v) => {
if (v) localText.value = ''
}
)
const close = () => {
emit('update:modelValue', false)
}
const onMask = () => {
if (props.submitting) return
emitSkip()
}
const emitSkip = () => {
close()
emit('skip')
}
const emitConfirm = () => {
emit('confirm', localText.value.trim())
}
defineExpose({ close })
</script>
<style scoped>
.tm-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.45);
z-index: 9999;
display: flex;
align-items: flex-end;
justify-content: center;
padding: 16px;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.tm-card {
width: 100%;
max-width: 420px;
background: #fff;
border-radius: 16px 16px 12px 12px;
padding: 18px 16px 16px;
box-shadow: 0 -8px 32px rgba(15, 23, 42, 0.12);
}
.tm-title {
display: block;
font-size: 16px;
font-weight: 700;
color: #0f172a;
margin-bottom: 12px;
}
.tm-input {
width: 100%;
min-height: 88px;
padding: 12px;
font-size: 15px;
line-height: 1.5;
color: #334155;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-sizing: border-box;
}
.tm-hint {
display: block;
font-size: 12px;
color: #94a3b8;
margin-top: 8px;
line-height: 1.4;
}
.tm-actions {
display: flex;
gap: 10px;
margin-top: 16px;
}
.tm-btn {
flex: 1;
height: 44px;
line-height: 44px;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
border: none;
}
.tm-btn-ghost {
background: #f1f5f9;
color: #475569;
}
.tm-btn-primary {
background: linear-gradient(135deg, #22c55e, #16a34a);
color: #fff;
}
.tm-btn[disabled] {
opacity: 0.55;
}
</style>

View File

@ -270,7 +270,7 @@
<button open-type="share" class="app-btn-main app-btn-primary"> <button open-type="share" class="app-btn-main app-btn-primary">
转发给宠主 转发给宠主
</button> </button>
<button class="app-btn-main app-btn-soft" @click="generatePoster"> <button class="app-btn-main app-btn-soft" @click="openPosterModal">
保存海报到相册 保存海报到相册
</button> </button>
</template> </template>
@ -295,6 +295,14 @@
</view> </view>
</view> </view>
<TestimonialModal
v-if="isStaff"
v-model="posterModalOpen"
:pet-name="reportData?.petName"
@skip="onTestimonialSkip"
@confirm="onTestimonialConfirm"
/>
<!-- #ifdef H5 --> <!-- #ifdef H5 -->
<canvas ref="posterCanvas" class="poster-offscreen" /> <canvas ref="posterCanvas" class="poster-offscreen" />
<!-- #endif --> <!-- #endif -->
@ -307,16 +315,18 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted, getCurrentInstance, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, getCurrentInstance, nextTick } from 'vue'
import { onLoad, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onLoad, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { getReportByToken, imgUrl, postReportHighlightStart } from '../../api/index.js' import { getReportByToken, imgUrl, postReportHighlightStart, submitReportTestimonial } from '../../api/index.js'
import { formatDateTimeYMDHM } from '../../utils/datetime.js' import { formatDateTimeYMDHM } from '../../utils/datetime.js'
import { drawReportPoster, POSTER_W, computePosterLogicalHeight } from '../../utils/reportPosterDraw.js' import { drawReportPoster, POSTER_W, computePosterLogicalHeight } from '../../utils/reportPosterDraw.js'
import AppIcon from '../../components/AppIcon.vue' import AppIcon from '../../components/AppIcon.vue'
import TestimonialModal from '../../components/report/TestimonialModal.vue'
import { getUserSession, isLoggedIn } from '../../utils/session.js' import { getUserSession, isLoggedIn } from '../../utils/session.js'
const loading = ref(true) const loading = ref(true)
const notFound = ref(false) const notFound = ref(false)
const reportData = ref(null) const reportData = ref(null)
const posterCanvas = ref(null) const posterCanvas = ref(null)
const posterModalOpen = ref(false)
const routeToken = ref('') const routeToken = ref('')
const isStaff = computed(() => isLoggedIn()) const isStaff = computed(() => isLoggedIn())
@ -549,6 +559,53 @@ onShareTimeline(() => {
} }
}) })
const openPosterModal = () => {
if (!reportData.value) return
posterModalOpen.value = true
}
const onTestimonialSkip = () => {
posterModalOpen.value = false
// #ifdef H5
runGeneratePosterH5('')
// #endif
// #ifdef MP-WEIXIN
runGeneratePosterMp('')
// #endif
// #ifndef H5
// #ifndef MP-WEIXIN
uni.showToast({ title: '请截图分享报告页', icon: 'none' })
// #endif
// #endif
}
const onTestimonialConfirm = async (text) => {
posterModalOpen.value = false
const t = (text || '').trim()
const tok = getRouteToken()
if (t && tok) {
try {
const res = await submitReportTestimonial(tok, t, true)
if (res.code !== 200) {
uni.showToast({ title: res.message || '寄语未保存,仍将生成海报', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '寄语未保存,仍将生成海报', icon: 'none' })
}
}
// #ifdef H5
await runGeneratePosterH5(t)
// #endif
// #ifdef MP-WEIXIN
await runGeneratePosterMp(t)
// #endif
// #ifndef H5
// #ifndef MP-WEIXIN
uni.showToast({ title: '当前环境无法导出海报,可截图分享', icon: 'none' })
// #endif
// #endif
}
// #ifdef H5 // #ifdef H5
const loadImageH5 = (src) => { const loadImageH5 = (src) => {
return new Promise((resolve) => { return new Promise((resolve) => {
@ -560,7 +617,7 @@ const loadImageH5 = (src) => {
}) })
} }
const generatePoster = async () => { const runGeneratePosterH5 = async (testimonial) => {
if (!reportData.value) return if (!reportData.value) return
const r = reportData.value const r = reportData.value
uni.showToast({ title: '正在生成海报...', icon: 'none' }) uni.showToast({ title: '正在生成海报...', icon: 'none' })
@ -573,7 +630,7 @@ const generatePoster = async () => {
const canvas = posterCanvas.value const canvas = posterCanvas.value
if (!canvas) return if (!canvas) return
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
const posterH = computePosterLogicalHeight(false) const posterH = computePosterLogicalHeight(false, testimonial || '')
canvas.width = POSTER_W canvas.width = POSTER_W
canvas.height = posterH canvas.height = posterH
drawReportPoster(ctx, { drawReportPoster(ctx, {
@ -588,7 +645,8 @@ const generatePoster = async () => {
beforeCount: beforePhotos.value.length, beforeCount: beforePhotos.value.length,
afterCount: afterPhotos.value.length, afterCount: afterPhotos.value.length,
beforeImg, beforeImg,
afterImg afterImg,
testimonial: testimonial || ''
}) })
const link = document.createElement('a') const link = document.createElement('a')
link.download = `服务报告_${r.petName || '宠物'}.png` link.download = `服务报告_${r.petName || '宠物'}.png`
@ -650,7 +708,7 @@ function loadCanvasImage(canvas, tempPath) {
}) })
} }
const generatePoster = async () => { const runGeneratePosterMp = async (testimonial) => {
if (!reportData.value) return if (!reportData.value) return
const r = reportData.value const r = reportData.value
uni.showLoading({ title: '生成海报中...', mask: true }) uni.showLoading({ title: '生成海报中...', mask: true })
@ -694,7 +752,7 @@ const generatePoster = async () => {
if (win && win.pixelRatio != null) dpr = Math.min(win.pixelRatio, 3) if (win && win.pixelRatio != null) dpr = Math.min(win.pixelRatio, 3)
else dpr = Math.min(uni.getSystemInfoSync().pixelRatio || 2, 3) else dpr = Math.min(uni.getSystemInfoSync().pixelRatio || 2, 3)
} catch (_) { dpr = 2 } } catch (_) { dpr = 2 }
const posterH = computePosterLogicalHeight(false) const posterH = computePosterLogicalHeight(false, testimonial || '')
canvas.width = POSTER_W * dpr canvas.width = POSTER_W * dpr
canvas.height = posterH * dpr canvas.height = posterH * dpr
ctx.scale(dpr, dpr) ctx.scale(dpr, dpr)
@ -715,7 +773,8 @@ const generatePoster = async () => {
beforeCount: beforePhotos.value.length, beforeCount: beforePhotos.value.length,
afterCount: afterPhotos.value.length, afterCount: afterPhotos.value.length,
beforeImg, beforeImg,
afterImg afterImg,
testimonial: testimonial || ''
}) })
} catch (drawErr) { } catch (drawErr) {
console.error(drawErr) console.error(drawErr)
@ -773,14 +832,6 @@ const generatePoster = async () => {
} }
// #endif // #endif
// #ifndef H5
// #ifndef MP-WEIXIN
const generatePoster = () => {
uni.showToast({ title: '请截图分享报告页', icon: 'none' })
}
// #endif
// #endif
onLoad((options) => { onLoad((options) => {
routeToken.value = options?.token || '' routeToken.value = options?.token || ''
}) })

View File

@ -9,10 +9,47 @@ export const POSTER_H = 1180
const FONT = '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif' const FONT = '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif'
/** 宠主寄语:每行约字数(与 canvas measure 近似一致即可) */
const TESTIMONIAL_CHARS_PER_LINE = 18
const TESTIMONIAL_MAX_LINES = 4
/**
* @param {string} [raw]
* @returns {string[]}
*/
export function buildTestimonialLines(raw) {
if (!raw || !String(raw).trim()) return []
let t = String(raw).trim()
if (t.length > 200) t = t.slice(0, 200)
const lines = []
let i = 0
while (i < t.length && lines.length < TESTIMONIAL_MAX_LINES) {
const remain = t.length - i
const take = Math.min(TESTIMONIAL_CHARS_PER_LINE, remain)
let chunk = t.slice(i, i + take)
if (remain > take && lines.length === TESTIMONIAL_MAX_LINES - 1) {
chunk = chunk.slice(0, Math.max(0, chunk.length - 1)) + '…'
lines.push(chunk)
break
}
lines.push(chunk)
i += take
}
return lines
}
function quoteBoxMetrics(lines) {
if (!lines.length) return { frameY: 98, quoteTop: 0, quoteH: 0 }
const quoteTop = 88
const quoteH = 16 + lines.length * 28 + 16
const frameY = quoteTop + quoteH + 12
return { frameY, quoteTop, quoteH }
}
/** 绘制前设置 canvas 高度用(与 drawReportPoster 内布局常量保持一致) */ /** 绘制前设置 canvas 高度用(与 drawReportPoster 内布局常量保持一致) */
export function computePosterLogicalHeight(hasQr) { export function computePosterLogicalHeight(hasQr, testimonialText) {
const PAD = 28 const lines = buildTestimonialLines(testimonialText || '')
const frameY = 98 const { frameY } = quoteBoxMetrics(lines)
const innerPad = 10 const innerPad = 10
const photoH = 520 const photoH = 520
const photoY = frameY + innerPad const photoY = frameY + innerPad
@ -56,14 +93,16 @@ function drawBgGradient(ctx, height) {
* @returns {number} 实际画布高度生成图片canvas 尺寸用 * @returns {number} 实际画布高度生成图片canvas 尺寸用
*/ */
export function drawReportPoster(ctx, data) { export function drawReportPoster(ctx, data) {
const { storeName, petName, beforeImg, afterImg, qrImg } = data const { storeName, petName, beforeImg, afterImg, qrImg, testimonial } = data
const name = storeName || '宠伴生活馆' const name = storeName || '宠伴生活馆'
const PAD = 28 const PAD = 28
const pet = petName || '宝贝' const pet = petName || '宝贝'
const quoteLines = buildTestimonialLines(testimonial || '')
const { frameY, quoteTop, quoteH } = quoteBoxMetrics(quoteLines)
const frameX = PAD const frameX = PAD
const frameY = 98
const frameW = POSTER_W - PAD * 2 const frameW = POSTER_W - PAD * 2
const frameR = 28 const frameR = 28
const innerPad = 10 const innerPad = 10
@ -73,7 +112,7 @@ export function drawReportPoster(ctx, data) {
const innerW = frameW - innerPad * 2 const innerW = frameW - innerPad * 2
const halfW = (innerW - gap) / 2 const halfW = (innerW - gap) / 2
const posterHeight = computePosterLogicalHeight(!!qrImg) const posterHeight = computePosterLogicalHeight(!!qrImg, testimonial || '')
const petBaseline = photoY + photoH + innerPad + 28 const petBaseline = photoY + photoH + innerPad + 28
const subBaseline = petBaseline + 40 const subBaseline = petBaseline + 40
let contentBottom = subBaseline + 22 let contentBottom = subBaseline + 22
@ -94,6 +133,38 @@ export function drawReportPoster(ctx, data) {
ctx.font = `13px ${FONT}` ctx.font = `13px ${FONT}`
ctx.fillText('洗护美容 · 真实对比', POSTER_W / 2, 76) ctx.fillText('洗护美容 · 真实对比', POSTER_W / 2, 76)
// ── 宠主寄语(可选)──
if (quoteLines.length && quoteH > 0) {
ctx.save()
ctx.shadowColor = 'rgba(120, 53, 15, 0.08)'
ctx.shadowBlur = 12
ctx.shadowOffsetY = 4
fillRR(ctx, PAD, quoteTop, POSTER_W - PAD * 2, quoteH, 18, '#fffbeb')
ctx.restore()
ctx.strokeStyle = 'rgba(245, 158, 11, 0.35)'
ctx.lineWidth = 1.5
roundRect(ctx, PAD, quoteTop, POSTER_W - PAD * 2, quoteH, 18)
ctx.stroke()
ctx.fillStyle = 'rgba(180, 83, 9, 0.45)'
ctx.font = `italic 26px ${FONT}`
ctx.textAlign = 'left'
ctx.fillText('“', PAD + 14, quoteTop + 30)
ctx.fillStyle = '#78350f'
ctx.font = `italic 22px ${FONT}`
ctx.textAlign = 'center'
const textStartY = quoteTop + 22 + 18
quoteLines.forEach((ln, idx) => {
ctx.fillText(ln, POSTER_W / 2, textStartY + idx * 28)
})
ctx.fillStyle = 'rgba(180, 83, 9, 0.45)'
ctx.font = `italic 26px ${FONT}`
ctx.textAlign = 'right'
ctx.fillText('”', POSTER_W - PAD - 14, quoteTop + quoteH - 14)
}
// ── 主视觉 ── // ── 主视觉 ──
ctx.save() ctx.save()
ctx.shadowColor = 'rgba(15, 23, 42, 0.08)' ctx.shadowColor = 'rgba(15, 23, 42, 0.08)'

View File

@ -92,10 +92,19 @@
<!-- 生成海报按钮 --> <!-- 生成海报按钮 -->
<div class="action-section"> <div class="action-section">
<button class="van-button van-button--primary van-button--round van-button--block" @click="generatePoster">生成图片分享朋友圈</button> <button class="van-button van-button--primary van-button--round van-button--block" @click="openPosterModal">生成图片分享朋友圈</button>
</div> </div>
</div> </div>
<!-- #ifdef H5 -->
<TestimonialModal
v-model="posterModalOpen"
:pet-name="reportData?.petName"
@skip="onPosterSkip"
@confirm="onPosterConfirm"
/>
<!-- #endif -->
<!-- Canvas海报隐藏 H5 --> <!-- Canvas海报隐藏 H5 -->
<!-- #ifdef H5 --> <!-- #ifdef H5 -->
<canvas ref="posterCanvas" style="position:fixed;top:-9999px;left:-9999px;" /> <canvas ref="posterCanvas" style="position:fixed;top:-9999px;left:-9999px;" />
@ -106,16 +115,21 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { getReportByToken, imgUrl } from '../api/index.js' import { getReportByToken, imgUrl, submitReportTestimonial } from '../api/index.js'
import { drawReportPoster, POSTER_W, computePosterLogicalHeight } from '../utils/reportPosterDraw.js'
import { formatDateTimeYMDHM } from '../utils/datetime.js' import { formatDateTimeYMDHM } from '../utils/datetime.js'
import AppIcon from '../components/AppIcon.vue' import AppIcon from '../components/AppIcon.vue'
import ReminderCard from '../components/report/ReminderCard.vue' import ReminderCard from '../components/report/ReminderCard.vue'
// #ifdef H5
import TestimonialModal from '../components/report/TestimonialModal.vue'
// #endif
import { navigateTo } from '../utils/globalState.js' import { navigateTo } from '../utils/globalState.js'
const loading = ref(true) const loading = ref(true)
const notFound = ref(false) const notFound = ref(false)
const reportData = ref(null) const reportData = ref(null)
const posterCanvas = ref(null) const posterCanvas = ref(null)
const posterModalOpen = ref(false)
const routeToken = ref('') const routeToken = ref('')
const brandHeaderSafeStyle = (() => { const brandHeaderSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20 const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
@ -157,174 +171,73 @@ const loadImage = (src) => {
}) })
} }
const generatePoster = async () => { const openPosterModal = () => {
if (!reportData.value) return
posterModalOpen.value = true
}
const onPosterSkip = () => {
posterModalOpen.value = false
runGeneratePoster('')
}
const onPosterConfirm = async (text) => {
posterModalOpen.value = false
const t = (text || '').trim()
const token = reportData.value?.reportToken || routeToken.value
if (t && token) {
try {
const res = await submitReportTestimonial(token, t, true)
if (res.code !== 200) {
uni.showToast({ title: res.message || '寄语未保存,仍将生成海报', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '寄语未保存,仍将生成海报', icon: 'none' })
}
}
await runGeneratePoster(t)
}
const runGeneratePoster = async (testimonial) => {
if (!reportData.value) return if (!reportData.value) return
uni.showToast({ title: '正在生成海报...', icon: 'none' }) uni.showToast({ title: '正在生成海报...', icon: 'none' })
const r = reportData.value const r = reportData.value
const canvas = posterCanvas.value const canvas = posterCanvas.value
if (!canvas) return
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
canvas.width = 750 const beforePath = r.beforePhotos?.[0] || r.beforePhoto
canvas.height = 1100 const afterPath = r.afterPhotos?.[0] || r.afterPhoto
const beforeUrl = beforePath ? imgUrl(typeof beforePath === 'string' ? beforePath : beforePath.url) : ''
ctx.fillStyle = '#ffffff' const afterUrl = afterPath ? imgUrl(typeof afterPath === 'string' ? afterPath : afterPath.url) : ''
ctx.fillRect(0, 0, 750, 1100)
const gradient = ctx.createLinearGradient(0, 0, 750, 300)
gradient.addColorStop(0, '#07c160')
gradient.addColorStop(1, '#10b76f')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, 750, 300)
const storeName = r.store?.name || '宠伴生活馆'
const storePhone = r.store?.phone || ''
const storeAddr = r.store?.address || ''
ctx.fillStyle = '#ffffff'
ctx.font = 'bold 36px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(storeName, 375, 70)
ctx.font = '20px sans-serif'
ctx.globalAlpha = 0.7
ctx.fillText('宠物服务,让爱更专业', 375, 105)
ctx.globalAlpha = 1
if (storePhone || storeAddr) {
ctx.font = '18px sans-serif'
ctx.globalAlpha = 0.85
const contactLine = [storePhone, storeAddr].filter(Boolean).join(' | ')
ctx.fillText(contactLine, 375, 138)
ctx.globalAlpha = 1
}
ctx.fillStyle = '#333333'
ctx.font = 'bold 36px sans-serif'
ctx.fillText('服务报告', 375, 220)
ctx.fillStyle = '#f8f6f3'
ctx.beginPath()
roundRect(ctx, 40, 260, 670, 220, 20)
ctx.fill()
const infoItems = [
['宠物名字', r.petName || '-'],
['服务项目', r.serviceType || '-'],
['服务时间', formatTime(r.appointmentTime) || '-'],
['服务技师', r.staffName || '-']
]
let y = 310
ctx.textAlign = 'left'
infoItems.forEach(([label, val]) => {
ctx.fillStyle = '#999999'
ctx.font = '22px sans-serif'
ctx.fillText(label, 80, y)
ctx.fillStyle = '#333333'
ctx.font = 'bold 24px sans-serif'
ctx.fillText(val, 220, y)
y += 48
})
ctx.fillStyle = '#f8f6f3'
ctx.beginPath()
roundRect(ctx, 40, 500, 670, 360, 20)
ctx.fill()
ctx.fillStyle = '#333333'
ctx.font = 'bold 24px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('服务前后对比', 375, 545)
const imgY = 575
const imgH = 260
const imgW = 300
ctx.fillStyle = '#e0e0e0'
ctx.beginPath()
roundRect(ctx, 60, imgY, imgW, imgH, 16)
ctx.fill()
ctx.fillStyle = '#999999'
ctx.font = '20px sans-serif'
ctx.fillText('服务前', 210, imgY + imgH/2)
ctx.fillStyle = '#e0e0e0'
ctx.beginPath()
roundRect(ctx, 390, imgY, imgW, imgH, 16)
ctx.fill()
ctx.fillStyle = '#999999'
ctx.fillText('服务后', 540, imgY + imgH/2)
if (r.remark) {
ctx.fillStyle = '#f8f6f3'
ctx.beginPath()
roundRect(ctx, 40, 880, 670, 100, 20)
ctx.fill()
ctx.fillStyle = '#666666'
ctx.font = '22px sans-serif'
ctx.textAlign = 'left'
const remark = r.remark
if (remark.length > 30) {
ctx.fillText(remark.substring(0, 30), 70, 920)
ctx.fillText(remark.substring(30), 70, 955)
} else {
ctx.fillText(remark, 70, 930)
}
}
ctx.fillStyle = '#07c160'
ctx.font = 'bold 22px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`${storeName}`, 375, 1050)
const beforeImgSrc = r.beforePhoto ? imgUrl(r.beforePhoto) : null
const afterImgSrc = r.afterPhoto ? imgUrl(r.afterPhoto) : null
const [beforeImg, afterImg] = await Promise.all([ const [beforeImg, afterImg] = await Promise.all([
beforeImgSrc ? loadImage(beforeImgSrc) : Promise.resolve(null), beforeUrl ? loadImage(beforeUrl) : Promise.resolve(null),
afterImgSrc ? loadImage(afterImgSrc) : Promise.resolve(null) afterUrl ? loadImage(afterUrl) : Promise.resolve(null)
]) ])
const posterH = computePosterLogicalHeight(false, testimonial || '')
if (beforeImg) { canvas.width = POSTER_W
ctx.save() canvas.height = posterH
ctx.beginPath() drawReportPoster(ctx, {
roundRect(ctx, 60, imgY, imgW, imgH, 16) storeName: r.store?.name || '',
ctx.clip() petName: r.petName || '',
ctx.drawImage(beforeImg, 60, imgY, imgW, imgH) beforeImg,
ctx.restore() afterImg,
} qrImg: null,
if (afterImg) { testimonial: testimonial || ''
ctx.save() })
ctx.beginPath()
roundRect(ctx, 390, imgY, imgW, imgH, 16)
ctx.clip()
ctx.drawImage(afterImg, 390, imgY, imgW, imgH)
ctx.restore()
}
const link = document.createElement('a') const link = document.createElement('a')
link.download = `服务报告_${r.petName || '宠物'}.png` link.download = `服务报告_${r.petName || '宠物'}.png`
link.href = canvas.toDataURL('image/png') link.href = canvas.toDataURL('image/png')
link.click() link.click()
uni.showToast({ title: '已下载海报', icon: 'success' })
} }
// #endif // #endif
// #ifndef H5 // #ifndef H5
const generatePoster = () => { const openPosterModal = () => {
uni.showToast({ title: '请在微信中长按保存海报', icon: 'none' }) uni.showToast({ title: '请在微信中长按保存海报', icon: 'none' })
} }
// #endif // #endif
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
ctx.lineTo(x + w, y + h - r)
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
ctx.lineTo(x + r, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
ctx.lineTo(x, y + r)
ctx.quadraticCurveTo(x, y, x + r, y)
ctx.closePath()
}
onLoad((options) => { onLoad((options) => {
routeToken.value = options?.token || '' routeToken.value = options?.token || ''
}) })