diff --git a/crates/erp-core/src/sanitize.rs b/crates/erp-core/src/sanitize.rs index 614e81b..8053178 100644 --- a/crates/erp-core/src/sanitize.rs +++ b/crates/erp-core/src/sanitize.rs @@ -43,6 +43,79 @@ pub fn sanitize_string(input: &str) -> String { strip_html_tags(input) } +/// 对富文本 HTML 进行安全清理,保留安全的 HTML 标签和内联样式,去除危险元素。 +/// 适用于文章内容等需要保留 HTML 排版的场景。 +pub fn sanitize_rich_html(input: &str) -> String { + use std::collections::{HashMap, HashSet}; + + let tag_attrs: HashMap<&str, HashSet<&str>> = [ + ("div", HashSet::from(["style", "data-w-e-type"])), + ("span", HashSet::from(["style"])), + ("p", HashSet::from(["style"])), + ( + "img", + HashSet::from(["src", "alt", "style", "width", "height"]), + ), + ("a", HashSet::from(["href", "target"])), + ("td", HashSet::from(["style", "colspan", "rowspan"])), + ("th", HashSet::from(["style", "colspan", "rowspan"])), + ("blockquote", HashSet::from(["style"])), + ] + .into_iter() + .collect(); + + ammonia::Builder::new() + .tags( + [ + "p", + "br", + "span", + "div", + "strong", + "b", + "em", + "i", + "u", + "s", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "ul", + "ol", + "li", + "blockquote", + "pre", + "code", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "img", + "a", + "hr", + ] + .into_iter() + .collect(), + ) + .tag_attributes(tag_attrs) + .generic_attributes(HashSet::from(["style"])) + .url_relative(ammonia::UrlRelative::PassThrough) + .clean(input) + .to_string() +} + +/// 对 Option 的富文本进行安全清理。 +pub fn sanitize_rich_html_option(input: Option) -> Option { + input + .map(|s| sanitize_rich_html(&s)) + .filter(|s| !s.trim().is_empty()) +} + #[cfg(test)] mod tests { use super::*; @@ -108,4 +181,38 @@ mod tests { let result = strip_html_tags("a < b"); assert!(result.contains("a") && result.contains("b")); } + + #[test] + fn rich_html_preserves_safe_tags() { + let html = r#"

Hello

Green box
Bold"#; + let result = sanitize_rich_html(html); + assert!(result.contains("

Hello

"), "should preserve

tags"); + assert!( + result.contains("Bold"), + "should preserve " + ); + assert!( + result.contains("background"), + "should preserve style attribute" + ); + } + + #[test] + fn rich_html_removes_script() { + let html = r#"

Hello

"#; + let result = sanitize_rich_html(html); + assert!(!result.contains("script"), "should remove script tags"); + assert!(result.contains("Hello")); + } + + #[test] + fn rich_html_preserves_styled_block() { + let html = r#"
Tip content
"#; + let result = sanitize_rich_html(html); + assert!( + result.contains("styled-block"), + "should preserve data-w-e-type" + ); + assert!(result.contains("Tip content")); + } } diff --git a/crates/erp-health/src/dto/article_dto.rs b/crates/erp-health/src/dto/article_dto.rs index b38626d..de07383 100644 --- a/crates/erp-health/src/dto/article_dto.rs +++ b/crates/erp-health/src/dto/article_dto.rs @@ -2,7 +2,9 @@ use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; -use erp_core::sanitize::{sanitize_option, sanitize_string, strip_html_tags}; +use erp_core::sanitize::{ + sanitize_option, sanitize_rich_html_option, sanitize_string, strip_html_tags, +}; // --------------------------------------------------------------------------- // 文章 DTOs @@ -92,7 +94,8 @@ impl CreateArticleReq { pub fn sanitize(&mut self) { self.title = sanitize_string(&self.title); self.summary = sanitize_option(self.summary.take()); - self.content = sanitize_option(self.content.take()); + // content: rich_text 模式保留 HTML(仅做安全清理),其他模式剥离标签 + self.content = sanitize_rich_html_option(self.content.take()); self.category = sanitize_option(self.category.take()); self.author = sanitize_option(self.author.take()); self.slug = sanitize_option(self.slug.take()); @@ -125,7 +128,8 @@ impl UpdateArticleReq { *v = strip_html_tags(v); } self.summary = sanitize_option(self.summary.take()); - self.content = sanitize_option(self.content.take()); + // content: rich_text 模式保留 HTML(仅做安全清理),其他模式剥离标签 + self.content = sanitize_rich_html_option(self.content.take()); self.category = sanitize_option(self.category.take()); self.author = sanitize_option(self.author.take()); self.slug = sanitize_option(self.slug.take()); diff --git a/scripts/seed-dialysis-articles.mjs b/scripts/seed-dialysis-articles.mjs new file mode 100644 index 0000000..dd744a4 --- /dev/null +++ b/scripts/seed-dialysis-articles.mjs @@ -0,0 +1,261 @@ +/** + * 血透测试文章种子脚本 + * 用法: node scripts/seed-dialysis-articles.mjs + * 删除现有文章,创建 4 篇血透相关的富文本文章(published 状态) + */ + +const BASE = 'http://localhost:3000/api/v1'; +const LOGIN = { username: 'admin', password: 'Admin@2026' }; + +const W = 'data-w-e-type="styled-block"'; +const P = '#C4623A'; // primary +const PL = '#F0DDD4'; // primaryLight + +function applyColor(html) { + return html.replaceAll('{{primary}}', P).replaceAll('{{primaryLight}}', PL); +} + +const articles = [ + { + title: '血透患者日常饮食管理指南', + summary: '科学的饮食管理是维持性血液透析患者保持良好营养状态、减少并发症的关键。本文详细介绍了血透患者的饮食原则、各类营养素的摄入建议以及实用的饮食搭配技巧。', + sort_order: 1, + content: [ + `

