From 3c627d9dd6275bb5ce43df262a838f7ed123ddae Mon Sep 17 00:00:00 2001 From: selfrelease Date: Mon, 28 Sep 2026 13:25:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BD=9B=E7=BB=8F=E8=AF=B5=E8=AF=BB?= =?UTF-8?q?=E7=AB=99=E7=82=B9=E5=88=9D=E7=89=88=EF=BC=88=E5=BF=83=E7=BB=8F?= =?UTF-8?q?/=E9=87=91=E5=88=9A=E7=BB=8F=20+=20=E5=8D=83=E9=97=AETTS?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 仿故宫词条页视觉:宣纸底、朱砂印章、横竖排、分页阅读 - 千问 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> --- .env.example | 8 + .gitignore | 4 + README.md | 15 ++ app.js | 530 +++++++++++++++++++++++++++++++++++++++++++++++++++ icon-180.png | Bin 0 -> 19838 bytes icon.svg | 11 ++ index.html | 85 +++++++++ run.md | 61 ++++++ server.py | 148 ++++++++++++++ style.css | 469 +++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 1331 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.js create mode 100644 icon-180.png create mode 100644 icon.svg create mode 100644 index.html create mode 100644 run.md create mode 100644 server.py create mode 100644 style.css diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..01f0a82 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bf91c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +cache/ +__pycache__/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..5bdf913 --- /dev/null +++ b/README.md @@ -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`。 diff --git a/app.js b/app.js new file mode 100644 index 0000000..30af11b --- /dev/null +++ b/app.js @@ -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} 音频数据 + */ +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"); diff --git a/icon-180.png b/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..7d51d23600e192cd5f2d4e60bbbc52dbfafe8eb9 GIT binary patch literal 19838 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 literal 0 HcmV?d00001 diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..6699c22 --- /dev/null +++ b/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + 经 + diff --git a/index.html b/index.html new file mode 100644 index 0000000..a4a062c --- /dev/null +++ b/index.html @@ -0,0 +1,85 @@ + + + + + +心经 + + + + + + + + +
+ +
+ +
+

般若波罗蜜多心经

+

唐三藏法师 玄奘 译

+
+
+ +
+
+ +
+ 经典 + + +
+
+ + +
+ +
+ 字号 + + + +
+
+ +
+
+ +
+
+ +
+ +
+ +
+
+
+ + + +
+ +
+

点击段落可从该段起诵读 · ← → 键翻页 · 语音由千问 Qwen3-TTS 合成 · 经文来源:故宫博物院

+
+ + + + + diff --git a/run.md b/run.md new file mode 100644 index 0000000..73d9b71 --- /dev/null +++ b/run.md @@ -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` 为全部恢复所需。 diff --git a/server.py b/server.py new file mode 100644 index 0000000..a21f101 --- /dev/null +++ b/server.py @@ -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() diff --git a/style.css b/style.css new file mode 100644 index 0000000..12cb278 --- /dev/null +++ b/style.css @@ -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; } +}