Files
utils/src/components/tools/SealGenerator.vue
zguiy a262c52c73
All checks were successful
continuous-integration/drone/pr Build is passing
增加印章
2025-06-29 03:01:56 +08:00

1533 lines
54 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.

<template>
<div class="space-y-6">
<div class="flex flex-col lg:flex-row gap-6">
<!-- 左侧配置面板 -->
<div class="flex-1 lg:max-w-2xl space-y-4">
<!-- 基础参数 -->
<div class="card p-6">
<h3 class="text-lg font-medium text-primary mb-4">
<FontAwesomeIcon :icon="['fas', 'cogs']" class="mr-2" />
基础参数
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 印章样式 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">印章样式</label>
<select v-model="sealType" class="select-field w-full">
<option value="company_round">公司圆章</option>
<option value="company_oval">公司椭圆章</option>
<option value="personal_square">个人方章</option>
<option value="personal_round">个人圆章</option>
</select>
</div>
<!-- 印章大小 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">印章大小 ({{ sealSize }}px)</label>
<input
v-model.number="sealSize"
type="range"
min="200"
max="600"
step="20"
class="slider w-full"
/>
</div>
<!-- 个人方章高度调整 -->
<div v-if="sealType === 'personal_square'">
<label class="block text-sm font-medium text-secondary mb-2">印章高度比例 ({{ personalHeight }})</label>
<input
v-model.number="personalHeight"
type="range"
min="0.5"
max="2.0"
step="0.1"
class="slider w-full"
/>
<div class="flex justify-between text-xs text-tertiary mt-1">
<span>扁平</span>
<span>正方</span>
<span>高长</span>
</div>
</div>
<!-- 印章颜色 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">印章颜色</label>
<div class="flex items-center gap-2">
<input
v-model="sealColor"
type="color"
class="w-10 h-10 rounded border border-primary/20"
/>
<input
v-model="sealColor"
type="text"
class="input-field flex-1"
placeholder="#FF0000"
/>
</div>
</div>
<!-- 背景颜色 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">背景颜色</label>
<div class="flex items-center gap-2">
<input
v-model="backgroundColor"
type="color"
class="w-10 h-10 rounded border border-primary/20"
/>
<input
v-model="backgroundColor"
type="text"
class="input-field flex-1"
placeholder="#FFFFFF"
/>
</div>
</div>
</div>
</div>
<!-- 主文字设置 -->
<div class="card p-6">
<h3 class="text-lg font-medium text-primary mb-4">
<FontAwesomeIcon :icon="['fas', 'font']" class="mr-2" />
主文字设置
</h3>
<div class="space-y-4">
<!-- 主文字内容 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">文字内容</label>
<input
v-model="mainText"
type="text"
class="input-field w-full"
:placeholder="sealType.startsWith('company') ? '公司名称' : '姓名'"
maxlength="20"
/>
</div>
<!-- 主文字大小 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">字体大小 ({{ mainFontSize }}px)</label>
<input
v-model.number="mainFontSize"
type="range"
min="16"
max="60"
step="2"
class="slider w-full"
/>
</div>
<!-- 主文字字体样式 -->
<div class="space-y-3">
<!-- 字体选择 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">字体</label>
<select v-model="mainFontFamily" class="select-field w-full">
<option value="SimSun">宋体 (SimSun)</option>
<option value="SimHei">黑体 (SimHei)</option>
<option value="KaiTi">楷体 (KaiTi)</option>
<option value="FangSong">仿宋 (FangSong)</option>
<option value="Microsoft YaHei">微软雅黑 (Microsoft YaHei)</option>
<option value="Arial">Arial</option>
<option value="Times New Roman">Times New Roman</option>
</select>
</div>
<!-- 字体样式 -->
<div class="flex gap-4">
<label class="flex items-center">
<input
v-model="mainFontWeight"
type="checkbox"
class="mr-2"
/>
<span class="text-sm text-secondary">加粗</span>
</label>
<label class="flex items-center">
<input
v-model="mainFontItalic"
type="checkbox"
class="mr-2"
/>
<span class="text-sm text-secondary">斜体</span>
</label>
</div>
</div>
<!-- 主文字布局仅公司章 -->
<div v-if="sealType.startsWith('company')" class="space-y-4">
<!-- 文字间距 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">文字间距 ({{ textSpacing }})</label>
<input
v-model.number="textSpacing"
type="range"
min="0.5"
max="1.5"
step="0.05"
class="slider w-full"
/>
<div class="flex justify-between text-xs text-tertiary mt-1">
<span>紧密</span>
<span>适中</span>
<span>宽松</span>
</div>
</div>
<!-- 主文字位置 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">文字位置 ({{ textMargin > 0 ? '+' : '' }}{{ textMargin }}px)</label>
<input
v-model.number="textMargin"
type="range"
min="-30"
max="30"
step="2"
class="slider w-full"
/>
<div class="flex justify-between text-xs text-tertiary mt-1">
<span>向外</span>
<span>标准</span>
<span>向内</span>
</div>
</div>
</div>
</div>
</div>
<!-- 中心文字设置仅公司章 -->
<div v-if="sealType.startsWith('company')" class="card p-6">
<h3 class="text-lg font-medium text-primary mb-4">
<FontAwesomeIcon :icon="['fas', 'dot-circle']" class="mr-2" />
中心文字设置
</h3>
<div class="space-y-4">
<!-- 中心文字内容 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">文字内容</label>
<input
v-model="centerText"
type="text"
class="input-field w-full"
placeholder="印"
/>
</div>
<!-- 中心文字大小 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">字体大小 ({{ centerFontSize }}px)</label>
<input
v-model.number="centerFontSize"
type="range"
min="20"
max="80"
step="2"
class="slider w-full"
/>
</div>
<!-- 中心文字字体样式 -->
<div class="space-y-3">
<!-- 字体选择 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">字体</label>
<select v-model="centerFontFamily" class="select-field w-full">
<option value="SimSun">宋体 (SimSun)</option>
<option value="SimHei">黑体 (SimHei)</option>
<option value="KaiTi">楷体 (KaiTi)</option>
<option value="FangSong">仿宋 (FangSong)</option>
<option value="Microsoft YaHei">微软雅黑 (Microsoft YaHei)</option>
<option value="Arial">Arial</option>
<option value="Times New Roman">Times New Roman</option>
</select>
</div>
<!-- 字体样式 -->
<div class="flex gap-4">
<label class="flex items-center">
<input
v-model="centerFontWeight"
type="checkbox"
class="mr-2"
/>
<span class="text-sm text-secondary">加粗</span>
</label>
<label class="flex items-center">
<input
v-model="centerFontItalic"
type="checkbox"
class="mr-2"
/>
<span class="text-sm text-secondary">斜体</span>
</label>
</div>
<!-- 中心图形设置 -->
<div class="mt-4 space-y-3">
<!-- 图形选择 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">中心图形</label>
<select v-model="centerShape" class="select-field w-full">
<option value="">无图形</option>
<option value="star">五角星</option>
<option value="circle">圆形</option>
<option value="diamond">菱形</option>
<option value="hexagram">六角星</option>
<option value="triangle">三角形</option>
</select>
</div>
<!-- 图形大小 -->
<div v-if="centerShape">
<label class="block text-sm font-medium text-secondary mb-2">图形大小 ({{ centerShapeSize }}px)</label>
<input
v-model.number="centerShapeSize"
type="range"
min="8"
max="30"
step="2"
class="slider w-full"
/>
<div class="flex justify-between text-xs text-tertiary mt-1">
<span></span>
<span></span>
<span></span>
</div>
</div>
<p class="text-xs text-tertiary">
智能居中无文字时图形居中无图形时文字居中
</p>
</div>
</div>
</div>
</div>
<!-- 抬头文字设置仅公司章 -->
<div v-if="sealType.startsWith('company')" class="card p-6">
<h3 class="text-lg font-medium text-primary mb-4">
<FontAwesomeIcon :icon="['fas', 'chevron-up']" class="mr-2" />
抬头文字设置
</h3>
<div class="space-y-4">
<!-- 抬头文字内容 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">文字内容</label>
<input
v-model="headerText"
type="text"
class="input-field w-full"
placeholder="例:总公司、分公司"
maxlength="10"
/>
</div>
<!-- 抬头文字大小 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">字体大小 ({{ headerFontSize }}px)</label>
<input
v-model.number="headerFontSize"
type="range"
min="12"
max="40"
step="2"
class="slider w-full"
/>
</div>
<!-- 抬头文字字体样式 -->
<div class="space-y-3">
<!-- 字体选择 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">字体</label>
<select v-model="headerFontFamily" class="select-field w-full">
<option value="SimSun">宋体 (SimSun)</option>
<option value="SimHei">黑体 (SimHei)</option>
<option value="KaiTi">楷体 (KaiTi)</option>
<option value="FangSong">仿宋 (FangSong)</option>
<option value="Microsoft YaHei">微软雅黑 (Microsoft YaHei)</option>
<option value="Arial">Arial</option>
<option value="Times New Roman">Times New Roman</option>
</select>
</div>
<!-- 字体样式 -->
<div class="flex gap-4">
<label class="flex items-center">
<input
v-model="headerFontWeight"
type="checkbox"
class="mr-2"
/>
<span class="text-sm text-secondary">加粗</span>
</label>
<label class="flex items-center">
<input
v-model="headerFontItalic"
type="checkbox"
class="mr-2"
/>
<span class="text-sm text-secondary">斜体</span>
</label>
</div>
</div>
</div>
</div>
<!-- 副文字设置仅公司章 -->
<div v-if="sealType.startsWith('company')" class="card p-6">
<h3 class="text-lg font-medium text-primary mb-4">
<FontAwesomeIcon :icon="['fas', 'chevron-down']" class="mr-2" />
副文字设置
</h3>
<div class="space-y-4">
<!-- 副文字内容 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">文字内容</label>
<input
v-model="subText"
type="text"
class="input-field w-full"
placeholder="例:有限公司、股份有限公司"
maxlength="10"
/>
</div>
<!-- 副文字大小 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">字体大小 ({{ subFontSize }}px)</label>
<input
v-model.number="subFontSize"
type="range"
min="12"
max="40"
step="2"
class="slider w-full"
/>
</div>
<!-- 副文字字体样式 -->
<div class="space-y-3">
<!-- 显示模式 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">显示模式</label>
<div class="flex gap-4">
<label class="flex items-center">
<input
v-model="subTextCurved"
type="radio"
:value="false"
class="mr-2"
/>
<span class="text-sm text-secondary">平直显示</span>
</label>
<label class="flex items-center">
<input
v-model="subTextCurved"
type="radio"
:value="true"
class="mr-2"
/>
<span class="text-sm text-secondary">弧形显示</span>
</label>
</div>
</div>
<!-- 副文字间距弧形模式 -->
<div v-if="subTextCurved">
<label class="block text-sm font-medium text-secondary mb-2">文字间距 ({{ subTextSpacing }})</label>
<input
v-model.number="subTextSpacing"
type="range"
min="0.5"
max="1.5"
step="0.05"
class="slider w-full"
/>
<div class="flex justify-between text-xs text-tertiary mt-1">
<span>紧密</span>
<span>适中</span>
<span>宽松</span>
</div>
</div>
<!-- 副文字位置 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">文字位置 ({{ subTextMargin > 0 ? '+' : '' }}{{ subTextMargin }}px)</label>
<input
v-model.number="subTextMargin"
type="range"
min="-30"
max="30"
step="2"
class="slider w-full"
/>
<div class="flex justify-between text-xs text-tertiary mt-1">
<span>向外</span>
<span>标准</span>
<span>向内</span>
</div>
</div>
<!-- 字体选择 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">字体</label>
<select v-model="subFontFamily" class="select-field w-full">
<option value="SimSun">宋体 (SimSun)</option>
<option value="SimHei">黑体 (SimHei)</option>
<option value="KaiTi">楷体 (KaiTi)</option>
<option value="FangSong">仿宋 (FangSong)</option>
<option value="Microsoft YaHei">微软雅黑 (Microsoft YaHei)</option>
<option value="Arial">Arial</option>
<option value="Times New Roman">Times New Roman</option>
</select>
</div>
<!-- 字体样式 -->
<div class="flex gap-4">
<label class="flex items-center">
<input
v-model="subFontWeight"
type="checkbox"
class="mr-2"
/>
<span class="text-sm text-secondary">加粗</span>
</label>
<label class="flex items-center">
<input
v-model="subFontItalic"
type="checkbox"
class="mr-2"
/>
<span class="text-sm text-secondary">斜体</span>
</label>
</div>
</div>
</div>
</div>
<!-- 边框设置 -->
<div class="card p-6">
<h3 class="text-lg font-medium text-primary mb-4">
<FontAwesomeIcon :icon="['fas', 'square']" class="mr-2" />
边框设置
</h3>
<div class="grid grid-cols-2 gap-4">
<!-- 外边线宽度 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">外边线宽度 ({{ outerBorderWidth }}px)</label>
<input
v-model.number="outerBorderWidth"
type="range"
min="1"
max="10"
step="1"
class="slider w-full"
/>
</div>
<!-- 内边线宽度 -->
<div v-if="sealType.startsWith('company')" class="col-span-2">
<label class="block text-sm font-medium text-secondary mb-2">内边线宽度 ({{ innerBorderWidth }}px)</label>
<input
v-model.number="innerBorderWidth"
type="range"
min="0"
max="5"
step="1"
class="slider w-full"
/>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="card p-6">
<div class="space-y-4">
<!-- 导出选项 -->
<div class="space-y-3">
<label class="flex items-center">
<input
v-model="exportTransparent"
type="checkbox"
class="mr-2"
/>
<span class="text-sm text-secondary">导出透明背景PNG</span>
</label>
<!-- 破损样式 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">破损样式</label>
<select v-model="damageStyle" class="select-field w-full">
<option value="none">无破损</option>
<option value="random">随机磨损</option>
<option value="cracks">裂纹破损</option>
<option value="edge">边缘磨损</option>
<option value="chunks">块状缺失</option>
<option value="stains">污渍斑点</option>
</select>
</div>
<!-- 破损程度 -->
<div v-if="damageStyle !== 'none'">
<label class="block text-sm font-medium text-secondary mb-2">破损程度 ({{ wearLevel }}%)</label>
<input
v-model.number="wearLevel"
type="range"
min="0"
max="50"
step="5"
class="slider w-full"
/>
<div class="flex justify-between text-xs text-tertiary mt-1">
<span>轻微</span>
<span>中等</span>
<span>严重</span>
</div>
<p class="text-xs text-tertiary mt-1">
{{ getDamageDescription(damageStyle) }}
</p>
</div>
</div>
<div class="flex gap-3">
<button
@click="downloadSeal"
class="btn-primary flex-1"
:disabled="!mainText.trim()"
>
<FontAwesomeIcon :icon="['fas', 'download']" class="mr-2" />
下载印章
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
清空
</button>
</div>
</div>
</div>
</div>
<!-- 右侧预览区域 -->
<div class="lg:fixed lg:right-4 lg:w-96 lg:max-h-[calc(100vh-2rem)]">
<div class="card p-6">
<h3 class="text-lg font-medium text-primary mb-4">
<FontAwesomeIcon :icon="['fas', 'eye']" class="mr-2" />
印章预览 - 实时显示
</h3>
<div class="flex justify-center items-center min-h-[400px] bg-block rounded-lg p-4">
<canvas
ref="sealCanvas"
:width="canvasWidth"
:height="canvasHeight"
class="border border-primary/20 rounded-lg shadow-lg"
style="max-width: 100%; height: auto;"
/>
</div>
<div class="mt-4 text-center">
<p class="text-sm text-tertiary">
<FontAwesomeIcon :icon="['fas', 'info-circle']" class="mr-1" />
左侧调整参数右侧实时预览
</p>
</div>
</div>
</div>
</div>
<!-- 使用说明 -->
<div class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">
<FontAwesomeIcon :icon="['fas', 'info-circle']" class="mr-2" />
使用说明
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 class="font-medium text-secondary mb-2">公司印章</h4>
<ul class="text-sm text-tertiary space-y-1">
<li> 支持圆形和椭圆形样式</li>
<li> 可设置主文字公司名称</li>
<li> 可添加中心文字抬头文字副文字</li>
<li> 支持五角星装饰</li>
<li> 可调节内外边框宽度</li>
</ul>
</div>
<div>
<h4 class="font-medium text-secondary mb-2">个人印章</h4>
<ul class="text-sm text-tertiary space-y-1">
<li> 支持方形和圆形样式</li>
<li> 适用于个人姓名印章</li>
<li> 支持2-4个汉字</li>
<li> 简洁美观的设计</li>
<li> 可自定义颜色和大小</li>
</ul>
</div>
</div>
<div class="mt-4 p-4 bg-block rounded-lg">
<h4 class="font-medium text-secondary mb-2">
<FontAwesomeIcon :icon="['fas', 'exclamation-triangle']" class="mr-2 text-yellow-500" />
重要提示
</h4>
<p class="text-sm text-tertiary">
本工具生成的印章仅供学习和测试使用请勿用于非法用途正式的公司印章需要到相关部门进行备案注册
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
// 响应式状态
const sealType = ref('company_round')
const sealSize = ref(300)
// 计算画布尺寸
const canvasWidth = computed(() => {
if (sealType.value === 'company_oval') {
return sealSize.value
}
return sealSize.value
})
const canvasHeight = computed(() => {
if (sealType.value === 'company_oval') {
return Math.round(sealSize.value * 0.8) // 椭圆形高度为宽度的80%
} else if (sealType.value === 'personal_square') {
return Math.round(sealSize.value * personalHeight.value) // 个人方章支持高度调整
}
return sealSize.value
})
const sealColor = ref('#FF0000')
const backgroundColor = ref('#FFFFFF')
const mainText = ref('子归云科技')
const centerText = ref('印')
const headerText = ref('')
const subText = ref('')
const mainFontSize = ref(24)
const centerFontSize = ref(40)
const headerFontSize = ref(18)
const subFontSize = ref(18)
const outerBorderWidth = ref(3)
const innerBorderWidth = ref(1)
const showStar = ref(true) // 保留用于兼容性
const centerShape = ref('star') // 中心图形类型
const centerShapeSize = ref(15) // 中心图形大小
const textSpacing = ref(1.0)
const textMargin = ref(0)
const personalHeight = ref(1.0) // 个人印章高度比例
// 主文字样式
const mainFontWeight = ref(false)
const mainFontItalic = ref(false)
const mainFontFamily = ref('SimSun')
// 中心文字样式
const centerFontWeight = ref(false)
const centerFontItalic = ref(false)
const centerFontFamily = ref('SimSun')
// 抬头文字样式
const headerFontWeight = ref(false)
const headerFontItalic = ref(false)
const headerFontFamily = ref('SimSun')
// 副文字样式
const subFontWeight = ref(false)
const subFontItalic = ref(false)
const subFontFamily = ref('SimSun')
const subTextCurved = ref(false) // 副文字是否弧形显示
const subTextSpacing = ref(1.0) // 副文字间距
const subTextMargin = ref(0) // 副文字位置调整
const exportTransparent = ref(true) // 是否导出透明背景
const wearLevel = ref(0) // 印章旧度0-50%
const damageStyle = ref('none') // 破损样式
const isPreviewFixed = ref(false)
const sealCanvas = ref<HTMLCanvasElement>()
// 构建字体样式字符串
const buildFontStyle = (fontSize: number, weight: boolean, italic: boolean, family: string) => {
const fontWeight = weight ? 'bold' : 'normal'
const fontStyle = italic ? 'italic' : 'normal'
return `${fontStyle} ${fontWeight} ${fontSize}px ${family}, serif`
}
// 获取破损样式描述
const getDamageDescription = (style: string) => {
const descriptions = {
none: '无破损效果',
random: '随机像素磨损,模拟自然使用痕迹',
cracks: '生成裂纹线条,模拟印章开裂',
edge: '边缘优先磨损,模拟边缘磨损',
chunks: '块状区域缺失,模拟大块破损',
stains: '污渍斑点效果,模拟墨水污染'
}
return descriptions[style as keyof typeof descriptions] || '未知样式'
}
// 绘制印章
const drawSeal = async (transparent = false) => {
await nextTick()
const canvas = sealCanvas.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
// 清空画布
ctx.clearRect(0, 0, canvasWidth.value, canvasHeight.value)
// 如果没有主文字,显示提示(仅在预览模式,非透明导出模式)
if (!mainText.value.trim() && !transparent) {
// 先绘制背景
ctx.fillStyle = backgroundColor.value
ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value)
ctx.fillStyle = '#999999'
ctx.font = buildFontStyle(16, false, false, 'SimSun')
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('请输入印章文字', canvasWidth.value / 2, canvasHeight.value / 2)
return
}
// 如果有破损效果且是预览模式,需要特殊处理
const hasDamage = damageStyle.value !== 'none' && wearLevel.value > 0
if (!transparent && hasDamage) {
// 预览模式且有破损效果:先在临时画布绘制完整印章,再复制到主画布
const tempCanvas = document.createElement('canvas')
tempCanvas.width = canvasWidth.value
tempCanvas.height = canvasHeight.value
const tempCtx = tempCanvas.getContext('2d')
if (!tempCtx) return
// 在临时画布绘制印章
tempCtx.strokeStyle = sealColor.value
tempCtx.fillStyle = sealColor.value
if (sealType.value.startsWith('company')) {
drawCompanySeal(tempCtx)
} else {
drawPersonalSeal(tempCtx)
}
// 应用破损效果到临时画布
applyDamageEffect(tempCtx)
// 绘制背景到主画布
ctx.fillStyle = backgroundColor.value
ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value)
// 将处理好的印章复制到主画布
ctx.drawImage(tempCanvas, 0, 0)
} else {
// 正常绘制流程(导出模式或无破损效果)
if (!transparent) {
ctx.fillStyle = backgroundColor.value
ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value)
}
// 根据印章类型绘制
if (sealType.value.startsWith('company')) {
drawCompanySeal(ctx)
} else {
drawPersonalSeal(ctx)
}
// 应用破损效果
if (hasDamage) {
applyDamageEffect(ctx)
}
}
}
// 绘制公司印章
const drawCompanySeal = (ctx: CanvasRenderingContext2D) => {
const centerX = canvasWidth.value / 2
const centerY = canvasHeight.value / 2
const radius = Math.min(canvasWidth.value, canvasHeight.value) * 0.4
ctx.strokeStyle = sealColor.value
ctx.fillStyle = sealColor.value
// 绘制外边框
ctx.lineWidth = outerBorderWidth.value
ctx.beginPath()
if (sealType.value === 'company_oval') {
// 椭圆形
ctx.ellipse(centerX, centerY, radius, radius * 0.8, 0, 0, 2 * Math.PI)
} else {
// 圆形
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI)
}
ctx.stroke()
// 绘制内边框
if (innerBorderWidth.value > 0) {
ctx.lineWidth = innerBorderWidth.value
ctx.beginPath()
if (sealType.value === 'company_oval') {
ctx.ellipse(centerX, centerY, radius - 15, (radius - 15) * 0.8, 0, 0, 2 * Math.PI)
} else {
ctx.arc(centerX, centerY, radius - 15, 0, 2 * Math.PI)
}
ctx.stroke()
}
// 绘制主文字(圆弧排列)
const textRadius = radius - 25 - textMargin.value // 应用外边距调整
drawCurvedText(ctx, mainText.value, centerX, centerY, textRadius, 0.8)
// 绘制抬头文字
if (headerText.value.trim()) {
ctx.font = buildFontStyle(headerFontSize.value, headerFontWeight.value, headerFontItalic.value, headerFontFamily.value)
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(headerText.value, centerX, centerY - radius * 0.3)
}
// 绘制副文字
if (subText.value.trim()) {
if (subTextCurved.value) {
// 弧形显示
drawSubCurvedText(ctx, subText.value, centerX, centerY, radius - 25 + subTextMargin.value)
} else {
// 平直显示
ctx.font = buildFontStyle(subFontSize.value, subFontWeight.value, subFontItalic.value, subFontFamily.value)
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(subText.value, centerX, centerY + radius * 0.7 + subTextMargin.value)
}
}
// 智能绘制中心图形和文字
const hasCenterText = centerText.value.trim()
const hasShape = centerShape.value !== ''
if (hasShape && hasCenterText) {
// 同时显示图形和文字:自适应布局
const shapeSize = centerShapeSize.value
const textSize = centerFontSize.value
// 计算自适应间距:基于图形和文字的大小
const baseSpacing = 5 // 基础间距
const shapeSpacing = shapeSize * 0.8 // 图形占用空间
const textSpacing = textSize * 0.6 // 文字占用空间
// 图形位置:向上偏移(大小越大偏移越多)
const shapeY = centerY - baseSpacing - textSpacing
// 文字位置:向下偏移(大小越大偏移越多)
const textY = centerY + baseSpacing + shapeSpacing
// 绘制图形
drawCenterShape(ctx, centerX, shapeY, shapeSize, centerShape.value)
// 绘制文字
ctx.font = buildFontStyle(centerFontSize.value, centerFontWeight.value, centerFontItalic.value, centerFontFamily.value)
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(centerText.value, centerX, textY)
} else if (hasShape && !hasCenterText) {
// 只显示图形:居中显示
drawCenterShape(ctx, centerX, centerY, centerShapeSize.value, centerShape.value)
} else if (!hasShape && hasCenterText) {
// 只显示文字:居中显示
ctx.font = buildFontStyle(centerFontSize.value, centerFontWeight.value, centerFontItalic.value, centerFontFamily.value)
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(centerText.value, centerX, centerY)
}
// 如果都不显示,则什么都不绘制
}
// 绘制个人印章
const drawPersonalSeal = (ctx: CanvasRenderingContext2D) => {
const centerX = canvasWidth.value / 2
const centerY = canvasHeight.value / 2
ctx.strokeStyle = sealColor.value
ctx.fillStyle = sealColor.value
ctx.lineWidth = outerBorderWidth.value
// 绘制边框
ctx.beginPath()
if (sealType.value === 'personal_round') {
// 圆形
const size = Math.min(canvasWidth.value, canvasHeight.value) * 0.8
ctx.arc(centerX, centerY, size / 2, 0, 2 * Math.PI)
} else {
// 方形/矩形
const width = canvasWidth.value * 0.8
const height = canvasHeight.value * 0.8
ctx.rect(centerX - width / 2, centerY - height / 2, width, height)
}
ctx.stroke()
// 绘制文字
const text = mainText.value.trim()
const textLength = text.length
ctx.font = buildFontStyle(mainFontSize.value, mainFontWeight.value, mainFontItalic.value, mainFontFamily.value)
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
if (textLength === 2) {
// 两字:上下排列
ctx.fillText(text[0], centerX, centerY - mainFontSize.value * 0.6)
ctx.fillText(text[1], centerX, centerY + mainFontSize.value * 0.6)
} else if (textLength === 3) {
// 三字:一字在上,两字在下
ctx.fillText(text[0], centerX, centerY - mainFontSize.value * 0.8)
ctx.fillText(text[1], centerX - mainFontSize.value * 0.7, centerY + mainFontSize.value * 0.4)
ctx.fillText(text[2], centerX + mainFontSize.value * 0.7, centerY + mainFontSize.value * 0.4)
} else if (textLength === 4) {
// 四字:田字格排列
ctx.fillText(text[0], centerX - mainFontSize.value * 0.6, centerY - mainFontSize.value * 0.6)
ctx.fillText(text[1], centerX + mainFontSize.value * 0.6, centerY - mainFontSize.value * 0.6)
ctx.fillText(text[2], centerX - mainFontSize.value * 0.6, centerY + mainFontSize.value * 0.6)
ctx.fillText(text[3], centerX + mainFontSize.value * 0.6, centerY + mainFontSize.value * 0.6)
} else {
// 其他情况:单行显示
ctx.fillText(text, centerX, centerY)
}
}
// 绘制弧形文字
const drawCurvedText = (ctx: CanvasRenderingContext2D, text: string, centerX: number, centerY: number, radius: number, startAngle: number) => {
ctx.font = buildFontStyle(mainFontSize.value, mainFontWeight.value, mainFontItalic.value, mainFontFamily.value)
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// 计算文字弧度范围,让文字均匀分布在上半圆(从左上到右上)
const baseArc = Math.PI * 0.75 // 基础弧度范围约135度
const totalArc = baseArc * textSpacing.value // 根据间距调整总弧度
const angleStep = totalArc / (text.length - 1) // 文字间的角度间隔
const centerAngle = Math.PI * 1.5 // 上半圆中心位置270度Canvas中的顶部
const startPos = centerAngle - totalArc / 2 // 从中心向左偏移一半弧度作为起始位置
// 椭圆形参数
const isOval = sealType.value === 'company_oval'
const radiusX = isOval ? radius : radius
const radiusY = isOval ? radius * 0.8 : radius // 椭圆形的Y轴半径是X轴的80%
for (let i = 0; i < text.length; i++) {
const angle = startPos + i * angleStep // 从左到右递增
// 根据印章类型计算文字位置
let x, y
if (isOval) {
// 椭圆形:使用椭圆方程计算位置
x = centerX + Math.cos(angle) * radiusX
y = centerY + Math.sin(angle) * radiusY
} else {
// 圆形:使用圆形方程计算位置
x = centerX + Math.cos(angle) * radius
y = centerY + Math.sin(angle) * radius
}
ctx.save()
ctx.translate(x, y)
// 椭圆形和圆形使用相同的文字旋转角度
ctx.rotate(angle + Math.PI / 2)
ctx.fillText(text[i], 0, 0)
ctx.restore()
}
}
// 绘制副文字弧形排列
const drawSubCurvedText = (ctx: CanvasRenderingContext2D, text: string, centerX: number, centerY: number, radius: number) => {
ctx.font = buildFontStyle(subFontSize.value, subFontWeight.value, subFontItalic.value, subFontFamily.value)
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// 计算文字弧度范围,让文字均匀分布在下半圆,以底部中心为基准居中分布
const baseArc = Math.PI * 0.6 // 基础弧度范围约108度
const totalArc = baseArc * subTextSpacing.value // 根据间距调整总弧度
const angleStep = totalArc / (text.length - 1) // 文字间的角度间隔
const centerAngle = Math.PI * 0.5 // 下半圆中心位置90度Canvas中的底部
const startPos = centerAngle - totalArc / 2 // 从中心向左偏移一半弧度作为起始位置
// 椭圆形参数
const isOval = sealType.value === 'company_oval'
const radiusX = isOval ? radius : radius
const radiusY = isOval ? radius * 0.8 : radius // 椭圆形的Y轴半径是X轴的80%
for (let i = 0; i < text.length; i++) {
const angle = startPos + (text.length - 1 - i) * angleStep // 从右到左分布,修复顺序
// 根据印章类型计算文字位置
let x, y
if (isOval) {
// 椭圆形:使用椭圆方程计算位置
x = centerX + Math.cos(angle) * radiusX
y = centerY + Math.sin(angle) * radiusY
} else {
// 圆形:使用圆形方程计算位置
x = centerX + Math.cos(angle) * radius
y = centerY + Math.sin(angle) * radius
}
ctx.save()
ctx.translate(x, y)
// 修复文字旋转角度,下半圆的文字需要向内旋转
ctx.rotate(angle - Math.PI / 2)
ctx.fillText(text[i], 0, 0)
ctx.restore()
}
}
// 绘制五角星
const drawStar = (ctx: CanvasRenderingContext2D, centerX: number, centerY: number, radius: number) => {
const spikes = 5
const outerRadius = radius
const innerRadius = radius * 0.4
ctx.beginPath()
for (let i = 0; i < spikes * 2; i++) {
const angle = (i * Math.PI) / spikes - Math.PI / 2
const r = i % 2 === 0 ? outerRadius : innerRadius
const x = centerX + Math.cos(angle) * r
const y = centerY + Math.sin(angle) * r
if (i === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
}
ctx.closePath()
ctx.fill()
}
// 绘制中心图形
const drawCenterShape = (ctx: CanvasRenderingContext2D, centerX: number, centerY: number, size: number, shape: string) => {
ctx.fillStyle = sealColor.value
switch (shape) {
case 'star':
drawStar(ctx, centerX, centerY, size)
break
case 'circle':
ctx.beginPath()
ctx.arc(centerX, centerY, size, 0, 2 * Math.PI)
ctx.fill()
break
case 'diamond':
ctx.beginPath()
ctx.moveTo(centerX, centerY - size)
ctx.lineTo(centerX + size, centerY)
ctx.lineTo(centerX, centerY + size)
ctx.lineTo(centerX - size, centerY)
ctx.closePath()
ctx.fill()
break
case 'hexagram':
// 绘制六角星(两个三角形重叠)
const hexSize = size * 0.8
ctx.beginPath()
// 第一个三角形(向上)
for (let i = 0; i < 3; i++) {
const angle = (i * 2 * Math.PI) / 3 - Math.PI / 2
const x = centerX + Math.cos(angle) * hexSize
const y = centerY + Math.sin(angle) * hexSize
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.closePath()
ctx.fill()
ctx.beginPath()
// 第二个三角形(向下)
for (let i = 0; i < 3; i++) {
const angle = (i * 2 * Math.PI) / 3 + Math.PI / 2
const x = centerX + Math.cos(angle) * hexSize
const y = centerY + Math.sin(angle) * hexSize
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.closePath()
ctx.fill()
break
case 'triangle':
ctx.beginPath()
ctx.moveTo(centerX, centerY - size)
ctx.lineTo(centerX + size * 0.866, centerY + size * 0.5)
ctx.lineTo(centerX - size * 0.866, centerY + size * 0.5)
ctx.closePath()
ctx.fill()
break
}
}
// 应用破损效果
const applyDamageEffect = (ctx: CanvasRenderingContext2D) => {
if (damageStyle.value === 'none' || wearLevel.value === 0) return
const width = canvasWidth.value
const height = canvasHeight.value
const intensity = wearLevel.value / 50 // 0-1.0 的强度,提高敏感度
// 使用destination-out合成模式让绘制的内容从印章中"减去"
ctx.globalCompositeOperation = 'destination-out'
switch (damageStyle.value) {
case 'random':
applyRandomNoise(ctx, width, height, intensity)
break
case 'cracks':
applyCracks(ctx, width, height, intensity)
break
case 'edge':
applyEdgeWear(ctx, width, height, intensity)
break
case 'chunks':
applyChunks(ctx, width, height, intensity)
break
case 'stains':
applyStains(ctx, width, height, intensity)
break
}
// 恢复正常合成模式
ctx.globalCompositeOperation = 'source-over'
}
// 简单随机数生成器(基于文字内容,保证同样的文字产生相同的效果)
const getRandomGenerator = () => {
let seed = 12345
const text = mainText.value || 'default'
for (let i = 0; i < text.length; i++) {
seed += text.charCodeAt(i) * (i + 1)
}
seed += sealSize.value
return (min: number = 0, max: number = 1) => {
seed = (seed * 9301 + 49297) % 233280
const value = seed / 233280
return min + value * (max - min)
}
}
// 随机磨损效果
const applyRandomNoise = (ctx: CanvasRenderingContext2D, width: number, height: number, intensity: number) => {
const random = getRandomGenerator()
// 基础斑点数量 + 随强度增加的斑点
const baseSpots = 8 + Math.floor(intensity * 10) // 基础8-18个斑点
const additionalSpots = Math.floor(intensity * 50) + Math.floor(intensity * intensity * 80) // 大幅增加斑点数量
const totalSpots = baseSpots + additionalSpots
for (let i = 0; i < totalSpots; i++) {
const centerX = random(0, width)
const centerY = random(0, height)
// 斑点大小:基础大小 + 随强度增长
const baseSize = random(0.8, 3) // 基础随机大小
const growthFactor = i < baseSpots ? 1 : (1 + intensity * 3) // 后期增加的斑点更大
const size = baseSize * growthFactor + (intensity * 5) + (intensity * intensity * 8) // 增加大小增长幅度
// 随机透明度:基础透明度 + 随机变化
const baseAlpha = 0.5 + intensity * 0.4 // 提高基础透明度
const randomAlpha = baseAlpha + random(-0.15, 0.25) // 添加随机变化
const finalAlpha = Math.max(0.3, Math.min(0.95, randomAlpha)) // 提高透明度范围
// 创建白色径向渐变斑点(参考污渍效果)
const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, size)
gradient.addColorStop(0, `rgba(255, 255, 255, ${finalAlpha})`)
gradient.addColorStop(0.6, `rgba(255, 255, 255, ${finalAlpha * 0.5})`)
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)')
ctx.fillStyle = gradient
ctx.beginPath()
ctx.arc(centerX, centerY, size, 0, Math.PI * 2)
ctx.fill()
}
}
// 裂纹效果
const applyCracks = (ctx: CanvasRenderingContext2D, width: number, height: number, intensity: number) => {
const random = getRandomGenerator()
const crackCount = Math.floor(intensity * 8) + 3 // 增加裂纹数量
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)' // 白色半透明裂纹
ctx.lineCap = 'round'
for (let i = 0; i < crackCount; i++) {
const startX = random(width * 0.1, width * 0.9) // 扩大范围
const startY = random(height * 0.1, height * 0.9)
const angle = random(0, Math.PI * 2)
const length = random(30, 120) * intensity // 增加长度
const segments = Math.floor(length / 5)
// 裂纹宽度:线性增长 + 平方增长
const baseWidth = random(0.8, 1.5)
ctx.lineWidth = baseWidth + (intensity * 2) + (intensity * intensity * 4) // 更明显的增长
ctx.beginPath()
ctx.moveTo(startX, startY)
let currentX = startX
let currentY = startY
for (let j = 0; j < segments; j++) {
const deviation = (random(-1, 1) * 10) * intensity
const segmentAngle = angle + deviation * Math.PI / 180
const segmentLength = length / segments
currentX += Math.cos(segmentAngle) * segmentLength
currentY += Math.sin(segmentAngle) * segmentLength
ctx.lineTo(currentX, currentY)
}
ctx.stroke()
}
}
// 边缘磨损效果
const applyEdgeWear = (ctx: CanvasRenderingContext2D, width: number, height: number, intensity: number) => {
const random = getRandomGenerator()
const centerX = width / 2
const centerY = height / 2
const radius = Math.min(width, height) * 0.45
// 在边缘附近生成磨损点
const wearCount = Math.floor(radius * intensity * 0.8) + 30 // 增加磨损点数量
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)' // 白色半透明磨损点
for (let i = 0; i < wearCount; i++) {
const angle = random(0, Math.PI * 2)
const distance = radius + random(-20, 8) // 扩大磨损范围
const x = centerX + Math.cos(angle) * distance
const y = centerY + Math.sin(angle) * distance
// 磨损点大小:线性增长 + 平方增长
const baseSize = random(0.8, 1.8)
const size = baseSize + (intensity * 2.5) + (intensity * intensity * 3) // 更明显的增长
ctx.beginPath()
ctx.arc(x, y, size, 0, Math.PI * 2)
ctx.fill()
}
}
// 块状缺失效果
const applyChunks = (ctx: CanvasRenderingContext2D, width: number, height: number, intensity: number) => {
const random = getRandomGenerator()
const chunkCount = Math.floor(intensity * 10) + 5 // 增加块状数量
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)' // 白色半透明块状
for (let i = 0; i < chunkCount; i++) {
const centerX = random(width * 0.05, width * 0.95) // 扩大范围
const centerY = random(height * 0.05, height * 0.95)
// 块状大小:线性增长 + 平方增长
const baseSize = random(2, 5)
const size = baseSize + (intensity * 8) + (intensity * intensity * 15) // 更明显的增长
// 绘制不规则块状
ctx.beginPath()
const points = 6 + Math.floor(random(0, 4))
for (let j = 0; j < points; j++) {
const angle = (j / points) * Math.PI * 2
const radius = size * (0.7 + random(0, 0.6))
const x = centerX + Math.cos(angle) * radius
const y = centerY + Math.sin(angle) * radius
if (j === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
}
ctx.closePath()
ctx.fill()
}
}
// 污渍斑点效果
const applyStains = (ctx: CanvasRenderingContext2D, width: number, height: number, intensity: number) => {
const random = getRandomGenerator()
const stainCount = Math.floor(intensity * 20) + 8 // 增加污渍数量
for (let i = 0; i < stainCount; i++) {
const centerX = random(0, width)
const centerY = random(0, height)
// 污渍大小:线性增长 + 平方增长
const baseSize = random(1.5, 4)
const size = baseSize + (intensity * 6) + (intensity * intensity * 10) // 更明显的增长
// 创建白色径向渐变污渍
const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, size)
gradient.addColorStop(0, `rgba(255, 255, 255, ${0.7 + intensity * 0.3})`)
gradient.addColorStop(0.7, `rgba(255, 255, 255, ${0.3 + intensity * 0.2})`)
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)')
ctx.fillStyle = gradient
ctx.beginPath()
ctx.arc(centerX, centerY, size, 0, Math.PI * 2)
ctx.fill()
}
}
// 下载印章
const downloadSeal = async () => {
try {
// 创建临时画布用于导出
const tempCanvas = document.createElement('canvas')
tempCanvas.width = canvasWidth.value
tempCanvas.height = canvasHeight.value
const tempCtx = tempCanvas.getContext('2d')
if (!tempCtx) return
// 如果不是透明导出,先绘制背景
if (!exportTransparent.value) {
tempCtx.fillStyle = backgroundColor.value
tempCtx.fillRect(0, 0, canvasWidth.value, canvasHeight.value)
}
// 绘制印章内容
tempCtx.strokeStyle = sealColor.value
tempCtx.fillStyle = sealColor.value
// 根据印章类型绘制
if (sealType.value.startsWith('company')) {
drawCompanySeal(tempCtx)
} else {
drawPersonalSeal(tempCtx)
}
// 应用破损效果
if (damageStyle.value !== 'none' && wearLevel.value > 0) {
applyDamageEffect(tempCtx)
}
// 生成下载链接
const link = document.createElement('a')
const backgroundSuffix = exportTransparent.value ? 'transparent' : 'background'
const damageSuffix = damageStyle.value !== 'none' && wearLevel.value > 0 ? `-${damageStyle.value}${wearLevel.value}` : ''
const namePrefix = mainText.value.trim() || centerText.value.trim() || 'custom'
link.download = `seal-${namePrefix}-${backgroundSuffix}${damageSuffix}-${Date.now()}.png`
link.href = tempCanvas.toDataURL('image/png')
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} catch (error) {
console.error('下载失败:', error)
}
}
// 清空所有内容
const clearAll = () => {
mainText.value = ''
centerText.value = '印'
headerText.value = ''
subText.value = ''
personalHeight.value = 1.0
// 重置所有字体样式
mainFontWeight.value = false
mainFontItalic.value = false
mainFontFamily.value = 'SimSun'
centerFontWeight.value = false
centerFontItalic.value = false
centerFontFamily.value = 'SimSun'
headerFontWeight.value = false
headerFontItalic.value = false
headerFontFamily.value = 'SimSun'
subFontWeight.value = false
subFontItalic.value = false
subFontFamily.value = 'SimSun'
subTextCurved.value = false
subTextSpacing.value = 1.0
subTextMargin.value = 0
centerShape.value = 'star'
centerShapeSize.value = 15
exportTransparent.value = false
wearLevel.value = 0
damageStyle.value = 'none'
// 清空后重新绘制
drawSeal()
}
// 监听所有参数变化,实时重新绘制
watch([sealType, sealSize, canvasWidth, canvasHeight, personalHeight,
mainFontWeight, mainFontItalic, mainFontFamily,
centerFontWeight, centerFontItalic, centerFontFamily,
headerFontWeight, headerFontItalic, headerFontFamily,
subFontWeight, subFontItalic, subFontFamily, subTextCurved, subTextSpacing, subTextMargin,
sealColor, backgroundColor, mainText, centerText, headerText, subText,
mainFontSize, centerFontSize, headerFontSize, subFontSize,
outerBorderWidth, innerBorderWidth, showStar, centerShape, centerShapeSize, textSpacing, textMargin, wearLevel, damageStyle], () => {
drawSeal()
}, { deep: true })
// 组件挂载时绘制初始印章
onMounted(() => {
drawSeal()
// 延迟启用fixed定位避免页面加载时的闪烁
setTimeout(() => {
isPreviewFixed.value = true
}, 100)
})
</script>
<style scoped>
.slider {
@apply appearance-none bg-gray-200 dark:bg-gray-700 rounded-lg h-2 outline-none;
}
.slider::-webkit-slider-thumb {
@apply appearance-none w-4 h-4 rounded-full cursor-pointer;
background-color: rgb(99, 102, 241);
}
.slider::-moz-range-thumb {
@apply w-4 h-4 rounded-full cursor-pointer border-none;
background-color: rgb(99, 102, 241);
}
</style>