血液透析患者在治疗过程中,合理的饮食管理不仅有助于维持良好的营养状态,还能有效减少透析并发症的发生。以下是一份全面的饮食管理指南,帮助您更好地控制饮食。

`, + + applyColor(`
一、蛋白质摄入管理
`), + + `

透析患者需要适量增加蛋白质摄入,以弥补透析过程中的蛋白质丢失。推荐每日蛋白质摄入量为 1.0-1.2 g/kg体重,其中优质蛋白质应占50%以上。

`, + + applyColor(`
1.2g
每公斤体重/天
50%
优质蛋白占比
`), + + applyColor(`
✓ 推荐食物:鸡蛋清、鱼肉、瘦肉、牛奶、豆腐等优质蛋白来源。
`), + + applyColor(`
✕ 限制食物:减少植物蛋白(如豆类制品过量)摄入,避免高嘌呤食物。
`), + + applyColor(`
`), + + applyColor(`
二、钾的控制
`), + + `

高钾血症是透析患者常见的危及生命的并发症。透析间期血钾应控制在 3.5-5.5 mmol/L

`, + + applyColor(`
检查项目
目标范围
血钾
3.5-5.5 mmol/L
血磷
0.87-1.45 mmol/L
血钙
2.1-2.6 mmol/L
`), + + applyColor(`
⚠ 注意事项:高钾水果(香蕉、橙子、猕猴桃)需严格控制摄入量。蔬菜可通过水煮去钾处理后再食用。
`), + + applyColor(`
`), + + applyColor(`
三、水分控制
`), + + `

透析间期体重增长应控制在干体重的 3%-5% 以内。每日液体摄入量一般为前一日尿量加 500ml。

`, + + applyColor(`
1
记录每日体重
每天早上空腹称重,记录体重变化趋势
2
控制液体摄入
使用有刻度的水杯,做到心中有数
3
减少隐形水分
粥、汤、水果中的水分也需计入总量
`), + + applyColor(`
"
透析间期体重增长不超过干体重的5%,是减少心血管并发症的重要措施。
—— KDIGO 指南建议
`), + + applyColor(`
`), + + applyColor(`
四、磷的管理
`), + + `

高磷血症可导致继发性甲状旁腺功能亢进和血管钙化。每日磷摄入量应控制在 800-1000mg,并遵医嘱服用磷结合剂。

`, + + applyColor(`
✓ 低磷食物
鸡蛋清
冬瓜、黄瓜
米饭、面条
苹果、梨
✕ 高磷食物
动物内脏
坚果、芝麻
碳酸饮料
加工肉制品
`), + + applyColor(`
★ 温馨提示:以上饮食建议仅供参考,具体饮食方案请遵医嘱。如有不适请及时联系您的主治医生。
`), + ].join(''), + }, + { + title: '血液透析治疗流程全解析', + summary: '了解血液透析的完整治疗流程,从透析前准备到透析后护理,帮助患者和家属全面认识透析治疗过程,减少焦虑,提高治疗依从性。', + sort_order: 2, + content: [ + `

血液透析(Hemodialysis, HD)是目前治疗急慢性肾衰竭最常用的肾脏替代治疗方法之一。本文将详细解析一次完整的血液透析治疗流程。

