feat: restore learning feedback and knowledge capabilities

This commit is contained in:
2026-07-18 00:50:11 +08:00
parent 876a23a123
commit 6bdc86e3fc
152 changed files with 30337 additions and 953 deletions
@@ -0,0 +1,128 @@
import type { SummaryCardResponse } from '@/types/api';
const WIDTH = 1080;
const PADDING = 84;
const CONTENT_WIDTH = WIDTH - PADDING * 2;
const safeFileName = (value: string) =>
(value || 'SOP总结卡').replace(/[\\/:*?"<>|\r\n]+/g, '-').slice(0, 48);
const wrapText = (context: CanvasRenderingContext2D, text: string, maxWidth: number) => {
const lines: string[] = [];
let current = '';
for (const char of String(text || '').trim()) {
const next = current + char;
if (current && context.measureText(next).width > maxWidth) {
lines.push(current);
current = char;
} else {
current = next;
}
}
if (current) lines.push(current);
return lines.length ? lines : [''];
};
export const downloadSummaryCardImage = async (card: SummaryCardResponse) => {
if (typeof document === 'undefined' || typeof URL === 'undefined') {
throw new Error('当前终端暂不支持下载图片');
}
if (document.fonts?.ready) await document.fonts.ready;
const canvas = document.createElement('canvas');
canvas.width = WIDTH;
canvas.height = 1;
const measureContext = canvas.getContext('2d');
if (!measureContext) throw new Error('图片生成失败');
measureContext.font = '800 56px system-ui, sans-serif';
const titleLines = wrapText(measureContext, card.title || '工作总结卡', CONTENT_WIDTH);
const sectionRows = [
['核心步骤', (card.steps || []).map((item) => `${item.label}:${item.desc}`)],
['推荐话术', card.scripts || []],
['常见异议与回应', (card.objections || []).map((item) => `${item.concern};回应:${item.response}`)],
['师傅提醒', card.reminders || []]
] as const;
measureContext.font = '400 28px system-ui, sans-serif';
const sections = sectionRows
.filter(([, rows]) => rows.length)
.map(([title, rows]) => ({
title,
rows: rows.map((row) => wrapText(measureContext, row, CONTENT_WIDTH - 64))
}));
let measuredY = 118 + 80 + titleLines.length * 76 + 14 + 70;
sections.forEach((section) => {
measuredY += 48;
section.rows.forEach((lines) => {
measuredY += Math.max(54, lines.length * 44 + 18);
});
measuredY += 36;
});
const height = Math.max(720, measuredY + 100);
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) throw new Error('图片生成失败');
context.fillStyle = '#f7f9fc';
context.fillRect(0, 0, WIDTH, height);
context.fillStyle = '#ffffff';
context.fillRect(36, 36, WIDTH - 72, height - 72);
let y = 118;
context.fillStyle = '#e60012';
context.font = '700 28px system-ui, sans-serif';
context.fillText('银城数字师傅 · SOP 总结卡', PADDING, y);
y += 80;
context.fillStyle = '#172033';
context.font = '800 56px system-ui, sans-serif';
titleLines.forEach((line) => {
context.fillText(line, PADDING, y);
y += 76;
});
y += 14;
context.fillStyle = '#e60012';
context.fillRect(PADDING, y, 88, 8);
y += 70;
sections.forEach((section) => {
context.fillStyle = '#172033';
context.font = '800 34px system-ui, sans-serif';
context.fillText(section.title, PADDING, y);
y += 48;
section.rows.forEach((lines, index) => {
context.fillStyle = '#fff0f1';
context.beginPath();
context.arc(PADDING + 18, y + 19, 18, 0, Math.PI * 2);
context.fill();
context.fillStyle = '#e60012';
context.font = '800 20px system-ui, sans-serif';
context.textAlign = 'center';
context.fillText(String(index + 1), PADDING + 18, y + 27);
context.textAlign = 'left';
context.fillStyle = '#3f4a5c';
context.font = '400 28px system-ui, sans-serif';
lines.forEach((line, lineIndex) => context.fillText(line, PADDING + 56, y + 28 + lineIndex * 44));
y += Math.max(54, lines.length * 44 + 18);
});
y += 36;
});
context.fillStyle = '#8a94a5';
context.font = '400 22px system-ui, sans-serif';
context.fillText('内容由数字师傅基于已授权 SOP 生成,请结合现场情况执行。', PADDING, y + 10);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((value) => value ? resolve(value) : reject(new Error('图片生成失败')), 'image/png');
});
canvas.width = 1;
canvas.height = 1;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${safeFileName(card.title)}.png`;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
};