268 lines
7.8 KiB
Vue
268 lines
7.8 KiB
Vue
<template>
|
||
<div class="page-wrap">
|
||
<h2 class="page-title">占位图生成</h2>
|
||
<p class="page-desc">前端开发常用的占位图(Placeholder):自定义尺寸与配色,下载 PNG 或复制 SVG 代码,纯本地生成。</p>
|
||
|
||
<t-card class="tool-section">
|
||
<div class="preview-wrap">
|
||
<canvas ref="canvasRef" class="preview-canvas img-previewable" title="点击预览大图" @click="previewCanvas" />
|
||
</div>
|
||
|
||
<div class="grid-form">
|
||
<div class="form-item">
|
||
<span class="form-label">宽度</span>
|
||
<t-input-number v-model="width" :min="1" :max="2000" :step="10" class="num-input" />
|
||
</div>
|
||
<div class="form-item">
|
||
<span class="form-label">高度</span>
|
||
<t-input-number v-model="height" :min="1" :max="2000" :step="10" class="num-input" />
|
||
</div>
|
||
<div class="form-item">
|
||
<span class="form-label">背景色</span>
|
||
<input type="color" v-model="bgColor" class="color-input" />
|
||
<span class="hex-text">{{ bgColor.toUpperCase() }}</span>
|
||
</div>
|
||
<div class="form-item">
|
||
<span class="form-label">文字色</span>
|
||
<input type="color" v-model="textColor" class="color-input" />
|
||
<span class="hex-text">{{ textColor.toUpperCase() }}</span>
|
||
</div>
|
||
<div class="form-item full">
|
||
<span class="form-label">文字</span>
|
||
<t-input v-model="text" placeholder="显示在图片中央的文字(可换行)" class="text-input" />
|
||
</div>
|
||
<div class="form-item full">
|
||
<span class="form-label">字号</span>
|
||
<t-slider v-model="fontRatio" :min="5" :max="30" :step="1" class="slider" />
|
||
<span class="hex-text">{{ fontRatio }}%</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<t-button theme="primary" @click="downloadPng">下载 PNG</t-button>
|
||
<CopyButton :text="svgCode" label="复制 SVG 代码" />
|
||
</div>
|
||
</t-card>
|
||
|
||
<t-card class="tool-section" title="SVG 代码">
|
||
<pre class="code-block">{{ svgCode }}</pre>
|
||
</t-card>
|
||
|
||
<HistoryPanel
|
||
:items="history.items"
|
||
@select="onHistorySelect"
|
||
@remove="history.remove"
|
||
@clear="history.clear"
|
||
/>
|
||
|
||
<!-- 占位图放大预览 -->
|
||
<AppImageViewer v-model:visible="viewerVisible" v-model:index="viewerIndex" :images="viewerImages" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import CopyButton from '@/components/common/CopyButton.vue'
|
||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||
import { useHistory } from '@/composables/useHistory'
|
||
import { useImageViewer } from '@/composables/useImageViewer'
|
||
import AppImageViewer from '@/components/common/AppImageViewer.vue'
|
||
import { downloadDataUrl } from '@/composables/useDownload'
|
||
|
||
interface PlaceholderParams {
|
||
width: number
|
||
height: number
|
||
bgColor: string
|
||
textColor: string
|
||
text: string
|
||
fontRatio: number
|
||
}
|
||
|
||
const history = useHistory('placeholder-image')
|
||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||
const width = ref(400)
|
||
const height = ref(300)
|
||
|
||
/** 点击画布放大预览(把当前画布导出为 PNG 后交给 ImageViewer) */
|
||
const { visible: viewerVisible, index: viewerIndex, images: viewerImages, open: openViewer } = useImageViewer()
|
||
function previewCanvas() {
|
||
const canvas = canvasRef.value
|
||
if (canvas) openViewer([canvas.toDataURL('image/png')])
|
||
}
|
||
const bgColor = ref('#667eea')
|
||
const textColor = ref('#ffffff')
|
||
const text = ref('400 x 300')
|
||
const fontRatio = ref(12)
|
||
|
||
const W = () => Math.min(2000, Math.max(1, Math.round(width.value)))
|
||
const H = () => Math.min(2000, Math.max(1, Math.round(height.value)))
|
||
const FONT_RATIO = () => Math.min(30, Math.max(5, Math.round(fontRatio.value))) / 100
|
||
|
||
function draw() {
|
||
const canvas = canvasRef.value
|
||
if (!canvas) return
|
||
const w = W()
|
||
const h = H()
|
||
canvas.width = w
|
||
canvas.height = h
|
||
const ctx = canvas.getContext('2d')
|
||
if (!ctx) return
|
||
ctx.fillStyle = bgColor.value
|
||
ctx.fillRect(0, 0, w, h)
|
||
ctx.fillStyle = textColor.value
|
||
ctx.textAlign = 'center'
|
||
ctx.textBaseline = 'middle'
|
||
const fontSize = Math.round(Math.min(w, h) * FONT_RATIO())
|
||
ctx.font = `${fontSize}px sans-serif`
|
||
const lines = text.value.split('\n')
|
||
lines.forEach((line, i) => {
|
||
const y = h / 2 + (i - (lines.length - 1) / 2) * fontSize * 1.25
|
||
ctx.fillText(line, w / 2, y)
|
||
})
|
||
}
|
||
|
||
/** 转义 SVG 文本 */
|
||
function escapeXml(s: string): string {
|
||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||
}
|
||
|
||
const svgCode = computed(() => {
|
||
const w = W()
|
||
const h = H()
|
||
const fontSize = Math.round(Math.min(w, h) * FONT_RATIO())
|
||
const lines = text.value.split('\n')
|
||
const tspans = lines
|
||
.map(
|
||
(line, i) =>
|
||
` <tspan x="50%" dy="${i === 0 ? 0 : fontSize * 1.25}">${escapeXml(line) || ' '}</tspan>`,
|
||
)
|
||
.join('\n')
|
||
return [
|
||
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`,
|
||
` <rect width="100%" height="100%" fill="${bgColor.value}"/>`,
|
||
` <text x="50%" y="50%" fill="${textColor.value}" font-size="${fontSize}" text-anchor="middle" dominant-baseline="central" font-family="sans-serif">`,
|
||
tspans,
|
||
` </text>`,
|
||
`</svg>`,
|
||
].join('\n')
|
||
})
|
||
|
||
function downloadPng() {
|
||
const canvas = canvasRef.value
|
||
if (!canvas) return
|
||
downloadDataUrl(canvas.toDataURL('image/png'), `placeholder-${W()}x${H()}.png`)
|
||
saveHistory()
|
||
}
|
||
|
||
function saveHistory() {
|
||
const p: PlaceholderParams = {
|
||
width: W(),
|
||
height: H(),
|
||
bgColor: bgColor.value,
|
||
textColor: textColor.value,
|
||
text: text.value,
|
||
fontRatio: fontRatio.value,
|
||
}
|
||
history.add(JSON.stringify(p))
|
||
}
|
||
|
||
function onHistorySelect(t: string) {
|
||
try {
|
||
const p = JSON.parse(t) as PlaceholderParams
|
||
if (typeof p.width === 'number') width.value = p.width
|
||
if (typeof p.height === 'number') height.value = p.height
|
||
if (typeof p.bgColor === 'string') bgColor.value = p.bgColor
|
||
if (typeof p.textColor === 'string') textColor.value = p.textColor
|
||
if (typeof p.text === 'string') text.value = p.text
|
||
if (typeof p.fontRatio === 'number') fontRatio.value = p.fontRatio
|
||
draw()
|
||
} catch {
|
||
// 忽略解析失败的历史
|
||
}
|
||
}
|
||
|
||
watch([width, height, bgColor, textColor, text, fontRatio], draw, { immediate: true })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page-wrap {
|
||
max-width: 860px;
|
||
}
|
||
.preview-wrap {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 16px;
|
||
border: 1px solid var(--td-border-level-1-color);
|
||
border-radius: 8px;
|
||
background: var(--td-bg-color-container);
|
||
margin-bottom: 16px;
|
||
}
|
||
.preview-canvas {
|
||
max-width: 100%;
|
||
height: auto;
|
||
border-radius: 4px;
|
||
}
|
||
.grid-form {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 12px 16px;
|
||
}
|
||
.form-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.form-item.full {
|
||
grid-column: 1 / -1;
|
||
}
|
||
.form-label {
|
||
flex-shrink: 0;
|
||
font-size: 14px;
|
||
color: var(--td-text-color-secondary);
|
||
}
|
||
.num-input {
|
||
width: 110px;
|
||
}
|
||
.color-input {
|
||
width: 36px;
|
||
height: 28px;
|
||
padding: 0;
|
||
border: none;
|
||
border-radius: 4px;
|
||
background: none;
|
||
cursor: pointer;
|
||
}
|
||
.hex-text {
|
||
font-family: ui-monospace, Consolas, monospace;
|
||
font-size: 12px;
|
||
color: var(--td-text-color-placeholder);
|
||
}
|
||
.text-input {
|
||
flex: 1;
|
||
}
|
||
.slider {
|
||
flex: 1;
|
||
min-width: 120px;
|
||
}
|
||
.form-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.code-block {
|
||
margin: 0;
|
||
padding: 12px 16px;
|
||
border: 1px solid var(--td-border-level-1-color);
|
||
border-radius: 6px;
|
||
background: var(--td-bg-color-container);
|
||
font-family: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
|
||
font-size: 13px;
|
||
line-height: 1.7;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
color: var(--td-text-color-primary);
|
||
}
|
||
</style>
|