|
|
@@ -0,0 +1,475 @@
|
|
|
+<!--
|
|
|
+ 隔天冲销结果页 / 退款凭证海报
|
|
|
+ 用途:供货商端隔天冲销(fundType=2 返充余额)成功后,用 canvas 绘制退款凭证并展示、保存相册;
|
|
|
+ 支持 mode=repost 从详情页重新生成凭证。
|
|
|
+ 谁用:admin/billing 冲销流程、售后转冲销、凭证详情重发入口。
|
|
|
+-->
|
|
|
+<template>
|
|
|
+ <view class="forward-result-page">
|
|
|
+ <!-- fundType=2:展示凭证海报 -->
|
|
|
+ <appResult v-if="Number(fundType) === 2" title="冲销成功">
|
|
|
+ <view v-if="posterLoading" class="status-text">凭证生成中...</view>
|
|
|
+ <view v-else-if="posterFail" class="fail-box">
|
|
|
+ <text class="status-text fail-text">凭证生成失败,请重试</text>
|
|
|
+ <button class="admin-button-com big blue action-btn" @click="generatePoster">重新生成凭证</button>
|
|
|
+ </view>
|
|
|
+ <view v-else-if="posterUrl" class="poster-preview">
|
|
|
+ <image
|
|
|
+ class="poster-img"
|
|
|
+ :src="posterUrl"
|
|
|
+ mode="widthFix"
|
|
|
+ show-menu-by-longpress
|
|
|
+ />
|
|
|
+ <text class="poster-tip">长按图片可转发或保存</text>
|
|
|
+ </view>
|
|
|
+ <button
|
|
|
+ v-if="posterUrl"
|
|
|
+ class="admin-button-com big blue action-btn"
|
|
|
+ @click="saveToAlbum"
|
|
|
+ >保存到相册</button>
|
|
|
+ <button
|
|
|
+ v-if="posterFail"
|
|
|
+ class="admin-button-com big action-btn"
|
|
|
+ @click="generatePoster"
|
|
|
+ >重新生成凭证</button>
|
|
|
+ <button class="admin-button-com big action-btn" @click="goBack">
|
|
|
+ {{ mode === 'repost' ? '返回' : '返回首页' }}
|
|
|
+ </button>
|
|
|
+ </appResult>
|
|
|
+
|
|
|
+ <!-- 非返余额:简单成功提示 -->
|
|
|
+ <appResult v-else :title="pageTitle">
|
|
|
+ <text class="simple-success">{{ simpleSuccessText }}</text>
|
|
|
+ <button class="admin-button-com big action-btn" @click="goBack">
|
|
|
+ {{ mode === 'repost' ? '返回' : '返回首页' }}
|
|
|
+ </button>
|
|
|
+ </appResult>
|
|
|
+
|
|
|
+ <!-- 离屏 canvas,用于绘制凭证海报 -->
|
|
|
+ <canvas
|
|
|
+ canvas-id="forwardPosterCanvas"
|
|
|
+ class="poster-canvas"
|
|
|
+ :style="{ width: canvasW + 'px', height: canvasH + 'px' }"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import dayjs from 'dayjs'
|
|
|
+import appResult from '@/components/app-result'
|
|
|
+import { getGatheringMiniCode } from '@/api/custom'
|
|
|
+import { getUserDet } from '@/api/member'
|
|
|
+
|
|
|
+/** 本地缓存键:跳转本页前写入,onLoad 与 URL 参数合并 */
|
|
|
+const STORAGE_KEY = 'forwardResultPayload'
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'forwardResult',
|
|
|
+ components: {
|
|
|
+ appResult
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ fundType: 0,
|
|
|
+ amount: 0,
|
|
|
+ customId: 0,
|
|
|
+ customName: '',
|
|
|
+ customBalance: 0,
|
|
|
+ forwardSn: '',
|
|
|
+ forwardId: 0,
|
|
|
+ orderId: 0,
|
|
|
+ mode: '',
|
|
|
+ posterUrl: '',
|
|
|
+ posterLoading: false,
|
|
|
+ posterFail: false,
|
|
|
+ canvasW: 360,
|
|
|
+ canvasH: 640
|
|
|
+ }
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ /** 非返余额时的页面标题 */
|
|
|
+ pageTitle() {
|
|
|
+ const t = Number(this.fundType)
|
|
|
+ if (t === 1) return '原路退回成功'
|
|
|
+ if (t === 3) return '冲销成功'
|
|
|
+ return '冲销成功'
|
|
|
+ },
|
|
|
+ /** 非返余额时的说明文案 */
|
|
|
+ simpleSuccessText() {
|
|
|
+ const t = Number(this.fundType)
|
|
|
+ const amt = this.formatMoney(this.amount)
|
|
|
+ if (t === 1) return `已原路退回 ¥${amt}`
|
|
|
+ if (t === 3) return `冲销 ¥${amt},仅记账(资金未变动)`
|
|
|
+ return `冲销成功,金额 ¥${amt}`
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onLoad(option) {
|
|
|
+ this.initPayload(option || {})
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ /**
|
|
|
+ * 合并 URL 参数与本地缓存,解析页面所需字段
|
|
|
+ * @param {Object} option 路由 query
|
|
|
+ */
|
|
|
+ initPayload(option) {
|
|
|
+ let cached = {}
|
|
|
+ try {
|
|
|
+ const raw = uni.getStorageSync(STORAGE_KEY)
|
|
|
+ if (raw) {
|
|
|
+ cached = typeof raw === 'string' ? JSON.parse(raw) : raw
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ cached = {}
|
|
|
+ }
|
|
|
+ const payload = Object.assign({}, cached, option || {})
|
|
|
+
|
|
|
+ this.fundType = Number(payload.fundType || 0)
|
|
|
+ this.amount = parseFloat(payload.amount || payload.actPrice || 0) || 0
|
|
|
+ this.customId = Number(payload.customId || 0)
|
|
|
+ this.customName = this.decodeName(payload.customName || '')
|
|
|
+ this.customBalance = parseFloat(payload.customBalance || 0) || 0
|
|
|
+ // forwardSn 优先取冲销单号,售后场景可传 refund orderSn
|
|
|
+ this.forwardSn = payload.forwardSn || payload.orderSn || ''
|
|
|
+ this.forwardId = Number(payload.forwardId || payload.refundId || 0)
|
|
|
+ this.orderId = Number(payload.orderId || 0)
|
|
|
+ this.mode = payload.mode || ''
|
|
|
+
|
|
|
+ // 返余额且有客户 id 时自动生成海报
|
|
|
+ if (Number(this.fundType) === 2 && this.customId > 0) {
|
|
|
+ this.generatePoster()
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /** 安全解码客户名 */
|
|
|
+ decodeName(name) {
|
|
|
+ if (!name) return ''
|
|
|
+ try {
|
|
|
+ return decodeURIComponent(name)
|
|
|
+ } catch (e) {
|
|
|
+ return name
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /** 金额保留两位小数展示 */
|
|
|
+ formatMoney(val) {
|
|
|
+ const n = parseFloat(val)
|
|
|
+ if (isNaN(n)) return '0.00'
|
|
|
+ return n.toFixed(2)
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 拉取小程序码与客户最新余额,下载二维码后绘制海报
|
|
|
+ * 副作用:更新 posterUrl / posterLoading / posterFail / customBalance
|
|
|
+ */
|
|
|
+ generatePoster() {
|
|
|
+ const that = this
|
|
|
+ if (!that.customId) {
|
|
|
+ that.posterFail = true
|
|
|
+ return
|
|
|
+ }
|
|
|
+ that.posterLoading = true
|
|
|
+ that.posterFail = false
|
|
|
+ that.posterUrl = ''
|
|
|
+
|
|
|
+ Promise.all([
|
|
|
+ getGatheringMiniCode({ id: that.customId }),
|
|
|
+ getUserDet({ id: that.customId })
|
|
|
+ ])
|
|
|
+ .then(([codeRes, userRes]) => {
|
|
|
+ if (!codeRes || codeRes.code != 1) {
|
|
|
+ throw new Error((codeRes && codeRes.msg) || '获取小程序码失败')
|
|
|
+ }
|
|
|
+ if (!userRes || userRes.code != 1) {
|
|
|
+ throw new Error((userRes && userRes.msg) || '获取客户信息失败')
|
|
|
+ }
|
|
|
+ // 用接口最新余额覆盖路由传入值
|
|
|
+ if (userRes.data && userRes.data.balance !== undefined && userRes.data.balance !== null) {
|
|
|
+ that.customBalance = parseFloat(userRes.data.balance) || 0
|
|
|
+ }
|
|
|
+ const imgUrl = (codeRes.data && (codeRes.data.imgUrl || codeRes.data.url)) || ''
|
|
|
+ if (!imgUrl) {
|
|
|
+ throw new Error('未获取到小程序码链接')
|
|
|
+ }
|
|
|
+ return that.downloadImage(imgUrl)
|
|
|
+ })
|
|
|
+ .then((qrPath) => {
|
|
|
+ return that.drawPoster(qrPath)
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ that.posterLoading = false
|
|
|
+ that.posterFail = true
|
|
|
+ })
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 下载远程图片到本地临时路径,供 canvas drawImage 使用
|
|
|
+ * @param {String} url 图片地址
|
|
|
+ * @returns {Promise<String>}
|
|
|
+ */
|
|
|
+ downloadImage(url) {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ uni.downloadFile({
|
|
|
+ url,
|
|
|
+ success(res) {
|
|
|
+ if (res.statusCode === 200 && res.tempFilePath) {
|
|
|
+ resolve(res.tempFilePath)
|
|
|
+ } else {
|
|
|
+ reject(new Error('download fail'))
|
|
|
+ }
|
|
|
+ },
|
|
|
+ fail() {
|
|
|
+ reject(new Error('download fail'))
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 在 canvas 上绘制退款凭证海报并导出临时图片
|
|
|
+ * @param {String} qrPath 小程序码本地路径
|
|
|
+ * @returns {Promise<void>}
|
|
|
+ */
|
|
|
+ drawPoster(qrPath) {
|
|
|
+ const that = this
|
|
|
+ const ctx = uni.createCanvasContext('forwardPosterCanvas', this)
|
|
|
+ const w = that.canvasW
|
|
|
+ const h = that.canvasH
|
|
|
+ const headerH = 130
|
|
|
+ const padX = 24
|
|
|
+ const contentW = w - padX * 2
|
|
|
+
|
|
|
+ // 整页白底 + 顶部蓝色标题区
|
|
|
+ ctx.setFillStyle('#ffffff')
|
|
|
+ ctx.fillRect(0, 0, w, h)
|
|
|
+ ctx.setFillStyle('#3385ff')
|
|
|
+ ctx.fillRect(0, 0, w, headerH)
|
|
|
+
|
|
|
+ ctx.setFillStyle('#ffffff')
|
|
|
+ ctx.setTextAlign('center')
|
|
|
+ ctx.setFontSize(26)
|
|
|
+ ctx.fillText('退款凭证', w / 2, 48)
|
|
|
+ ctx.setFontSize(22)
|
|
|
+ const nameText = that.customName || '客户'
|
|
|
+ ctx.fillText(nameText.length > 14 ? nameText.substr(0, 14) + '…' : nameText, w / 2, 88)
|
|
|
+
|
|
|
+ // 退款金额(红色大号)
|
|
|
+ ctx.setFillStyle('#e02020')
|
|
|
+ ctx.setFontSize(44)
|
|
|
+ ctx.fillText('¥' + that.formatMoney(that.amount), w / 2, 168)
|
|
|
+
|
|
|
+ ctx.setFillStyle('#333333')
|
|
|
+ ctx.setFontSize(22)
|
|
|
+ ctx.fillText('已返充到余额', w / 2, 208)
|
|
|
+
|
|
|
+ // 信息区起始 Y
|
|
|
+ let rowY = 252
|
|
|
+ const labelX = padX
|
|
|
+ const valueX = w - padX
|
|
|
+ const rowGap = 36
|
|
|
+
|
|
|
+ ctx.setTextAlign('left')
|
|
|
+ that.drawInfoRow(ctx, '最新余额', '¥' + that.formatMoney(that.customBalance), labelX, valueX, rowY, false)
|
|
|
+ rowY += rowGap
|
|
|
+
|
|
|
+ // 冲销单号:标签与单号同一行,单号字号自适应
|
|
|
+ ctx.setFillStyle('#666666')
|
|
|
+ ctx.setFontSize(20)
|
|
|
+ ctx.setTextAlign('left')
|
|
|
+ ctx.fillText('冲销单号', labelX, rowY)
|
|
|
+ const snText = that.forwardSn || '-'
|
|
|
+ const snStartX = labelX + 88
|
|
|
+ const snMaxW = valueX - snStartX
|
|
|
+ ctx.setFillStyle('#333333')
|
|
|
+ ctx.setTextAlign('left')
|
|
|
+ const snSize = that.fitSnFontSize(ctx, snText, snMaxW, 16)
|
|
|
+ ctx.setFontSize(snSize)
|
|
|
+ ctx.fillText(snText, snStartX, rowY)
|
|
|
+ rowY += rowGap
|
|
|
+
|
|
|
+ const dateStr = dayjs().format('YYYY-MM-DD HH:mm')
|
|
|
+ that.drawInfoRow(ctx, '日期', dateStr, labelX, valueX, rowY, true)
|
|
|
+ rowY += rowGap + 16
|
|
|
+
|
|
|
+ // 小程序码
|
|
|
+ const qrSize = 180
|
|
|
+ const qrX = (w - qrSize) / 2
|
|
|
+ const qrY = rowY
|
|
|
+ ctx.drawImage(qrPath, qrX, qrY, qrSize, qrSize)
|
|
|
+
|
|
|
+ // 底部提示
|
|
|
+ ctx.setFillStyle('#999999')
|
|
|
+ ctx.setFontSize(18)
|
|
|
+ ctx.setTextAlign('center')
|
|
|
+ ctx.fillText('长按识别查看余额明细', w / 2, qrY + qrSize + 36)
|
|
|
+
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ ctx.draw(false, () => {
|
|
|
+ setTimeout(() => {
|
|
|
+ uni.canvasToTempFilePath(
|
|
|
+ {
|
|
|
+ canvasId: 'forwardPosterCanvas',
|
|
|
+ fileType: 'jpg',
|
|
|
+ quality: 1,
|
|
|
+ success(res) {
|
|
|
+ that.posterUrl = res.tempFilePath
|
|
|
+ that.posterLoading = false
|
|
|
+ that.posterFail = false
|
|
|
+ resolve()
|
|
|
+ },
|
|
|
+ fail() {
|
|
|
+ that.posterLoading = false
|
|
|
+ that.posterFail = true
|
|
|
+ reject(new Error('export fail'))
|
|
|
+ }
|
|
|
+ },
|
|
|
+ that
|
|
|
+ )
|
|
|
+ }, 300)
|
|
|
+ })
|
|
|
+ })
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 绘制一行 label + value(value 右对齐)
|
|
|
+ * @param {Object} ctx canvas 上下文
|
|
|
+ * @param {String} label 左侧标签
|
|
|
+ * @param {String} value 右侧值
|
|
|
+ * @param {Number} labelX 标签 x
|
|
|
+ * @param {Number} valueX 值右边界 x
|
|
|
+ * @param {Number} y 基线 y
|
|
|
+ * @param {Boolean} valueSmall 值是否用小号字(16)
|
|
|
+ */
|
|
|
+ drawInfoRow(ctx, label, value, labelX, valueX, y, valueSmall) {
|
|
|
+ ctx.setFillStyle('#666666')
|
|
|
+ ctx.setFontSize(20)
|
|
|
+ ctx.setTextAlign('left')
|
|
|
+ ctx.fillText(label, labelX, y)
|
|
|
+ ctx.setFillStyle('#333333')
|
|
|
+ ctx.setFontSize(valueSmall ? 16 : 20)
|
|
|
+ ctx.setTextAlign('right')
|
|
|
+ ctx.fillText(value, valueX, y)
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 冲销单号过长时缩小字号,保证同一行展示完整
|
|
|
+ * @param {Object} ctx canvas 上下文
|
|
|
+ * @param {String} text 单号文本
|
|
|
+ * @param {Number} maxWidth 可用宽度(px)
|
|
|
+ * @param {Number} startSize 起始字号
|
|
|
+ * @returns {Number} 最终使用的字号
|
|
|
+ */
|
|
|
+ fitSnFontSize(ctx, text, maxWidth, startSize) {
|
|
|
+ let size = startSize || 16
|
|
|
+ const minSize = 10
|
|
|
+ ctx.setFontSize(size)
|
|
|
+ while (size > minSize && ctx.measureText(text).width > maxWidth) {
|
|
|
+ size -= 1
|
|
|
+ ctx.setFontSize(size)
|
|
|
+ }
|
|
|
+ return size
|
|
|
+ },
|
|
|
+
|
|
|
+ /** 保存海报到系统相册 */
|
|
|
+ saveToAlbum() {
|
|
|
+ const that = this
|
|
|
+ if (!that.posterUrl) {
|
|
|
+ that.$msg && that.$msg('请等待凭证生成完成')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ uni.showLoading({ title: '正在保存...', mask: true })
|
|
|
+ uni.saveImageToPhotosAlbum({
|
|
|
+ filePath: that.posterUrl,
|
|
|
+ success() {
|
|
|
+ uni.hideLoading()
|
|
|
+ uni.showToast({ title: '保存成功', icon: 'success' })
|
|
|
+ },
|
|
|
+ fail() {
|
|
|
+ uni.hideLoading()
|
|
|
+ uni.showToast({ title: '保存失败,请检查相册权限', icon: 'none' })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ },
|
|
|
+
|
|
|
+ /** 返回上一页或工作台首页 */
|
|
|
+ goBack() {
|
|
|
+ if (this.mode === 'repost') {
|
|
|
+ uni.navigateBack({ delta: 1 })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ this.$util.pageTo({
|
|
|
+ url: '/admin/home/workbench',
|
|
|
+ type: 4,
|
|
|
+ query: { tabIndex: 0 }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.forward-result-page {
|
|
|
+ min-height: 100vh;
|
|
|
+ background-color: #f5f5f5;
|
|
|
+}
|
|
|
+
|
|
|
+.status-text {
|
|
|
+ margin-bottom: 30upx;
|
|
|
+ font-size: 28upx;
|
|
|
+ color: #666666;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.fail-box {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.fail-text {
|
|
|
+ color: #e02020;
|
|
|
+}
|
|
|
+
|
|
|
+.poster-preview {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ width: 100%;
|
|
|
+ margin-bottom: 30upx;
|
|
|
+}
|
|
|
+
|
|
|
+.poster-img {
|
|
|
+ width: 640upx;
|
|
|
+ max-width: 100%;
|
|
|
+ border-radius: 12upx;
|
|
|
+ box-shadow: 0 8upx 24upx rgba(0, 0, 0, 0.12);
|
|
|
+}
|
|
|
+
|
|
|
+.poster-tip {
|
|
|
+ margin-top: 20upx;
|
|
|
+ font-size: 24upx;
|
|
|
+ color: #999999;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.simple-success {
|
|
|
+ margin-bottom: 40upx;
|
|
|
+ font-size: 30upx;
|
|
|
+ color: #333333;
|
|
|
+ text-align: center;
|
|
|
+ line-height: 1.6;
|
|
|
+}
|
|
|
+
|
|
|
+.action-btn {
|
|
|
+ margin-bottom: 30upx;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+/* 离屏 canvas,不参与页面布局 */
|
|
|
+.poster-canvas {
|
|
|
+ position: fixed;
|
|
|
+ left: -9999px;
|
|
|
+ top: -9999px;
|
|
|
+ z-index: -1;
|
|
|
+}
|
|
|
+</style>
|