新增用户评价组件及文档 2026-04-18 13:54
This commit is contained in:
parent
d632e895e8
commit
d93ed027c7
@ -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 = {}
|
||||||
|
|||||||
150
src/components/report/TestimonialModal.vue
Normal file
150
src/components/report/TestimonialModal.vue
Normal 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>
|
||||||
@ -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 || ''
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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)'
|
||||||
|
|||||||
@ -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 || ''
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user