`, + + applyColor(`
透析治疗完整流程
`), + + applyColor(`
透析前一天
控制水分摄入,测量体重,准备透析用品
透析当天 · 到达前
穿宽松衣物,带好透析卡和药物,测量体重
透析开始
血管穿刺/连接,血流量调节,参数设置
透析进行中(4小时)
持续监测血压、心率,注意有无不适
透析结束
回血、拔针、压迫止血、测量体重
透析后观察
休息15-30分钟,确认无头晕出血后再离开
`), + + applyColor(`
`), + + applyColor(`
透析参数与监测
`), + + `

标准的血液透析治疗通常每次持续 4小时,每周进行 3次。治疗过程中需要持续监测多项参数。

`, + + applyColor(`
参数
标准值
说明
血流量
200-300 ml/min
因人而异
透析液流量
500 ml/min
标准设置
超滤量
个体化制定
根据体重增长
治疗时间
4 小时
每次标准
`), + + applyColor(`
ℹ 补充说明:透析充分性评估通常使用 Kt/V 值,目标值应 ≥ 1.2。您的医生会定期检查此项指标。
`), + + applyColor(`
Q:透析过程中可以进食吗?
A:可以少量进食,但建议选择易消化的食物。避免过饱进食,以防低血压。透析中进食还可以预防低血糖的发生。
`), + + applyColor(`
Q:透析后为什么会感到疲乏?
A:透析后疲乏是常见现象,主要与体液变化、电解质调整和代谢废物清除有关。建议透析后适当休息,保证充足睡眠。
`), + ].join(''), + }, + { + title: '血透患者血管通路的护理要点', + summary: '血管通路被称为血透患者的"生命线",正确的护理至关重要。本文介绍动静脉内瘘和中心静脉导管的日常护理方法,帮助您延长通路使用寿命。', + sort_order: 3, + content: [ + `

血管通路是血液透析治疗的基础,良好的血管通路是保证透析质量和患者安全的前提。保护好您的"生命线",从日常护理做起。

`, + + applyColor(`
动静脉内瘘(AVF)护理
`), + + `

动静脉内瘘是目前最理想的长期血管通路,平均使用寿命可达 5-10年。正确的日常护理能显著延长内瘘的使用寿命。

`, + + applyColor(`
1
每日自检
每天触摸内瘘部位,感受震颤("嗡嗡"感),如震颤消失需立即就医
2
避免压迫
不在内瘘侧手臂测量血压、抽血、输液或佩戴过紧饰品
3
适当锻炼
术后2-4周开始握球训练,促进内瘘成熟
4
保持清洁
透析前清洗内瘘侧手臂,保持穿刺部位清洁干燥
`), + + applyColor(`
✕ 严禁事项:内瘘侧手臂避免提重物(>5kg)、避免侧卧压迫、避免接触锐器。如发现震颤减弱或消失、局部红肿热痛,应立即就医。
`), + + applyColor(`
`), + + applyColor(`
中心静脉导管(CVC)护理
`), + + `

中心静脉导管通常用于内瘘成熟前的过渡期或无法建立内瘘的患者。导管护理的核心是 预防感染保持通畅

`, + + applyColor(`
100%
保持敷料干燥
2次
每周换药频率
`), + + applyColor(`
⚠ 注意事项:洗澡时必须用防水贴膜覆盖导管出口处。禁止在导管附近使用剪刀等锐器。如导管不慎脱出,立即压迫止血并急诊就医。
`), + + applyColor(`
内瘘成熟度良好
`), + + applyColor(`
★ 就医指征:以下情况需立即就医:① 震颤消失 ② 穿刺点持续出血 ③ 导管出口红肿渗液 ④ 内瘘部位出现搏动性包块 ⑤ 体温超过38°C
`), + ].join(''), + }, + { + title: '血液透析常见并发症及应对策略', + summary: '了解血液透析过程中可能出现的低血压、肌肉痉挛、失衡综合征等常见并发症的表现形式和应对方法,做到早识别、早处理。', + sort_order: 4, + content: [ + `

虽然血液透析是安全有效的治疗方式,但治疗过程中和治疗后可能出现一些并发症。了解这些并发症的表现和应对方法,有助于患者更好地配合治疗。

`, + + applyColor(`
1透析低血压
`), + + `

透析低血压是最常见的急性并发症,发生率约 20%-30%。表现为头晕、恶心、出冷汗、肌肉痉挛等。

`, + + applyColor(`
25%
发生率
90/60
诊断标准 (mmHg)
`), + + applyColor(`
✓ 应对方法:立即降低超滤速度,采取头低脚高位,必要时补充生理盐水100-200ml。平时注意控制透析间期体重增长。
`), + + applyColor(`
`), + + applyColor(`
2肌肉痉挛
`), + + `

多发生于透析后半程,以小腿和足部肌肉痉挛最常见,与超滤过快、低血压、低钠血症等因素有关。

