feat: 佛经诵读站点初版(心经/金刚经 + 千问TTS)

- 仿故宫词条页视觉:宣纸底、朱砂印章、横竖排、分页阅读
- 千问 qwen3-tts-flash 服务端代理 + 磁盘/会话双缓存
- 整文合并为单 WAV 播放,段间静音内嵌,锁屏后台不断
- 循环开关、点击段落起读、跟读高亮自动滚动翻页
- 移动端折叠控制栏,部署于 fj.all8ai.top

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-09-28 13:25:43 +08:00
co-authored by Devin
commit 3c627d9dd6
10 changed files with 1331 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# 阿里云百炼 / MaaS 网关密钥(请勿提交真实密钥到 Git)
DASHSCOPE_API_KEY=sk-xxxxxxxx
# DashScope 兼容接口地址(/api/v1 结尾)
DASHSCOPE_BASE=https://llm-znfsxsdp6uwik7ad.cn-beijing.maas.aliyuncs.com/api/v1
# TTS 模型
TTS_MODEL=qwen3-tts-flash
# 本地服务端口
PORT=8000
+4
View File
@@ -0,0 +1,4 @@
.env
cache/
__pycache__/
.DS_Store
+15
View File
@@ -0,0 +1,15 @@
# 般若波罗蜜多心经 · 金刚般若波罗蜜经 · 诵读页
仿故宫博物院词条页设计的佛经诵读页,收录《心经》(玄奘译,12 段)与《金刚经》(鸠摩罗什译,32 品共 36 段,分 9 页阅读)。支持千问 Qwen3-TTS 多音色朗读、逐段高亮跟读、点击段落起读、自动翻页、大中小字号与横竖排切换。
## 快速开始
```bash
cp .env.example .env # 填入 DASHSCOPE_API_KEY
python3 server.py # 打开 http://127.0.0.1:8000
```
- `/#jingang` 直达金刚经,`/#vertical` 直达竖排,`/#jingang-vertical` 组合可用
- ← → 键翻页
详见 `run.md`。
+530
View File
@@ -0,0 +1,530 @@
/**
* 心经 / 金刚经 诵读页前端逻辑:经文渲染、分页阅读、逐段高亮朗读、音色选择。
*/
// ===== 经文数据 =====
// segments 项:{ h: 品名(可选), g: 品名小注(可选), t: 正文 }
const SUTRAS = [
{
id: "xinjing",
name: "心经",
title: "般若波罗蜜多心经",
seal: "心经",
translator: "唐三藏法师 玄奘 译",
source: "故宫博物院",
sourceUrl: "https://www.dpm.org.cn/lemmas/243222.html",
pageSize: 12, // 短篇一屏读完
segments: [
{ t: "观自在菩萨,行深般若波罗蜜多时,照见五蕴皆空,度一切苦厄。" },
{ t: "舍利子,色不异空,空不异色。色即是空,空即是色。受、想、行、识,亦复如是。" },
{ t: "舍利子,是诸法空相,不生不灭,不垢不净,不增不减。" },
{ t: "是故空中无色,无受、想、行、识;无眼、耳、鼻、舌、身、意;无色、声、香、味、触、法;" },
{ t: "无眼界,乃至无意识界;" },
{ t: "无无明,亦无无明尽,乃至无老死,亦无老死尽;" },
{ t: "无苦、集、灭、道;无智亦无得。" },
{ t: "以无所得故,菩提萨埵,依般若波罗蜜多故,心无挂碍。" },
{ t: "无挂碍故,无有恐怖,远离颠倒梦想,究竟涅槃。" },
{ t: "三世诸佛,依般若波罗蜜多故,得阿耨多罗三藐三菩提。" },
{ t: "故知般若波罗蜜多,是大神咒,是大明咒,是无上咒,是无等等咒,能除一切苦,真实不虚。" },
{ t: "故说般若波罗蜜多咒,即说咒曰:揭谛揭谛,波罗揭谛,波罗僧揭谛,菩提萨婆诃。" },
],
},
{
id: "jingang",
name: "金刚经",
title: "金刚般若波罗蜜经",
seal: "金刚经",
translator: "姚秦三藏法师 鸠摩罗什 译",
source: "维基文库 · 大正新修大藏经",
sourceUrl: "https://zh.wikisource.org/wiki/金剛般若波羅蜜經_(鳩摩羅什)",
pageSize: 4, // 每页 4 段
segments: [
{ h: "法会因由分第一", g: "说法聚会,由此起因", t: "如是我闻。一时,佛在舍卫国祇树给孤独园,与大比丘众千二百五十人俱。尔时,世尊食时,著衣持钵,入舍卫大城乞食。于其城中,次第乞已,还至本处。饭食讫,收衣钵,洗足已,敷座而坐。" },
{ h: "善现启请分第二", g: "善现长老,启请佛训", t: "时,长老须菩提,在大众中,即从座起,偏袒右肩,右膝著地,合掌恭敬,而白佛言:“希有世尊!如来善护念诸菩萨,善付嘱诸菩萨。世尊!善男子、善女人,发阿耨多罗三藐三菩提心,应云何住?云何降伏其心?”佛言:“善哉,善哉。须菩提!如汝所说:如来善护念诸菩萨,善付嘱诸菩萨。汝今谛听,当为汝说:善男子、善女人,发阿耨多罗三藐三菩提心,应如是住,如是降伏其心。”“唯然,世尊!愿乐欲闻。”" },
{ h: "大乘正宗分第三", g: "最大之乘,最正之宗", t: "佛告须菩提:“诸菩萨摩诃萨,应如是降伏其心:所有一切众生之类,若卵生、若胎生、若湿生、若化生;若有色、若无色;若有想、若无想、若非有想非无想,我皆令入无馀涅槃而灭度之。如是灭度无量无数无边众生,实无众生得灭度者。何以故?须菩提!若菩萨有我相、人相、众生相、寿者相,即非菩萨。”" },
{ h: "妙行无住分第四", g: "奥妙之行,本无住著", t: "“复次,须菩提!菩萨于法,应无所住,行于布施。所谓不住色布施,不住声、香、味、触、法布施。须菩提!菩萨应如是布施,不住于相。何以故?若菩萨不住相布施,其福德不可思量。”“须菩提!于意云何?东方虚空,可思量不?”“不也,世尊!”“须菩提!南、西、北方,四维上下虚空,可思量不?”“不也,世尊!”“须菩提!菩萨无住相布施,福德亦复如是不可思量。须菩提!菩萨但应如所教住。”" },
{ h: "如理实见分第五", g: "自如之理,乃见真实", t: "“须菩提!于意云何?可以身相见如来不?”“不也,世尊!不可以身相得见如来。何以故?如来所说身相,即非身相。”佛告须菩提:“凡所有相,皆是虚妄。若见诸相非相,即见如来。”" },
{ h: "正信希有分第六", g: "生正信心,最为希有", t: "须菩提白佛言:“世尊!颇有众生,得闻如是言说章句,生实信不?”佛告须菩提:“莫作是说。如来灭后,后五百岁,有持戒修福者,于此章句能生信心,以此为实,当知是人不于一佛、二佛、三四五佛而种善根,已于无量千万佛所种诸善根,闻是章句,乃至一念生净信者,须菩提!如来悉知悉见,是诸众生得如是无量福德。何以故?是诸众生无复我相、人相、众生相、寿者相。”“无法相,亦无非法相。何以故?是诸众生若心取相,则为著我、人、众生、寿者。若取法相,即著我、人、众生、寿者。何以故?若取非法相,即著我、人、众生、寿者。是故不应取法,不应取非法。以是义故,如来常说:汝等比丘,知我说法,如筏喻者,法尚应舍,何况非法。”" },
{ h: "无得无说分第七", g: "空则无得,寂则无说", t: "“须菩提!于意云何?如来得阿耨多罗三藐三菩提耶?如来有所说法耶?”须菩提言:“如我解佛所说义,无有定法,名阿耨多罗三藐三菩提,亦无有定法,如来可说。何以故?如来所说法,皆不可取、不可说;非法,非非法。所以者何?一切贤圣,皆以无为法而有差别。”" },
{ h: "依法出生分第八", g: "诸佛之法,依此生出", t: "“须菩提!于意云何?若人满三千大千世界七宝以用布施,是人所得福德,宁为多不?”须菩提言:“甚多,世尊!何以故?是福德即非福德性,是故如来说福德多。”“若复有人,于此经中受持,乃至四句偈等,为他人说,其福胜彼。何以故?须菩提!一切诸佛,及诸佛阿耨多罗三藐三菩提法,皆从此经出。须菩提!所谓佛法者,即非佛法,是名佛法。”" },
{ h: "一相无相分第九", g: "只此一相,本来无形", t: "“须菩提!于意云何?须陀洹能作是念:我得须陀洹果不?”须菩提言:“不也,世尊!何以故?须陀洹名为入流,而无所入;不入色、声、香、味、触、法,是名须陀洹。”“须菩提!于意云何?斯陀含能作是念:我得斯陀含果不?”须菩提言:“不也,世尊!何以故?斯陀含名一往来,而实无往来,是名斯陀含。”“须菩提!于意云何?阿那含能作是念:我得阿那含果不?”须菩提言:“不也,世尊!何以故?阿那含名为不来,而实无不来,是故名阿那含。”“须菩提!于意云何?阿罗汉能作是念:我得阿罗汉道不?”须菩提言:“不也,世尊!何以故?实无有法名阿罗汉。世尊!若阿罗汉作是念:我得阿罗汉道,即为著我、人、众生、寿者。世尊!佛说我得无诤三昧,人中最为第一,是第一离欲阿罗汉。世尊!我不作是念:我是离欲阿罗汉。世尊!我若作是念:我得阿罗汉道,世尊则不说须菩提是乐阿兰那行者。以须菩提实无所行,而名须菩提,是乐阿兰那行。”" },
{ h: "庄严净土分第十", g: "成就庄严,净明心地", t: "佛告须菩提:“于意云何?如来昔在燃灯佛所,于法有所得不?”“不也,世尊!如来在燃灯佛所,于法实无所得。”“须菩提!于意云何?菩萨庄严佛土不?”“不也,世尊!何以故?庄严佛土者,即非庄严,是名庄严。”“是故,须菩提!诸菩萨摩诃萨,应如是生清净心,不应住色生心,不应住声、香、味、触、法生心,应无所住而生其心。”“须菩提!譬如有人,身如须弥山王,于意云何?是身为大不?”须菩提言:“甚大,世尊!何以故?佛说非身,是名大身。”" },
{ h: "无为福胜分第十一", g: "修无为福,胜于布施", t: "“须菩提!如恒河中所有沙数,如是沙等恒河,于意云何?是诸恒河沙,宁为多不?”须菩提言:“甚多,世尊!但诸恒河,尚多无数,何况其沙。”“须菩提!我今实言告汝:若有善男子、善女人,以七宝满尔所恒河沙数三千大千世界,以用布施,得福多不?”须菩提言:“甚多,世尊!”佛告须菩提:“若善男子、善女人,于此经中,乃至受持四句偈等,为他人说,而此福德,胜前福德。”" },
{ h: "尊重正教分第十二", g: "受持正教,天人尊重", t: "“复次,须菩提!随说是经,乃至四句偈等,当知此处,一切世间,天、人、阿修罗,皆应供养,如佛塔庙。何况有人,尽能受持读诵。须菩提!当知是人成就最上第一希有之法,若是经典所在之处,即为有佛,若尊重弟子。”" },
{ h: "如法受持分第十三", g: "当如此法,承受奉持", t: "尔时,须菩提白佛言:“世尊!当何名此经?我等云何奉持?”佛告须菩提:“是经名为金刚般若波罗蜜,以是名字,汝当奉持。所以者何?须菩提!佛说般若波罗蜜,即非般若波罗蜜,是名般若波罗蜜。须菩提!于意云何?如来有所说法不?”须菩提白佛言:“世尊!如来无所说。”“须菩提!于意云何?三千大千世界所有微尘,是为多不?”须菩提言:“甚多,世尊!”“须菩提!诸微尘,如来说非微尘,是名微尘。如来说世界,非世界,是名世界。须菩提!于意云何?可以三十二相见如来不?”“不也,世尊!不可以三十二相得见如来。何以故?如来说三十二相,即是非相,是名三十二相。”“须菩提!若有善男子、善女人,以恒河沙等身命布施;若复有人,于此经中,乃至受持四句偈等,为他人说,其福甚多!”" },
{ h: "离相寂灭分第十四", g: "离诸形相,自得寂灭", t: "尔时,须菩提闻说是经,深解义趣,涕泪悲泣,而白佛言:“希有世尊!佛说如是甚深经典,我从昔来所得慧眼,未曾得闻如是之经。世尊!若复有人,得闻是经,信心清净,则生实相,当知是人,成就第一希有功德。世尊!是实相者,则是非相,是故如来说名实相。世尊!我今得闻如是经典,信解受持,不足为难;若当来世,后五百岁,其有众生,得闻是经,信解受持,是人则为第一希有。何以故?此人无我相、无人相、无众生相、无寿者相。所以者何?我相,即是非相;人相、众生相、寿者相,即是非相。何以故?离一切诸相,则名诸佛。”" },
{ t: "佛告须菩提:“如是,如是!若复有人,得闻是经,不惊、不怖、不畏,当知是人,甚为希有。何以故?须菩提!如来说:第一波罗蜜,即非第一波罗蜜,是名第一波罗蜜。须菩提!忍辱波罗蜜,如来说非忍辱波罗蜜,是名忍辱波罗蜜。何以故?须菩提!如我昔为歌利王割截身体,我于尔时,无我相、无人相、无众生相、无寿者相。何以故?我于往昔,节节支解时,若有我相、人相、众生相、寿者相,应生瞋恨。须菩提!又念过去,于五百世,作忍辱仙人,于尔所世,无我相、无人相、无众生相、无寿者相。是故须菩提!菩萨应离一切相,发阿耨多罗三藐三菩提心。不应住色生心,不应住声、香、味、触、法生心,应生无所住心。若心有住,则为非住。”" },
{ t: "“是故佛说:菩萨心不应住色布施。须菩提!菩萨为利益一切众生故,应如是布施。如来说:一切诸相,即是非相。又说:一切众生,即非众生。须菩提!如来是真语者、实语者、如语者、不诳语者、不异语者。”“须菩提!如来所得法,此法无实无虚。须菩提!若菩萨心住于法而行布施,如人入暗,则无所见;若菩萨心不住法而行布施,如人有目,日光明照,见种种色。”“须菩提!当来之世,若有善男子、善女人,能于此经,受持读诵,则为如来,以佛智慧,悉知是人,悉见是人,皆得成就无量无边功德。”" },
{ h: "持经功德分第十五", g: "受持此经,功德无量", t: "“须菩提!若有善男子、善女人,初日分以恒河沙等身布施,中日分复以恒河沙等身布施,后日分亦以恒河沙等身布施,如是无量百千万亿劫,以身布施;若复有人,闻此经典,信心不逆,其福胜彼,何况书写、受持、读诵、为人解说。”“须菩提!以要言之,是经有不可思议、不可称量、无边功德。如来为发大乘者说,为发最上乘者说。若有人能受持读诵,广为人说,如来悉知是人,悉见是人,皆得成就不可量、不可称、无有边、不可思议功德。”“如是人等,则为荷担如来阿耨多罗三藐三菩提。何以故?须菩提!若乐小法者,著我见、人见、众生见、寿者见,则于此经不能听受、读诵,为人解说。”“须菩提!在在处处,若有此经,一切世间,天、人、阿修罗,所应供养;当知此处,则为是塔,皆应恭敬,作礼围绕,以诸华香而散其处。”" },
{ h: "能净业障分第十六", g: "若能清净,业障尽消", t: "“复次,须菩提!善男子、善女人,受持读诵此经,若为人轻贱,是人先世罪业,应堕恶道,以今世人轻贱故,先世罪业则为消灭,当得阿耨多罗三藐三菩提。”“须菩提!我念过去无量阿僧祇劫,于燃灯佛前,得值八百四千万亿那由他诸佛,悉皆供养承事,无空过者;若复有人,于后末世,能受持读诵此经,所得功德,于我所供养诸佛功德,百分不及一,千万亿分、乃至算数譬喻所不能及。”“须菩提!若善男子、善女人,于后末世,有受持读诵此经,所得功德,我若具说者,或有人闻,心则狂乱,狐疑不信。须菩提!当知是经义不可思议,果报亦不可思议!”" },
{ h: "究竟无我分第十七", g: "成佛究竟,本无我相", t: "尔时,须菩提白佛言:“世尊!善男子、善女人,发阿耨多罗三藐三菩提心,云何应住?云何降伏其心?”佛告须菩提:“善男子、善女人,发阿耨多罗三藐三菩提心者,当生如是心:我应灭度一切众生。灭度一切众生已,而无有一众生实灭度者。何以故?须菩提!若菩萨有我相、人相、众生相、寿者相,则非菩萨。所以者何?须菩提!实无有法,发阿耨多罗三藐三菩提心者。”“须菩提!于意云何?如来于燃灯佛所,有法得阿耨多罗三藐三菩提不?”“不也,世尊!如我解佛所说义,佛于燃灯佛所,无有法得阿耨多罗三藐三菩提。”" },
{ t: "佛言:“如是,如是!须菩提!实无有法,如来得阿耨多罗三藐三菩提。须菩提!若有法,如来得阿耨多罗三藐三菩提者,燃灯佛则不与我授记:‘汝于来世,当得作佛,号释迦牟尼。’以实无有法得阿耨多罗三藐三菩提,是故燃灯佛与我授记,作是言:‘汝于来世,当得作佛,号释迦牟尼。’何以故?如来者,即诸法如义。”“若有人言:如来得阿耨多罗三藐三菩提。须菩提!实无有法,佛得阿耨多罗三藐三菩提。须菩提!如来所得阿耨多罗三藐三菩提,于是中无实无虚。是故如来说:一切法皆是佛法。须菩提!所言一切法者,即非一切法,是故名一切法。”" },
{ t: "“须菩提!譬如人身长大。”须菩提言:“世尊!如来说:人身长大,则为非大身,是名大身。”“须菩提!菩萨亦如是。若作是言:我当灭度无量众生,则不名菩萨。何以故?须菩提!实无有法名为菩萨。是故佛说一切法,无我、无人、无众生、无寿者。”“须菩提!若菩萨作是言:‘我当庄严佛土’,是不名菩萨。何以故?如来说:庄严佛土者,即非庄严,是名庄严。”“须菩提!若菩萨通达无我、法者,如来说名真是菩萨。”" },
{ h: "一体同观分第十八", g: "万法归一,更无异观", t: "“须菩提!于意云何?如来有肉眼不?”“如是,世尊!如来有肉眼。”“须菩提!于意云何?如来有天眼不?”“如是,世尊!如来有天眼。”“须菩提!于意云何?如来有慧眼不?”“如是,世尊!如来有慧眼。”“须菩提!于意云何?如来有法眼不?”“如是,世尊!如来有法眼。”“须菩提!于意云何?如来有佛眼不?”“如是,世尊!如来有佛眼。”“须菩提!于意云何?如恒河中所有沙,佛说是沙不?”“如是,世尊!如来说是沙。”“须菩提!于意云何?如一恒河中所有沙,有如是沙等恒河,是诸恒河所有沙数,佛世界如是,宁为多不?”“甚多,世尊。”佛告须菩提:“尔所国土中,所有众生,若干种心,如来悉知。何以故?如来说:诸心皆为非心,是名为心。所以者何?须菩提!过去心不可得,现在心不可得,未来心不可得。”" },
{ h: "法界通化分第十九", g: "法身遍界,通化无边", t: "“须菩提!于意云何?若有人满三千大千世界七宝,以用布施,是人以是因缘,得福多不?”“如是,世尊!此人以是因缘,得福甚多。”“须菩提!若福德有实,如来不说得福德多;以福德无故,如来说得福德多。”" },
{ h: "离色离相分第二十", g: "色相皆妄,离妄见性", t: "“须菩提!于意云何?佛可以具足色身见不?”“不也,世尊!如来不应以具足色身见。何以故?如来说具足色身,即非具足色身,是名具足色身。”“须菩提!于意云何?如来可以具足诸相见不?”“不也,世尊!如来不应以具足诸相见。何以故?如来说诸相具足,即非具足,是名诸相具足。”" },
{ h: "非说所说分第二十一", g: "法无可说,所说非法", t: "“须菩提!汝勿谓如来作是念:我当有所说法。莫作是念。何以故?若人言如来有所说法,即为谤佛,不能解我所说故。须菩提!说法者,无法可说,是名说法。”尔时,慧命须菩提白佛言:“世尊!颇有众生,于未来世,闻说是法,生信心不?”佛言:“须菩提!彼非众生,非不众生。何以故?须菩提!众生众生者,如来说非众生,是名众生。”" },
{ h: "无法可得分第二十二", g: "悟性空故,无法可得", t: "须菩提白佛言:“世尊!佛得阿耨多罗三藐三菩提,为无所得耶?”佛言:“如是,如是。须菩提!我于阿耨多罗三藐三菩提乃至无有少法可得,是名阿耨多罗三藐三菩提。”" },
{ h: "净心行善分第二十三", g: "以清净心,行诸善法", t: "“复次,须菩提!是法平等,无有高下,是名阿耨多罗三藐三菩提;以无我、无人、无众生、无寿者,修一切善法,则得阿耨多罗三藐三菩提。须菩提!所言善法者,如来说即非善法,是名善法。”" },
{ h: "福智无比分第二十四", g: "福智甚大,无物可比", t: "“须菩提!若三千大千世界中,所有诸须弥山王,如是等七宝聚,有人持用布施;若人以此般若波罗蜜经,乃至四句偈等,受持读诵,为他人说,于前福德百分不及一,百千万亿分、乃至算数譬喻所不能及。”" },
{ h: "化无所化分第二十五", g: "圣凡同性,化无所化", t: "“须菩提!于意云何?汝等勿谓如来作是念:我当度众生。须菩提!莫作是念。何以故?实无有众生如来度者。若有众生如来度者,如来则有我、人、众生、寿者。须菩提!如来说有我者,则非有我,而凡夫之人以为有我。须菩提!凡夫者,如来说则非凡夫,是名凡夫。”" },
{ h: "法身非相分第二十六", g: "清净法身,非属相貌", t: "“须菩提!于意云何?可以三十二相观如来不?”须菩提言:“如是!如是!以三十二相观如来。”佛言:“须菩提!若以三十二相观如来者,转轮圣王则是如来。”须菩提白佛言:“世尊!如我解佛所说义,不应以三十二相观如来。”尔时,世尊而说偈言:“若以色见我,以音声求我,是人行邪道,不能见如来。”" },
{ h: "无断无灭分第二十七", g: "依法修持,不应断灭", t: "“须菩提!汝若作是念:如来不以具足相故,得阿耨多罗三藐三菩提。须菩提!莫作是念:如来不以具足相故,得阿耨多罗三藐三菩提。”“须菩提!汝若作是念,发阿耨多罗三藐三菩提心者,说诸法断灭。莫作是念!何以故?发阿耨多罗三藐三菩提心者,于法不说断灭相。”" },
{ h: "不受不贪分第二十八", g: "一尘不染,何贪何受", t: "“须菩提!若菩萨以满恒河沙等世界七宝持用布施;若复有人,知一切法无我,得成于忍,此菩萨胜前菩萨所得功德。何以故?须菩提!以诸菩萨不受福德故。”须菩提白佛言:“世尊!云何菩萨不受福德?”“须菩提!菩萨所作福德,不应贪著,是故说不受福德。”" },
{ h: "威仪寂静分第二十九", g: "真性寂静,不假威仪", t: "“须菩提!若有人言:如来若来若去,若坐若卧,是人不解我所说义。何以故?如来者,无所从来,亦无所去,故名如来。”" },
{ h: "一合理相分第三十", g: "一合之理,实无有相", t: "“须菩提!若善男子、善女人,以三千大千世界碎为微尘,于意云何?是微尘众宁为多不?”须菩提言:“甚多,世尊!何以故?若是微尘众实有者,佛则不说是微尘众。所以者何?佛说微尘众,则非微尘众,是名微尘众。”“世尊!如来所说三千大千世界,则非世界,是名世界。何以故?若世界实有者,则是一合相。如来说一合相,则非一合相,是名一合相。”“须菩提!一合相者,则是不可说;但凡夫之人,贪著其事。”" },
{ h: "知见不生分第三十一", g: "如此知见,法相不生", t: "“须菩提!若人言:佛说我见、人见、众生见、寿者见。须菩提!于意云何?是人解我所说义不?”“不也,世尊!是人不解如来所说义。何以故?世尊说我见、人见、众生见、寿者见,即非我见、人见、众生见、寿者见,是名我见、人见、众生见、寿者见。”“须菩提!发阿耨多罗三藐三菩提心者,于一切法,应如是知,如是见,如是信解,不生法相。须菩提!所言法相者,如来说即非法相,是名法相。”" },
{ h: "应化非真分第三十二", g: "应现设化,亦非真实", t: "“须菩提!若有人以满无量阿僧祇世界七宝,持用布施;若有善男子、善女人,发菩提心者,持于此经,乃至四句偈等,受持读诵,为人演说,其福胜彼。云何为人演说?不取于相,如如不动。何以故?一切有为法,如梦幻泡影,如露亦如电,应作如是观。”佛说是经已,长老须菩提及诸比丘、比丘尼、优婆塞、优婆夷、一切世间天、人、阿修罗,闻佛所说,皆大欢喜,信受奉行。" },
],
},
];
// ===== 可选音色(模型 qwen3-tts-flash) =====
const VOICE_GROUPS = [
{
label: "诵读音色",
voices: [
["Seren", "Seren 塞伦 · 温润女声"],
["Ethan", "Ethan 伊森 · 阳光男声"],
],
},
];
const sutraEl = document.getElementById("sutra");
const voiceSelect = document.getElementById("voice-select");
const btnPlay = document.getElementById("btn-play");
const statusEl = document.getElementById("status");
const progressEl = document.getElementById("progress");
const audioEl = document.getElementById("audio");
const btnLayout = document.getElementById("btn-layout");
const pagerEl = document.getElementById("pager");
const playerCard = document.getElementById("player-card");
const btnExpand = document.getElementById("btn-expand");
const btnPrev = document.getElementById("btn-prev");
const btnNext = document.getElementById("btn-next");
const btnLoop = document.getElementById("btn-loop");
const pageInfo = document.getElementById("page-info");
/** 音频缓存:key = `${经id}|${音色}|${段序号}`,value = Blob */
const audioCache = new Map();
let playToken = 0; // 播放会话令牌,防止旧异步任务串扰
let playing = false;
let inflight = null; // 进行中的 fetch AbortController
let loopMode = false; // 循环诵读开关:播完全文回到开头继续
let segTimes = []; // 合并音频中各段的起始秒
let currentSeg = -1; // 正在朗读的段号
let sutra = null; // 当前经
let segments = []; // 当前经分段
let currentPage = 0;
let pageCount = 1;
let segEls = [];
let dots = [];
const currentVoice = () => voiceSelect.value;
const pageOf = (i) => Math.floor(i / sutra.pageSize);
// ===== 音色下拉 =====
VOICE_GROUPS.forEach((group) => {
const target = VOICE_GROUPS.length > 1 ? voiceSelect.appendChild(Object.assign(document.createElement("optgroup"), { label: group.label })) : voiceSelect;
group.voices.forEach(([value, label]) => {
const opt = document.createElement("option");
opt.value = value;
opt.textContent = label;
target.appendChild(opt);
});
});
voiceSelect.value = "Seren";
// ===== 状态 / 高亮 =====
function setStatus(text, isError = false) {
statusEl.textContent = text;
statusEl.classList.toggle("error", isError);
}
function setPlayingUI(on) {
playing = on;
btnPlay.classList.toggle("playing", on);
btnPlay.setAttribute("aria-label", on ? "停止" : "播放");
btnPlay.querySelector(".play-label").textContent = on ? "停 止" : "播 放";
}
function highlight(index) {
segEls.forEach((el) => {
const i = Number(el.dataset.index);
el.classList.toggle("playing", i === index);
el.classList.toggle("done", i < index);
});
dots.forEach((d, i) => {
d.classList.toggle("now", i === index);
d.classList.toggle("done", index >= 0 && i < index);
});
// 跟读滚动:把正在朗读的段落平滑移到显示区中央
const active = segEls.find((el) => Number(el.dataset.index) === index);
active?.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" });
}
function clearHighlight() {
segEls.forEach((el) => el.classList.remove("playing", "done"));
dots.forEach((d) => d.classList.remove("now", "done"));
}
// ===== 渲染 =====
function renderDots() {
progressEl.innerHTML = "";
dots = segments.map(() => {
const d = document.createElement("span");
d.className = "dot";
progressEl.appendChild(d);
return d;
});
}
/** 渲染当前页的经文分段(含品名标题)。 */
function renderPage(page) {
currentPage = page;
sutraEl.innerHTML = "";
const start = page * sutra.pageSize;
const end = Math.min(start + sutra.pageSize, segments.length);
segEls = [];
for (let i = start; i < end; i += 1) {
const seg = segments[i];
if (seg.h) {
const h = document.createElement("h4");
h.className = "chap";
h.innerHTML = "";
const name = document.createElement("span");
name.className = "chap-name";
name.textContent = seg.h;
h.appendChild(name);
if (seg.g) {
const gloss = document.createElement("span");
gloss.className = "chap-gloss";
gloss.textContent = seg.g;
h.appendChild(gloss);
}
sutraEl.appendChild(h);
}
const p = document.createElement("p");
p.className = "seg";
p.dataset.index = i;
p.textContent = seg.t;
p.setAttribute("role", "button");
p.setAttribute("tabindex", "0");
const label = seg.h ? seg.h : `第 ${i + 1} 段`;
p.setAttribute("aria-label", `朗读:${label}`);
p.addEventListener("click", () => playFrom(i));
p.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
playFrom(i);
}
});
sutraEl.appendChild(p);
segEls.push(p);
}
sutraEl.scrollTop = 0;
sutraEl.scrollLeft = 0;
updatePager();
}
function updatePager() {
const multi = pageCount > 1;
pagerEl.hidden = !multi;
pageInfo.textContent = `第 ${currentPage + 1} / ${pageCount} 页`;
btnPrev.disabled = currentPage === 0;
btnNext.disabled = currentPage === pageCount - 1;
}
/** 手动翻页(停止当前朗读)。 */
function gotoPage(page) {
if (page < 0 || page >= pageCount || page === currentPage) return;
stopPlayback();
renderPage(page);
setStatus(`已翻到第 ${page + 1} / ${pageCount} 页`);
}
/** 切换经文。 */
function selectSutra(id) {
const next = SUTRAS.find((s) => s.id === id);
if (!next || next === sutra) return;
stopPlayback();
sutra = next;
segments = sutra.segments;
pageCount = Math.ceil(segments.length / sutra.pageSize);
document.title = sutra.name;
document.getElementById("sutra-title").textContent = sutra.title;
document.getElementById("sutra-sub").textContent = sutra.translator;
const sealEl = document.getElementById("seal");
sealEl.textContent = sutra.seal;
sealEl.dataset.chars = sutra.seal.length;
const src = document.getElementById("sutra-source");
src.textContent = sutra.source;
src.href = sutra.sourceUrl;
document.querySelectorAll(".sutra-btn").forEach((b) => {
b.classList.toggle("active", b.dataset.sutra === id);
});
renderDots();
renderPage(0);
setStatus(`已切换到《${sutra.name}》· 共 ${segments.length} 段${pageCount > 1 ? ` / ${pageCount} 页` : ""}`);
}
// ===== 播放控制 =====
/** 取消进行中的请求与播放,令牌递增使旧循环失效。 */
function stopPlayback() {
playToken += 1;
setPlayingUI(false);
if (inflight) {
inflight.abort();
inflight = null;
}
audioEl.pause();
audioEl.ontimeupdate = null;
audioEl.onended = null;
clearHighlight();
currentSeg = -1;
}
/**
* 获取指定段的音频 Blob,命中缓存则直接返回。
* @param {number} index 段序号
* @returns {Promise<Blob>} 音频数据
*/
async function fetchSegment(index) {
const key = `${sutra.id}|${currentVoice()}|${index}`;
if (audioCache.has(key)) return audioCache.get(key);
inflight = new AbortController();
try {
const resp = await fetch("/api/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: segments[index].t, voice: currentVoice() }),
signal: inflight.signal,
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${resp.status}`);
}
const blob = await resp.blob();
audioCache.set(key, blob);
return blob;
} finally {
inflight = null;
}
}
/**
* 解析 WAV 文件头,返回 {rate, channels, pcm};格式不符返回 null。
* @param {ArrayBuffer} buf WAV 数据
*/
function parseWav(buf) {
const v = new DataView(buf);
const tag = (o) => v.getUint32(o, true); // 块 ID 按小端读
if (buf.byteLength < 44 || tag(0) !== 0x46464952) return null; // 'RIFF'
let off = 12;
let fmt = null;
let pcm = null;
while (off + 8 <= buf.byteLength) {
const id = tag(off);
const sz = v.getUint32(off + 4, true);
if (id === 0x20746d66) { // 'fmt '
fmt = { channels: v.getUint16(off + 10, true), rate: v.getUint32(off + 12, true), bits: v.getUint16(off + 22, true) };
} else if (id === 0x61746164) { // 'data'
// 流式编码的 WAV 可能写占位符长度(如 0x7fffffxx),按实际文件长度截断
const realLen = Math.min(sz, buf.byteLength - (off + 8));
pcm = new Uint8Array(buf, off + 8, realLen);
break;
}
off += 8 + sz + (sz % 2);
}
if (!fmt || !pcm || fmt.bits !== 16) return null;
return { rate: fmt.rate, channels: fmt.channels, pcm };
}
/**
* 将全部段落 PCM 合并为一个 WAV:段间 0.8s 静音、末尾 3s 静音(循环间隔)。
* 合并成单文件后整个诵读只播一个音频,无 JS 事件链,手机锁屏/后台也不中断。
* @param {Blob[]} blobs 各段音频
* @returns {Promise<{url: string, times: number[]}>} 音频地址与各段起始秒
*/
async function buildCombined(blobs) {
const parts = [];
const times = [];
let rate = 24000;
let channels = 1;
let cursor = 0; // 已写入字节数
for (const b of blobs) {
const w = parseWav(await b.arrayBuffer());
if (!w) throw new Error("音频格式不支持");
rate = w.rate;
channels = w.channels;
if (parts.length) {
const gap = new Uint8Array(Math.floor(rate * 0.8) * channels * 2);
parts.push(gap);
cursor += gap.length;
}
times.push(cursor / (rate * channels * 2));
parts.push(w.pcm);
cursor += w.pcm.length;
}
parts.push(new Uint8Array(Math.floor(rate * 3) * channels * 2)); // 尾部 3s 静音
const total = parts.reduce((s, p) => s + p.length, 0);
const out = new ArrayBuffer(44 + total);
const v = new DataView(out);
const wstr = (o, s) => { for (let k = 0; k < s.length; k += 1) v.setUint8(o + k, s.charCodeAt(k)); };
wstr(0, "RIFF"); v.setUint32(4, 36 + total, true); wstr(8, "WAVE");
wstr(12, "fmt "); v.setUint32(16, 16, true); v.setUint16(20, 1, true);
v.setUint16(22, channels, true); v.setUint32(24, rate, true);
v.setUint32(28, rate * channels * 2, true); v.setUint16(32, channels * 2, true); v.setUint16(34, 16, true);
wstr(36, "data"); v.setUint32(40, total, true);
let o = 44;
for (const p of parts) {
new Uint8Array(out, o, p.length).set(p);
o += p.length;
}
return { url: URL.createObjectURL(new Blob([out], { type: "audio/wav" })), times };
}
/** 播放进度回调:按当前时间定位段号,高亮并自动翻页滚动。 */
function onTimeUpdate() {
const t = audioEl.currentTime;
let i = segTimes.length - 1;
while (i > 0 && segTimes[i] > t) i -= 1;
if (i === currentSeg) return;
currentSeg = i;
const targetPage = pageOf(i);
if (targetPage !== currentPage) renderPage(targetPage);
highlight(i);
}
/**
* 从第 index 段开始连续朗读到全文结束;开启循环时 audio.loop 原生回卷。
* 先并发补齐所有段音频再合并为单个 WAV 播放,中途无 JS 事件链衔接,
* 手机锁屏/后台不会中断;高亮靠 timeupdate 按 segTimes 时间轴定位。
*/
async function playFrom(index) {
stopPlayback();
const token = ++playToken;
setPlayingUI(true);
setStatus("正在准备音频…");
const blobs = new Array(segments.length);
let next = 0;
let done = 0;
const worker = async () => {
while (next < segments.length && token === playToken) {
const i = next;
next += 1;
blobs[i] = await fetchSegment(i);
done += 1;
setStatus(`正在准备音频 ${done} / ${segments.length}…`);
}
};
try {
await Promise.all([worker(), worker(), worker(), worker()]);
if (token !== playToken) return;
const built = await buildCombined(blobs);
if (token !== playToken) return;
segTimes = built.times;
currentSeg = -1;
audioEl.src = built.url;
audioEl.loop = loopMode;
audioEl.ontimeupdate = onTimeUpdate;
audioEl.onended = () => {
setPlayingUI(false);
setStatus("诵读完毕");
clearHighlight();
};
audioEl.currentTime = built.times[index];
await audioEl.play();
setStatus(`正在朗读 · ${currentVoice()}`);
} catch (err) {
if (err.name === "AbortError") return; // 已被停止/新播放取代
setStatus(`语音合成失败:${err.message}`, true);
setPlayingUI(false);
clearHighlight();
}
}
/** 循环诵读开关:开时读完全文自动回到第一段继续。 */
btnLoop.addEventListener("click", () => {
loopMode = !loopMode;
audioEl.loop = loopMode; // 播放中切换立即生效
btnLoop.classList.toggle("active", loopMode);
btnLoop.setAttribute("aria-pressed", String(loopMode));
setStatus(loopMode ? "循环诵读:开" : "循环诵读:关");
});
btnPlay.addEventListener("click", () => {
if (playing || inflight) {
stopPlayback();
setStatus("已停止");
} else {
playFrom(0);
}
});
voiceSelect.addEventListener("change", () => {
if (playing || inflight) stopPlayback();
setStatus(`已切换音色:${voiceSelect.value},点击播放开始诵读`);
});
// 移动端设置区折叠/展开(齿轮图标按钮,桌面端不可见)
btnExpand.addEventListener("click", () => {
const expanded = playerCard.classList.toggle("expanded");
btnExpand.classList.toggle("open", expanded);
btnExpand.querySelector(".expand-label").textContent = expanded ? "收起" : "设置";
btnExpand.setAttribute("aria-expanded", String(expanded));
});
btnPrev.addEventListener("click", () => gotoPage(currentPage - 1));
btnNext.addEventListener("click", () => gotoPage(currentPage + 1));
document.querySelectorAll(".sutra-btn").forEach((btn) => {
btn.addEventListener("click", () => selectSutra(btn.dataset.sutra));
});
// 键盘 ← → 翻页(下拉框聚焦时不拦截)
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "SELECT") return;
if (e.key === "ArrowLeft") gotoPage(currentPage - 1);
if (e.key === "ArrowRight") gotoPage(currentPage + 1);
});
// ===== 字号与排版 =====
document.querySelectorAll(".size-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".size-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
sutraEl.classList.remove("size-sm", "size-md", "size-lg");
sutraEl.classList.add(`size-${btn.dataset.size}`);
});
});
const mobileMQ = matchMedia("(max-width: 640px)");
btnLayout.addEventListener("click", () => {
const vertical = sutraEl.classList.toggle("vertical");
btnLayout.textContent = vertical ? "切换横排" : "切换竖排";
btnLayout.classList.toggle("active", vertical);
});
// 移动端不提供竖排:窗口收窄时若处于竖排则切回横排
mobileMQ.addEventListener("change", () => {
if (mobileMQ.matches && sutraEl.classList.contains("vertical")) {
btnLayout.click();
}
});
// ===== 初始化(支持 #jingang / #vertical 锚点;手机端忽略竖排) =====
if (location.hash.includes("vertical") && !mobileMQ.matches) {
btnLayout.click();
}
selectSutra(location.hash.includes("jingang") ? "jingang" : "xinjing");
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="seal" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#a8322b"/>
<stop offset="1" stop-color="#7c1f1a"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="10" fill="url(#seal)"/>
<rect x="5" y="5" width="54" height="54" rx="6" fill="none" stroke="#fdf8ee" stroke-width="1.6" opacity="0.45"/>
<text x="32" y="45" font-family="Songti SC, STSong, 'Noto Serif SC', serif" font-size="38" font-weight="700" fill="#fdf8ee" text-anchor="middle">经</text>
</svg>

After

Width:  |  Height:  |  Size: 592 B

+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>心经</title>
<link rel="icon" type="image/svg+xml" href="icon.svg">
<link rel="apple-touch-icon" href="icon-180.png">
<link rel="stylesheet" href="style.css?v=4">
</head>
<body>
<div class="site-logo" aria-hidden="true">
<img src="icon.svg" alt="" class="logo-mark">
<span class="logo-text">佛经诵读</span>
</div>
<main class="page">
<section class="hero">
<div class="seal" id="seal" aria-hidden="true">心经</div>
<div class="hero-text">
<h1 class="sutra-title" id="sutra-title">般若波罗蜜多心经</h1>
<p class="subtitle" id="sutra-sub">唐三藏法师 玄奘 译</p>
</div>
</section>
<section class="panel player-card" id="player-card" aria-label="语音诵读">
<div class="player-row">
<button id="btn-play" class="btn-play" type="button" aria-label="播放或停止">
<svg viewBox="0 0 24 24" class="icon icon-play"><path d="M8 5v14l11-7z"/></svg>
<svg viewBox="0 0 24 24" class="icon icon-stop"><rect x="6" y="6" width="12" height="12" rx="1.5"/></svg>
<span class="play-label">播 放</span>
</button>
<div class="tool-group" role="group" aria-label="经典">
<span class="tool-name">经典</span>
<button type="button" data-sutra="xinjing" class="sutra-btn active">心经</button>
<button type="button" data-sutra="jingang" class="sutra-btn">金刚经</button>
</div>
<div class="player-meta">
<label class="voice-label" for="voice-select">诵读音色</label>
<select id="voice-select"></select>
</div>
<span class="flex-break" aria-hidden="true"></span>
<div class="tool-group" role="group" aria-label="字号">
<span class="tool-name">字号</span>
<button type="button" data-size="lg" class="size-btn">大</button>
<button type="button" data-size="md" class="size-btn active">中</button>
<button type="button" data-size="sm" class="size-btn">小</button>
</div>
<div class="tool-group" role="group" aria-label="排版">
<button type="button" id="btn-layout" class="layout-btn">切换竖排</button>
</div>
<div class="tool-group" role="group" aria-label="模式">
<button type="button" id="btn-loop" class="layout-btn" aria-pressed="false">循环</button>
</div>
<div id="status" class="status" role="status"></div>
<button type="button" id="btn-expand" class="expand-btn" aria-expanded="false" aria-label="展开设置" title="设置">
<span class="expand-label">设置</span>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
</div>
<div id="progress" class="progress" aria-hidden="true"></div>
</section>
<article class="sutra-paper">
<div id="sutra" class="sutra size-md" role="document"></div>
</article>
<nav class="pager" id="pager" aria-label="分页" hidden>
<button type="button" id="btn-prev" class="pager-btn">‹ 上一页</button>
<span id="page-info" class="page-info"></span>
<button type="button" id="btn-next" class="pager-btn">下一页 ›</button>
</nav>
</main>
<footer class="site-footer">
<p>点击段落可从该段起诵读 · ← → 键翻页 · 语音由千问 Qwen3-TTS 合成 · 经文来源:<a id="sutra-source" href="https://www.dpm.org.cn/lemmas/243222.html" target="_blank" rel="noopener">故宫博物院</a></p>
</footer>
<audio id="audio" preload="auto"></audio>
<script src="app.js?v=4"></script>
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
# 运行手册 · 心经诵读站点
## 技术栈
| 项 | 版本 / 说明 |
|---|---|
| 前端 | 原生 HTML + CSS + JS(无框架、无构建) |
| 后端 | Python 3 标准库 `http.server`(无第三方依赖)[Native] |
| TTS | 千问 `qwen3-tts-flash`,走 DashScope 兼容接口 `/api/v1/services/aigc/multimodal-generation/generation` |
## 首次准备
```bash
cd "/Users/freedak/Documents/AIDashboard/心经"
cp .env.example .env # 填入真实 DASHSCOPE_API_KEY
```
`.env` 变量:`DASHSCOPE_API_KEY`(必填)、`DASHSCOPE_BASE`、`TTS_MODEL`、`PORT`(默认 8000)。
## 应用启停
```bash
# 启动 [Native]
python3 server.py
# 打开 http://127.0.0.1:8000
# 停止:Ctrl+C
```
## 接口
- `POST /api/tts`,请求体 `{"text": "...", "voice": "Cherry"}`(≤600 字),返回 `audio/wav` 字节流;合成结果缓存在 `cache/` 目录。
- 静态文件由同一端口托管。
## 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 页面报"未配置 DASHSCOPE_API_KEY" | `.env` 缺失或键名不对 | 检查 `.env` |
| 502 / TTS 服务返回 4xx | 密钥失效、音色与模型不匹配 | 换音色或核对密钥 |
| 浏览器播放无声 | 系统静音 / 自动播放策略 | 点击播放按钮触发 |
## 端口表
| 服务 | 端口 |
|---|---|
| 本地 Web + API | 8000(`.env` 的 `PORT` 可改) |
| 线上应用 | 8018(127.0.0.1,仅经 nginx 反代) |
## 部署备份
**线上部署**(2026-09-28 已上线):
- 域名:`https://fj.all8ai.top`(HTTP 301 → HTTPS,Let's Encrypt 证书自动续期)
- 服务器:`154.8.162.18`(OpenCloudOS 9.4),应用目录 `/opt/fj`
- 进程:`systemctl status|restart fj`(`/etc/systemd/system/fj.service`,Restart=always)
- 反代:`/etc/nginx/conf.d/fj.conf` → `http://127.0.0.1:8018`(与 gugong/bs 等站点并存,互不冲突)
- 环境变量:服务器上 `/opt/fj/.env`(`PORT=8018`、密钥等,不入 Git)
- 更新发布:本地改完后 `rsync -avz --exclude='.git' --exclude='cache' index.html style.css app.js server.py .env root@154.8.162.18:/opt/fj/`,如有 Python 改动再 `systemctl restart fj`;纯前端改动刷新即可
备份:`cache/` 可删(自动重建);`/opt/fj` 目录 + `fj.service` + `fj.conf` 为全部恢复所需。
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""心经诵读站点后端:静态文件托管 + 千问 TTS 代理。"""
import hashlib
import json
import os
import urllib.error
import urllib.request
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
CACHE_DIR = BASE_DIR / "cache"
ENV_PATH = BASE_DIR / ".env"
def load_env(path: Path) -> None:
"""读取 .env 文件并注入环境变量(不覆盖已有变量)。"""
if not path.exists():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())
load_env(ENV_PATH)
API_KEY = os.environ.get("DASHSCOPE_API_KEY", "")
DASHSCOPE_BASE = os.environ.get(
"DASHSCOPE_BASE",
"https://llm-znfsxsdp6uwik7ad.cn-beijing.maas.aliyuncs.com/api/v1",
).rstrip("/")
TTS_MODEL = os.environ.get("TTS_MODEL", "qwen3-tts-flash")
PORT = int(os.environ.get("PORT", "8000"))
GENERATION_URL = f"{DASHSCOPE_BASE}/services/aigc/multimodal-generation/generation"
def http_post_json(url: str, payload: dict, headers: dict, timeout: int = 60) -> dict:
"""发送 JSON POST 请求并返回解析后的响应。"""
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json", **headers},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def synthesize(text: str, voice: str) -> bytes:
"""调用千问 TTS 合成语音,返回 wav 音频字节流。"""
payload = {
"model": TTS_MODEL,
"input": {"text": text, "voice": voice},
}
data = http_post_json(GENERATION_URL, payload, {"Authorization": f"Bearer {API_KEY}"})
audio_url = (data.get("output") or {}).get("audio", {}).get("url")
if not audio_url:
raise RuntimeError(f"TTS 响应缺少音频地址:{json.dumps(data, ensure_ascii=False)[:300]}")
with urllib.request.urlopen(audio_url, timeout=60) as resp:
return resp.read()
class Handler(SimpleHTTPRequestHandler):
"""静态资源 + /api/tts 合成接口。"""
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(BASE_DIR), **kwargs)
def do_POST(self) -> None:
"""处理 POST 请求,目前仅支持 /api/tts。"""
if self.path != "/api/tts":
self._send_json({"error": "not found"}, status=404)
return
try:
length = int(self.headers.get("Content-Length", "0"))
body = json.loads(self.rfile.read(length).decode("utf-8"))
except (ValueError, json.JSONDecodeError):
self._send_json({"error": "请求体不是合法 JSON"}, status=400)
return
text = (body.get("text") or "").strip()
voice = (body.get("voice") or "").strip()
if not text or not voice:
self._send_json({"error": "缺少 text 或 voice 参数"}, status=400)
return
if len(text) > 600:
self._send_json({"error": "文本超过 600 字上限"}, status=400)
return
if not API_KEY:
self._send_json({"error": "服务端未配置 DASHSCOPE_API_KEY"}, status=500)
return
cache_key = hashlib.sha256(f"{TTS_MODEL}|{voice}|{text}".encode("utf-8")).hexdigest()[:24]
cache_file = CACHE_DIR / f"{cache_key}.wav"
try:
if cache_file.exists():
audio = cache_file.read_bytes()
else:
audio = synthesize(text, voice)
CACHE_DIR.mkdir(exist_ok=True)
cache_file.write_bytes(audio)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:300]
self._send_json({"error": f"TTS 服务返回 {exc.code}:{detail}"}, status=502)
return
except Exception as exc: # noqa: BLE001 - 统一兜底,向前端返回可读错误
self._send_json({"error": f"语音合成失败:{exc}"}, status=502)
return
self.send_response(200)
self.send_header("Content-Type", "audio/wav")
self.send_header("Content-Length", str(len(audio)))
self.end_headers()
self.wfile.write(audio)
def _send_json(self, obj: dict, status: int = 200) -> None:
"""以 JSON 形式返回响应。"""
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def end_headers(self) -> None:
"""本地开发环境禁用静态资源缓存,避免页面拿到旧版 JS/CSS。"""
if not self.path.startswith("/api/"):
self.send_header("Cache-Control", "no-cache")
super().end_headers()
def log_message(self, fmt, *args) -> None:
"""精简请求日志输出。"""
if "/api/" in self.path:
super().log_message(fmt, *args)
if __name__ == "__main__":
server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
print(f"心经诵读站点已启动:http://127.0.0.1:{PORT}")
print(f"TTS 模型:{TTS_MODEL} | 上游:{DASHSCOPE_BASE}")
if not API_KEY:
print("警告:未配置 DASHSCOPE_API_KEY,请复制 .env.example 为 .env 并填入密钥")
server.serve_forever()
+469
View File
@@ -0,0 +1,469 @@
/* ===== 基础与配色 ===== */
:root {
--paper: #f6f1e6;
--paper-deep: #efe6d3;
--ink: #2b2620;
--ink-soft: #5c5347;
--cinnabar: #9e2b25;
--cinnabar-deep: #7c1f1a;
--gold: #b08a4f;
--gold-soft: #d3b98a;
--hairline: rgba(94, 72, 45, 0.22);
--radius: 10px;
--shadow: 0 10px 30px rgba(73, 48, 24, 0.12);
font-size: 16px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: "Songti SC", "STSong", "Noto Serif SC", "Noto Serif CJK SC", "SimSun", serif;
color: var(--ink);
background:
radial-gradient(1200px 600px at 50% -8%, rgba(158, 43, 37, 0.07), transparent 60%),
radial-gradient(900px 500px at 85% 110%, rgba(176, 138, 79, 0.10), transparent 55%),
repeating-linear-gradient(0deg, transparent 0 26px, rgba(94, 72, 45, 0.025) 26px 27px),
var(--paper);
line-height: 1.6;
display: flex;
flex-direction: column;
overflow: hidden; /* 一屏内显示,页面本身不滚动 */
}
/* ===== 左上角品牌 Logo ===== */
.site-logo {
position: fixed;
top: 16px;
left: 26px;
z-index: 30;
display: flex;
align-items: center;
gap: 10px;
user-select: none;
}
.logo-mark {
width: 32px;
height: 32px;
border-radius: 6px;
box-shadow: 0 3px 10px rgba(158, 43, 37, 0.3);
}
.logo-text {
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.35em;
color: var(--cinnabar);
}
/* ===== 主体:铺满全宽、撑满剩余高度 ===== */
.page {
flex: 1;
min-height: 0;
width: 100%;
padding: 22px 40px 26px;
display: flex;
flex-direction: column;
gap: 16px;
}
.hero {
flex: none;
display: flex;
align-items: center;
justify-content: center;
gap: 26px;
}
.seal {
width: 56px;
height: 56px;
flex: none;
display: grid;
place-items: center;
background: linear-gradient(145deg, var(--cinnabar), var(--cinnabar-deep));
color: #fdf8ee;
font-size: 1.25rem;
font-weight: 700;
letter-spacing: 0.1em;
writing-mode: vertical-rl;
border-radius: 6px;
box-shadow: var(--shadow), inset 0 0 0 3px rgba(253, 248, 238, 0.35);
transform: rotate(-3deg);
user-select: none;
}
/* 3 字及以上:改长条形印章,避免文字挤出 */
.seal[data-chars="3"] {
height: 84px;
font-size: 1.05rem;
letter-spacing: 0.05em;
}
.seal[data-chars="4"],
.seal[data-chars="5"] {
height: 96px;
font-size: 0.9rem;
letter-spacing: 0.02em;
}
.sutra-title {
font-size: clamp(1.7rem, 3.2vw, 2.5rem);
letter-spacing: 0.28em;
font-weight: 700;
white-space: nowrap;
}
.subtitle {
margin-top: 6px;
text-align: center;
color: var(--ink-soft);
letter-spacing: 0.5em;
text-indent: 0.5em;
font-size: 0.9rem;
}
/* ===== 卡片面板 ===== */
.panel {
background: rgba(255, 252, 245, 0.78);
border: 1px solid var(--hairline);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
/* ===== 播放控制栏(一行放下全部控件) ===== */
.player-card { flex: none; padding: 14px 22px 12px; }
.player-row {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.btn-play {
width: 52px;
height: 52px;
flex: none;
border: none;
border-radius: 50%;
cursor: pointer;
display: grid;
place-items: center;
color: #fdf8ee;
background: linear-gradient(145deg, var(--cinnabar), var(--cinnabar-deep));
box-shadow: 0 6px 16px rgba(158, 43, 37, 0.35);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.btn-play:hover { transform: scale(1.05); box-shadow: 0 8px 20px rgba(158, 43, 37, 0.45); }
.btn-play:active { transform: scale(0.97); }
.btn-play:focus-visible { outline: 3px solid var(--gold); outline-offset: 3px; }
.btn-play .icon { width: 24px; height: 24px; fill: currentColor; }
.btn-play .icon-stop { display: none; }
.btn-play.playing .icon-play { display: none; }
.btn-play.playing .icon-stop { display: block; }
.player-meta { display: flex; align-items: center; gap: 12px; }
.voice-label { font-size: 0.85rem; color: var(--ink-soft); letter-spacing: 0.2em; white-space: nowrap; }
#voice-select {
appearance: none;
font-family: inherit;
font-size: 0.92rem;
color: var(--ink);
background:
linear-gradient(45deg, transparent 50%, var(--gold) 50%) right 14px center / 7px 7px no-repeat,
linear-gradient(135deg, var(--gold) 50%, transparent 50%) right 9px center / 7px 7px no-repeat,
rgba(255, 255, 255, 0.7);
border: 1px solid var(--hairline);
border-radius: 8px;
padding: 8px 34px 8px 12px;
cursor: pointer;
min-width: 220px;
}
#voice-select:focus-visible { outline: 3px solid var(--gold-soft); outline-offset: 1px; }
/* ===== 字号 / 排版工具(并入控制栏) ===== */
.tool-group { display: flex; align-items: center; gap: 8px; }
.tool-name { font-size: 0.8rem; color: var(--ink-soft); letter-spacing: 0.2em; }
.size-btn, .layout-btn, .sutra-btn, .pager-btn {
font-family: inherit;
border: 1px solid var(--hairline);
background: transparent;
color: var(--ink-soft);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.size-btn { width: 36px; height: 32px; font-size: 0.88rem; }
.layout-btn, .sutra-btn, .pager-btn { padding: 6px 14px; font-size: 0.86rem; letter-spacing: 0.1em; }
.size-btn:hover, .layout-btn:hover, .sutra-btn:hover, .pager-btn:hover { border-color: var(--gold); color: var(--ink); }
.size-btn.active, .layout-btn.active, .sutra-btn.active {
background: var(--cinnabar);
border-color: var(--cinnabar);
color: #fdf8ee;
}
.size-btn:focus-visible, .layout-btn:focus-visible, .sutra-btn:focus-visible, .pager-btn:focus-visible {
outline: 3px solid var(--gold-soft);
outline-offset: 1px;
}
.pager-btn:disabled { opacity: 0.4; cursor: default; }
.pager-btn:disabled:hover { border-color: var(--hairline); color: var(--ink-soft); }
.status {
flex: 1;
min-width: 180px;
text-align: right;
font-size: 0.84rem;
color: var(--ink-soft);
letter-spacing: 0.05em;
}
.status.error { color: var(--cinnabar); }
/* 移动端强制换行占位 / 折叠按钮 / 文字标签(桌面端隐藏) */
.flex-break { display: none; }
.expand-btn { display: none; }
.play-label, .expand-label { display: none; }
.progress {
display: flex;
gap: 5px;
margin-top: 12px;
}
.progress .dot {
flex: 1;
height: 4px;
border-radius: 2px;
background: rgba(94, 72, 45, 0.15);
transition: background 0.3s ease;
}
.progress .dot.done { background: var(--gold); }
.progress .dot.now { background: var(--cinnabar); }
/* ===== 经文纸面:撑满剩余高度 ===== */
.sutra-paper {
flex: 1;
min-height: 0;
background:
radial-gradient(800px 400px at 50% 0%, rgba(176, 138, 79, 0.06), transparent 60%),
#fdfaf2;
border: 1px solid var(--hairline);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: clamp(20px, 3vh, 44px) clamp(28px, 4vw, 72px);
position: relative;
display: flex;
justify-content: center;
}
.sutra-paper::before,
.sutra-paper::after {
content: "";
position: absolute;
inset: 10px;
border: 1px solid rgba(176, 138, 79, 0.28);
border-radius: 6px;
pointer-events: none;
}
.sutra-paper::after { inset: 14px; border-color: rgba(176, 138, 79, 0.14); }
/* 横排:多栏铺满整宽,整体垂直居中 */
.sutra {
flex: 1;
min-width: 0;
align-self: center;
max-height: 100%;
overflow: auto;
text-align: justify;
letter-spacing: 0.12em;
column-width: 22em;
column-gap: 3.5em;
column-fill: balance;
column-rule: 1px solid rgba(176, 138, 79, 0.18);
scrollbar-width: thin;
}
.sutra.size-sm { font-size: 1.05rem; line-height: 2; }
.sutra.size-md { font-size: 1.3rem; line-height: 2.05; }
.sutra.size-lg { font-size: 1.55rem; line-height: 2.1; }
/* 品名标题:横排为居中标题块 */
.chap {
text-align: center;
font-size: 0.92em;
font-weight: 700;
letter-spacing: 0.35em;
color: var(--cinnabar);
padding: 0.4em 0 0.35em;
break-after: avoid; /* 标题不与正文分列 */
}
.chap-gloss {
display: inline-block;
margin-inline-start: 0.8em;
font-weight: 400;
font-size: 0.78em;
letter-spacing: 0.15em;
color: var(--gold);
}
.seg {
display: block;
cursor: pointer;
border-radius: 4px;
padding: 2px 4px;
margin-bottom: 0.45em;
transition: background 0.25s ease, color 0.25s ease;
}
.seg:hover { background: rgba(176, 138, 79, 0.14); }
.seg.playing {
background: rgba(158, 43, 37, 0.10);
color: var(--cinnabar-deep);
box-shadow: inset 0 -2px 0 var(--cinnabar);
}
.seg.done { color: var(--ink-soft); }
/* 竖排:从右往左逐列铺满整宽 */
.sutra.vertical {
writing-mode: vertical-rl;
flex: 0 0 auto;
width: auto;
align-self: stretch;
height: 100%;
text-align: left;
column-width: auto;
column-rule: none;
overflow-x: auto;
overflow-y: hidden;
letter-spacing: 0.22em;
}
.sutra.vertical .seg.playing { box-shadow: inset 2px 0 0 var(--cinnabar); }
/* 竖排:品名与正文恢复内联流式排布 */
.sutra.vertical .chap {
display: inline;
padding: 0;
}
.sutra.vertical .chap-name::after { content: " "; }
.sutra.vertical .chap-gloss { display: none; }
.sutra.vertical .seg { display: inline; margin: 0; padding: 0 1px; }
/* ===== 分页条 ===== */
.pager {
flex: none;
display: flex;
align-items: center;
justify-content: center;
gap: 18px;
}
.pager[hidden] { display: none; }
.page-info {
font-size: 0.85rem;
color: var(--ink-soft);
letter-spacing: 0.15em;
min-width: 96px;
text-align: center;
}
/* ===== 页脚 ===== */
.site-footer {
flex: none;
border-top: 1px solid var(--hairline);
padding: 10px 32px 12px;
text-align: center;
font-size: 0.78rem;
color: var(--ink-soft);
}
.site-footer a { color: var(--cinnabar); text-decoration: none; border-bottom: 1px solid var(--gold-soft); }
.site-footer a:hover { color: var(--cinnabar-deep); }
/* ===== 响应式 ===== */
@media (max-width: 900px) {
.page { padding: 16px 20px 20px; }
.status { text-align: left; }
}
@media (max-width: 640px) {
/* Logo 与印章重叠,小屏隐藏 */
.site-logo { display: none; }
.page { padding: 12px 12px 14px; gap: 10px; }
.hero { gap: 12px; }
.seal { width: 44px; height: 44px; font-size: 1rem; }
.seal[data-chars="3"] { height: 62px; font-size: 0.9rem; }
.seal[data-chars="4"], .seal[data-chars="5"] { height: 74px; font-size: 0.8rem; }
.sutra-title { font-size: 1.35rem; letter-spacing: 0.12em; }
.subtitle { font-size: 0.78rem; letter-spacing: 0.3em; text-indent: 0.3em; margin-top: 4px; }
/* 控制栏:折叠为一行文字按钮(▶ 播放 长条 + 设置),点"设置"展开全部控件 */
.player-card { padding: 8px 10px; }
.player-row { gap: 8px; }
.btn-play {
order: 1;
flex: 1;
width: auto;
height: 38px;
border-radius: 19px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
font-size: 0.88rem;
letter-spacing: 0.15em;
}
.btn-play .icon { width: 15px; height: 15px; }
.btn-play .play-label { display: inline; }
.player-meta { flex: 1; min-width: 0; order: 2; }
.flex-break { display: block; order: 3; flex-basis: 100%; height: 0; }
.tool-group { gap: 6px; }
.tool-group[aria-label="经典"] { order: 4; }
.tool-group[aria-label="字号"] { order: 5; }
.tool-group[aria-label="排版"] { order: 6; display: none; } /* 手机端不提供竖排 */
.tool-group[aria-label="模式"] { order: 7; }
.status { display: none; } /* 手机端去掉状态文字,只留经书内容 */
/* 设置:与播放按钮同行的文字按钮 */
.expand-btn {
display: flex;
align-items: center;
justify-content: center;
order: 8;
flex: none;
height: 38px;
padding: 0 16px;
font-family: inherit;
font-size: 0.85rem;
letter-spacing: 0.12em;
color: var(--ink-soft);
background: transparent;
border: 1px solid var(--hairline);
border-radius: 19px;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease;
}
.expand-btn svg { display: none; }
.expand-btn .expand-label { display: inline; }
.expand-btn:hover { border-color: var(--gold); color: var(--ink); }
.expand-btn:focus-visible { outline: 3px solid var(--gold-soft); outline-offset: 1px; }
.expand-btn.open { color: var(--cinnabar); border-color: var(--cinnabar); }
/* 折叠态:隐藏音色/经典/字号/排版控件与进度条,只留居中的播放按钮 */
.player-card:not(.expanded) .player-meta,
.player-card:not(.expanded) .tool-group,
.player-card:not(.expanded) .progress,
.player-card:not(.expanded) .flex-break { display: none; }
.player-card:not(.expanded) .player-row { justify-content: center; }
.player-card.expanded { padding-bottom: 12px; }
.voice-label, .tool-name { display: none; }
#voice-select { min-width: 0; width: 100%; }
/* 触屏目标 ≥36px,紧凑以容纳一行 */
.size-btn { width: 36px; height: 36px; font-size: 0.85rem; }
.layout-btn, .sutra-btn, .pager-btn { padding: 8px 10px; font-size: 0.82rem; min-height: 36px; }
.sutra-paper { padding: 18px 16px; }
.sutra-paper::before { inset: 6px; }
.sutra-paper::after { inset: 9px; }
.sutra { column-width: auto; }
.sutra.size-sm { font-size: 0.95rem; line-height: 1.9; }
.sutra.size-md { font-size: 1.12rem; line-height: 1.95; }
.sutra.size-lg { font-size: 1.32rem; line-height: 2; }
.pager { gap: 12px; }
.page-info { min-width: 0; font-size: 0.8rem; }
.site-footer { padding: 8px 14px; font-size: 0.7rem; }
}