649 lines
16 KiB
Vue
649 lines
16 KiB
Vue
<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>
|