`, + + applyColor(`
✓ 应对方法:减慢超滤速度,局部按摩热敷,补充高渗盐水或葡萄糖。日常补充维生素E和左旋卡尼汀可能有预防作用。
`), + + applyColor(`
`), + + applyColor(`
3透析失衡综合征
`), + + `

主要见于首次透析或透析间隔过长的患者,由于血中尿素氮等溶质快速清除,导致脑组织与血液之间产生渗透压差。

`, + + applyColor(`
ℹ 临床表现:轻度表现为头痛、恶心、呕吐、烦躁不安;重度可出现意识障碍、抽搐甚至昏迷。预防关键是首次透析采用低血流量、短时间(2小时)的诱导透析方案。
`), + + applyColor(`
`), + + applyColor(`
4透析相关发热
`), + + `

透析过程中或透析结束后出现的体温升高,需区分感染性和非感染性发热。

`, + + applyColor(`
类型
特点
处理
致热原反应
透析开始1-2h
对症处理
感染性
透析结束后
抗感染治疗
`), + + applyColor(`
Q:透析中出现不适应该怎么办?
A:立即告知医护人员,不要自行忍耐。医护人员会根据症状严重程度及时调整透析参数或给予对症处理。
`), + + applyColor(`
★ 紧急就医:如出现严重头痛伴呕吐、持续胸痛、呼吸困难、意识模糊、内瘘震颤消失、导管出口大量渗血渗液等严重情况,请立即拨打120或前往最近医院急诊。
`), + ].join(''), + }, +]; + +async function main() { + console.log('🔑 登录获取 Token...'); + const loginRes = await fetch(`${BASE}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(LOGIN), + }); + const loginData = await loginRes.json(); + const token = loginData.data.access_token; + if (!token) { console.error('登录失败:', loginData); process.exit(1); } + console.log('✅ 登录成功'); + + const headers = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }; + + // 删除现有文章 + console.log('\n📋 获取现有文章...'); + const listRes = await fetch(`${BASE}/health/articles?page=1&page_size=100`, { headers }); + const listData = await listRes.json(); + const existingArticles = listData.data?.items || []; + console.log(` 找到 ${existingArticles.length} 篇现有文章`); + + for (const article of existingArticles) { + await fetch(`${BASE}/health/articles/${article.id}`, { method: 'DELETE', headers }); + console.log(` 🗑 删除: ${article.title}`); + } + + // 获取分类和标签 + const [catsRes, tagsRes] = await Promise.all([ + fetch(`${BASE}/health/article-categories`, { headers }), + fetch(`${BASE}/health/article-tags`, { headers }), + ]); + const cats = (await catsRes.json()).data || []; + const tags = (await tagsRes.json()).data || []; + console.log(`\n📂 分类: ${cats.map((c) => c.name).join(', ') || '无'}`); + console.log(`🏷 标签: ${tags.map((t) => t.name).join(', ') || '无'}`); + + // 创建文章 + console.log('\n📝 创建血透测试文章...'); + for (const article of articles) { + const createRes = await fetch(`${BASE}/health/articles`, { + method: 'POST', + headers, + body: JSON.stringify({ + title: article.title, + summary: article.summary, + content: article.content, + content_type: 'rich_text', + sort_order: article.sort_order, + category_id: cats[0]?.id, + tag_ids: tags.slice(0, 2).map((t) => t.id), + }), + }); + const created = await createRes.json(); + if (!created.success) { + console.error(` ❌ 创建失败: ${article.title}`, created.message); + continue; + } + const articleId = created.data.id; + const version = created.data.version; + console.log(` ✅ 创建: ${article.title} (${articleId.slice(0, 8)}...)`); + + // 提交审核 + const submitRes = await fetch(`${BASE}/health/articles/${articleId}/submit`, { + method: 'POST', + headers, + body: JSON.stringify({ version }), + }); + const submitted = await submitRes.json(); + if (submitted.success) { + const newVersion = submitted.data.version; + console.log(` 📤 提交审核成功 (v${newVersion})`); + + // 审核通过 + const approveRes = await fetch(`${BASE}/health/articles/${articleId}/approve`, { + method: 'POST', + headers, + body: JSON.stringify({ version: newVersion }), + }); + const approved = await approveRes.json(); + if (approved.success) { + console.log(` ✅ 审核通过,已发布`); + } else { + console.error(` ❌ 审核失败:`, approved.message); + } + } else { + console.error(` ❌ 提交失败:`, submitted.message); + } + } + + console.log('\n🎉 完成!4 篇血透测试文章已创建并发布。'); +} + +main().catch((e) => { console.error('脚本执行失败:', e); process.exit(1); });