feat: 历史记录增强+IPC响应式Proxy修复+水印异常处理
This commit is contained in:
+66
-16
@@ -43,8 +43,31 @@ function getDb() {
|
||||
ai_score REAL,
|
||||
teacher_score REAL,
|
||||
confidence REAL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
question_title TEXT DEFAULT '',
|
||||
max_score REAL DEFAULT 100,
|
||||
reference_answer TEXT DEFAULT '',
|
||||
rubric TEXT DEFAULT '',
|
||||
deductions TEXT DEFAULT '',
|
||||
comment TEXT DEFAULT '',
|
||||
result_json TEXT DEFAULT '',
|
||||
image_data TEXT DEFAULT ''
|
||||
)`).run()
|
||||
// migrate: add columns for existing DBs
|
||||
const migrateCols: { name: string; type: string }[] = [
|
||||
{ name: 'question_title', type: 'TEXT DEFAULT \'\'' },
|
||||
{ name: 'max_score', type: 'REAL DEFAULT 100' },
|
||||
{ name: 'reference_answer', type: 'TEXT DEFAULT \'\'' },
|
||||
{ name: 'rubric', type: 'TEXT DEFAULT \'\'' },
|
||||
{ name: 'deductions', type: 'TEXT DEFAULT \'\'' },
|
||||
{ name: 'comment', type: 'TEXT DEFAULT \'\'' },
|
||||
{ name: 'result_json', type: 'TEXT DEFAULT \'\'' },
|
||||
{ name: 'image_data', type: 'TEXT DEFAULT \'\'' },
|
||||
{ name: 'reference_answer_image', type: 'TEXT DEFAULT \'\'' }
|
||||
]
|
||||
for (const col of migrateCols) {
|
||||
try { db.prepare(`ALTER TABLE history ADD COLUMN ${col.name} ${col.type}`).run() } catch (_) {}
|
||||
}
|
||||
db.prepare(`CREATE TABLE IF NOT EXISTS exam (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
@@ -180,6 +203,15 @@ export function setupIpc(win: BrowserWindow): void {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('history:getById', (_event, id: number) => {
|
||||
try {
|
||||
const record = getDb().prepare('SELECT * FROM history WHERE id = ?').get(id) as any
|
||||
return record || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('grading:submit', (_event, data: {
|
||||
questionId: number
|
||||
imagePath: string
|
||||
@@ -187,22 +219,40 @@ export function setupIpc(win: BrowserWindow): void {
|
||||
teacherScore: number
|
||||
confidence: number
|
||||
studentAnswer?: string
|
||||
questionTitle?: string
|
||||
maxScore?: number
|
||||
referenceAnswer?: string
|
||||
rubric?: string
|
||||
deductions?: string[]
|
||||
comment?: string
|
||||
resultJson?: string
|
||||
imageData?: string
|
||||
referenceAnswerImage?: string
|
||||
}) => {
|
||||
try {
|
||||
getDb().prepare(`INSERT INTO history (question_id, image_path, student_answer, ai_score, teacher_score, confidence)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`).run(
|
||||
data.questionId || 0,
|
||||
data.imagePath || '',
|
||||
data.studentAnswer || '',
|
||||
data.aiScore,
|
||||
data.teacherScore,
|
||||
data.confidence
|
||||
)
|
||||
return true
|
||||
} catch (err) {
|
||||
log.error('grading:submit error:', err)
|
||||
return false
|
||||
}
|
||||
log.info('grading:submit called', JSON.stringify({ ...data, imageData: data.imageData ? `len=${data.imageData.length}` : 'empty', referenceAnswerImage: data.referenceAnswerImage ? `len=${data.referenceAnswerImage.length}` : 'empty' }))
|
||||
const deductionsStr = JSON.stringify(data.deductions || [])
|
||||
getDb().prepare(`INSERT INTO history
|
||||
(question_id, image_path, student_answer, ai_score, teacher_score, confidence,
|
||||
question_title, max_score, reference_answer, rubric, deductions, comment, result_json, image_data,
|
||||
reference_answer_image)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
data.questionId || 0,
|
||||
data.imagePath || '',
|
||||
data.studentAnswer || '',
|
||||
data.aiScore,
|
||||
data.teacherScore,
|
||||
data.confidence,
|
||||
data.questionTitle || '',
|
||||
data.maxScore ?? 100,
|
||||
data.referenceAnswer || '',
|
||||
data.rubric || '',
|
||||
deductionsStr,
|
||||
data.comment || '',
|
||||
data.resultJson || '',
|
||||
data.imageData || '',
|
||||
data.referenceAnswerImage || ''
|
||||
)
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('template:list', () => {
|
||||
|
||||
@@ -33,7 +33,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
history: {
|
||||
list: () => ipcRenderer.invoke('history:list'),
|
||||
delete: (id: number) => ipcRenderer.invoke('history:delete', id)
|
||||
delete: (id: number) => ipcRenderer.invoke('history:delete', id),
|
||||
getById: (id: number) => ipcRenderer.invoke('history:getById', id)
|
||||
},
|
||||
|
||||
grading: {
|
||||
|
||||
@@ -76,8 +76,12 @@ onMounted(() => {
|
||||
if (window.electronAPI) {
|
||||
cleanupScreenshotListener = window.electronAPI.screenshot.onCompleted(async (base64: string) => {
|
||||
if (!appStore.isGrading) {
|
||||
gradingStore.setImage(await addWatermark(base64, '学生答案'))
|
||||
startGrading()
|
||||
try {
|
||||
gradingStore.setImage(await addWatermark(base64, '学生答案'))
|
||||
startGrading()
|
||||
} catch (err) {
|
||||
ElMessage.error('水印处理失败:' + (err as Error).message)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -95,7 +99,12 @@ async function selectImage() {
|
||||
if (!window.electronAPI) return
|
||||
const data = await window.electronAPI.dialog.openImage()
|
||||
if (data) {
|
||||
gradingStore.setImage(await addWatermark(data.base64, '学生答案'))
|
||||
try {
|
||||
gradingStore.setImage(await addWatermark(data.base64, '学生答案'))
|
||||
} catch (err) {
|
||||
ElMessage.error('水印处理失败:' + (err as Error).message)
|
||||
return
|
||||
}
|
||||
startGrading()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<el-button text size="small" @click="closeDrawer">✕</el-button>
|
||||
</div>
|
||||
|
||||
<div class="error-message" v-if="errorMsg" style="padding: 12px 16px;">{{ errorMsg }}</div>
|
||||
<div class="drawer-body" v-if="gradingStore.currentResult">
|
||||
<div class="error-message" v-if="errorMsg">{{ errorMsg }}</div>
|
||||
<div class="question-nav" v-if="questions.length > 1">
|
||||
<el-button size="small" @click="prevQuestion">上一题</el-button>
|
||||
<span class="question-indicator">{{ currentIdx + 1 }} / {{ questions.length }}</span>
|
||||
@@ -187,14 +187,25 @@ async function prevQuestion() {
|
||||
|
||||
async function saveAndAdvance(targetIdx: number) {
|
||||
try {
|
||||
await window.electronAPI.grading.submit({
|
||||
const result = gradingStore.currentResult
|
||||
const raw = {
|
||||
questionId: 0,
|
||||
imagePath: '',
|
||||
aiScore: gradingStore.currentResult?.score ?? 0,
|
||||
aiScore: result?.score ?? 0,
|
||||
teacherScore: teacherScore.value,
|
||||
confidence: gradingStore.currentResult?.confidence ?? 0,
|
||||
studentAnswer: ''
|
||||
})
|
||||
confidence: result?.confidence ?? 0,
|
||||
studentAnswer: '',
|
||||
questionTitle: currentQuestion.value.questionTitle,
|
||||
maxScore: currentQuestion.value.maxScore,
|
||||
referenceAnswer: currentQuestion.value.referenceAnswer,
|
||||
rubric: currentQuestion.value.rubric,
|
||||
deductions: result?.deductions,
|
||||
comment: result?.comment,
|
||||
resultJson: JSON.stringify(result),
|
||||
imageData: gradingStore.currentImage || '',
|
||||
referenceAnswerImage: currentQuestion.value.referenceAnswerImage || ''
|
||||
}
|
||||
await window.electronAPI.grading.submit(JSON.parse(JSON.stringify(raw)))
|
||||
await window.electronAPI?.rubric.setNextIndex(targetIdx)
|
||||
gradingStore.reset()
|
||||
closeDrawer()
|
||||
@@ -208,23 +219,40 @@ async function reGrade() {
|
||||
}
|
||||
|
||||
async function confirmScore() {
|
||||
errorMsg.value = ''
|
||||
const result = gradingStore.currentResult
|
||||
if (!result) return
|
||||
if (!result) {
|
||||
errorMsg.value = '暂无评分结果可保存(currentResult 为空)'
|
||||
return
|
||||
}
|
||||
if (!window.electronAPI?.grading?.submit) {
|
||||
errorMsg.value = 'electronAPI.grading.submit 不可用'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electronAPI.grading.submit({
|
||||
const raw = {
|
||||
questionId: 0,
|
||||
imagePath: '',
|
||||
aiScore: result.score,
|
||||
teacherScore: teacherScore.value,
|
||||
confidence: result.confidence,
|
||||
studentAnswer: ''
|
||||
})
|
||||
ElMessage.success('评分已保存')
|
||||
studentAnswer: '',
|
||||
questionTitle: currentQuestion.value.questionTitle,
|
||||
maxScore: currentQuestion.value.maxScore,
|
||||
referenceAnswer: currentQuestion.value.referenceAnswer,
|
||||
rubric: currentQuestion.value.rubric,
|
||||
deductions: result.deductions,
|
||||
comment: result.comment,
|
||||
resultJson: JSON.stringify(result),
|
||||
imageData: gradingStore.currentImage || '',
|
||||
referenceAnswerImage: currentQuestion.value.referenceAnswerImage || ''
|
||||
}
|
||||
await window.electronAPI.grading.submit(JSON.parse(JSON.stringify(raw)))
|
||||
gradingStore.reset()
|
||||
closeDrawer()
|
||||
} catch (err) {
|
||||
ElMessage.error('保存失败:' + (err as Error).message)
|
||||
errorMsg.value = '保存失败:' + (err as Error).message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
+238
-7
@@ -2,17 +2,18 @@
|
||||
<el-dialog v-model="visible" title="历史记录" width="800px" fullscreen :modal="false" @close="handleClose">
|
||||
<el-table :data="records" style="width: 100%" size="small" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="ai_score" label="AI分数" width="80" />
|
||||
<el-table-column prop="teacher_score" label="教师分数" width="80" />
|
||||
<el-table-column prop="confidence" label="可信度" width="80">
|
||||
<el-table-column prop="ai_score" label="AI分" width="70" />
|
||||
<el-table-column prop="teacher_score" label="教师分" width="70" />
|
||||
<el-table-column prop="confidence" label="可信度" width="70">
|
||||
<template #default="{ row }">
|
||||
{{ Math.round(row.confidence * 100) }}%
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="student_answer" label="答案摘要" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="created_at" label="时间" width="160" />
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<el-table-column prop="question_title" label="题目" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="created_at" label="时间" width="150" />
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text size="small" type="primary" @click="viewResult(row)">查看</el-button>
|
||||
<el-button text size="small" type="danger" @click="deleteRecord(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -23,10 +24,75 @@
|
||||
<el-button size="small" @click="refresh">刷新</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<div v-if="detailVisible" class="history-result-overlay" @click.self="detailVisible = false">
|
||||
<div class="history-result-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">评分详情</span>
|
||||
<el-button text size="small" @click="detailVisible = false">✕</el-button>
|
||||
</div>
|
||||
<div class="panel-body" v-if="detail">
|
||||
<div class="question-title" v-if="detail.question_title">
|
||||
{{ detail.question_title }}
|
||||
</div>
|
||||
<div class="question-maxscore" v-if="detail.max_score">
|
||||
满分:{{ detail.max_score }}
|
||||
</div>
|
||||
|
||||
<div class="result-section" v-if="detail.rubric">
|
||||
<div class="section-title">当前评分标准</div>
|
||||
<div class="rubric-text">{{ detail.rubric }}</div>
|
||||
</div>
|
||||
|
||||
<div class="result-section" v-if="detail.image_data">
|
||||
<div class="section-title">学生答案图片</div>
|
||||
<img class="result-image" :src="'data:image/png;base64,' + detail.image_data" />
|
||||
</div>
|
||||
|
||||
<div class="result-section" v-if="detail.reference_answer || detail.reference_answer_image">
|
||||
<div class="section-title">参考答案</div>
|
||||
<div class="reference-text" v-if="detail.reference_answer">{{ detail.reference_answer }}</div>
|
||||
<img v-if="detail.reference_answer_image" class="result-image" :src="detail.reference_answer_image" />
|
||||
</div>
|
||||
|
||||
<div class="result-score">
|
||||
<div class="score-value">{{ detail.ai_score }}</div>
|
||||
<div class="score-label">建议分数</div>
|
||||
</div>
|
||||
|
||||
<div class="result-confidence">
|
||||
<div class="confidence-bar">
|
||||
<div class="confidence-fill" :style="{ width: Math.round(detail.confidence * 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="confidence-text">可信度:{{ Math.round(detail.confidence * 100) }}%</div>
|
||||
</div>
|
||||
|
||||
<div class="result-section" v-if="deductionsList.length">
|
||||
<div class="section-title">扣分项</div>
|
||||
<div class="deduction-item" v-for="(item, idx) in deductionsList" :key="idx">
|
||||
{{ idx + 1 }}. {{ item }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-section" v-if="detail.comment">
|
||||
<div class="section-title">评语</div>
|
||||
<div class="comment-text">{{ detail.comment }}</div>
|
||||
</div>
|
||||
|
||||
<div class="result-section">
|
||||
<div class="section-title">教师评分</div>
|
||||
<div class="teacher-score-display">{{ detail.teacher_score }} / {{ detail.max_score || 100 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<el-button size="small" @click="detailVisible = false">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import type { HistoryRecord } from '@/types'
|
||||
@@ -36,6 +102,13 @@ const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const records = ref<HistoryRecord[]>([])
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detail = ref<HistoryRecord | null>(null)
|
||||
const deductionsList = computed(() => {
|
||||
if (!detail.value?.deductions) return []
|
||||
try { return JSON.parse(detail.value.deductions) } catch { return [] }
|
||||
})
|
||||
|
||||
watch(() => appStore.isHistoryDialogOpen, (val) => {
|
||||
visible.value = val
|
||||
if (val) refresh()
|
||||
@@ -54,6 +127,21 @@ async function refresh() {
|
||||
}
|
||||
}
|
||||
|
||||
async function viewResult(row: HistoryRecord) {
|
||||
if (!window.electronAPI) return
|
||||
try {
|
||||
const full = await window.electronAPI.history.getById(row.id)
|
||||
if (full) {
|
||||
detail.value = full
|
||||
detailVisible.value = true
|
||||
} else {
|
||||
ElMessage.warning('未找到记录详情')
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.error('加载详情失败:' + (err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecord(id: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该记录?', '提示')
|
||||
@@ -69,3 +157,146 @@ function handleClose() {
|
||||
window.electronAPI?.dialog.close()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.history-result-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99998;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.history-result-panel {
|
||||
width: 480px;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.panel-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.question-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.question-maxscore {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.result-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #909399;
|
||||
}
|
||||
.rubric-text {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
background: #fff7e6;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.reference-text {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
background: #f0f9eb;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.result-image {
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
.result-score {
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.score-value {
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
color: #409EFF;
|
||||
line-height: 1;
|
||||
}
|
||||
.score-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.result-confidence {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.confidence-bar {
|
||||
height: 6px;
|
||||
background: #e4e7ed;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.confidence-fill {
|
||||
height: 100%;
|
||||
background: #67c23a;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.confidence-text {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
text-align: right;
|
||||
}
|
||||
.deduction-item {
|
||||
font-size: 13px;
|
||||
color: #f56c6c;
|
||||
line-height: 1.6;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.comment-text {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
background: #f5f7fa;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.teacher-score-display {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #409EFF;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -41,6 +41,15 @@ export interface HistoryRecord {
|
||||
teacher_score: number
|
||||
confidence: number
|
||||
created_at: string
|
||||
question_title?: string
|
||||
max_score?: number
|
||||
reference_answer?: string
|
||||
rubric?: string
|
||||
deductions?: string
|
||||
comment?: string
|
||||
result_json?: string
|
||||
image_data?: string
|
||||
reference_answer_image?: string
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
@@ -67,6 +76,7 @@ export interface ElectronAPI {
|
||||
history: {
|
||||
list: () => Promise<HistoryRecord[]>
|
||||
delete: (id: number) => Promise<boolean>
|
||||
getById: (id: number) => Promise<HistoryRecord | null>
|
||||
}
|
||||
grading: {
|
||||
submit: (data: {
|
||||
@@ -76,6 +86,15 @@ export interface ElectronAPI {
|
||||
teacherScore: number
|
||||
confidence: number
|
||||
studentAnswer?: string
|
||||
questionTitle?: string
|
||||
maxScore?: number
|
||||
referenceAnswer?: string
|
||||
rubric?: string
|
||||
deductions?: string[]
|
||||
comment?: string
|
||||
resultJson?: string
|
||||
imageData?: string
|
||||
referenceAnswerImage?: string
|
||||
}) => Promise<boolean>,
|
||||
storeData: (data: any) => Promise<void>,
|
||||
getData: () => Promise<any>
|
||||
|
||||
+14
-5
@@ -2,16 +2,25 @@ export function addWatermark(base64: string, text: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
let w = img.width
|
||||
let h = img.height
|
||||
const MAX = 2048
|
||||
let scale = 1
|
||||
if (w > MAX || h > MAX) {
|
||||
scale = Math.min(MAX / w, MAX / h, 1)
|
||||
w = Math.round(w * scale)
|
||||
h = Math.round(h * scale)
|
||||
}
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.width
|
||||
canvas.height = img.height
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return reject(new Error('Canvas 2D context not available'))
|
||||
ctx.drawImage(img, 0, 0)
|
||||
ctx.font = `bold ${Math.max(28, Math.round(img.width / 20))}px sans-serif`
|
||||
ctx.drawImage(img, 0, 0, w, h)
|
||||
ctx.font = `bold ${Math.max(28, Math.round(w / 20))}px sans-serif`
|
||||
ctx.fillStyle = 'rgba(255, 0, 0, 0.85)'
|
||||
ctx.textBaseline = 'top'
|
||||
const padding = Math.max(8, Math.round(img.width / 60))
|
||||
const padding = Math.max(8, Math.round(w / 60))
|
||||
ctx.fillText(text, padding, padding)
|
||||
resolve(canvas.toDataURL('image/png').replace(/^data:image\/png;base64,/, ''))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user