Files
qitongxue-wx/src/pages/tools/image/qrcode.vue
2026-09-29 10:57:55 +08:00

649 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 二维码生成(T04)
*
* 布局走 `ToolPage`,流程走 `useImageTool`(onReady 绑画布 + running 防双击)。
* 类型:文本 / 网址 / WiFi / 名片;输入即实时预览(350ms 防抖);空内容走占位、不报错。
* 高级项:中心 Logo(自选或默认)、纠错级别(M/H)、输出尺寸。
* 命名走 `contentDigestName` → `二维码生成-{内容摘要}-{时间戳}.png`(PRD-01 R5-9)。
* 保存为 `silent: true` + 自弹一条带文件名的反馈,避免双 toast。
*/
import { reactive, ref, watch } from 'vue'
import ToolPage from '@/components/tool/ToolPage.vue'
import ToolRunning from '@/components/tool/ToolRunning.vue'
import VipDock from '@/components/vip/VipDock.vue'
import { useImageTool } from '@/composables/useImageTool'
import { beginDraw, chooseOneImage, exportFile, loadImage, saveResult } from '@/utils/image/core'
import { contentDigestName } from '@/utils/image/naming'
import { makeMatrix } from '@/utils/image/qrcode'
type QrType = 'text' | 'url' | 'wifi' | 'vcard'
type QrEcc = 'M' | 'H'
interface QrParams {
type: QrType
text: string
wifiSsid: string
wifiPwd: string
wifiEnc: 'WPA' | 'WEP' | 'nopass'
vcName: string
vcTel: string
vcOrg: string
ecc: QrEcc
size: number
logoOn: boolean
fg: string
bg: string
}
const P = reactive<QrParams>({
type: 'text',
text: '',
wifiSsid: '',
wifiPwd: '',
wifiEnc: 'WPA',
vcName: '',
vcTel: '',
vcOrg: '',
ecc: 'M',
size: 720,
logoOn: false,
fg: '#3D2A1E',
bg: '#FFFFFF',
})
const TYPES: Array<{ key: QrType, label: string }> = [
{ key: 'text', label: '文本' },
{ key: 'url', label: '网址' },
{ key: 'wifi', label: 'WiFi' },
{ key: 'vcard', label: '名片' },
]
const ECCS: Array<{ key: QrEcc, label: string }> = [
{ key: 'M', label: 'M · 标准' },
{ key: 'H', label: 'H · 强容错' },
]
const SIZES = [480, 720, 1080]
const PALETTES = [
{ fg: '#3D2A1E', bg: '#FFFFFF', name: '暖炭' },
{ fg: '#FF6B35', bg: '#FFF9F2', name: '元气' },
{ fg: '#0D9488', bg: '#F0FDFA', name: '薄荷' },
{ fg: '#111111', bg: '#FFFFFF', name: '经典' },
]
const isH5 = ref(false)
// #ifdef H5
isH5.value = true
// #endif
const previewPath = ref('')
const notice = ref('')
const savedName = ref('')
const logoPath = ref('')
/** 按当前类型拼装二维码承载内容(空内容 → 空串,走占位不报错) */
function buildPayload(p: QrParams): string {
switch (p.type) {
case 'text':
return p.text.trim()
case 'url': {
const t = p.text.trim()
if (!t)
return ''
// 缺协议时自动补 https://(输入框仍可编辑)
return /^[a-z][a-z0-9+.-]*:\/\//i.test(t) ? t : `https://${t}`
}
case 'wifi': {
const ssid = p.wifiSsid.trim()
if (!ssid)
return ''
const pwd = p.wifiEnc === 'nopass' ? '' : p.wifiPwd
return `WIFI:T:${p.wifiEnc};S:${ssid};P:${pwd};;`
}
case 'vcard': {
const name = p.vcName.trim()
const tel = p.vcTel.trim()
if (!name && !tel)
return ''
const lines = ['BEGIN:VCARD', 'VERSION:3.0']
if (name)
lines.push(`FN:${name}`, `N:${name};;;;`)
if (p.vcOrg.trim())
lines.push(`ORG:${p.vcOrg.trim()}`)
if (tel)
lines.push(`TEL;TYPE=CELL:${tel}`)
lines.push('END:VCARD')
return lines.join('\n')
}
default:
return ''
}
}
/** 画中心 Logo:清白底(H 级纠错可承受中心遮挡)→ 自选图 / 默认橙底「码」 */
async function drawLogo(ctx: CanvasRenderingContext2D, size: number) {
const box = Math.round(size * 0.24)
const x = (size - box) / 2
const y = (size - box) / 2
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(x, y, box, box)
if (logoPath.value) {
const limg = await loadImage(logoPath.value)
ctx.drawImage(limg.img as CanvasImageSource, x, y, box, box)
return
}
ctx.fillStyle = '#ff6b35'
ctx.fillRect(x, y, box, box)
ctx.fillStyle = '#FFFFFF'
try {
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = `bold ${Math.round(box * 0.56)}px sans-serif`
}
catch {
/* 个别端不支持 font:退回默认字体,不影响可扫 */
}
ctx.fillText('码', size / 2, size / 2)
}
const tool = useImageTool<{ path: string }, QrParams>({
params: P,
requireSrc: false,
autoRun: false,
run: async (_src, p) => {
const payload = buildPayload(p)
const { count, isDark } = makeMatrix(payload, p.ecc)
const quiet = 2 // 四周静区(模块数)
const total = count + quiet * 2
const cell = p.size / total
const { ctx } = beginDraw(p.size, p.size)
ctx.save()
ctx.fillStyle = p.bg
ctx.fillRect(0, 0, p.size, p.size)
ctx.fillStyle = p.fg
for (let row = 0; row < count; row++) {
for (let col = 0; col < count; col++) {
if (isDark(row, col))
ctx.fillRect((col + quiet) * cell, (row + quiet) * cell, Math.ceil(cell), Math.ceil(cell))
}
}
ctx.restore()
if (p.logoOn)
await drawLogo(ctx, p.size)
return { path: await exportFile('png', 1) }
},
})
const { running } = tool
/** 实时预览:容量预检 → 超长直接提示(不产空白码)→ 生成 */
async function refresh() {
const payload = buildPayload(P)
if (!payload) {
previewPath.value = ''
notice.value = ''
return
}
try {
makeMatrix(payload, P.ecc)
}
catch {
previewPath.value = ''
notice.value = '内容过长,建议缩短或改用短链'
return
}
notice.value = ''
const r = await tool.doRun()
if (r)
previewPath.value = r.path
}
let timer: ReturnType<typeof setTimeout> | null = null
function schedulePreview() {
if (timer)
clearTimeout(timer)
timer = setTimeout(() => {
timer = null
refresh()
}, 350)
}
watch(
() => [P.type, P.text, P.wifiSsid, P.wifiPwd, P.wifiEnc, P.vcName, P.vcTel, P.vcOrg, P.ecc, P.size, P.logoOn, P.fg, P.bg],
schedulePreview,
)
function chooseType(t: QrType) {
if (P.type === t)
return
P.type = t
savedName.value = ''
schedulePreview()
}
async function pickLogo() {
try {
const p = await chooseOneImage()
logoPath.value = p.path
P.logoOn = true
// 中心镂空需强容错,自动提升到 H
P.ecc = 'H'
schedulePreview()
}
catch {
/* 取消 */
}
}
async function save() {
if (!previewPath.value)
return
const payload = buildPayload(P)
const name = contentDigestName(payload, 'png')
const res = await saveResult(previewPath.value, name, { silent: true })
if (res.ok) {
savedName.value = res.savedName
uni.showToast({
title: `${isH5.value ? '已开始下载' : '已保存到相册'}:${res.savedName}`,
icon: 'none',
})
}
else if (res.reason === 'NO_ALBUM_AUTH') {
uni.showModal({
title: '需要相册权限',
content: '请在设置中允许保存到相册',
confirmText: '去设置',
success: (r) => {
if (r.confirm)
uni.openSetting({})
},
})
}
else {
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
}
}
</script>
<template>
<ToolPage title="二维码生成" subtitle="文本 / 网址 / WiFi / 名片" :need-canvas="true">
<!-- 类型分段 -->
<view class="seg">
<view
v-for="t in TYPES"
:key="t.key"
class="seg__item"
:class="{ 'seg__item--on': P.type === t.key }"
@tap="chooseType(t.key)"
>
{{ t.label }}
</view>
</view>
<!-- 内容输入 -->
<view class="card">
<text class="card__title">
内容
</text>
<template v-if="P.type === 'text' || P.type === 'url'">
<textarea
v-if="P.type === 'text'"
v-model="P.text"
class="content"
placeholder="输入文本…"
placeholder-style="color:#C9AE9C"
:maxlength="500"
/>
<input
v-else
v-model="P.text"
class="input"
placeholder="example.com 或 https://example.com"
placeholder-style="color:#C9AE9C"
:maxlength="500"
>
<text v-if="P.type === 'url'" class="field-tip">
未填协议时会自动补 https://,可手动编辑。
</text>
</template>
<template v-else-if="P.type === 'wifi'">
<input v-model="P.wifiSsid" class="input" placeholder="WiFi 名称(SSID)" placeholder-style="color:#C9AE9C" :maxlength="64">
<input v-model="P.wifiPwd" class="input" placeholder="密码" placeholder-style="color:#C9AE9C" :maxlength="64">
<view class="chips">
<view
v-for="e in [{ k: 'WPA', l: 'WPA/WPA2' }, { k: 'WEP', l: 'WEP' }, { k: 'nopass', l: '无密码' }]"
:key="e.k"
class="chip"
:class="{ 'chip--on': P.wifiEnc === e.k }"
@tap="P.wifiEnc = e.k as QrParams['wifiEnc']"
>
{{ e.l }}
</view>
</view>
</template>
<template v-else>
<input v-model="P.vcName" class="input" placeholder="姓名" placeholder-style="color:#C9AE9C" :maxlength="40">
<input v-model="P.vcTel" class="input" placeholder="电话" placeholder-style="color:#C9AE9C" :type="'text'" :maxlength="40">
<input v-model="P.vcOrg" class="input" placeholder="公司 / 组织(可选)" placeholder-style="color:#C9AE9C" :maxlength="40">
</template>
</view>
<!-- 配色 -->
<view class="card">
<text class="card__title">
配色
</text>
<view class="palettes">
<view
v-for="p in PALETTES"
:key="p.name"
class="palette press"
:class="{ 'palette--on': P.fg === p.fg && P.bg === p.bg }"
hover-class="press-active"
@tap="((P.fg = p.fg), (P.bg = p.bg))"
>
<view class="palette__dot" :style="{ background: p.fg }" />
<text>{{ p.name }}</text>
</view>
</view>
</view>
<!-- 高级 -->
<view class="card">
<text class="card__title">
高级
</text>
<view class="row">
<text class="row__label">
中心 Logo
</text>
<view class="row__ctl">
<view class="mini press" :class="{ 'mini--on': !P.logoOn }" hover-class="press-active" @tap="P.logoOn = false">
关闭
</view>
<view class="mini press" :class="{ 'mini--on': P.logoOn }" hover-class="press-active" @tap="P.logoOn = true">
开启
</view>
<view class="mini press" hover-class="press-active" @tap="pickLogo">
选图
</view>
</view>
</view>
<view class="row">
<text class="row__label">
纠错级别
</text>
<view class="row__ctl">
<view
v-for="e in ECCS"
:key="e.key"
class="mini press"
:class="{ 'mini--on': P.ecc === e.key }"
hover-class="press-active"
@tap="P.ecc = e.key"
>
{{ e.label }}
</view>
</view>
</view>
<view class="row">
<text class="row__label">
输出尺寸
</text>
<view class="row__ctl">
<view
v-for="s in SIZES"
:key="s"
class="mini press"
:class="{ 'mini--on': P.size === s }"
hover-class="press-active"
@tap="P.size = s"
>
{{ s }}px
</view>
</view>
</view>
<text class="tip">
中心加 Logo 会遮挡部分码点,建议纠错级别选 H 以保证可扫。
</text>
</view>
<!-- 预览 -->
<view class="result">
<image v-if="previewPath" :src="previewPath" mode="aspectFit" class="result__img" />
<view v-else class="result__ph">
<text class="result__ph-text">
{{ notice || '输入内容后自动生成预览' }}
</text>
</view>
<view v-if="notice && previewPath" class="notice">
<text class="notice__text">
{{ notice }}
</text>
</view>
<view class="sbtn press" :class="{ 'sbtn--busy': running || !previewPath }" hover-class="press-active" @tap="save">
{{ running ? '生成中…' : '保存到相册' }}
</view>
<text v-if="savedName" class="saved">
已保存:{{ savedName }}
</text>
</view>
<ToolRunning :show="running" text="生成二维码…" />
<VipDock />
</ToolPage>
</template>
<style scoped>
.seg {
display: flex;
gap: 14rpx;
background: #ffffff;
border: 2rpx solid #ffe3d3;
border-radius: 999rpx;
padding: 8rpx;
}
.seg__item {
flex: 1;
text-align: center;
padding: 18rpx 0;
border-radius: 999rpx;
font-size: 26rpx;
font-weight: 700;
color: #9a7b65;
transition: all 0.2s ease;
}
.seg__item--on {
background: linear-gradient(135deg, #ff9f4d, #ff6b35);
color: #ffffff;
box-shadow: 0 8rpx 18rpx rgba(251, 78, 20, 0.28);
}
.card {
background: #ffffff;
border: 2rpx solid #ffe3d3;
border-radius: 32rpx;
padding: 28rpx;
display: flex;
flex-direction: column;
gap: 18rpx;
}
.card__title {
font-size: 26rpx;
font-weight: 700;
color: #3d2a1e;
}
.content {
width: 100%;
box-sizing: border-box;
height: 160rpx;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
border-radius: 20rpx;
padding: 20rpx 24rpx;
font-size: 28rpx;
color: #3d2a1e;
}
.input {
width: 100%;
box-sizing: border-box;
height: 84rpx;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
border-radius: 20rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #3d2a1e;
}
.field-tip {
font-size: 22rpx;
color: #9a7b65;
}
.chips {
display: flex;
gap: 14rpx;
}
.chip {
padding: 12rpx 28rpx;
border-radius: 999rpx;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
font-size: 24rpx;
color: #3d2a1e;
}
.chip--on {
border-color: #ff6b35;
color: #ff6b35;
font-weight: 700;
}
.palettes {
display: flex;
gap: 16rpx;
flex-wrap: wrap;
}
.palette {
display: flex;
align-items: center;
gap: 10rpx;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
border-radius: 999rpx;
padding: 10rpx 24rpx;
font-size: 24rpx;
color: #3d2a1e;
}
.palette--on {
border-color: #ff6b35;
box-shadow: 0 6rpx 14rpx rgba(251, 78, 20, 0.2);
}
.palette__dot {
width: 24rpx;
height: 24rpx;
border-radius: 50%;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.row__label {
flex-shrink: 0;
font-size: 24rpx;
color: #9a7b65;
}
.row__ctl {
display: flex;
gap: 12rpx;
flex-wrap: wrap;
justify-content: flex-end;
}
.mini {
padding: 12rpx 24rpx;
border-radius: 999rpx;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
font-size: 22rpx;
color: #3d2a1e;
}
.mini--on {
background: #ff6b35;
border-color: #ff6b35;
color: #ffffff;
font-weight: 700;
}
.tip {
font-size: 22rpx;
color: #9a7b65;
line-height: 1.6;
}
.result {
display: flex;
flex-direction: column;
align-items: center;
gap: 22rpx;
background: #ffffff;
border: 1rpx solid rgba(255, 195, 155, 0.5);
border-radius: 32rpx;
padding: 32rpx;
}
.result__img {
width: 440rpx;
height: 440rpx;
border-radius: 20rpx;
background: #fff9f2;
}
.result__ph {
width: 440rpx;
height: 440rpx;
border-radius: 20rpx;
background: #fff9f2;
border: 2rpx dashed #ffd4b8;
display: flex;
align-items: center;
justify-content: center;
padding: 0 30rpx;
box-sizing: border-box;
}
.result__ph-text {
font-size: 24rpx;
color: #b3907c;
text-align: center;
line-height: 1.6;
}
.sbtn {
width: 100%;
text-align: center;
padding: 26rpx 0;
border-radius: 999rpx;
font-size: 30rpx;
font-weight: 800;
color: #ffffff;
background: linear-gradient(135deg, #ff9f4d, #ff6b35);
box-shadow: 0 12rpx 26rpx rgba(251, 78, 20, 0.3);
}
.sbtn--busy {
opacity: 0.5;
}
.notice {
width: 100%;
background: #fff3ec;
border: 2rpx solid #ffd4b8;
border-radius: 20rpx;
padding: 16rpx 24rpx;
box-sizing: border-box;
}
.notice__text {
font-size: 24rpx;
color: #b5501e;
}
.saved {
font-size: 22rpx;
color: #0d9488;
word-break: break-all;
text-align: center;
}
</style>