import moment from 'moment'; import fs from 'fs'; import { promises as fsPromises } from 'fs'; import commonModel from '../../model/commonModel.js'; import config from '../../config/index.js'; import _ from 'lodash'; import axios from 'axios'; import { Encrypt, Decrypt } from '../../util/crypto/index.js'; import { stringUtils } from '../../util/stringClass.js'; import WXBizDataCrypt from '../../util/WXBizDataCrypt.js'; import { globalCache } from '../../util/GlobalCache.js'; import constantClass from '../../util/constant/index.js'; import yjbdc from '../../model/yjbdc.js'; import aiController from './aiController.js'; import PDFDocument from 'pdfkit'; import tencentcloud from 'tencentcloud-sdk-nodejs-ocr'; const OcrClient = tencentcloud.ocr.v20181119.Client; const ONE_DAY_MAX_BUILD_COUNT=12;//一天最大生成数 //AI生成文章 export async function GenerateArticle(ctx) { const url='GenerateArticle?UserID='+ctx.query.UserID; let result = globalCache.get(url); // 检查是否是队列状态而不是正在处理状态 if (result && result.isQueued) { // 如果是队列状态,继续处理 result = 0; } if (result === 0) { const params = ctx.request.body; const words = params.Words; let articleStyle = params.ArticleStyle; if (words){ //生成中 result = { errcode: 10000, result: "-2" }; globalCache.set(url, result, config.BufferMemoryTime); console.log("生成中,暂停生成60秒"); const menuConfig=constantClass.GetYJBDCGenerateConfig(); //console.log("content:"+content); let level=""; if (!params.Level || params.Level=="" || params.Level==undefined || params.Level>="6"){ params.Level=0; } level=menuConfig.Level[Number(params.Level)].Name; let articleStyleContent=""; for(let i=0;i { // 检查句子是否包含中文字符 if (/[\u4e00-\u9fa5]/.test(sentence)) { chineseSentences.push(sentence); } else { englishSentences.push(sentence); } }); // 更新ArticleEnglish数组,只保留英文句子 enhancedJsonObj.ArticleEnglish = englishSentences; // 如果ArticleChinese不存在或不是数组,则创建它 if (!enhancedJsonObj.ArticleChinese || !Array.isArray(enhancedJsonObj.ArticleChinese)) { enhancedJsonObj.ArticleChinese = []; } // 将中文句子添加到ArticleChinese数组中 chineseSentences.forEach(sentence => { if (!enhancedJsonObj.ArticleChinese.includes(sentence)) { enhancedJsonObj.ArticleChinese.push(sentence); } }); } // 将增强后的对象转回JSON字符串 result2 = JSON.stringify(enhancedJsonObj); //console.log("FormsOfWords已增强,添加了单词变形和拼写错误检测"); // 记录增强后的单词数量 if (enhancedJsonObj.FormsOfWords && Array.isArray(enhancedJsonObj.FormsOfWords)) { console.log(`增强了${enhancedJsonObj.FormsOfWords.length}个单词的变形形式`); } let timeEnd=new Date().getTime(); let param2={}; param2.UserID=ctx.query.UserID; param2.CreateTime=moment().format('YYYY-MM-DD HH:mm:ss'); param2.Words=words; param2.Level=params.Level; param2.articleStyle=articleStyle; param2.AIProvider=aiProvider; param2.BuildStr=content; param2.GenerateTime=Math.round((timeEnd-timeStart)/1000); console.log("生成时间:"+param2.GenerateTime+"秒"); // 尝试解析JSON以获取ArticleStart const jsonObj2 = JSON.parse(result2); param2.ArticleStart = jsonObj2.ArticleEnglish && jsonObj2.ArticleEnglish.length > 0 ? jsonObj2.ArticleEnglish.join("\r\n") : "No content available"; // 去除JSON字符串中的所有换行符(\r\n, \n, \r) param2.JSONString=JSON.stringify(result2.replace(/[\r\n]+/g, '')); param2.Flag=0; let idInsert= await yjbdc.AddArticleInfo(param2); //result = { errcode: 10000, result: result2 }; let result3={}; result3.ID=idInsert.insertId; result3.Content=result2; result3.IsNew=true; result = { errcode: 10000, result: result3 }; globalCache.delete(url); console.log("删除缓存,恢复生成"); } catch(err){ console.error("AI生成错误:"+err); result = { errcode: 10000, result: "-1" }; } } else{ console.log("空单词串"); result = { errcode: 10000, result: "-1" }; } } ctx.body = result; } //小程序登录 export async function YJBDCLogin(ctx) { let param = ctx.request.body; if (param.param) { const paramStr = Decrypt(param.param, config.urlSecrets.aes_key, config.urlSecrets.aes_iv); //console.log("paramStr:"+paramStr); param = JSON.parse(paramStr); } const code = param.Code; //console.log("code:"+code); const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${config.wx.yjbdc_appid}&secret=${config.wx.yjbdc_appsecret}&js_code=${code}&grant_type=authorization_code`; let result = await axios.get(url) .then(res => { const json = res.data; //console.log("json:"+json); if (json && json.openid) { param.OpenID = json.openid; param.sessionKey = json.session_key; if (json.unionid) param.UnionID = json.unionid; return {errcode: 10000}; } else { return json; } }) .catch(err => { return {errcode: 101, errStr: err}; }); if (result.errcode == 10000) { delete param.Code; if (param.sessionKey && param.iv && param.encryptedData){ //console.log("param.sessionKey:"+param.sessionKey); const pc = new WXBizDataCrypt(config.wx.yjbdc_appid, param.sessionKey); const dataUnionID = pc.decryptData(param.encryptedData , param.iv); //console.log(dataUnionID); param.UnionID = dataUnionID.unionId; } delete param.sessionKey; delete param.iv; delete param.encryptedData; //todo //param.OpenID="o4UHq4gaNlHfdTWxgl3fTgC1mFsI"; let userList = await yjbdc.GetUsersInfo(param); if (userList.length > 0) { param.LastLoginTime = new Date(); const time1 = moment(userList[0].ProductServiceTime).format('YYYY-MM-DD HH:mm:ss'); const time3 = moment().format('YYYY-MM-DD HH:mm:ss'); if (time1 < time3) param.IsMember = 0; delete param.Introducer; delete param.UserSource; delete param.SourceID; //console.log(param.NickName); if (param.NickName == "陌生用户") { delete param.NickName; delete param.AvatarUrl; delete param.Language; delete param.Gender; delete param.City; delete param.Province; delete param.Country; } await yjbdc.UpdateUsers(param); userList = await yjbdc.GetUsersInfo(param); } else { param.NickName = "陌生用户"; param.AvatarUrl = "../images/userface_default.png"; param.CreateTime = new Date(); param.LastLoginTime = param.CreateTime; param.ProductServiceTime = param.CreateTime; const inseredID = await yjbdc.AddUsers(param); userList = await yjbdc.GetUsersInfo(param); } delete userList[0].OpenID; delete userList[0].UnionID; //产品支付是否显示 if (param.ProgramVersion) { let param2 = { ProgramID: 186, Version: param.ProgramVersion, }; let result3 = await commonModel.GetProductVersionList(param2); if (result3) { if ((param2.Version == result3[0].Version && result3[0].IsShowPay <= 0) || param2.Version > result3[0].Version) { userList[0].IsShow = result3[0].IsShowPay; } else { userList[0].IsShow = 1; } //针对iphone测试用户,永远是无支付状态 if (userList[0].Brand == 'iPhone' && userList[0].WXLanguage == 'en-US' && userList[0].UserSource == '1001' && userList[0].IsPay == 0) { userList[0].IsShow = 0; } //针对微信测试用户,永远是无支付状态 if ((userList[0].UserSource=='1001' && userList[0].System=="iOS 10.0.1") || (!userList[0].UserSource && (!userList[0].LastUserSource || userList[0].LastUserSource>10000)) || userList[0].NickName.indexOf("dgztest")>=0){ userList[0].IsShow=-1; } if (userList[0].IsMember===1) userList[0].IsShow=1; } } result = {errcode: 10000, result: userList[0]}; } ctx.body = result; } //OCR获取单词 export async function OCRImageData(ctx) { const params = ctx.request.body; const clientConfig = { credential: { secretId: config.tencentcloud.secretId, secretKey: config.tencentcloud.secretKey, }, region: "ap-guangzhou", profile: { httpProfile: { endpoint: "ocr.tencentcloudapi.com", }, }, }; // 实例化要请求产品的client对象,clientProfile是可选的 const client = new OcrClient(clientConfig); const result = await client.GeneralBasicOCR(params); let param2={}; param2.UserID=ctx.query.UserID; param2.CreateTime=moment().format('YYYY-MM-DD HH:mm:ss'); param2.Params=JSON.stringify(params); param2.JSONString=JSON.stringify(result); await yjbdc.AddOCRInfo(param2); ctx.body = {"errcode": 10000, result}; } export async function GetYJBDCGenerateConfig(ctx) { const param = { UserID: ctx.query.UserID || 0, }; let result=constantClass.GetYJBDCGenerateConfig(); for (let i=0;i3){ result.AIVersion.splice(2,result.AIVersion.length-2); if (param.UserID==185){ const configArr=constantClass.GetYJBDCGenerateConfig(); result.AIVersion.push(configArr.AIVersion[5]); } // if (param.UserID<4){ // const configArr=constantClass.GetYJBDCGenerateConfig(); // result.AIVersion.push(configArr.AIVersion[6]); // } } ctx.body = {"errcode": 10000, result:result}; } //获得文章列表或具体文章 export async function GetYJBDCArticleList(ctx) { const param = { UserID: ctx.query.UserID || 0, ID:ctx.query.ID || 0, IsFine:ctx.query.IsFine || 0,//是否是精选文章 IsTodayCount: ctx.query.IsTodayCount || false, IsNew: ctx.query.IsNew || 0, PageID: ctx.query.PageID || 999999, PageCount: ctx.query.PageCount || 10, }; if (param.IsFine) param.PageCount=9999; // 尝试从缓存获取 const url='GetYJBDCArticleList?IsFine='+param.IsFine+'&UserID='+param.UserID+'&ID='+param.ID; let result = globalCache.get(url); if (param.IsNew==1) result=0; if (result === 0) { result = await yjbdc.GetYJBDCArticleList(param); let menuConfig=constantClass.GetYJBDCGenerateConfig(); // 随机选择三个不重复的索引用于今日推荐 let recommendIndices = []; if(result.length > 3) { while(recommendIndices.length < 3) { const randomIndex = Math.floor(Math.random() * result.length); if(!recommendIndices.includes(randomIndex)) { recommendIndices.push(randomIndex); } } } else { // 如果结果少于3个,全部标记为推荐 for(let i = 0; i < result.length; i++) { recommendIndices.push(i); } } for(let i=0;i=today) count++; if (result[i].Flag==0 && result[i].ReadCount==0) unReadCount++; } let maxcount=ONE_DAY_MAX_BUILD_COUNT; if (param.UserID<4){ maxcount=100; } result={ TodayCount:count, MaxCount:maxcount, UnReadCount:unReadCount, }; } else if (!param.ID){ let arr=[],count=0,total=0; for(let i=0;iresult[i].ID && count0) result[0].RowsCount=total; } // console.log(result.length); // console.log(result.TodayCount); ctx.body = {"errcode": 10000, result:result}; } //删除生成的文章 export async function DeleteYJBDCArticleList(ctx) { const param = { UserID: ctx.query.UserID || 0, ID: ctx.query.ID || 0, Flag:-1, }; if (param.ID>0){ const url='GetYJBDCArticleList?UserID='+param.UserID+"&ID=0"; globalCache.delete(url); await yjbdc.UpdateYJBDCArticle(param); } ctx.body = {"errcode": 10000}; } // 更新文章数据(目前仅用于网站) export async function UpdateYJBDCArticle(ctx) { const param = ctx.request.body; if (param.ID>0 && param.UserID<12 && param.Source=="web"){ const url='GetYJBDCArticleList?IsFine=0&UserID='+param.UserID+"&ID=0"; globalCache.delete(url); const url2='GetYJBDCArticleList?IsFine=0&UserID='+param.UserID+"&ID="+param.ID; globalCache.delete(url2); delete param.LevelStr; delete param.CreateTime; delete param.IsRecommend; await yjbdc.UpdateYJBDCArticle(param); } ctx.body = {"errcode": 10000}; } //更新阅读数 export async function UpdateYJBDCArticleReadCount(ctx) { const param = { UserID: ctx.query.UserID || 0, ID: ctx.query.ID || 0, ReadCount:1, }; if (param.ID>0){ await yjbdc.UpdateYJBDCArticle(param); } ctx.body = {"errcode": 10000}; } export async function AddOrDeleteYJBDCUserCollect(ctx) { const param = { UserID: ctx.query.UserID || 0, Word: ctx.query.Word || '', CreateTime:moment().format("YYYY-MM-DD HH:mm:ss"), }; let list=await yjbdc.GetYJBDCUserCollect(param); if (list && list.length>0) await yjbdc.DeleteYJBDCUserCollect(param); else await yjbdc.AddYJBDCUserCollect(param); let param2={} param2.UserID=param.UserID; let result=await yjbdc.GetYJBDCUserCollect(param2); ctx.body = {"errcode": 10000,result:result}; } export async function DeleteYJBDCUserCollect(ctx) { const param = { UserID: ctx.query.UserID || 0, WordArr:ctx.query.WordArr || "", }; let arr=param.WordArr.split(","); for(let i=0;i { if (/[\u4E00-\u9FFF]/.test(text)) { return 'ChineseFont'; } return defaultFont; }; // 收集生成的 PDF 数据 const chunks = []; doc.on('data', (chunk) => chunks.push(chunk)); // 像素到点(pt)的转换函数 const pixelToPt = (pixel) => pixel * (595.28 / 2100); // 获取文章内容 let articleText = ""; if (content.ArticleEnglish) { articleText = Array.isArray(content.ArticleEnglish) ? content.ArticleEnglish.join(" ") : content.ArticleEnglish; } else { articleText = "No content available"; } articleText = articleText.replace(/<[^>]*>/g, ''); articleText = articleText.replace(/(^|[,.]\s+)'([^']+)'/g, '$1"$2"'); // 获取单词列表 let words = []; if (content.Words) { words = typeof content.Words === 'string' ? content.Words.split(",") : (Array.isArray(content.Words) ? content.Words : []); } // 获取问题列表 let questions = []; if (content.Question && Array.isArray(content.Question)) { questions = content.Question; } let articleStyleArr=menuConfig.ArticleStyle; let articleStyle="Story"; for(let i=0;i { doc.text(word, pixelToPt(122), wordY, { width: pixelToPt(535), align: 'left' }); wordY += pixelToPt(70); }); // 6. 文章内容 // 先计算文章内容的行数 doc.font('Helvetica'); // 使用48字号计算文本高度 const fontSize48 = pixelToPt(48); doc.fontSize(fontSize48); const textHeight48 = doc.heightOfString(articleText, { width: pixelToPt(1240), lineGap: pixelToPt(40.5) }); // 计算行数 (文本高度 / (字体大小 + 行间距)) const lineHeight = fontSize48 + pixelToPt(40.5); const lineCount = Math.ceil(textHeight48 / lineHeight); // 如果行数超过18行,则使用42字号 const fontSize = lineCount > 18 ? pixelToPt(42) : pixelToPt(48); // 渲染文章内容 doc.fontSize(fontSize) .text(articleText, pixelToPt(740), pixelToPt(105), { width: pixelToPt(1240), lineGap: pixelToPt(40.5) }); // 7. 黑线 const articleEndY = doc.y; const wordListEndY = doc.y; // 假设单词列表的Y坐标 const maxY = Math.max(articleEndY, wordListEndY); // 检查内容是否过长,需要分页 const pageHeight = doc.page.height; const availableHeight = pageHeight - pixelToPt(400); // 预留底部空间 const needsNewPage = articleEndY > availableHeight; if (doc && typeof doc.line === 'function') { doc.line(20, maxY, 200, maxY); } // 如果内容过长,则创建新页面 if (needsNewPage) { // 在第一页底部添加提示 doc.font('Helvetica') .fontSize(pixelToPt(24)) .text("(Continued on next page...)", pixelToPt(740), pageHeight - pixelToPt(100), { align: 'center' }); // 添加新页面 doc.addPage(); // 在新页面顶部添加标题 doc.font(selectFont(articleStyle)) .fontSize(pixelToPt(36)) .text(articleStyle + " (Continued)", pixelToPt(120), pixelToPt(50)); // 在新页面添加黑线 doc.rect(pixelToPt(120), pixelToPt(100), pixelToPt(1860), pixelToPt(10)) .fill('black'); } else { // 如果不需要分页,正常添加黑线 doc.rect(pixelToPt(120), articleEndY + pixelToPt(41), pixelToPt(1860), pixelToPt(10)) .fill('black'); } // 8-13. 问题和答案 if (questions.length > 0) { // 定义问题的X坐标位置 const questionXPositions = [ pixelToPt(120), // 问题1 pixelToPt(120), // 问题2 pixelToPt(740), // 问题3 pixelToPt(740), // 问题4 pixelToPt(1360) // 问题5 ]; // 存储每列最后一个选项的Y坐标位置 let lastOptionYPositions = { column1: 0, // 用于跟踪第一列(问题1)的最后位置 column2: 0, // 用于跟踪第二列(问题3)的最后位置 }; // 确定问题的起始Y坐标 // 如果内容过长需要分页,则在新页面上从固定位置开始 // 否则在文章内容下方开始 const questionsStartY = needsNewPage ? pixelToPt(150) : // 新页面上的起始位置 articleEndY + pixelToPt(130); // 原页面上的起始位置 // 首先渲染问题1、3、5(第一行问题) const firstRowQuestions = [0, 2, 4]; // 问题1、3、5的索引 for (const i of firstRowQuestions) { if (i < questions.length && questions[i]) { const currentX = questionXPositions[i]; const currentY = questionsStartY; // 使用计算出的起始Y坐标 questions[i].QuestionEnglish = questions[i].QuestionEnglish.replace(/(^|[,.]\s+)'([^']+)'/g, '$1"$2"'); // 渲染问题文本 doc.font('Helvetica-Bold') .fontSize(pixelToPt(36)) .text(`${i+1}. ${questions[i].QuestionEnglish || `Question ${i+1}`}`, currentX, currentY, { width: pixelToPt(540), align: 'left' }); // 获取问题文本渲染后的Y坐标位置 const questionEndY = doc.y; // 选项起始位置 = 问题结束位置 + 20像素间距 let optionY = questionEndY + pixelToPt(28); // 渲染选项 const options = questions[i].OptionsEnglish || []; const optionLabels = ['A', 'B', 'C', 'D']; // 设置选项标签宽度和选项总宽度 const labelWidth = pixelToPt(40); // 标签(A./B./C./D.)的宽度 const totalWidth = pixelToPt(496); // 选项的总宽度 const contentWidth = totalWidth - labelWidth; // 选项内容的宽度 options.forEach((opt, index) => { if (index < optionLabels.length) { // 提取选项内容,移除可能存在的标签前缀 let optionContent = opt.replace(/^[A-D]\.\s*/, ''); // 渲染选项标签(A./B./C./D.) doc.font('Helvetica') .fontSize(pixelToPt(36)) .text(`${optionLabels[index]}.`, currentX, optionY, { width: labelWidth, align: 'left' }); // 渲染选项内容,确保折行时与内容第一行对齐 doc.font('Helvetica') .fontSize(pixelToPt(36)) .text(optionContent, currentX + labelWidth, optionY, { width: contentWidth, align: 'left' }); // 更新选项Y坐标,为下一个选项做准备 // 获取当前位置,确保下一个选项在当前选项完全渲染后的位置 optionY = doc.y + pixelToPt(8); } }); // 保存该列最后一个选项的Y坐标 if (i === 0) { lastOptionYPositions.column1 = doc.y; } else if (i === 2) { lastOptionYPositions.column2 = doc.y; } } } // 然后渲染问题2和4(第二行问题) if (questions.length > 1 && questions[1]) { // 问题2位于问题1的选项下方60像素处 const question2Y = lastOptionYPositions.column1 + pixelToPt(75); doc.font('Helvetica-Bold') .fontSize(pixelToPt(36)) .text(`2. ${questions[1].QuestionEnglish || "Question 2"}`, questionXPositions[1], question2Y, { width: pixelToPt(540), align: 'left' }); // 获取问题文本渲染后的Y坐标位置 const questionEndY = doc.y; // 选项起始位置 = 问题结束位置 + 20像素间距 let optionY = questionEndY + pixelToPt(28); // 渲染选项 const options = questions[1].OptionsEnglish || []; const optionLabels = ['A', 'B', 'C', 'D']; // 设置选项标签宽度和选项总宽度 const labelWidth = pixelToPt(40); // 标签(A./B./C./D.)的宽度 const totalWidth = pixelToPt(496); // 选项的总宽度 const contentWidth = totalWidth - labelWidth; // 选项内容的宽度 options.forEach((opt, index) => { if (index < optionLabels.length) { // 提取选项内容,移除可能存在的标签前缀 let optionContent = opt.replace(/^[A-D]\.\s*/, ''); // 渲染选项标签(A./B./C./D.) doc.font('Helvetica') .fontSize(pixelToPt(36)) .text(`${optionLabels[index]}.`, questionXPositions[1], optionY, { width: labelWidth, align: 'left' }); // 渲染选项内容,确保折行时与内容第一行对齐 doc.font('Helvetica') .fontSize(pixelToPt(36)) .text(optionContent, questionXPositions[1] + labelWidth, optionY, { width: contentWidth, align: 'left' }); // 更新选项Y坐标,为下一个选项做准备 optionY = doc.y + pixelToPt(8); } }); } if (questions.length > 3 && questions[3]) { // 问题4位于问题3的选项下方60像素处 const question4Y = lastOptionYPositions.column2 + pixelToPt(75); doc.font('Helvetica-Bold') .fontSize(pixelToPt(36)) .text(`4. ${questions[3].QuestionEnglish || "Question 4"}`, questionXPositions[3], question4Y, { width: pixelToPt(540), align: 'left' }); // 获取问题文本渲染后的Y坐标位置 const questionEndY = doc.y; // 选项起始位置 = 问题结束位置 + 20像素间距 let optionY = questionEndY + pixelToPt(28); // 渲染选项 const options = questions[3].OptionsEnglish || []; const optionLabels = ['A', 'B', 'C', 'D']; // 设置选项标签宽度和选项总宽度 const labelWidth = pixelToPt(40); // 标签(A./B./C./D.)的宽度 const totalWidth = pixelToPt(496); // 选项的总宽度 const contentWidth = totalWidth - labelWidth; // 选项内容的宽度 options.forEach((opt, index) => { if (index < optionLabels.length) { // 提取选项内容,移除可能存在的标签前缀 let optionContent = opt.replace(/^[A-D]\.\s*/, ''); // 渲染选项标签(A./B./C./D.) doc.font('Helvetica') .fontSize(pixelToPt(36)) .text(`${optionLabels[index]}.`, questionXPositions[3], optionY, { width: labelWidth, align: 'left' }); // 渲染选项内容,确保折行时与内容第一行对齐 doc.font('Helvetica') .fontSize(pixelToPt(36)) .text(optionContent, questionXPositions[3] + labelWidth, optionY, { width: contentWidth, align: 'left' }); // 更新选项Y坐标,为下一个选项做准备 optionY = doc.y + pixelToPt(8); } }); } // 问题5 if (questions.length > 4 && questions[4]) { const currentX = questionXPositions[4]; // 如果分页,问题5的Y坐标与问题1和3相同 const currentY = questionsStartY; doc.font('Helvetica-Bold') .fontSize(pixelToPt(36)) .text(`5. ${questions[4].QuestionEnglish || "Question 5"}`, currentX, currentY, { width: pixelToPt(540), align: 'left' }); const questionEndY = doc.y; let optionY = questionEndY + pixelToPt(28); // 渲染选项 const options = questions[4].OptionsEnglish || []; const optionLabels = ['A', 'B', 'C', 'D']; // 设置选项标签宽度和选项总宽度 const labelWidth = pixelToPt(40); // 标签(A./B./C./D.)的宽度 const totalWidth = pixelToPt(496); // 选项的总宽度 const contentWidth = totalWidth - labelWidth; // 选项内容的宽度 options.forEach((opt, index) => { if (index < optionLabels.length) { // 提取选项内容,移除可能存在的标签前缀 let optionContent = opt.replace(/^[A-D]\.\s*/, ''); // 渲染选项标签(A./B./C./D.) doc.font('Helvetica') .fontSize(pixelToPt(36)) .text(`${optionLabels[index]}.`, currentX, optionY, { width: labelWidth, align: 'left' }); // 渲染选项内容,确保折行时与内容第一行对齐 doc.font('Helvetica') .fontSize(pixelToPt(36)) .text(optionContent, currentX + labelWidth, optionY, { width: contentWidth, align: 'left' }); // 更新选项Y坐标,为下一个选项做准备 optionY = doc.y + pixelToPt(8); } }); } } // 14. 虚线 // 计算虚线的位置 - 如果是分页,则在新页面上绘制 const lastContentY = needsNewPage ? doc.page.height - pixelToPt(190) : // 新页面上的位置 doc.page.height - pixelToPt(190); // 原页面上的位置 doc.strokeColor('#D2D2D2') .dash(pixelToPt(20), { space: pixelToPt(28) }) // 设置虚线样式:宽20像素,间隔20像素 .lineWidth(pixelToPt(10)) // 线高10像素 .moveTo(pixelToPt(120), lastContentY) .lineTo(pixelToPt(120) + pixelToPt(1630), lastContentY) .stroke() .undash(); // 重置虚线样式 // 15-19. 答案和底部信息 doc.font('Helvetica-Bold') // 使用Helvetica-Bold作为Semibold替代 .fontSize(pixelToPt(36)) .fillColor('black'); // 重置颜色为黑色 const answersY = doc.page.height - pixelToPt(144); // 获取问题答案 const answers = []; if (questions.length > 0) { for (let i = 0; i < Math.min(questions.length, 5); i++) { const answer = questions[i].Answer || ""; answers.push(`${i+1}. ${answer}`); } } // 显示答案(如果存在) const answerPositions = [120, 262, 411, 561, 711]; for (let i = 0; i < Math.min(answers.length, 5); i++) { doc.text(answers[i], pixelToPt(answerPositions[i]), answersY+2, { width: pixelToPt(100), align: 'left' }); } // 20-21. 应用名称 doc.font('ChineseFont') .fontSize(pixelToPt(36)) .text("语境背单词(微信小程序)", pixelToPt(1338), answersY,{ width: pixelToPt(440), align: 'right' }); // 添加二维码图片 try { // 计算图片位置:距离右边120像素,距离底部100像素 // 由于PDFKit使用左上角坐标系,需要计算左上角坐标 const qrCodeWidth = pixelToPt(200); const qrCodeHeight = pixelToPt(200); const qrCodeX = doc.page.width - pixelToPt(120) - qrCodeWidth; // 右边距离转换为左边距离 const qrCodeY = doc.page.height - pixelToPt(100) - qrCodeHeight; // 底部距离转换为顶部距离 // 添加二维码图片 doc.image('./public/images/acode/YJBDC_QRCode.png', qrCodeX, qrCodeY, { width: qrCodeWidth, height: qrCodeHeight }); console.log("QR Code added successfully"); } catch (imgError) { console.error("Error adding QR Code image:", imgError); } // 结束PDF生成 doc.end(); // 等待PDF生成完成 const pdfBuffer = await new Promise((resolve) => { doc.on('end', () => { resolve(Buffer.concat(chunks)); }); }); let filename="语境背单词_"+moment().format("YYMMDD_HHmm"); // 设置响应头 ctx.set('Content-Type', 'application/pdf'); // 使用ASCII文件名作为主文件名,确保兼容性 const asciiFilename = 'yjbdc_' + moment().format("YYMMDD_HHmm") + '.pdf'; // 对中文文件名进行URL编码,用于filename*参数 const encodedFilename = encodeURIComponent(filename + '.pdf'); // 设置Content-Disposition头部,使用标准格式 // 首先提供ASCII文件名,然后提供UTF-8编码的文件名 ctx.set('Content-Disposition', `attachment; filename=${asciiFilename}; filename*=UTF-8''${encodedFilename}`); ctx.body = pdfBuffer; } catch (error) { console.error("Error generating PDF:", error); ctx.status = 500; ctx.body = { error: "Failed to generate PDF" }; } } //生成二维码 export async function BuildYJBDCQRCode(ctx) { try { // 获取微信访问令牌 const tokenUrl = `https://api.weixin.qq.com/cgi-bin/token?appid=${config.wx.yjbdc_appid}&secret=${config.wx.yjbdc_appsecret}&grant_type=client_credential`; const tokenResponse = await axios.get(tokenUrl); const tokenData = tokenResponse.data; if (!tokenData || !tokenData.access_token) { ctx.status = 400; ctx.body = { errcode: 101, errStr: '获取微信访问令牌失败' }; return; } const accessToken = tokenData.access_token; // 生成小程序码 const qrCodeUrl = `https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=${accessToken}`; const path = './public/images/acode/'; const imageUrl = 'YJBDC_QRCode.png'; const fullPath = path + imageUrl; // 确保目录存在 if (!fs.existsSync(path)) { fs.mkdirSync(path, { recursive: true }); } const postData = { width: 280, scene: "SourceID=187" }; // 使用axios获取二维码并保存到文件 const qrCodeResponse = await axios({ method: 'POST', url: qrCodeUrl, data: postData, responseType: 'stream' }); // 创建写入流 const writer = fs.createWriteStream(fullPath); // 将响应数据写入文件 qrCodeResponse.data.pipe(writer); // 返回成功响应 ctx.body = { errcode: 10000, message: "二维码生成请求已发送" }; // 处理文件写入完成事件 writer.on('finish', () => { console.log("二维码生成成功:", fullPath); }); // 处理错误 writer.on('error', (err) => { console.error("二维码文件写入失败:", err); }); } catch (error) { console.error("生成二维码失败:", error); ctx.status = 500; ctx.body = { errcode: 500, errStr: '生成二维码失败', error: error.message }; } } export async function GetWordChinese(ctx) { const param = { UserID: ctx.query.UserID || 0, Word: ctx.query.Word || '', Level: ctx.query.Level || '0' }; if (!param.Word) { ctx.body = {"errcode": 10001, "errStr": "单词不能为空"}; return; } // 使用单词作为缓存键,为每个单词单独缓存结果 const cacheKey = `GetWordChinese_${param.Word}_${param.Level}`; let result = globalCache.get(cacheKey); if (!result) { // 缓存未命中,从数据库获取 result = await yjbdc.GetWordChinese(param); // 如果没有找到结果,尝试查找单词的原形 if (!result || result.length === 0) { // 使用stringUtils.getWordBaseForm获取可能的原形 const possibleBaseWords = stringUtils.getWordBaseForm(param.Word); // 尝试每个可能的原形 for (const baseWord of possibleBaseWords) { //console.log(`尝试查找单词 ${param.Word} 的可能原形: ${baseWord}`); const baseParam = {...param, Word: baseWord}; const baseResult = await yjbdc.GetWordChinese(baseParam); if (baseResult && baseResult.length > 0) { //console.log(`找到单词 ${param.Word} 的原形 ${baseWord}`); result = baseResult; break; } } } // 缓存结果,使用较长的过期时间,因为单词释义很少变化 if (result && result.length > 0) { globalCache.set(cacheKey, result, config.BufferMemoryTimeHighBest); //console.log(`缓存单词 ${param.Word} 的释义,7天有效期`); } } // 根据Level筛选合适的单词释义 let selectedResult = null; if (result && result.length > 0) { // 将Level转换为数字 const level = parseInt(param.Level); // 根据不同Level筛选结果 if (level >= 0 && level <= 2) { // Level 0-2,返回数组单词第一条 const filtered = result.filter(item => item.BookID >= 151); if (filtered.length > 0) { selectedResult = filtered[0]; } else { // 如果没有符合条件的,返回BookID较小的第一条 selectedResult = result[0]; } } else if (level === 3) { // Level 3,优选返回BookID >= 169的 const filtered = result.filter(item => item.BookID >= 169); if (filtered.length > 0) { selectedResult = filtered[0]; } else { // 如果没有符合条件的,返回BookID较大的第一条 result.sort((a, b) => b.BookID - a.BookID); selectedResult = result[0]; } } else if (level === 4) { // Level 4,优选返回BookID >= 173的 const filtered = result.filter(item => item.BookID >= 173); if (filtered.length > 0) { selectedResult = filtered[0]; } else { // 如果没有符合条件的,返回BookID较大的第一条 result.sort((a, b) => b.BookID - a.BookID); selectedResult = result[0]; } } else if (level === 5) { // Level 5,优选返回BookID >= 178的 const filtered = result.filter(item => item.BookID >= 178); if (filtered.length > 0) { selectedResult = filtered[0]; } else { // 如果没有符合条件的,返回BookID较大的第一条 result.sort((a, b) => b.BookID - a.BookID); selectedResult = result[0]; } } // 移除 BookID 字段 if (selectedResult) { delete selectedResult.BookID; } } ctx.body = {"errcode": 10000, result: selectedResult}; } export async function YJBDC_Articles_Admin(ctx) { //console.log("yjbdc_articles"); const data = await fsPromises.readFile("./public/mg/yjbdc_articles.html"); ctx.body = data.toString(); };