1.环境
nutui-uniapp+vue3+ts+unocss
2.功能源码
包含ai生成逻辑,内容生成实时打字机功能,ai数据处理等
<script setup lang="ts">
import {
queryAIParams,
} from '@/api/pagesA'
import { submitFn } from '@/api/ai'
import Navbar from '@/components/navBar/index.vue'
import { useAuthStore } from '@/store'
import { imgQuality40 } from '@/utils/imageUrl'
import { isFastClick } from '@/utils/shared'
import { loadingProp, warningProp, successProp } from '@/utils/tostProps'
import { useToast } from 'nutui-uniapp/composables'
import {
transformToInlineStyleFragment,
parseHealthContentByAngleBrackets,
extractMainTitle,
parseContent,
} from '@/utils/html'
import TextDecoder from '@/utils/TextDecoder'
const authStore = useAuthStore()
const toast = useToast()
// 状态管理
const textContent = ref(
''
// `认识慢性疾病:管理、预防与生活方式 日常生活中,许多人都会听到身边有人提到高血压、糖尿病或者慢性疼痛这样的问题。管理、预防与生活方式 日常生活中,许多人都会听到身边有人提到高血压、糖尿病或者慢性疼痛这样的问题。管理、预防与生活方式 日常生活中,许多人都会听到身边有人提到高血压、糖尿病或者慢性疼痛这样的问题。有些人或许家中也有长辈需要每天按医嘱吃药、控制饮食,也有人疑惑:慢性病到底是怎么发生的?自己有没有风险?其实,慢性疾病离我们并不遥远,但也没必要谈虎色变。把握一些基本知识,调整生活习惯,大多数慢性病其实都能管得住,不必为未知的小担心而焦虑。 01 简单聊一聊:慢性疾病到底是什么? 慢性疾病,这个词其实并不复杂。简单来说,就是那些拖得比较久、病程长、进展慢的健康问题,比如高血压、糖尿病、慢阻肺,甚至有的人常年腰腿痛、关节不舒服,也算在内。这类毛病不像感冒发烧那样来得快去得也快,反而像个"邻居",你把它管好了,也能平安无事。每个人都有可能在某一时段遇到慢性病的困扰,但大可不必被吓到,一方面这些疾病的早期征兆往不明显,另一方面通过科学管理,生活质量一样可以很好。 根据世界卫生组织的数据,全球约70%的死亡和慢性疾病有关(WHO, Noncommunicable diseases, 2021)。在中国,慢性病和相关并发症更是影响了上亿人。但不用太担心,这意味着:如果我们能早理解,日常多注意,很多慢性病其实可以很好地被控制住。 02 常见信号:身体在提醒你些什么? 总是觉得累: 不是工作太拼,休息够了还是无精打采?慢性疾病常以持续疲劳开场,特别是糖尿病、高血压患者,容易觉得乏力。 疼痛持续而不明原因: 比如膝盖、腰背、肩颈等关节疼痛,时间一长,总觉得这就是“老化”,其实很可能是慢性炎症在作祟。 体重变化奇怪: 没有刻意减肥,但体重慢减少,或反复无明显理由地增加,这也是信号之一。 情绪波动与睡眠变差: 睡不安稳、容易焦虑,常被忽视。慢性病容易让人体力和情绪一起受影响。 特殊病例一例: 比如有位男士,因为意外导致头部受伤后反复头晕、恶心呕吐、视力异常,经检查初步诊断为头部外伤。这种情况下,慢性躯体不适信号和急性症状不同,需要及时检查(病例参考见下文)。 这些信号像是身体的小闹钟,及时注意有助于早干预。不过,光凭这些症状还难以判断是哪种慢性病,最好能跟专业医生沟通一下。 ⚙️ 03 慢性病为什么会找上门? 说起来,慢性疾病出现,并不是哪一天突然冒出来的“大麻烦”,而是多种小因素积累的结果。具体来说,有以下几类主要原因: 遗传因素:某些慢性疾病(如高血压、糖尿病)遗传倾向明显。如果家里长辈有类似病史,个人风险会更高。 年龄相关变化:年龄的增长,意味着身体各个系统都在缓慢变化,比如代谢变慢、血管弹性下降,慢病随年龄见多不怪。 生活习惯:饮食结构单一、运动太少、长期压力大、熬夜,这些看似“习以为常”的习惯,其实就像慢磨损的零件。比如研究发现,长期缺乏规律锻炼增加心脑血管病风险(Booth et al., Waging war on modern chronic diseases, JAMA, 2012)。 环境因素:长期接触空气污染、某些职业暴露,也会提升患慢性肺病等风险。从中可以看出,致病机理往是综合的,不是单一原因能解释全部。不过,生活习惯调整依然是相对好控制的一环。从中可以看出,致病机理往是综合的,不是单一原因能解释全部。不过,生活习惯调整依然是相对好控制的一环。从中可以看出,致病机理往是综合的,不是单一原因能解释全部。不过,生活习惯调整依然是相对好控制的一环。从中可以看出,致病机理往是综合的,不是单一原因能解释全部。不过,生活习惯调整依然是相对好控制的一环。 从中可以看出,致病机理往是综合的,不是单一原因能解释全部。不过,生活习惯调整依然是相对好控制的一环。`
)
const generatedText = ref('')
// 内容示例格式:
// `<div class="material-guidance-wrapper">
// <h1 id="material_title" class="material-custom-title">
// 认识慢性疾病:管理、预防与生活方式
// <span class="material-book-emoji">📚</span>
// </h1>
// <!-- 开头部分 -->
// <div class="material-intro-block">
// 日常生活中,许多人都会听到身边有人提到高血压、糖尿病或者慢性疼痛这样的问题。有些人或许家中也有长辈需要每天按医嘱吃药、控制饮食,也有人疑惑:慢性病到底是怎么发生的?自己有没有风险?其实,慢性疾病离我们并不遥远,但也没必要谈虎色变。把握一些基本知识,调整生活习惯,大多数慢性病其实都能管得住,不必为未知的小担心而焦虑。
// </div>
// <!-- 01 什么是慢性疾病 -->
// <section class="material-section" style="background: linear-gradient(90deg, #f4f7fa 0%, #e6f2ff 100%);">
// <h2 class="material-section-title material-icon-with-bg">
// <span class="material-section-emoji">🌱</span>
// 01 简单聊一聊:慢性疾病到底是什么?
// </h2>
// <div class="material-section-content">
// <p>
// 慢性疾病,这个词其实并不复杂。简单来说,就是那些拖得比较久、病程长、进展慢的健康问题,比如高血压、糖尿病、慢阻肺,甚至有的人常年腰腿痛、关节不舒服,也算在内。这类毛病不像感冒发烧那样来得快去得也快,反而像个"邻居",你把它管好了,也能平安无事。每个人都有可能在某一时段遇到慢性病的困扰,但大可不必被吓到,一方面这些疾病的早期征兆往不明显,另一方面通过科学管理,生活质量一样可以很好。
// </p>
// <p>
// 根据世界卫生组织的数据,全球约70%的死亡和慢性疾病有关(WHO, Noncommunicable diseases, 2021)。在中国,慢性病和相关并发症更是影响了上亿人。但不用太担心,这意味着:如果我们能早理解,日常多注意,很多慢性病其实可以很好地被控制住。
// </p>
// </div>
// </section>
// <!-- 02 慢性疾病的常见症状是什么? -->
// <section class="material-section" style="background: linear-gradient(90deg, #e8f6ef 0%, #daf8e3 100%);">
// <h2 class="material-section-title material-icon-with-bg">
// <span class="material-section-emoji">🔍</span>
// 02 常见信号:身体在提醒你些什么?
// </h2>
// <div class="material-section-content">
// <ul class="material-ul-points">
// <li>
// <strong>总是觉得累:</strong>
// 不是工作太拼,休息够了还是无精打采?慢性疾病常以持续疲劳开场,特别是糖尿病、高血压患者,容易觉得乏力。
// </li>
// <li>
// <strong>疼痛持续而不明原因:</strong>
// 比如膝盖、腰背、肩颈等关节疼痛,时间一长,总觉得这就是“老化”,其实很可能是慢性炎症在作祟。
// </li>
// <li>
// <strong>体重变化奇怪:</strong>
// 没有刻意减肥,但体重慢减少,或反复无明显理由地增加,这也是信号之一。
// </li>
// <li>
// <strong>情绪波动与睡眠变差:</strong>
// 睡不安稳、容易焦虑,常被忽视。慢性病容易让人体力和情绪一起受影响。
// </li>
// <li>
// <strong>特殊病例一例:</strong>
// 比如有位男士,因为意外导致头部受伤后反复头晕、恶心呕吐、视力异常,经检查初步诊断为头部外伤。这种情况下,慢性躯体不适信号和急性症状不同,需要及时检查(病例参考见下文)。
// </li>
// </ul>
// <p>
// 这些信号像是身体的小闹钟,及时注意有助于早干预。不过,光凭这些症状还难以判断是哪种慢性病,最好能跟专业医生沟通一下。
// </p>
// </div>
// </section>
// <!-- 03 慢性疾病的主要致病机理是什么? -->
// <section class="material-section" style="background: linear-gradient(90deg, #faf6e8 0%, #f9ebda 100%);">
// <h2 class="material-section-title material-icon-with-bg">
// <span class="material-section-emoji">⚙️</span>
// 03 慢性病为什么会找上门?
// </h2>
// <div class="material-section-content">
// <p>
// 说起来,慢性疾病出现,并不是哪一天突然冒出来的“大麻烦”,而是多种小因素积累的结果。具体来说,有以下几类主要原因:
// </p>
// <ul class="material-ul-points">
// <li>
// <strong>遗传因素:</strong>某些慢性疾病(如高血压、糖尿病)遗传倾向明显。如果家里长辈有类似病史,个人风险会更高。
// </li>
// <li>
// <strong>年龄相关变化:</strong>年龄的增长,意味着身体各个系统都在缓慢变化,比如代谢变慢、血管弹性下降,慢病随年龄见多不怪。
// </li>
// <li>
// <strong>生活习惯:</strong>饮食结构单一、运动太少、长期压力大、熬夜,这些看似“习以为常”的习惯,其实就像慢磨损的零件。比如研究发现,长期缺乏规律锻炼增加心脑血管病风险(Booth et al., Waging war on modern chronic diseases, JAMA, 2012)。
// </li>
// <li>
// <strong>环境因素:</strong>长期接触空气污染、某些职业暴露,也会提升患慢性肺病等风险。
// </li>
// </ul>
// <p>
// 从中可以看出,致病机理往是综合的,不是单一原因能解释全部。不过,生活习惯调整依然是相对好控制的一环。<br/>
// <span class="material-cite-block">
// <em>参考文献:</em> Booth, F. W., Roberts, C. K., & Laye, M. J. (2012). Lack of exercise is a major cause of chronic diseases. The Journal of Physiology, 590(3), 703-731.
// </span>
// </p>
// </div>
// </section>
// <!-- 04 如何进行慢性疾病的诊断? -->
// <section class="material-section" style="background: linear-gradient(90deg, #f9f7fc 0%, #e9e3fa 100%);">
// <h2 class="material-section-title material-icon-with-bg">
// <span class="material-section-emoji">🧪</span>
// 04 慢性病怎么查出来?——诊断流程一览
// </h2>
// <div class="material-section-content">
// <ol class="material-ol-points">
// <li>
// <strong>医生问诊:</strong>
// 先和医生详细聊:不舒服多久了?有没有类似家族史?生活习惯怎么样?
// </li>
// <li>
// <strong>体格检查:</strong>
// 医生会做一些基础检查,比如量血压、听心肺,有时会摸肚子、看看关节活动度。
// </li>
// <li>
// <strong>实验室检查:</strong>
// 常见的有血常规、生化全套(如血糖、血脂、肝肾功能等)。对有疑似糖尿病、高血压、肝病等患者尤其重要。
// </li>
// <li>
// <strong>影像学检查:</strong>
// 具体包括X光、CT、超声等。以案例为例,有男性患者头部外伤后反复头晕,经颅脑平扫、DR鼻骨侧位等影像学手段确认病因,这些工具同样适用于判断慢性息肉、关节退变等疾病。
// </li>
// <li>
// <strong>专科诊断:</strong>
// 必要时,医生还会安排专项检查,如心电图、心脏彩超等,判断器官功能。
// </li>
// </ol>
// <p>
// 检查流程其实并不复杂,很多慢性疾病都是通过这些环节逐步排查、最后锁定的。遇到不明原因的不适,拖拉更容易耽误治疗。<br/>
// <span class="material-cite-block">
// <em>资料参考:</em> Mayo Clinic, “Head injury: First aid”, 2022.
// </span>
// </p>
// </div>
// </section>
// <!-- 05 慢性疾病的治疗方法和预期效果有哪些? -->
// <section class="material-section" style="background: linear-gradient(90deg, #e4f3fd 0%, #c6e2f7 100%);">
// <h2 class="material-section-title material-icon-with-bg">
// <span class="material-section-emoji">💡</span>
// 05 管理慢性病:有哪些靠谱的办法?
// </h2>
// <div class="material-section-content">
// <p>
// 既然慢性疾病"难缠",那治疗和管理有哪些常见方法?其实,主要有以下三类:
// </p>
// <ul class="material-ul-points">
// <li>
// <strong>药物干预:</strong>如降压药、降糖药、抗炎药,根据具体病种组合使用。需要注意的是,药物调整需在医生指导下进行,切勿擅自加减。
// </li>
// <li>
// <strong>生活方式调整:</strong>规律作息、劳逸结合、适当运动,可根据个人情况选择散步、游泳、慢跑等。比如规律运动有助于改善胰岛素敏感性,辅助控制糖尿病(Colberg et al., Exercise and Type 2 Diabetes, Diabetes Care, 2016)。
// </li>
// <li>
// <strong>心理支持与健康教育:</strong>面对慢病,情绪波动正常,焦虑时家人和医生多一些沟通,也可以参与病友支持小组,获得更多力量和经验分享。
// </li>
// </ul>
// <p>
// 治疗效果因人而异,但多数慢性病通过上述方法能获得很大改善。比如高血压患者有规律监测和全程管理,患心脑血管意外的概率会大减少。
// </p>
// <p class="material-cite-block">
// <em>参考文献:</em> Colberg, S. R., et al. (2016). Exercise and Type 2 Diabetes. Diabetes Care, 39(11), 2065-2079.
// </p>
// </div>
// </section>
// <!-- 06 日常管理与预防慢性疾病的方法有哪些? -->
// <section class="material-section" style="background: linear-gradient(90deg, #fffbe6 0%, #fff2cc 100%);">
// <h2 class="material-section-title material-icon-with-bg">
// <span class="material-section-emoji">🥗</span>
// 06 生活小贴士:健康管理怎么做更靠谱?
// </h2>
// <div class="material-section-content">
// <ul class="material-ul-points">
// <li>
// <strong>每日动一动:</strong>
// 散步、快走、骑自行车、打太极都可以,运动有助于心血管和代谢健康,最好的办法是每天累计30分钟以上。
// </li>
// <li>
// <strong>饮食有讲究:</strong>
// 多吃蔬菜水果(促进肠道健康),适当摄入全谷类(有助稳定血糖),优质蛋白(比如豆制品、瘦肉、鱼类)有助于保持身体机能。
// </li>
// <li>
// <strong>定时体检:</strong>
// 建议40岁开始,每1-2年做一次基础健康体检,关注血压、血糖、血脂等指标。
// </li>
// <li>
// <strong>调节情绪:</strong>
// 保持乐观,遇到压力时,可以和朋友聊、听音乐或练深呼吸。长期压力过大会影响免疫力和慢病管理。
// </li>
// <li>
// <strong>充足睡眠:</strong>
// 每天7-8小时较为理想,睡眠质量好有助于修复机体,降低多种慢病风险。
// </li>
// <li>
// <strong>出现异常及时就诊:</strong>
// 有不明原因体重变化、持续乏力、食欲减退、疼痛等新症状时,最好能预约专业医生,避免“小问题拖成大麻烦”。
// </li>
// </ul>
// <p>
// 小结一下,健康生活并不复杂,养成这些好习惯,慢性病风险自然会减少,不仅是长寿,更重要的是生活质量好,能享受喜欢的事。
// </p>
// </div>
// </section>
// <!-- 结束语 -->
// <div class="material-end-block">
// <span class="material-heart-emoji">💚</span>
// 慢性疾病虽然常见,也不必因此焦虑。核心在于多关注一点身体变化,养成良好的作息和饮食习惯。有了基础知识和方法做支撑,生活也会多一份踏实和底气。快把这份指南推荐给身边的朋友和家人,让健康多一份主动权!
// </div>
// <!-- 文献引用 -->
// <div class="material-reference-block">
// <h3 class="material-ref-title">参考文献</h3>
// <ol class="material-ref-list">
// <li>
// World Health Organization (2021). Noncommunicable diseases. Retrieved from <a href="https://www.who.int/news-room/fact-sheets/detail/noncommunicable-diseases" target="_blank">https://www.who.int/news-room/fact-sheets/detail/noncommunicable-diseases</a>
// </li>
// <li>
// Booth, F. W., Roberts, C. K., & Laye, M. J. (2012). Lack of exercise is a major cause of chronic diseases. The Journal of Physiology, 590(3), 703-731. <a href="https://pubmed.ncbi.nlm.nih.gov/22289907/" target="_blank">PubMed</a>
// </li>
// <li>
// Colberg, S. R., et al. (2016). Exercise and Type 2 Diabetes. Diabetes Care, 39(11), 2065-2079. <a href="https://pubmed.ncbi.nlm.nih.gov/27926890/" target="_blank">PubMed</a>
// </li>
// <li>
// Mayo Clinic Staff. (2022). Head injury: First aid. Mayo Clinic. <a href="https://www.mayoclinic.org/first-aid/first-aid-head-trauma/basics/art-20056626" target="_blank">Link</a>
// </li>
// </ol>
// </div>
// </div>
// <style>
// .material-guidance-wrapper {
// max-width: 790px;
// margin: 18px auto 30px auto;
// font-family: 'PingFang SC', 'Microsoft YaHei', Arial, Helvetica, sans-serif;
// color: #232729;
// background-color: #fb;
// border-radius: 17px;
// overflow: hidden;
// box-shadow: 0 4px 36px 0 rgba(186,199,214,0.13);
// padding-bottom: 40px;
// }
// .material-custom-title {
// font-size: 2.2em;
// text-align: center;
// padding-top: 35px;
// color: #205180;
// letter-spacing: 2px;
// margin-bottom: 18px;
// background: linear-gradient(90deg, #f8fafc 60%, #e7f2ff 100%);
// border-bottom: 2px solid #aad3fa;
// border-radius: 0 0 13px 13px;
// box-shadow: 0 2px 12px 0 rgba(140,170,220,0.07);
// }
// .material-book-emoji {
// font-size: 1.1em;
// margin-left: 0.2em;
// vertical-align: middle;
// }
// .material-intro-block {
// padding: 27px 34px 7px 36px;
// font-size: 1.18em;
// background: linear-gradient(90deg, #f8fbff 0%, #fefefe 100%);
// border-left: 5px solid #b6d3ee;
// margin-bottom: 3px;
// line-height: 1.7;
// border-radius: 0 20px 20px 0;
// }
// .material-section {
// margin: 26px 34px 25px 34px;
// padding: 34px 36px 6px 36px;
// border-radius: 19px;
// box-shadow: 0 2px 18px 0 rgba(215,225,239,.07);
// transition: box-shadow 0.3s;
// }
// .material-section-title {
// font-size: 1.51em;
// color: #316399;
// margin-bottom: 14px;
// display: flex;
// align-items: center;
// font-weight: 600;
// letter-spacing: .5px;
// }
// .material-section-emoji {
// font-size: 1.32em;
// margin-right: 0.45em;
// background: rgba(210, 230, 245, 0.52);
// padding: 2.5px 8px;
// border-radius: 8px;
// }
// .material-section-content {
// font-size: 1.12em;
// line-height: 1.82;
// color: #212c35;
// margin-top: -10px;
// }
// .material-ul-points {
// margin-left: 0;
// margin-bottom: 12px;
// padding-left: 21px;
// list-style-type: disc;
// }
// .material-ul-points li {
// margin-bottom: 13px;
// padding-left: 1px;
// }
// .material-ol-points {
// margin-left: 0;
// margin-bottom: 18px;
// padding-left: 23px;
// list-style-type: decimal;
// }
// .material-ol-points li {
// margin-bottom: 12px;
// }
// .material-cite-block {
// display: block;
// color: #7297d1;
// font-size: .96em;
// font-style: italic;
// margin-top: 9px;
// }
// .material-end-block {
// margin: 38px 36px 9px 36px;
// padding: 24px 24px 21px 24px;
// border-radius: 16px;
// background: linear-gradient(90deg,#eefdff 40%,#f7f6f2 100%);
// font-size: 1.13em;
// color: #297a49;
// box-shadow: 0 1px 8px 0 rgba(170,220,180,0.17);
// text-align: center;
// }
// .material-heart-emoji {
// font-size: 1.17em;
// margin-right: 0.2em;
// }
// .material-reference-block {
// padding: 28px 36px 15px 36px;
// margin: 32px 34px 0 34px;
// border-radius: 13px;
// background: linear-gradient(90deg,#e8f4ff 10%,#fbf9ff 90%);
// color: #295799;
// }
// .material-ref-title {
// margin: 0;
// font-size: 1.12em;
// font-weight: 700;
// color: #3970b5;
// margin-bottom: 14px;
// letter-spacing: 0.7px;
// }
// .material-ref-list {
// margin: 0;
// padding-left: 18px;
// font-size: 0.97em;
// line-height: 1.8;
// }
// .material-ref-list a {
// color: #2173a7;
// text-decoration: none;
// border-bottom: 1px dotted #2173a7;
// margin-left: 2px;
// }
// .material-ref-list a:hover {
// text-decoration: underline;
// }
// </style>
// `
const contentTitle = ref('')
const newGeneratedText = ref(``) // 处理为行内样式的数据
// const TDK = ref<any>(null);
const TDK = ref<any>({})
// {
// title: "认识慢性疾病:管理、预防与生活方式",
// description:
// "了解慢性疾病的成因、症状及有效管理方法。掌握这些知识能帮助你或家人预防高血压、糖尿病等常见慢性病,提升生活质量。",
// keywords: "慢性疾病, 高血压, 糖尿病, 管理方法, 生活方式",
// cover:
// "https://ystcdn.venuertc.com/venue/AI/25f122b2-6f4d-4bb6-a646-7c34ec415e7f.jpg",
// seo_analysis: {
// core_keywords: ["慢性疾病", "高血压", "糖尿病"],
// long_tail_keywords: [
// "慢性疾病症状",
// "如何管理糖尿病",
// "高血压的治疗方法",
// "慢性病预防措施",
// ],
// target_audience: "关注健康的成年人,特别是中老年群体及其家属。",
// search_intent:
// "用户希望获取有关慢性病的预防和管理信息,以及相关症状的认知。",
// },
// }
const scrollTarget = ref('scroll-to-bottom')
const scrollTop = ref(0)
const showOverlayFlag = ref(false) // 生成中
const showTipPropFlag = ref(false) // 提示弹窗
const tipType = ref(1) // 提示类型 1文本字数提示 2录制前提示
const scrollViewHeight = ref(0)
const isGenerating = ref(false)
const controller = ref<any>(null)
const isStreamEnded = ref(false)
// 打字机逻辑
const pendingText = ref('')
const isTyping = ref(false)
let typingTimer: any = null
const lastScrollTime = ref(0)
const lastChunk = ref('')
const lastContentLength = ref(0)
// 解析参数
const pageType = ref(1) // 1生成文章 2生成脚本(视频) 3待录制
const operationType = ref('add') // add添加新 edit修改 look查看
const aiParams = ref<any>({}) // ai参数
const promptId = ref(0) // 指令id
const sprId = ref(0) // sprId
onLoad(async () => {
// 获取默认滚动区域高度
updateScrollViewHeight()
// 获取 AI 配置
await fetchPromptId()
// 自动生成文章
startStreamRequest()
})
// 获取 AI 配置
async function fetchPromptId() {
try {
const res: any = await queryAIParams({
sprId: sprId.value,
promptId: Number(promptId.value),
}).queryFn()
if (res.code === 401) return
if (res.code !== 200) return toast.warning(res.msg || 'AI配置获取失败', warningProp)
else if (res.data) return (aiParams.value = { ...res.data })
} catch (err) {
toast.warning('网络错误', warningProp)
}
}
// ========== 更新滚动区域高度 ==========
function updateScrollViewHeight() {
calcScrollViewHeight()
}
// ✅ 计算 scroll-view 高度
function calcScrollViewHeight() {
const query = uni.createSelectorQuery()
// console.log("query-----", query);
// 直接通过 class 或 id 查询
query.select('.nav-bar').boundingClientRect()
query.select('.bto-box').boundingClientRect()
query.selectViewport().boundingClientRect() // 获取窗口大小
query.exec((res) => {
if (!res || res?.length < 3) return
// console.log("res-----", res);
const viewport = res[2] // selectViewport
const header = res[0]
const footer = res[1]
const windowHeight = viewport.height
const headerHeight = header ? header.height : 0
const footerHeight = footer ? footer.height : ((158 * 2) / 750) * uni.upx2px(750) // 兜底 158rpx 转 px
// 计算 scroll-view 高度(单位 px)
const heightInPx = windowHeight - headerHeight - footerHeight
// 转回 rpx 显示(可选),或直接用 px
scrollViewHeight.value = heightInPx // scroll-view 支持 px
// console.log("scrollViewHeight.value----", scrollViewHeight.value);
})
}
// ========== 重构的 SSE 流处理逻辑 ==========
function createDifyStream(payload: any, cb: any) {
let buffer = ''
let streamEnded = false
// const decoder = new TextDecoder("utf-8");
// key 生成内容调用接口的参数,自行取舍
const key = 'app-key'
const req: any = uni.request({
url: 'api', // 接口地址
method: 'POST',
timeout: 300000, // 设置为 5 分钟(默认 60 秒,最大可设 300000 = 5 分钟)
enableChunked: true,
header: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
Accept: 'text/event-stream',
},
data: payload,
success: () => {
processBuffer()
// 兜底:如果还没结束,强制触发 onFinish
if (!streamEnded) {
streamEnded = true
cb.onFinish?.()
}
},
fail: (err) => {
cb.onError?.(new Error(err.errMsg))
if (err.errMsg.includes('timeout')) {
wx.showToast({
title: '网络较慢,请稍后重试',
icon: 'none',
})
}
},
})
if (req?.onChunkReceived) {
req.onChunkReceived((res: any) => {
const arrayBuffer = new Uint8Array(res.data)
const chunk = new TextDecoder().decode(arrayBuffer)
buffer += chunk
processBuffer()
})
}
function processBuffer() {
const lines = buffer.split('\n')
buffer = lines.pop() || '' // 保留未完成行
for (const line of lines) {
// console.log("SSE Line:", line); // 调试
if (!line.startsWith('data:')) continue
const dataStr = line.slice(5).trim()
// [DONE] 表示流结束
if (dataStr === '[DONE]') {
if (!streamEnded) {
streamEnded = true
// console.log("Received [DONE] -> onFinish triggered");
cb.onFinish?.()
}
continue
}
if (!dataStr) continue
try {
const json = JSON.parse(dataStr)
// console.log("Parsed JSON:", json);
// 多种结束信号兼容
if (
json.is_finished === true ||
json.event === 'message_end' ||
json.status === 'completed' ||
json.final_answer !== undefined
) {
if (!streamEnded) {
streamEnded = true
// 延迟1秒后处理
setTimeout(() => {
// 结束前处理数据
// 纯文本
const str = parseHealthContentByAngleBrackets(generatedText.value)
textContent.value = str
// console.log("🎬 纯文本------", textContent.value);
// html+TDK
const data = parseContent(generatedText.value)
generatedText.value = data.html
TDK.value = data.tdk
// console.log("🎬 html+TDK------", TDK.value);
// 转为行内
const div = transformToInlineStyleFragment(generatedText.value)
newGeneratedText.value = div
// console.log("🎬 转为行内------", newGeneratedText.value);
// 标题提取
if (!TDK.value?.title) {
contentTitle.value = extractMainTitle(generatedText.value) || ''
// console.log("🎬 标题提取------", newGeneratedText.value);
}
// 关闭 清除
cb.onFinish?.()
}, 1000)
}
}
// 提取文本内容(兼容不同字段)
const text = json.answer || json.text || json.content
if (text && !streamEnded) {
cb.onPartialAnswer?.(text)
}
} catch (err) {
console.error('Parse error:', err, 'Data:', dataStr)
}
}
}
return {
abort() {
req?.abort?.()
},
isEnded() {
return streamEnded
},
}
}
// ========== 优化的滚动到底部逻辑 ==========
function scrollIfNeeded() {
const now = Date.now()
if (now - lastScrollTime.value < 50) return
lastScrollTime.value = now
nextTick(() => {
const query = uni.createSelectorQuery()
query.select('.scroll-content').boundingClientRect()
query.exec((res) => {
// console.log("scroll-content--res-----", res[0].height);
scrollTop.value = res[0].height
})
})
}
// ========== 开始输入 ==========
function startTyping() {
if (isTyping.value) return
if (!pendingText.value) return
isTyping.value = true
typingTimer = setInterval(() => {
if (!pendingText.value) {
stopTyping()
return
}
// 每次取 10~20 字符,减少 DOM 更新次数
const chunk = pendingText.value.slice(0, 20)
generatedText.value += chunk
pendingText.value = pendingText.value.slice(20)
scrollIfNeeded()
}, 30)
}
// ========== 停止输入 ==========
function stopTyping() {
// console.log("stopTyping-------");
if (typingTimer) clearInterval(typingTimer)
typingTimer = null
if (pendingText.value) {
generatedText.value += pendingText.value
pendingText.value = ''
}
isTyping.value = false
scrollIfNeeded()
// console.log("stopTyping----end---");
}
// ========== 开始请求 ==========
async function startStreamRequest() {
if (isGenerating.value) return
isGenerating.value = true
showOverlayFlag.value = true
newGeneratedText.value = ''
generatedText.value = ''
pendingText.value = ''
stopTyping()
lastChunk.value = ''
lastContentLength.value = 0
textContent.value = ''
// 构建请求负载
const payload = {
inputs: {
doctor: aiParams.value.userName || '',
workunit: aiParams.value.hospital || '',
dept: aiParams.value.department,
role: aiParams.value.roleType,
MedicalFieldDescription: aiParams.value.medicalFieldDescription,
contentType: aiParams.value.contentType,
isCover: aiParams.value.isCover ? 1 : 0,
isSearch: aiParams.value.isSearch ? 1 : 0,
},
promptId: promptId.value.toString(),
// message: "内容生成",
// query: JSON.stringify(da || aiParams.value.sprContent),
query: JSON.stringify(aiParams.value.sprContent),
response_mode: 'streaming',
stream: true,
user: `miAPP-${authStore.userInfo.userId}-${authStore.userInfo.userName}`,
}
controller.value = createDifyStream(payload, {
onPartialAnswer: (frag: string) => {
// console.log("onPartialAnswer-------");
if (!frag || frag === lastChunk.value) return
lastChunk.value = frag
const cleanFrag = frag.replace(/[\x00-\x08\v\f\x0E-\x1F\x7F-\x9F]/g, '')
if (!cleanFrag) return
pendingText.value += cleanFrag
const newLen = generatedText.value.length + pendingText.value.length
if (newLen <= lastContentLength.value) return
lastContentLength.value = newLen
if (!isTyping.value) startTyping()
},
onFinish: () => {
// console.log("onFinish-------");
stopTyping()
isGenerating.value = false
isStreamEnded.value = true
showOverlayFlag.value = false
},
onError: (err: Error) => {
// console.log("onError-------");
// console.error(err);
stopTyping()
isGenerating.value = false
showOverlayFlag.value = false
},
})
}
// ========== 提交 ==========
async function submit() {
if (isFastClick(500)) return console.warn('⚠️ 防止快速点击,跳过提交')
toast.loading('加载中', loadingProp)
// 文章流程需要限制字数超过1500 视频根据生成内容来
if (pageType.value === 1 && textContent.value.length < 1500) {
toast.hide()
tipType.value = 1
return (showTipPropFlag.value = true)
}
const data: any = {
name: TDK.value.title || contentTitle.value,
content: generatedText.value,
sourceId: sprId.value,
promptId: Number(promptId.value),
}
if (TDK.value) {
data.name = TDK.value.title
data.title = TDK.value.title
data.seoTitle = TDK.value.title
data.seoKeywords = TDK.value.keywords
data.seoDescription = TDK.value.description
data.imageUrl = TDK.value.cover
data.seoAnalysis = TDK.value.seo_analysis
}
// console.log("📝 提交数据:", JSON.stringify(data, null, 2));
// 调用就提交接口处理
await submitFn(data).queryFn()
}
// ========== 组件销毁 ==========
onBeforeUnmount(() => {
console.log('🧹 组件销毁开始')
if (controller.value) {
console.log('🛑 中止 SSE 流请求')
controller.value.abort()
controller.value = null
}
stopTyping() // 确保清理打字机
console.log('🧹 打字机清理完成')
console.log('✅ AI生成组件已销毁')
})
</script>
<template>
<div class="page h-screen flex flex-col justify-between bg-white">
<Navbar title="ai生成" fixed :placeholder="true" backgroundColor="#FFFFFF" />
<div class="flex-1 overflow-hidden bg-[#FEFEFE]">
<scroll-view
:scroll-top="scrollTop"
:scroll-y="true"
:scroll-into-view="scrollTarget"
:scroll-with-animation="true"
:show-scrollbar="false"
:enhanced="true"
:style="{ height: `${scrollViewHeight}px` }"
class="border border-[#eee] rounded-[16rpx] bg-[#FCFCFC] p-[30rpx]"
>
<div class="scroll-content">
<div v-if="newGeneratedText" v-html="newGeneratedText"></div>
<div v-else-if="generatedText" v-html="generatedText"></div>
<div v-else class="text-[32rpx] text-[#888888]">AI 正在思考中...</div>
<div
v-if="textContent.length"
class="w-full min-h-[50rpx] text-[#85BFFB] text-[28rpx] mt-[20rpx]"
>
共计<span>{{ textContent.length }}</span
>字
</div>
<div class="w-full h-[100rpx]"></div>
</div>
</scroll-view>
</div>
<div class="h-[158rpx] w-full">
<div
class="fixed bottom-0 z-10 h-[158rpx] w-full flex items-center justify-between bg-white px-[38rpx]"
>
<div class="flex flex-1 items-center justify-around">
<div class="flex items-center" @click="startStreamRequest">
<img
:src="`https://ystcdn.venuertc.com/venue/app/static/2025-08-22/a054f4cb-e841-4989-af50-e3c14704eb91.png${imgQuality40}`"
class="h-[36rpx] w-[36rpx]"
/>
<span class="ml-[8rpx] text-[32rpx] text-[#38393C] font-500">重写</span>
</div>
<!-- <div class="flex items-center" @click="editGenerateContent">
<img
:src="`https://ystcdn.venuertc.com/venue/app/static/2025-08-13/85e1b66b-4f0b-421a-9478-b4bc2e60d1a2.png${imgQuality40}`"
class="w-[36rpx] h-[36rpx]"
/>
<span class="text-[#38393C] text-[32rpx] font-500 ml-[8rpx]"
>编辑</span
>
</div> -->
</div>
<div
v-if="times"
class="h-[96rpx] w-[392rpx] rounded-full bg-[#FFFFFF] text-center text-[#1089FF] font-600 leading-[96rpx] border-solid border-[2rpx] border-[#1089FF]"
>
阅读中{{ displayTime }}
</div>
<div
v-else
class="h-[96rpx] w-[392rpx] rounded-full bg-[#1089FF] text-center text-white font-600 leading-[96rpx]"
@click="submit"
>
<span v-if="pageType === 1">保存提交</span>
<span
v-else-if="
pageType === 1 && ['editArticle', 'editTask', 'reassignTask'].includes(operationType)
"
>保存提交</span
>
<span v-else-if="pageType === 2">保存提交</span>
<span
v-else-if="
pageType === 2 && ['editVideo', 'editTask', 'reassignTask'].includes(operationType)
"
>重新录制</span
>
</div>
</div>
</div>
<nut-overlay v-model:visible="showOverlayFlag" :z-index="2000" :close-on-click-overlay="false">
<div class="h-full w-full flex items-center justify-center">
<div class="flex flex-col items-center text-white">
<img
:src="`https://ystcdn.venuertc.com/venue/app/static/2025-08-14/35f2b461-940f-4f33-a97c-0d0d372512b3.gif${imgQuality40}`"
class="h-[206rpx] w-[206rpx]"
/>
<div>生成中,请耐心等待…</div>
<div class="mt-[46rpx] w-[514rpx] text-center">
温馨提示:医学知识具有专业性,当前科普内容由人工智能辅助生成,需经医疗从业者二次核验,建议结合专业诊疗意见综合参考。
</div>
</div>
</div>
</nut-overlay>
</div>
</template>
<style lang="scss" scoped>
/* 解决小程序和app滚动条的问题 */
/* #ifdef MP-WEIXIN || APP-PLUS */
::-webkit-scrollbar {
display: none;
width: 0 !important;
height: 0 !important;
color: transparent;
appearance: none;
background: transparent;
}
/* #endif */
/* 解决H5 的问题 */
/* #ifdef H5 */
uni-scroll-view .uni-scroll-view::-webkit-scrollbar {
display: none;
width: 0 !important;
height: 0 !important;
color: transparent;
appearance: none;
background: transparent;
}
/* #endif */
/* 修复内容高度不足时底部空白问题 */
.scroll-view {
display: flex;
flex-direction: column;
min-height: 100%;
& > div {
flex: 1 0 auto;
}
/* 确保内容区域可伸缩 */
.content-container {
flex: 1;
min-height: 100%;
}
}
</style>
3.相关数据处理函数
/**
* 将包含 <style> 的 HTML 字符串转换为行内样式
* 严格保留原有标签结构,不增不删任何属性,仅合并 style
*/
export function transformToInlineStyleFragment(htmlContent: string): string {
if (!htmlContent || typeof htmlContent !== "string") return "";
// 1. 提取并解析 <style> 中的 CSS 规则
const styleMap: Record<string, Record<string, string>> = {};
const styleRegex = /<style[^>]*>([\s\S]*?)<\/style>/gi;
let styleMatch;
while ((styleMatch = styleRegex.exec(htmlContent)) !== null) {
const cssText = styleMatch[1];
const cleanCss = cssText.replace(/\/\*[\s\S]*?\*\//g, ""); // 移除注释
const ruleRegex = /([^{]+)\{([^}]*)\}/g;
let rule;
while ((rule = ruleRegex.exec(cleanCss)) !== null) {
const selectorStr = rule[1].trim();
const declaration = rule[2].trim();
const selectors = selectorStr.split(",").map((s) => s.trim());
const styleObj: Record<string, string> = {};
declaration
.split(";")
.map((p) => p.trim())
.filter((p) => p)
.forEach((prop) => {
const [k, v] = prop
.split(":")
.map((s) => s?.trim())
.filter(Boolean) as [string, string];
if (k && v) {
styleObj[k] = v;
}
});
selectors.forEach((sel) => {
if (sel && sel !== "") {
styleMap[sel] = { ...styleMap[sel], ...styleObj };
}
});
}
}
// 2. 移除所有 <style> 标签
let tempHtml = htmlContent.replace(styleRegex, "");
// 3. 匹配每一个 HTML 标签(支持自闭合、属性顺序保留)
const tagRegex = /<([a-zA-Z][a-zA-Z0-9:]*)([^>]*)>/g;
tempHtml = tempHtml.replace(
tagRegex,
(fullMatch, tagName: string, attrs = "") => {
const attrsTrimmed = attrs.trim();
// 解析 id, class, style
const idMatch = /id\s*=\s*"([^"]*)"/.exec(attrs);
const classMatch = /class\s*=\s*"([^"]*)"/.exec(attrs);
const styleMatch = /style\s*=\s*"([^"]*)"/.exec(attrs);
const id = idMatch ? `#${idMatch[1]}` : null;
const classes = classMatch
? classMatch[1]
.split(/\s+/)
.filter(Boolean)
.map((c) => `.${c}`)
: [];
const existingStyleText = styleMatch ? styleMatch[1] : "";
// 构建最终 style 对象,按优先级合并
const finalStyle: Record<string, string> = {};
// 1. 通配符 *
if (styleMap["*"]) Object.assign(finalStyle, styleMap["*"]);
// 2. 标签选择器
const lowerTagName = tagName.toLowerCase();
if (styleMap[lowerTagName])
Object.assign(finalStyle, styleMap[lowerTagName]);
// 3. 类选择器(按顺序)
classes.forEach((cls) => {
if (styleMap[cls]) Object.assign(finalStyle, styleMap[cls]);
});
// 4. ID 选择器(最高优先级之一)
if (id && styleMap[id]) Object.assign(finalStyle, styleMap[id]);
// 5. 原有行内样式(最高优先级,覆盖前面所有)
if (existingStyleText) {
existingStyleText.split(";").forEach((pair) => {
const [k, v] = pair
.split(":")
.map((s) => s.trim())
.filter(Boolean) as [string, string];
if (k && v) {
finalStyle[k] = v;
}
});
}
// 生成新的 style 字符串(保留原始格式风格:k: v)
const newStyleStr = Object.entries(finalStyle)
.map(([k, v]) => `${k}: ${v}`)
.join("; ")
.replace(/\s*;\s*/g, "; "); // 标准化空格
// 重新构建属性字符串(保留原始属性顺序)
let newAttrs = attrsTrimmed;
if (newStyleStr) {
if (styleMatch) {
// 替换原有 style 属性(精确匹配)
const styleAttrRegex = /style\s*=\s*"([^"]*)"/;
newAttrs = newAttrs.replace(styleAttrRegex, `style="${newStyleStr}"`);
} else {
// 添加 style 属性(放在最后)
newAttrs = newAttrs + ` style="${newStyleStr}"`;
}
}
// 返回完整标签
return `<${tagName} ${newAttrs.trim()}>`;
}
);
return tempHtml;
}
/**
* 纯文本提取(支持可选的“开始输出正文”标记)
* - 若存在“开始输出正文”,则从此处开始
* - 自动去除 <style> 标签及其后内容、参考文献及之后内容
* - 清理 HTML 标签、纯 emoji 行、多余空白
*/
export function parseHealthContentByAngleBrackets(rawText: string): string {
if (!rawText || typeof rawText !== "string") {
return "";
}
let text = rawText;
// 1. 统一并清理换行符和特殊符号
text = text
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.replace(/↵/g, "\n")
.replace(/\n+/g, "\n");
// 2. 可选:从“开始输出正文”之后开始(若存在)
const startMarker = "开始输出正文";
const startIndex = text.indexOf(startMarker);
if (startIndex !== -1) {
text = text.slice(startIndex + startMarker.length);
} else {
console.warn(
"未找到 '开始输出正文' 标记,将处理全文",
rawText.substring(0, 100) + "..."
);
}
// 3. 截断:遇到 <style 或 </style> 就停止(不区分大小写)
const styleRegex = /<\s*(?:\/\s*)?style\b/i;
const styleMatch = styleRegex.exec(text);
if (styleMatch) {
text = text.slice(0, styleMatch.index);
}
// 4. 去除“参考文献”及之后的内容
const refIndex = text.indexOf("参考文献");
if (refIndex !== -1) {
text = text.slice(0, refIndex);
}
// 5. 去除所有 HTML 标签:<xxx>、</xxx>、<xxx/> 等
let plainText = text.replace(/<[^>]+>/g, "");
// 6. 去除仅包含 emoji 的行
plainText = plainText
.split("\n")
.map((line) => line.trim())
.filter((line) => {
if (!line) return false;
const noSpaces = line.replace(/\s/g, "");
if (!noSpaces) return false;
// 判断是否全为 emoji(含组合符)
const isOnlyEmoji = /^[\p{Extended_Pictographic}\u{200D}]+$/u.test(
noSpaces
);
return !isOnlyEmoji;
})
.join(" "); // 合并为单行,用空格连接
// 7. 清理多余空白
const result = plainText.replace(/\s+/g, " ").trim();
return result;
}
/**
* 从 HTML 内容中智能提取最可能的主标题文本(安全版,防卡死)
*
* @param {string} html - HTML 字符串
* @returns {string|null} 提取出的标题文本,未找到则返回 null
*/
export function extractMainTitle(html: string): string | null {
if (typeof html !== "string" || !html.trim()) {
console.warn("Invalid HTML content");
return null;
}
// ✅ 安全限制:截断过长 HTML(防攻击或性能问题)
const MAX_LENGTH = 50_000;
const truncatedHtml = html.length > MAX_LENGTH ? html.slice(0, MAX_LENGTH) : html;
// ✅ 使用非贪婪但安全的正则,限制匹配范围
const tagPattern = /<(h1|h2|h3|h4|title|div|p|span)[^>]*?(?:class\s*=\s*["'][^"']*?(?:title|headline|heading|header|top)[^"']*?["'])?[^>]*>([^<]{1,200}?)<\/\1>/gi;
const candidates = [];
let match;
let index = 0;
// ✅ 防止无限循环:限制最大匹配次数
const MAX_MATCHES = 50;
while ((match = tagPattern.exec(truncatedHtml)) !== null && index++ < MAX_MATCHES) {
const [, tag, text] = match;
const classAttr = /class\s*=\s*["'][^"']*?(?:title|headline|heading|header|top)[^"']*?["']/i.test(match[0]);
candidates.push({
tag,
text: text.trim(),
hasTitleClass: classAttr,
index: match.index,
});
}
if (candidates.length === 0) {
// 回退:取前 200 字的纯文本(去标签)
return extractPlainTextFallback(truncatedHtml);
}
// 评分排序
const scored = candidates.map((item, i) => {
let score = 0;
if (item.tag === 'h1') score += 40;
else if (item.tag === 'h2') score += 30;
else if (['h3', 'h4'].includes(item.tag)) score += 10;
if (item.hasTitleClass) score += 20;
const len = item.text.length;
if (len >= 5 && len <= 100) score += 10;
else if (len === 0) score -= 50;
// 越靠前越好
score += Math.max(0, 20 - i * 2);
return { ...item, score };
});
scored.sort((a, b) => b.score - a.score);
const best = scored[0];
return best.score > 0 ? cleanText(best.text) : null;
}
/**
* 纯文本回退策略:去除 HTML 标签,取开头有意义文本
*/
function extractPlainTextFallback(html: string): string | null {
// 去除标签(安全方式)
const plain = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
if (!plain) return null;
// 取前几句
const firstSentence = plain.split(/[,,。.;]/)[0];
if (firstSentence.length >= 5 && firstSentence.length <= 100) {
return cleanText(firstSentence);
}
return cleanText(plain.substring(0, 50));
}
/**
* 清理文本:去空格、转义字符等
*/
function cleanText(text: string): string {
return text
.replace(/ /g, ' ')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&/g, '&')
.trim();
}
/**
* 解析 HTML 并提取疑似标题的候选元素
*/
function findTitleCandidates(html: string) {
const candidates = [];
const tagMatchRegex =
/<([a-zA-Z]+)([^>]*)>([^<]*<[^>]+>[^<]*)*[^<]*<\/\1>|<([a-zA-Z]+)([^>]*)\s*\/>/g;
const selfClosingTags = new Set(["br", "hr", "img", "input", "meta", "link"]);
let match;
while ((match = tagMatchRegex.exec(html)) !== null) {
const full = match[0];
const tag = match[1] || match[4]; // 匹配开始标签名
const attrs = match[2] || match[5] || "";
const innerHTML = match[3] || "";
// 跳过自闭合标签
if (selfClosingTags.has(tag.toLowerCase())) continue;
// 提取 class 属性
const classMatch = attrs.match(/class\s*=\s*["']([^"']*)["']/i);
const className = classMatch ? classMatch[1] : "";
const text = extractTextContent(innerHTML).trim();
// 只保留可能为标题的标签或含关键词 class
const isHeadingTag = /^h[1-6]$/i.test(tag);
const hasTitleClass = /\b(title|headline|heading|header)\b/i.test(
className
);
if (isHeadingTag || hasTitleClass) {
candidates.push({
tag: tag.toLowerCase(),
class: className,
text,
html: full,
});
}
}
return candidates;
}
/**
* 从 HTML 片段中提取纯文本(去标签)
*/
function extractTextContent(html: string): string {
return html.replace(/<[^>]+>/g, "").trim();
}
/**
* 回退方案:提取 HTML 中前几个有意义的文本块(用于无明确标题时)
*/
function extractTopTextualContent(html: string): string | null {
// 移除 script/style
const plain = html
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
// 匹配块级标签中的文本
const blockTags = ["p", "div", "h1", "h2", "h3", "section", "article", "li"];
const parts: any = [];
blockTags.forEach((tag) => {
const regex = new RegExp(`<${tag}[^>]*>([^<]+)<\\/${tag}>`, "gi");
let m;
while ((m = regex.exec(plain)) !== null) {
const text = cleanText(m[1]);
if (text.length > 5 && text.length < 150) {
parts.push({ text, index: m.index });
}
}
});
// 按出现位置排序,取最前面的
parts.sort((a: any, b: any) => a.index - b.index);
return parts.length > 0 ? parts[0].text : null;
}
/**
* 解析数据 保留ai文章/脚本的标签+样式内容
*/
export function parseContent(content: any) {
// 1. 统一换行符:将 ↵ \r\n 替换为 \n
const normalized = content.replace(/↵/g, "\n").replace(/\r\n/g, "\n");
// 2. 定位 "TDK信息开始输出" 标记
const tdkMarker = "TDK信息开始输出";
const tdkIndex = normalized.indexOf(tdkMarker);
if (tdkIndex === -1) {
console.log("未匹配到'TDK信息开始输出'------");
return {
tdk: null,
html: content,
};
}
// 3. 提取 TDK 之后的内容,用于解析 JSON
const afterTdk = normalized.slice(tdkIndex + tdkMarker.length).trim();
// 4. 找到第一个 '{' 开始提取 JSON
const jsonStartIdx = afterTdk.indexOf("{");
if (jsonStartIdx === -1) {
console.log("未找到 JSON 起始符 {------");
return {
tdk: null,
html: content,
};
}
let jsonString = "";
let braceCount = 0;
const chars = afterTdk.substring(jsonStartIdx);
for (let i = 0; i < chars.length; i++) {
const char = chars[i];
jsonString += char;
if (char === "{") braceCount++;
if (char === "}") braceCount--;
if (braceCount === 0) break; // 完整闭合
}
if (braceCount !== 0) {
console.log("JSON 括号未闭合-----");
return {
tdk: null,
html: content,
};
}
// 清理并解析 JSON
const cleanedJson = jsonString
.replace(/\n/g, " ")
.replace(/\s+/g, " ")
.replace(/,\s*\}/g, "}")
.replace(/,\s*\]/g, "]")
.trim();
let tdkData;
try {
tdkData = JSON.parse(cleanedJson);
} catch (e: any) {
console.error("JSON 解析失败:", e.message);
console.error("待解析字符串:", cleanedJson);
console.log(`JSON 格式错误:${e.message}----`);
return {
tdk: null,
html: content,
};
}
// 5. 提取 TDK 之前的内容
const beforeTdk = normalized.slice(0, tdkIndex);
// 6. 找到第一个 '<' 的位置,只保留从这里开始的 HTML(包含 style 标签)
const firstLessThan = beforeTdk.indexOf("<");
if (firstLessThan === -1) {
console.log("未找到 HTML 起始标签 <-----");
return {
tdk: null,
html: content,
};
}
const htmlWithStyle = beforeTdk.slice(firstLessThan).trim(); // 包含完整的 HTML 和 <style>...</style>
// 7. 返回结果:tdk + 合并后的完整 HTML(含 style 标签)
return {
tdk: tdkData,
html: htmlWithStyle, // ✅ 包含 <style> 和 </style> 的完整 HTML
};
}