diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0bd3fb7..96194489 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,16 +93,20 @@ jobs: } EOF - # 使用临时配置进行版本分析 - OUTPUT=$(pnpm semantic-release --dry-run -c .releaserc.temp.js 2>&1) - echo "$OUTPUT" + # 使用临时配置进行版本分析,输出到临时文件 + pnpm semantic-release --dry-run -c .releaserc.temp.js > semantic_output.txt 2>&1 + + # 显示输出内容以便调试 + echo "=== Semantic Release Output ===" + cat semantic_output.txt + echo "=== End of Output ===" # 清理临时文件 rm -f .releaserc.temp.js - # 检查是否有新版本需要发布 - if echo "$OUTPUT" | grep -q "The next release version is"; then - NEXT_VERSION=$(echo "$OUTPUT" | grep "The next release version is" | sed 's/.*The next release version is \([0-9.a-z-]*\).*/\1/') + # 检查是否有新版本需要发布(从文件中搜索,避免变量长度限制) + if grep -q "next release version is" semantic_output.txt; then + NEXT_VERSION=$(grep "next release version is" semantic_output.txt | sed 's/.*next release version is \([0-9.a-z-]*\).*/\1/' | head -1) echo "✅ Next version: $NEXT_VERSION" echo "next_version=$NEXT_VERSION" >> $GITHUB_OUTPUT echo "should_release=true" >> $GITHUB_OUTPUT @@ -110,6 +114,9 @@ jobs: echo "ℹ️ No new version to release" echo "should_release=false" >> $GITHUB_OUTPUT fi + + # 清理临时输出文件 + rm -f semantic_output.txt env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md index cfa44d42..01039d57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,44 @@ - 在布局实现上,后续优先使用 flex 布局(尽量避免使用 grid 作为默认方案)。 - 项目优先使用 antd 组件库,如果组件可以被 antd 复用则优先使用 antd 而不是自定义开发 +## 主题变量使用最佳实践 + +项目启用了 Ant Design 的 CSS 变量模式 (`cssVar: true`),在 styled-components 中应采用分类使用策略: + +### 使用 CSS 变量的场景(主题相关属性): + +- 颜色系统:`var(--ant-color-bg-elevated, fallback)` +- 阴影效果:`var(--ant-box-shadow-secondary, fallback)` +- 主题切换时会发生变化的属性 + +### 使用 JS 变量的场景(设计系统常量): + +- 尺寸间距:`${SPACING.XS}px`、`${BORDER_RADIUS.SM}px` +- 动画配置:`${ANIMATION_DURATION.SLOW}`、`${EASING.APPLE}` +- 层级关系:`${Z_INDEX.MODAL}` +- 字体配置:`${FONT_SIZES.SM}px`、`${FONT_WEIGHTS.MEDIUM}` +- 毛玻璃效果:`${GLASS_EFFECT.BACKGROUND_ALPHA.LIGHT}` + +### 推荐模式: + +```typescript +const StyledComponent = styled.div` + /* 主题相关:使用 CSS 变量 */ + background: var(--ant-color-bg-elevated, rgba(0, 0, 0, ${GLASS_EFFECT.BACKGROUND_ALPHA.LIGHT})); + color: var(--ant-color-white, #ffffff); + box-shadow: var(--ant-box-shadow-secondary, ${SHADOWS.SM}); + + /* 设计系统常量:使用 JS 变量 */ + padding: ${SPACING.XS}px ${SPACING.MD}px; + border-radius: ${BORDER_RADIUS.SM}px; + font-size: ${FONT_SIZES.SM}px; + transition: opacity ${ANIMATION_DURATION.SLOW} ${EASING.APPLE}; + z-index: ${Z_INDEX.MODAL}; +` +``` + +这种混合模式既保持了类型安全和构建时优化,又支持主题的运行时切换,是当前架构下的最佳实践。 + # State Management - 项目使用 Zustand 作为状态管理库,配合 Immer 中间件支持不可变状态更新,使用自定义的中间件栈包含持久化、DevTools 和订阅选择器功能 diff --git a/LICENSE b/LICENSE index 261eeb9e..0ad25db4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,661 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index e50518f1..25d3d6e9 100644 --- a/README.md +++ b/README.md @@ -156,10 +156,10 @@ pnpm test:ui ## 🙏 致谢 -| 项目名 | 简介 | -| --- | --- | -| [Cherry Studio](https://github.com/CherryHQ/cherry-studio) | 一款为创造而生的 AI 助手 | -| [DashPlayer](https://github.com/solidSpoon/DashPlayer) | 为英语学习者量身打造的视频播放器 | +| 项目名 | 简介 | +| ------------------------------------------------------ | -------------------------------- | +| [EchoPlayer](https://github.com/CherryHQ/EchoPlayer) | 一款为创造而生的 AI 助手 | +| [DashPlayer](https://github.com/solidSpoon/DashPlayer) | 为英语学习者量身打造的视频播放器 | --- diff --git a/electron.vite.config.ts b/electron.vite.config.ts index da28b75f..7b77040e 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -18,21 +18,49 @@ function ffmpegDownloadPlugin() { // 只在生产构建时下载 FFmpeg if (!isProd) return - console.log('Downloading FFmpeg...') + // 根据构建目标决定下载哪个平台 + const targetPlatform = process.env.BUILD_TARGET_PLATFORM || process.platform + const targetArch = process.env.BUILD_TARGET_ARCH || process.arch + + // 检查是否已存在,避免重复下载 + const ffmpegPath = path.resolve( + 'resources/ffmpeg', + `${targetPlatform}-${targetArch}`, + targetPlatform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' + ) + + if (fs.existsSync(ffmpegPath)) { + console.log(`FFmpeg already exists for ${targetPlatform}-${targetArch}`) + return + } - try { - // 根据构建目标决定下载哪个平台 - const targetPlatform = process.env.BUILD_TARGET_PLATFORM || process.platform - const targetArch = process.env.BUILD_TARGET_ARCH || process.arch + console.log(`Downloading FFmpeg for ${targetPlatform}-${targetArch}...`) + try { await new Promise((resolve, reject) => { - const downloadScript = spawn( - 'tsx', - ['scripts/download-ffmpeg.ts', 'platform', targetPlatform, targetArch], - { - stdio: 'inherit' + // 在不同环境中使用不同的命令来确保兼容性 + let command: string + let args: string[] + + if (process.platform === 'win32') { + // Windows 环境:使用 npm run 调用脚本,更可靠 + command = 'npm' + args = ['run', 'ffmpeg:download'] + } else { + // Unix 环境:直接使用 tsx + command = 'tsx' + args = ['scripts/download-ffmpeg.ts', 'platform', targetPlatform, targetArch] + } + + const downloadScript = spawn(command, args, { + stdio: 'inherit', + shell: process.platform === 'win32', + env: { + ...process.env, + BUILD_TARGET_PLATFORM: targetPlatform, + BUILD_TARGET_ARCH: targetArch } - ) + }) downloadScript.on('close', (code) => { if (code === 0) { @@ -48,7 +76,8 @@ function ffmpegDownloadPlugin() { }) }) } catch (error) { - console.warn('FFmpeg Download failed', error) + console.error('FFmpeg Download failed:', error) + throw new Error(`Failed to download FFmpeg for ${targetPlatform}-${targetArch}: ${error}`) } } } diff --git a/package.json b/package.json index 92a27f60..ff4da17c 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,12 @@ "start": "electron-vite preview", "dev": "electron-vite dev", "build": "npm run typecheck && electron-vite build", + "build:release": "npm run typecheck && electron-vite build", "postinstall": "electron-builder install-app-deps", "build:unpack": "npm run build && electron-builder --dir --publish never", - "build:win": "npm run build && electron-builder --win --publish never", - "build:win:x64": "npm run build && electron-builder --win --x64 --publish never", - "build:win:arm64": "npm run build && electron-builder --win --arm64 --publish never", + "build:win": "cross-env BUILD_TARGET_PLATFORM=win32 npm run build && electron-builder --win --publish never", + "build:win:x64": "cross-env BUILD_TARGET_PLATFORM=win32 BUILD_TARGET_ARCH=x64 npm run build && electron-builder --win --x64 --publish never", + "build:win:arm64": "cross-env BUILD_TARGET_PLATFORM=win32 BUILD_TARGET_ARCH=arm64 npm run build && electron-builder --win --arm64 --publish never", "build:mac": "electron-vite build && electron-builder --mac --publish never", "build:mac:x64": "npm run build && electron-builder --mac --x64 --publish never", "build:mac:arm64": "npm run build && electron-builder --mac --arm64 --publish never", @@ -47,10 +48,10 @@ "version:prerelease": "tsx scripts/version-manager.ts prerelease", "version:beta": "tsx scripts/version-manager.ts minor beta", "version:beta-patch": "tsx scripts/version-manager.ts patch beta", - "release": "npm run build && electron-builder --publish onTagOrDraft", - "release:all": "npm run build && electron-builder --publish always", - "release:never": "npm run build && electron-builder --publish never", - "release:draft": "npm run build && electron-builder --publish onTagOrDraft", + "release": "npm run ffmpeg:download-all && npm run build:release && electron-builder --publish onTagOrDraft", + "release:all": "npm run ffmpeg:download-all && npm run build:release && electron-builder --publish always", + "release:never": "npm run ffmpeg:download-all && npm run build:release && electron-builder --publish never", + "release:draft": "npm run ffmpeg:download-all && npm run build:release && electron-builder --publish onTagOrDraft", "migrate": "tsx src/main/db/migration-cli.ts", "migrate:up": "npm run migrate up", "migrate:down": "npm run migrate down", @@ -71,7 +72,8 @@ "ffmpeg:download-all": "tsx scripts/download-ffmpeg.ts all", "ffmpeg:clean": "tsx scripts/download-ffmpeg.ts clean", "ffmpeg:test": "tsx scripts/test-ffmpeg-integration.ts", - "prebuild": "npm run ffmpeg:download" + "prebuild": "npm run ffmpeg:download", + "prebuild:release": "echo 'FFmpeg already downloaded by release script'" }, "dependencies": { "@ant-design/icons": "^6.0.1", @@ -128,6 +130,7 @@ "@welldone-software/why-did-you-render": "^10.0.1", "cli-progress": "^3.12.0", "code-inspector-plugin": "^1.2.7", + "cross-env": "^10.0.0", "electron": "37.2.4", "electron-builder": "26.0.19", "electron-devtools-installer": "^4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eac64848..bed42240 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,6 +171,9 @@ importers: code-inspector-plugin: specifier: ^1.2.7 version: 1.2.7 + cross-env: + specifier: ^10.0.0 + version: 10.0.0 electron: specifier: 37.2.4 version: 37.2.4 @@ -685,6 +688,9 @@ packages: '@emotion/unitless@0.8.1': resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==} + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild/aix-ppc64@0.25.8': resolution: {integrity: sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==} engines: {node: '>=18'} @@ -2584,6 +2590,11 @@ packages: cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} + cross-env@10.0.0: + resolution: {integrity: sha512-aU8qlEK/nHYtVuN4p7UQgAwVljzMg8hB4YK5ThRqD2l/ziSnryncPNn7bMLt5cFYsKVKBh8HqLqyCoTupEUu7Q==} + engines: {node: '>=20'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -7433,6 +7444,8 @@ snapshots: '@emotion/unitless@0.8.1': {} + '@epic-web/invariant@1.0.0': {} + '@esbuild/aix-ppc64@0.25.8': optional: true @@ -9622,6 +9635,11 @@ snapshots: cross-dirname@0.1.0: optional: true + cross-env@10.0.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 diff --git a/scripts/download-ffmpeg.ts b/scripts/download-ffmpeg.ts index f4017661..442d5034 100644 --- a/scripts/download-ffmpeg.ts +++ b/scripts/download-ffmpeg.ts @@ -376,11 +376,28 @@ class FFmpegDownloader { // CLI 入口 async function main() { const args = process.argv.slice(2) - const command = args[0] || 'current' + let command = args[0] const downloader = new FFmpegDownloader() try { + // 优先检查环境变量,如果设置了构建目标则使用目标平台 + if (process.env.BUILD_TARGET_PLATFORM) { + console.log( + `检测到构建目标平台: ${process.env.BUILD_TARGET_PLATFORM}-${process.env.BUILD_TARGET_ARCH || process.arch}` + ) + await downloader.downloadFFmpeg( + process.env.BUILD_TARGET_PLATFORM, + process.env.BUILD_TARGET_ARCH || process.arch + ) + return + } + + // 如果没有环境变量,按原逻辑处理命令参数 + if (!command) { + command = 'current' + } + switch (command) { case 'all': await downloader.downloadAllPlatforms() @@ -416,6 +433,10 @@ async function main() { win32: x64, arm64 darwin: x64, arm64 linux: x64, arm64 + +环境变量: + BUILD_TARGET_PLATFORM - 构建目标平台 (win32, darwin, linux) + BUILD_TARGET_ARCH - 构建目标架构 (x64, arm64) `) } } catch (error) { diff --git a/src/main/__tests__/DictionaryService.test.ts b/src/main/__tests__/DictionaryService.test.ts new file mode 100644 index 00000000..b7cbed82 --- /dev/null +++ b/src/main/__tests__/DictionaryService.test.ts @@ -0,0 +1,485 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock logger service - 简化版本,专注于核心功能测试 +vi.mock('@logger', () => ({ + loggerService: { + withContext: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + })) + } +})) + +import DictionaryService from '../services/DictionaryService' + +// Mock fetch globally +const mockFetch = vi.fn() +global.fetch = mockFetch as any + +describe('DictionaryService', () => { + let dictionaryService: DictionaryService + const mockEvent = {} as Electron.IpcMainInvokeEvent + + beforeEach(() => { + vi.clearAllMocks() + dictionaryService = new DictionaryService() + }) + + describe('queryEudic - 核心功能测试', () => { + describe('✅ 成功场景', () => { + it('应该成功查询单词并返回完整数据 - hello 示例', async () => { + // 模拟欧陆词典 hello 的真实 HTML 响应 + const mockHtmlResponse = ` + + + + + + + +
+
+ +
+
+
    +
  1. int. 喂;哈罗
  2. +
  3. n. 表示问候, 惊奇或唤起注意时的用语
  4. +
+
+
+
+
Hello, how are you?
+
你好,你好吗?
+
+ + + ` + + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockHtmlResponse) + }) + + const result = await dictionaryService.queryEudic(mockEvent, 'hello') + + // 验证返回结果 + expect(result.success).toBe(true) + expect(result.data).toBeDefined() + expect(result.data!.word).toBe('hello') + expect(result.data!.phonetic).toBe("/hə'ləʊ/") + expect(result.data!.definitions).toHaveLength(2) + expect(result.data!.definitions[0]).toEqual({ + partOfSpeech: 'int.', + meaning: '喂;哈罗' + }) + expect(result.data!.definitions[1]).toEqual({ + partOfSpeech: 'n.', + meaning: '表示问候, 惊奇或唤起注意时的用语' + }) + + // 验证API调用 + expect(mockFetch).toHaveBeenCalledWith( + 'https://dict.eudic.net/dicts/MiniDictSearch2?word=hello&context=hello', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'User-Agent': expect.stringContaining('Mozilla') + }) + }) + ) + }) + + it('应该成功解析简单格式的词典响应 - program 示例', async () => { + const mockSimpleHtmlResponse = ` + + +
/ˈproʊɡræm/
+
+ n. 程序,节目 +
+ + + ` + + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockSimpleHtmlResponse) + }) + + const result = await dictionaryService.queryEudic(mockEvent, 'program', 'computer program') + + expect(result.success).toBe(true) + expect(result.data!.word).toBe('program') + expect(result.data!.phonetic).toBe('/ˈproʊɡræm/') + expect(result.data!.definitions).toHaveLength(1) + expect(result.data!.definitions[0]).toEqual({ + partOfSpeech: 'n.', + meaning: '程序,节目' + }) + + // 验证URL编码处理 + expect(mockFetch).toHaveBeenCalledWith( + 'https://dict.eudic.net/dicts/MiniDictSearch2?word=program&context=computer+program', + expect.any(Object) + ) + }) + + it('应该使用备用解析策略处理复杂HTML结构', async () => { + const mockComplexHtmlResponse = ` + + +
/test/
+
+
    +
  • v. 测试,检验
  • +
  • n. 测试,试验
  • +
  • adj. 测试的
  • +
+
+ + + ` + + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockComplexHtmlResponse) + }) + + const result = await dictionaryService.queryEudic(mockEvent, 'test') + + expect(result.success).toBe(true) + expect(result.data!.definitions).toHaveLength(3) + expect(result.data!.definitions[0].partOfSpeech).toBe('v.') + expect(result.data!.definitions[0].meaning).toBe('测试,检验') + }) + + it('应该正确解析例句和翻译', async () => { + const mockHtmlWithExamplesAndTranslations = ` + +
+
  • v. 学习
  • +
    +
    I learn English every day.
    +
    Learning is fun.
    +
    我每天学习英语。
    +
    学习很有趣。
    + + ` + + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockHtmlWithExamplesAndTranslations) + }) + + const result = await dictionaryService.queryEudic(mockEvent, 'learn') + + expect(result.success).toBe(true) + expect(result.data!.examples).toEqual(['I learn English every day.', 'Learning is fun.']) + expect(result.data!.translations).toEqual(['我每天学习英语。', '学习很有趣。']) + }) + }) + + describe('❌ 错误处理 - 健壮性测试', () => { + it('应该处理HTTP错误', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found' + }) + + const result = await dictionaryService.queryEudic(mockEvent, 'nonexistent') + + expect(result.success).toBe(false) + expect(result.error).toBe('HTTP 404: Not Found') + expect(result.data).toBeUndefined() + }) + + it('应该处理网络错误', async () => { + const networkError = new Error('Network connection failed') + mockFetch.mockRejectedValue(networkError) + + const result = await dictionaryService.queryEudic(mockEvent, 'hello') + + expect(result.success).toBe(false) + expect(result.error).toBe('Network connection failed') + }) + + it('应该处理非Error类型的异常', async () => { + mockFetch.mockRejectedValue('String error') + + const result = await dictionaryService.queryEudic(mockEvent, 'hello') + + expect(result.success).toBe(false) + expect(result.error).toBe('网络错误') + }) + + it('应该处理无法解析出释义的情况', async () => { + const mockEmptyHtmlResponse = ` + + +
    No useful content here
    + + + ` + + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockEmptyHtmlResponse) + }) + + const result = await dictionaryService.queryEudic(mockEvent, 'unknown') + + expect(result.success).toBe(false) + expect(result.error).toBe('未能从HTML中解析出任何释义') + }) + }) + + describe('🛡️ HTML解析健壮性', () => { + it('应该处理带有特殊字符的内容', async () => { + const mockSpecialCharHtml = ` + + +
    /ˈspɛʃəl/
    +
    +
      +
    1. adj. 特殊的;特别的;"专门的
    2. +
    +
    + + + ` + + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockSpecialCharHtml) + }) + + const result = await dictionaryService.queryEudic(mockEvent, 'special') + + expect(result.success).toBe(true) + expect(result.data!.definitions[0].meaning).toContain('特殊的;特别的;"专门的') + }) + + it('应该正确处理音标格式变化', async () => { + const mockPhoneticVariations = ` + + + UK /juːˈnaɪtɪd/ +
    +
  • adj. 联合的,统一的
  • +
    + + + ` + + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockPhoneticVariations) + }) + + const result = await dictionaryService.queryEudic(mockEvent, 'united') + + expect(result.success).toBe(true) + expect(result.data!.phonetic).toBe('UK /juːˈnaɪtɪd/') + }) + + it('应该限制备用解析策略的结果数量', async () => { + const mockManyItemsHtml = ` + + +
      +
    • 第一个中文释义
    • +
    • 第二个中文释义
    • +
    • 第三个中文释义
    • +
    • 第四个中文释义
    • +
    • 第五个中文释义
    • +
    • 第六个中文释义
    • +
    • 第七个中文释义
    • +
    + + + ` + + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockManyItemsHtml) + }) + + const result = await dictionaryService.queryEudic(mockEvent, 'many') + + expect(result.success).toBe(true) + expect(result.data!.definitions.length).toBe(5) // 应该被限制为最多5个 + }) + + it('应该忽略过长或过短的无关内容', async () => { + const mockNoisyHtml = ` + + +
      +
    • a
    • +
    • 这是一个正常长度的中文释义
    • +
    • ${'很'.repeat(250)}
    • +
    • 另一个正常的释义
    • +
    + + + ` + + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockNoisyHtml) + }) + + const result = await dictionaryService.queryEudic(mockEvent, 'noisy') + + expect(result.success).toBe(true) + expect(result.data!.definitions.length).toBe(2) + expect( + result.data!.definitions.every( + (def) => def.meaning.length >= 3 && def.meaning.length < 200 + ) + ).toBe(true) + }) + }) + + describe('⚙️ 参数处理', () => { + it('应该正确处理URL编码', async () => { + const mockHtmlResponse = ` + +
    +
  • 测试内容
  • +
    + + ` + + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockHtmlResponse) + }) + + await dictionaryService.queryEudic(mockEvent, 'hello world', 'test context') + + const expectedUrl = + 'https://dict.eudic.net/dicts/MiniDictSearch2?word=hello+world&context=test+context' + expect(mockFetch).toHaveBeenCalledWith(expectedUrl, expect.any(Object)) + }) + + it('应该在context为空时使用word作为默认context', async () => { + const mockHtmlResponse = ` + +
    +
  • 测试内容
  • +
    + + ` + + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockHtmlResponse) + }) + + await dictionaryService.queryEudic(mockEvent, 'test') + + const expectedUrl = 'https://dict.eudic.net/dicts/MiniDictSearch2?word=test&context=test' + expect(mockFetch).toHaveBeenCalledWith(expectedUrl, expect.any(Object)) + }) + }) + + describe('🔄 边界情况和压力测试', () => { + it('应该处理空字符串查询', async () => { + const mockHtmlResponse = `
    Empty query
    ` + + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockHtmlResponse) + }) + + const result = await dictionaryService.queryEudic(mockEvent, '') + + expect(result.success).toBe(false) + expect(result.error).toBe('未能从HTML中解析出任何释义') + }) + + it('应该处理大量HTML内容', async () => { + const largeMockHtml = ` + +
    /lærdʒ/
    +
    +
  • adj. 大的
  • +
    + ${'
    无关内容
    '.repeat(1000)} + + ` + + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(largeMockHtml) + }) + + const result = await dictionaryService.queryEudic(mockEvent, 'large') + + expect(result.success).toBe(true) + expect(result.data!.definitions[0].meaning).toBe('大的') + }) + + it('应该处理畸形HTML', async () => { + const malformedHtml = ` + +
    /test/
    +
    +
  • adj. 测试的 +
  • + + ` + + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(malformedHtml) + }) + + const result = await dictionaryService.queryEudic(mockEvent, 'malformed') + + // 应该能处理畸形HTML而不抛出异常 + expect(result.success).toBe(true) + }) + + it('应该处理没有例句和翻译的情况', async () => { + const mockHtmlWithoutExamplesAndTranslations = ` + +
    +
  • n. 单词
  • +
    + + ` + + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockHtmlWithoutExamplesAndTranslations) + }) + + const result = await dictionaryService.queryEudic(mockEvent, 'word') + + expect(result.success).toBe(true) + expect(result.data!.examples).toBeUndefined() + expect(result.data!.translations).toBeUndefined() + }) + }) + }) +}) diff --git a/src/main/services/TrayService.ts b/src/main/services/TrayService.ts index 1d20f0e1..ba65280a 100644 --- a/src/main/services/TrayService.ts +++ b/src/main/services/TrayService.ts @@ -50,7 +50,7 @@ export class TrayService { this.tray.setContextMenu(this.contextMenu) } - this.tray.setToolTip('Cherry Studio') + this.tray.setToolTip('EchoPlayer') this.tray.on('right-click', () => { if (this.contextMenu) { diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index 3c8ffaac..a08af60f 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -117,14 +117,14 @@ export function getFileExt(filePath: string): string { } /** - * Returns the path to the CherryStudio temporary directory. + * Returns the path to the EchoPlayer temporary directory. * - * The path is constructed by joining the Electron system temporary directory with "CherryStudio". + * The path is constructed by joining the Electron system temporary directory with "EchoPlayer". * - * @returns The full filesystem path for CherryStudio's temporary directory. + * @returns The full filesystem path for EchoPlayer's temporary directory. */ export function getTempDir() { - return path.join(app.getPath('temp'), 'CherryStudio') + return path.join(app.getPath('temp'), 'EchoPlayer') } export function getFilesDir() { @@ -132,7 +132,7 @@ export function getFilesDir() { } export function getConfigDir() { - return path.join(os.homedir(), '.cherrystudio', 'config') + return path.join(os.homedir(), '.echoplayer', 'config') } export function getCacheDir() { @@ -144,7 +144,7 @@ export function getAppConfigDir(name: string) { } export function getMcpDir() { - return path.join(os.homedir(), '.cherrystudio', 'mcp') + return path.join(os.homedir(), '.echoplayer', 'mcp') } /** diff --git a/src/main/utils/init.ts b/src/main/utils/init.ts index a691e427..e9eeb39c 100644 --- a/src/main/utils/init.ts +++ b/src/main/utils/init.ts @@ -17,7 +17,7 @@ function hasWritePermission(path: string) { } function getConfigDir() { - return path.join(os.homedir(), '.cherrystudio', 'config') + return path.join(os.homedir(), '.echoplayer', 'config') } export function initAppDataDir() { @@ -51,13 +51,13 @@ function getAppDataPathFromConfig() { if (isLinux && process.env.APPIMAGE) { // 如果是 AppImage 打包的应用,直接使用 APPIMAGE 环境变量 // 这样可以确保获取到正确的可执行文件路径 - executablePath = path.join(path.dirname(process.env.APPIMAGE), 'cherry-studio.appimage') + executablePath = path.join(path.dirname(process.env.APPIMAGE), 'EchoPlayer.appimage') } if (isWin && isPortable) { executablePath = path.join( process.env.PORTABLE_EXECUTABLE_DIR || '', - 'cherry-studio-portable.exe' + 'EchoPlayer-portable.exe' ) } @@ -94,15 +94,12 @@ export function updateAppDataConfig(appDataPath: string) { const configPath = path.join(configDir, 'config.json') let executablePath = app.getPath('exe') if (isLinux && process.env.APPIMAGE) { - executablePath = path.join(path.dirname(process.env.APPIMAGE), 'cherry-studio.appimage') + executablePath = path.join(path.dirname(process.env.APPIMAGE), 'EchoPlayer.appimage') } // 如果是 Windows 可移植版本,则使用 PORTABLE_EXECUTABLE_FILE 环境变量 if (isWin && isPortable) { - executablePath = path.join( - process.env.PORTABLE_EXECUTABLE_DIR || '', - 'cherry-studio-portable.exe' - ) + executablePath = path.join(process.env.PORTABLE_EXECUTABLE_DIR || '', 'EchoPlayer-portable.exe') } if (!fs.existsSync(configPath)) { diff --git a/src/main/utils/systemInfo.ts b/src/main/utils/systemInfo.ts index 4eaedeed..71cb3276 100644 --- a/src/main/utils/systemInfo.ts +++ b/src/main/utils/systemInfo.ts @@ -89,5 +89,5 @@ export function getSystemInfo(): SystemInfo { export function generateUserAgent(): string { const systemInfo = getSystemInfo() - return `Mozilla/5.0 (${systemInfo.osString}; ${systemInfo.archString}) AppleWebKit/537.36 (KHTML, like Gecko) CherryStudio/${systemInfo.appVersion} Chrome/124.0.0.0 Safari/537.36` + return `Mozilla/5.0 (${systemInfo.osString}; ${systemInfo.archString}) AppleWebKit/537.36 (KHTML, like Gecko) EchoPlayer/${systemInfo.appVersion} Chrome/124.0.0.0 Safari/537.36` } diff --git a/src/preload/index.ts b/src/preload/index.ts index d929be23..d8b2cc20 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,7 +2,7 @@ import { electronAPI } from '@electron-toolkit/preload' import { UpgradeChannel } from '@shared/config/constant' import { LogLevel, LogSourceWithContext } from '@shared/config/logger' import { IpcChannel } from '@shared/IpcChannel' -import { FFmpegVideoInfo, Shortcut, ThemeMode } from '@types' +import { DictionaryResponse, FFmpegVideoInfo, Shortcut, ThemeMode } from '@types' import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron' import type { FileMetadata, @@ -308,6 +308,12 @@ const api = { shell.openExternal(url, options) }, + // 词典服务 API + dictionary: { + queryEudic: (word: string, context?: string): Promise => + ipcRenderer.invoke(IpcChannel.Dictionary_Eudic, word, context) + }, + // 数据库相关 API db: { // 文件 DAO diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts index 217ee518..917e7ce1 100644 --- a/src/renderer/src/i18n/label.ts +++ b/src/renderer/src/i18n/label.ts @@ -179,7 +179,8 @@ const shortcutKeys = [ 'subtitle_mode_none', 'subtitle_mode_original', 'subtitle_mode_translated', - 'subtitle_mode_bilingual' + 'subtitle_mode_bilingual', + 'copy_subtitle' ] as const const shortcutKeyMap = shortcutKeys.reduce( diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 378da448..42a7197b 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -106,6 +106,10 @@ "fullscreen": { "enter": "全屏", "exit": "退出全屏" + }, + "copy": { + "success": "已复制", + "failed": "复制失败,无法访问剪贴板" } }, "subtitles": { @@ -290,6 +294,7 @@ "clear_shortcut": "清除快捷键", "clear_topic": "清空消息", "copy_last_message": "复制上一条消息", + "copy_subtitle": "复制字幕", "enabled": "启用", "escape_fullscreen": "退出全屏", "exit_fullscreen": "退出全屏", diff --git a/src/renderer/src/infrastructure/constants/shortcuts.const.ts b/src/renderer/src/infrastructure/constants/shortcuts.const.ts index a4efa9cb..a0024f38 100644 --- a/src/renderer/src/infrastructure/constants/shortcuts.const.ts +++ b/src/renderer/src/infrastructure/constants/shortcuts.const.ts @@ -130,5 +130,12 @@ export const DEFAULT_SHORTCUTS: Shortcut[] = [ editable: true, enabled: true, system: false + }, + { + key: 'copy_subtitle', + shortcut: ['CommandOrControl', 'C'], + editable: true, + enabled: true, + system: false } ] diff --git a/src/renderer/src/pages/player/components/SubtitleContent.tsx b/src/renderer/src/pages/player/components/SubtitleContent.tsx index 72a5be19..855dbc6f 100644 --- a/src/renderer/src/pages/player/components/SubtitleContent.tsx +++ b/src/renderer/src/pages/player/components/SubtitleContent.tsx @@ -235,26 +235,6 @@ export const SubtitleContent = memo(function SubtitleContent({ return undefined }, [selectionState.startIndex, selectionState.endIndex, handleGlobalClick]) - // === 键盘复制支持 === - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === 'c') { - const selection = window.getSelection() - const selectedText = selection?.toString() - - if (selectedText) { - // 复制到剪贴板 - navigator.clipboard - .writeText(selectedText) - .then(() => { - logger.info('字幕文本已复制到剪贴板', { length: selectedText.length }) - }) - .catch((error) => { - logger.error('复制到剪贴板失败', { error }) - }) - } - } - }, []) - // === 渲染分词文本 === const renderTokenizedText = useCallback( (tokens: WordToken[]) => { @@ -366,8 +346,6 @@ export const SubtitleContent = memo(function SubtitleContent({ ref={containerRef} className={className} style={style} - onKeyDown={handleKeyDown} - tabIndex={0} // 使元素可聚焦,支持键盘操作 role="region" data-testid="subtitle-content" > diff --git a/src/renderer/src/pages/player/components/SubtitleOverlay.tsx b/src/renderer/src/pages/player/components/SubtitleOverlay.tsx index 12716cb3..73cd2e0b 100644 --- a/src/renderer/src/pages/player/components/SubtitleOverlay.tsx +++ b/src/renderer/src/pages/player/components/SubtitleOverlay.tsx @@ -11,10 +11,21 @@ */ import { loggerService } from '@logger' +import { + ANIMATION_DURATION, + BORDER_RADIUS, + EASING, + FONT_SIZES, + FONT_WEIGHTS, + GLASS_EFFECT, + SHADOWS, + SPACING, + Z_INDEX +} from '@renderer/infrastructure/styles/theme' import { usePlayerStore } from '@renderer/state' import { SubtitleBackgroundType, SubtitleDisplayMode } from '@types' import { Tooltip } from 'antd' -import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react' +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' @@ -70,6 +81,37 @@ export const SubtitleOverlay = memo(function SubtitleOverlay({ // === 本地状态 === const overlayRef = useRef(null) + const [toastVisible, setToastVisible] = useState(false) + const [toastMessage, setToastMessage] = useState('') + const hideToastTimerRef = useRef | null>(null) + + // === 复制成功toast监听器 === + useEffect(() => { + const handleSubtitleCopied = (event: CustomEvent<{ message: string }>) => { + const { message } = event.detail + setToastMessage(message) + setToastVisible(true) + + // 2秒后自动隐藏toast(防抖) + if (hideToastTimerRef.current) { + clearTimeout(hideToastTimerRef.current) + } + hideToastTimerRef.current = setTimeout(() => { + setToastVisible(false) + hideToastTimerRef.current = null + }, 800) + } + + window.addEventListener('subtitle-copied', handleSubtitleCopied as EventListener) + + return () => { + if (hideToastTimerRef.current) { + clearTimeout(hideToastTimerRef.current) + hideToastTimerRef.current = null + } + window.removeEventListener('subtitle-copied', handleSubtitleCopied as EventListener) + } + }, []) // === 初始化和容器边界更新 === useEffect(() => { @@ -434,6 +476,10 @@ export const SubtitleOverlay = memo(function SubtitleOverlay({ data-testid="subtitle-resize-handle" /> + + + {toastMessage} + ) }) @@ -569,3 +615,28 @@ const ResizeHandle = styled.div<{ $visible: boolean }>` box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4); } ` + +const ToastContainer = styled.div<{ $visible: boolean }>` + position: absolute; + top: -40px; + left: 50%; + transform: translateX(-50%); + opacity: ${(props) => (props.$visible ? 1 : 0)}; + visibility: ${(props) => (props.$visible ? 'visible' : 'hidden')}; + transition: opacity ${ANIMATION_DURATION.SLOW} ${EASING.APPLE}; + z-index: ${Z_INDEX.MODAL}; + pointer-events: none; +` + +const ToastContent = styled.div` + background: rgba(0, 0, 0, ${GLASS_EFFECT.BACKGROUND_ALPHA.LIGHT}); + color: #ffffff; + padding: ${SPACING.XS}px ${SPACING.MD}px; + border-radius: ${BORDER_RADIUS.SM}px; + font-size: ${FONT_SIZES.SM}px; + font-weight: ${FONT_WEIGHTS.MEDIUM}; + white-space: nowrap; + backdrop-filter: blur(${GLASS_EFFECT.BLUR_STRENGTH.SUBTLE}px); + border: 1px solid rgba(255, 255, 255, ${GLASS_EFFECT.BORDER_ALPHA.SUBTLE}); + box-shadow: ${SHADOWS.SM}; +` diff --git a/src/renderer/src/pages/player/engine/PlayerOrchestrator.ts b/src/renderer/src/pages/player/engine/PlayerOrchestrator.ts index f1c3ae80..8b9e82af 100644 --- a/src/renderer/src/pages/player/engine/PlayerOrchestrator.ts +++ b/src/renderer/src/pages/player/engine/PlayerOrchestrator.ts @@ -249,10 +249,48 @@ export class PlayerOrchestrator { } try { + // 记录播放前的状态 + const wasActuallyPaused = this.videoController.isPaused() + const wasContextPaused = this.context.paused + + logger.debug('requestPlay initiated', { + wasActuallyPaused, + wasContextPaused, + currentTime: this.context.currentTime + }) + + // 乐观更新内部状态 + if (wasContextPaused) { + this.updateContext({ paused: false }) + } + await this.videoController.play() - logger.debug('Command: requestPlay executed') + logger.debug('Command: requestPlay executed successfully') + + // 延迟验证播放是否真正开始 + setTimeout(() => { + if (this.videoController?.isPaused()) { + logger.warn('Video element still paused after play() call, investigating...', { + contextPaused: this.context.paused, + videoPaused: this.videoController?.isPaused() + }) + + // 尝试再次播放 + this.videoController?.play().catch((retryError) => { + logger.error('Retry play() also failed:', { retryError }) + // 回滚乐观更新 + this.updateContext({ paused: true }) + }) + } + }, 150) } catch (error) { logger.error('Failed to execute requestPlay:', { error }) + // 回滚乐观更新 + this.updateContext({ paused: true }) + + // 尝试强制同步状态 + this.syncPlaybackState() + // 不重新抛出异常,让调用者能正常处理 } } @@ -265,8 +303,47 @@ export class PlayerOrchestrator { return } - this.videoController.pause() - logger.debug('Command: requestPause executed') + try { + // 记录暂停前的状态 + const wasActuallyPaused = this.videoController.isPaused() + const wasContextPaused = this.context.paused + + logger.debug('requestPause initiated', { + wasActuallyPaused, + wasContextPaused, + currentTime: this.context.currentTime + }) + + // 乐观更新内部状态 + if (!wasContextPaused) { + this.updateContext({ paused: true }) + } + + this.videoController.pause() + logger.debug('Command: requestPause executed successfully') + + // 延迟验证暂停是否真正生效 + setTimeout(() => { + if (!this.videoController?.isPaused()) { + logger.warn('Video element still playing after pause() call, investigating...', { + contextPaused: this.context.paused, + videoPaused: this.videoController?.isPaused() + }) + + // 尝试再次暂停 + this.videoController?.pause() + // 强制同步状态 + this.syncPlaybackState() + } + }, 50) + } catch (error) { + logger.error('Failed to execute requestPause:', { error }) + // 回滚乐观更新 + this.updateContext({ paused: false }) + + // 尝试强制同步状态 + this.syncPlaybackState() + } } /** @@ -278,10 +355,34 @@ export class PlayerOrchestrator { return } - if (this.videoController.isPaused()) { - await this.requestPlay() - } else { - this.requestPause() + // 使用内部上下文状态而非直接查询视频元素,避免状态不同步问题 + const isPaused = this.context.paused + + try { + if (isPaused) { + await this.requestPlay() + // 验证播放操作是否成功 + setTimeout(() => { + if (this.videoController?.isPaused() && !this.context.paused) { + logger.warn('Play command failed to take effect, attempting sync') + this.syncPlaybackState() + } + }, 100) + } else { + this.requestPause() + // 验证暂停操作是否成功 + setTimeout(() => { + if (!this.videoController?.isPaused() && this.context.paused) { + logger.warn('Pause command failed to take effect, attempting sync') + this.syncPlaybackState() + } + }, 50) + } + } catch (error) { + logger.error('Failed to toggle play state:', { error, isPaused }) + // 尝试强制同步状态 + this.syncPlaybackState() + // 不重新抛出异常,让调用者能正常处理 } } @@ -514,6 +615,47 @@ export class PlayerOrchestrator { // === 私有方法 === + /** + * 同步播放状态 - 解决内部状态与视频元素状态不一致的问题 + */ + private syncPlaybackState(): void { + if (!this.videoController) return + + const actualPaused = this.videoController.isPaused() + const contextPaused = this.context.paused + + if (actualPaused !== contextPaused) { + logger.warn('Playback state mismatch detected, syncing...', { + contextPaused, + actualPaused, + currentTime: this.context.currentTime + }) + + // 更新内部上下文状态 + this.updateContext({ paused: actualPaused }) + + // 同步到外部状态管理器 + this.stateUpdater?.setPlaying(!actualPaused) + + // 同步调度器状态 + if (actualPaused) { + if (this.clockScheduler.getState() !== 'paused') { + this.clockScheduler.pause() + logger.debug('ClockScheduler synced to paused state') + } + } else { + if (this.clockScheduler.getState() !== 'running') { + this.clockScheduler.resume() + logger.debug('ClockScheduler synced to running state') + } + } + + logger.debug('Playback state synchronized successfully', { + newState: actualPaused ? 'paused' : 'playing' + }) + } + } + /** * 重置播放器状态(用户主动跳转时) * 清理未发布的意图、重置字幕锁定状态、重载所有策略 diff --git a/src/renderer/src/pages/player/engine/__tests__/PlayerOrchestrator.playback-reliability.test.ts b/src/renderer/src/pages/player/engine/__tests__/PlayerOrchestrator.playback-reliability.test.ts new file mode 100644 index 00000000..6eb7d42b --- /dev/null +++ b/src/renderer/src/pages/player/engine/__tests__/PlayerOrchestrator.playback-reliability.test.ts @@ -0,0 +1,347 @@ +import { LoopMode } from '@types' +import { afterEach, beforeEach, describe, expect, it, type Mocked, vi } from 'vitest' + +import { PlayerOrchestrator, StateUpdater, VideoController } from '../PlayerOrchestrator' + +describe('PlayerOrchestrator - 播放/暂停可靠性测试', () => { + let orchestrator: PlayerOrchestrator + let mockVideoController: Mocked + let mockStateUpdater: Mocked + let mockClockScheduler: any + + const context = { + currentTime: 10, + duration: 100, + paused: true, // 初始状态为暂停 + playbackRate: 1, + activeCueIndex: -1, + subtitles: [], + loopEnabled: false, + loopMode: LoopMode.SINGLE, + loopCount: -1, + loopRemainingCount: -1, + autoPauseEnabled: false, + pauseOnSubtitleEnd: false, + resumeEnabled: false, + resumeDelay: 5000, + volume: 1 + } + + afterEach(() => { + vi.restoreAllMocks() + }) + + beforeEach(() => { + // Mock console methods to avoid test output pollution + vi.spyOn(console, 'debug').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + // 创建 mock VideoController + mockVideoController = { + play: vi.fn().mockResolvedValue(undefined), + pause: vi.fn(), + seek: vi.fn(), + setPlaybackRate: vi.fn(), + setVolume: vi.fn(), + setMuted: vi.fn(), + getCurrentTime: vi.fn().mockReturnValue(10), + getDuration: vi.fn().mockReturnValue(100), + isPaused: vi.fn().mockReturnValue(true), // 初始状态为暂停 + getPlaybackRate: vi.fn().mockReturnValue(1), + getVolume: vi.fn().mockReturnValue(1), + isMuted: vi.fn().mockReturnValue(false) + } + + // 创建 mock StateUpdater + mockStateUpdater = { + setCurrentTime: vi.fn(), + setDuration: vi.fn(), + setPlaying: vi.fn(), + updateLoopRemaining: vi.fn(), + setPlaybackRate: vi.fn(), + setVolume: vi.fn(), + setMuted: vi.fn(), + setSeeking: vi.fn(), + setEnded: vi.fn(), + updateUIState: vi.fn() + } + + // 初始化 orchestrator + orchestrator = new PlayerOrchestrator({ ...context }) + orchestrator.connectVideoController(mockVideoController) + orchestrator.connectStateUpdater(mockStateUpdater) + + // 访问私有的 clockScheduler 进行 mock + const scheduler = (orchestrator as any).clockScheduler + if (scheduler) { + mockClockScheduler = scheduler + vi.spyOn(scheduler, 'getState').mockReturnValue('paused') + vi.spyOn(scheduler, 'pause').mockImplementation(() => {}) + vi.spyOn(scheduler, 'resume').mockImplementation(() => {}) + } + }) + + describe('requestTogglePlay - 使用内部状态修复', () => { + it('应该使用内部上下文状态而非视频元素状态来判断播放/暂停', async () => { + // 设置场景:内部状态为暂停,但视频元素状态为播放(状态不同步) + orchestrator.updateContext({ paused: true }) + mockVideoController.isPaused.mockReturnValue(false) // 视频元素状态不同步 + + await orchestrator.requestTogglePlay() + + // 应该根据内部状态(true)执行播放操作,而不是根据视频元素状态(false) + expect(mockVideoController.play).toHaveBeenCalled() + expect(mockVideoController.pause).not.toHaveBeenCalled() + }) + + it('应该正确处理内部状态为播放时的切换', async () => { + // 设置场景:内部状态为播放 + orchestrator.updateContext({ paused: false }) + mockVideoController.isPaused.mockReturnValue(true) // 视频元素状态不同步 + + await orchestrator.requestTogglePlay() + + // 应该根据内部状态(false)执行暂停操作 + expect(mockVideoController.pause).toHaveBeenCalled() + expect(mockVideoController.play).not.toHaveBeenCalled() + }) + + it('应该处理播放操作失败的情况', async () => { + const playError = new Error('DOMException: play() failed') + mockVideoController.play.mockRejectedValue(playError) + orchestrator.updateContext({ paused: true }) + + // 不应该抛出异常 + await expect(orchestrator.requestTogglePlay()).resolves.toBeUndefined() + + // 应该记录错误并尝试同步状态 + expect(mockVideoController.play).toHaveBeenCalled() + }) + + it('应该在播放失败后验证状态并尝试同步', async () => { + orchestrator.updateContext({ paused: true }) + mockVideoController.isPaused.mockReturnValue(true) + + await orchestrator.requestTogglePlay() + + // 使用 setTimeout 模拟延迟验证 + await new Promise((resolve) => setTimeout(resolve, 150)) + + // 验证播放调用确实发生了 + expect(mockVideoController.play).toHaveBeenCalled() + }) + }) + + describe('syncPlaybackState - 状态同步修复', () => { + it('应该检测到内部状态与视频元素状态不一致', () => { + // 设置不一致的状态 + orchestrator.updateContext({ paused: true }) // 内部状态:暂停 + mockVideoController.isPaused.mockReturnValue(false) // 视频状态:播放 + + // 调用私有方法进行状态同步测试 + const syncMethod = (orchestrator as any).syncPlaybackState.bind(orchestrator) + syncMethod() + + // 应该同步内部状态到视频元素的实际状态 + const newContext = orchestrator.getContext() + expect(newContext.paused).toBe(false) // 应该被同步为播放状态 + expect(mockStateUpdater.setPlaying).toHaveBeenCalledWith(true) + }) + + it('应该同步ClockScheduler状态', () => { + // 设置状态不一致 + orchestrator.updateContext({ paused: false }) // 内部状态:播放 + mockVideoController.isPaused.mockReturnValue(true) // 视频状态:暂停 + + if (mockClockScheduler) { + mockClockScheduler.getState.mockReturnValue('running') // 调度器状态:运行中 + } + + // 调用状态同步 + const syncMethod = (orchestrator as any).syncPlaybackState.bind(orchestrator) + syncMethod() + + // 应该同步调度器状态 + if (mockClockScheduler) { + expect(mockClockScheduler.pause).toHaveBeenCalled() + } + }) + + it('当状态一致时不应该进行不必要的同步操作', () => { + // 设置一致的状态 + orchestrator.updateContext({ paused: true }) + mockVideoController.isPaused.mockReturnValue(true) + + const syncMethod = (orchestrator as any).syncPlaybackState.bind(orchestrator) + syncMethod() + + // 不应该调用状态更新方法 + expect(mockStateUpdater.setPlaying).not.toHaveBeenCalled() + }) + }) + + describe('requestPlay - 乐观更新和验证', () => { + it('应该立即更新内部状态(乐观更新)', async () => { + orchestrator.updateContext({ paused: true }) + + // 让play()调用需要一些时间 + mockVideoController.play.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 50)) + ) + + const playPromise = orchestrator.requestPlay() + + // play()调用完成前,内部状态应该已经更新 + const contextDuringPlay = orchestrator.getContext() + expect(contextDuringPlay.paused).toBe(false) + + await playPromise + }) + + it('应该在播放失败时回滚乐观更新', async () => { + orchestrator.updateContext({ paused: true }) + const playError = new Error('Play failed') + mockVideoController.play.mockRejectedValue(playError) + + await orchestrator.requestPlay() + + // 状态应该被回滚到暂停 + const finalContext = orchestrator.getContext() + expect(finalContext.paused).toBe(true) + }) + + it('应该在延迟验证中检测播放失败', async () => { + orchestrator.updateContext({ paused: true }) + mockVideoController.isPaused.mockReturnValue(true) // 播放后仍然是暂停状态 + + await orchestrator.requestPlay() + + // 等待延迟验证执行 + await new Promise((resolve) => setTimeout(resolve, 200)) + + // 应该尝试重试播放 + expect(mockVideoController.play).toHaveBeenCalledTimes(2) // 一次正常调用,一次重试 + }) + }) + + describe('requestPause - 状态验证', () => { + it('应该立即更新内部状态', () => { + orchestrator.updateContext({ paused: false }) + + orchestrator.requestPause() + + // 内部状态应该立即更新 + const context = orchestrator.getContext() + expect(context.paused).toBe(true) + }) + + it('应该在延迟验证中检测暂停失败', async () => { + orchestrator.updateContext({ paused: false }) + mockVideoController.isPaused.mockReturnValue(false) // 暂停后仍然是播放状态 + + orchestrator.requestPause() + + // 等待延迟验证 + await new Promise((resolve) => setTimeout(resolve, 100)) + + // 应该尝试重试暂停 + expect(mockVideoController.pause).toHaveBeenCalledTimes(2) + }) + }) + + describe('错误恢复和边界情况', () => { + it('应该处理videoController未连接的情况', async () => { + const orphanOrchestrator = new PlayerOrchestrator(context) + + // 不应该抛出异常 + await expect(orphanOrchestrator.requestTogglePlay()).resolves.toBeUndefined() + await expect(orphanOrchestrator.requestPlay()).resolves.toBeUndefined() + expect(() => orphanOrchestrator.requestPause()).not.toThrow() + }) + + it('应该处理同步状态时videoController为null的情况', () => { + const orphanOrchestrator = new PlayerOrchestrator(context) + const syncMethod = (orphanOrchestrator as any).syncPlaybackState.bind(orphanOrchestrator) + + // 不应该抛出异常 + expect(() => syncMethod()).not.toThrow() + }) + + it('应该处理连续快速的播放/暂停切换', async () => { + orchestrator.updateContext({ paused: true }) + + // 快速连续调用 + const promise1 = orchestrator.requestTogglePlay() + const promise2 = orchestrator.requestTogglePlay() + const promise3 = orchestrator.requestTogglePlay() + + await Promise.all([promise1, promise2, promise3]) + + // 应该至少有一次播放调用 + expect(mockVideoController.play).toHaveBeenCalled() + }) + + it('应该处理play()返回rejected Promise的情况', async () => { + const notAllowedError = new DOMException('play() failed', 'NotAllowedError') + mockVideoController.play.mockRejectedValue(notAllowedError) + orchestrator.updateContext({ paused: true }) + + // 不应该抛出未捕获的异常 + await expect(orchestrator.requestTogglePlay()).resolves.toBeUndefined() + + // 状态应该被正确恢复 + const context = orchestrator.getContext() + expect(context.paused).toBe(true) + }) + }) + + describe('实际使用场景模拟', () => { + it('应该正确处理用户快捷键触发的播放/暂停', async () => { + // 模拟用户按下空格键时的状态 + orchestrator.updateContext({ paused: true, currentTime: 30.5 }) + mockVideoController.getCurrentTime.mockReturnValue(30.5) + + await orchestrator.requestTogglePlay() + + expect(mockVideoController.play).toHaveBeenCalled() + + // 模拟播放成功后再次按空格暂停 + orchestrator.updateContext({ paused: false }) + await orchestrator.requestTogglePlay() + + expect(mockVideoController.pause).toHaveBeenCalled() + }) + + it('应该正确处理浏览器自动暂停后的恢复播放', async () => { + // 模拟浏览器自动暂停(例如标签页失去焦点) + orchestrator.updateContext({ paused: false }) // 内部认为还在播放 + mockVideoController.isPaused.mockReturnValue(true) // 但实际已暂停 + + // 用户尝试恢复播放 + await orchestrator.requestTogglePlay() + + // 应该调用暂停(因为内部状态为播放) + expect(mockVideoController.pause).toHaveBeenCalled() + + // 然后状态同步应该修正这个问题 + const syncMethod = (orchestrator as any).syncPlaybackState.bind(orchestrator) + syncMethod() + + const newContext = orchestrator.getContext() + expect(newContext.paused).toBe(true) // 状态已同步 + }) + + it('应该处理网络问题导致的播放失败', async () => { + const networkError = new DOMException('Network error', 'NetworkError') + mockVideoController.play.mockRejectedValue(networkError) + orchestrator.updateContext({ paused: true }) + + await orchestrator.requestPlay() + + // 状态应该被回滚 + const context = orchestrator.getContext() + expect(context.paused).toBe(true) + }) + }) +}) diff --git a/src/renderer/src/pages/player/engine/__tests__/PlayerOrchestrator.test.ts b/src/renderer/src/pages/player/engine/__tests__/PlayerOrchestrator.test.ts index a26ec9f0..611d68a5 100644 --- a/src/renderer/src/pages/player/engine/__tests__/PlayerOrchestrator.test.ts +++ b/src/renderer/src/pages/player/engine/__tests__/PlayerOrchestrator.test.ts @@ -79,13 +79,15 @@ describe('PlayerOrchestrator - 命令系统测试', () => { }) it('should handle requestTogglePlay command when paused', async () => { - mockVideoController.isPaused.mockReturnValue(true) + // 修复:设置内部上下文状态为暂停,这是新的逻辑依赖 + orchestrator.updateContext({ paused: true }) await orchestrator.requestTogglePlay() expect(mockVideoController.play).toHaveBeenCalled() }) it('should handle requestTogglePlay command when playing', async () => { - mockVideoController.isPaused.mockReturnValue(false) + // 修复:设置内部上下文状态为播放,这是新的逻辑依赖 + orchestrator.updateContext({ paused: false }) await orchestrator.requestTogglePlay() expect(mockVideoController.pause).toHaveBeenCalled() }) diff --git a/src/renderer/src/pages/player/hooks/usePlayerCommands.ts b/src/renderer/src/pages/player/hooks/usePlayerCommands.ts index a50dfe43..6d13366a 100644 --- a/src/renderer/src/pages/player/hooks/usePlayerCommands.ts +++ b/src/renderer/src/pages/player/hooks/usePlayerCommands.ts @@ -32,11 +32,25 @@ export function usePlayerCommands() { return } + const context = orchestrator.getContext() + const isCurrentlyPaused = orchestrator.isPaused() + + logger.debug('playPause command initiated', { + contextPaused: context.paused, + actuallyPaused: isCurrentlyPaused, + currentTime: context.currentTime, + timestamp: Date.now() + }) + try { await orchestrator.requestTogglePlay() - logger.info('Command: playPause executed') + logger.info('Command: playPause executed successfully') } catch (error) { - logger.error('Failed to execute playPause command:', { error }) + logger.error('Failed to execute playPause command:', { + error, + contextPaused: context.paused, + actuallyPaused: isCurrentlyPaused + }) } }, [orchestrator]) diff --git a/src/renderer/src/pages/player/hooks/usePlayerShortcuts.ts b/src/renderer/src/pages/player/hooks/usePlayerShortcuts.ts index 1c205742..51ea4612 100644 --- a/src/renderer/src/pages/player/hooks/usePlayerShortcuts.ts +++ b/src/renderer/src/pages/player/hooks/usePlayerShortcuts.ts @@ -2,16 +2,99 @@ import { loggerService } from '@logger' import { useShortcut } from '@renderer/infrastructure/hooks/useShortcut' import { usePlayerStore } from '@renderer/state/stores/player.store' import { SubtitleDisplayMode } from '@types' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' import { usePlayerCommands } from './usePlayerCommands' import useSubtitleOverlay from './useSubtitleOverlay' const logger = loggerService.withContext('TransportBar') +/** + * Registers global keyboard shortcuts for player controls and subtitle-related actions. + * + * Sets up shortcuts for playback (play/pause, seek, volume, loop), subtitle navigation + * (previous/next, replay), subtitle display mode toggles (none/original/translated/bilingual), + * subtitle panel toggle, cycling favorite playback rates, and copying the current subtitle to the clipboard. + * + * The copy action selects text according to the current subtitle display mode: + * - ORIGINAL: original text + * - TRANSLATED: translated text, falling back to original if missing + * - BILINGUAL: original and translated joined by a newline + * - NONE or unsupported: no copy performed + * + * Side effects: + * - Invokes player command functions and store actions. + * - Writes subtitle text to the clipboard via `navigator.clipboard.writeText`. + * - Emits a `CustomEvent` named `subtitle-copied` with a localized success or failure message. + * - Logs informational and error events via the module logger. + */ export function usePlayerShortcuts() { + const { t } = useTranslation() const cmd = usePlayerCommands() - const { setDisplayMode } = useSubtitleOverlay() + const { setDisplayMode, currentSubtitle } = useSubtitleOverlay() const { toggleSubtitlePanel, cycleFavoriteRateNext, cycleFavoriteRatePrev } = usePlayerStore() + const displayMode = usePlayerStore((s) => s.subtitleOverlay.displayMode) + + // 复制字幕内容处理函数 + const handleCopySubtitle = useCallback(async () => { + try { + let textToCopy = '' + + if (currentSubtitle) { + // 根据显示模式复制相应的字幕内容 + switch (displayMode) { + case SubtitleDisplayMode.ORIGINAL: + textToCopy = currentSubtitle.originalText + break + case SubtitleDisplayMode.TRANSLATED: + textToCopy = currentSubtitle.translatedText || currentSubtitle.originalText + break + case SubtitleDisplayMode.BILINGUAL: { + const texts = [currentSubtitle.originalText, currentSubtitle.translatedText].filter( + Boolean + ) + textToCopy = texts.join('\n') + break + } + default: + logger.warn('当前显示模式不支持复制') + return // NONE 模式不复制 + } + logger.info('复制字幕内容', { + mode: displayMode, + length: textToCopy.length + }) + } else { + logger.warn('没有当前字幕内容') + return + } + + if (textToCopy) { + await navigator.clipboard.writeText(textToCopy) + + // 触发自定义事件显示toast + window.dispatchEvent( + new CustomEvent('subtitle-copied', { + detail: { + message: t('player.controls.copy.success') + } + }) + ) + } + } catch (error) { + logger.error('复制字幕失败', { error }) + + // 错误情况下也使用toast显示 + window.dispatchEvent( + new CustomEvent('subtitle-copied', { + detail: { + message: t('player.controls.copy.failed') + } + }) + ) + } + }, [currentSubtitle, displayMode, t]) useShortcut('play_pause', () => { cmd.playPause() @@ -87,4 +170,9 @@ export function usePlayerShortcuts() { cycleFavoriteRatePrev() logger.info('播放速度切换: 上一个常用速度') }) + + // 复制字幕内容 + useShortcut('copy_subtitle', () => { + handleCopySubtitle() + }) } diff --git a/src/renderer/src/pages/player/hooks/useSubtitleOverlay.ts b/src/renderer/src/pages/player/hooks/useSubtitleOverlay.ts index e6ddf397..4470f5f4 100644 --- a/src/renderer/src/pages/player/hooks/useSubtitleOverlay.ts +++ b/src/renderer/src/pages/player/hooks/useSubtitleOverlay.ts @@ -36,6 +36,9 @@ export function useSubtitleOverlay(): SubtitleOverlay { // 字幕引擎 const { currentSubtitle, currentIndex } = useSubtitleEngine() + // 当前播放时间 + const currentTime = usePlayerStore((s) => s.currentTime) + const subtitleOverlayConfig = usePlayerStore((s) => s.subtitleOverlay) const setSubtitleOverlay = usePlayerStore((s) => s.setSubtitleOverlay) @@ -54,10 +57,17 @@ export function useSubtitleOverlay(): SubtitleOverlay { // === 计算是否应该显示 === const shouldShow = useMemo(() => { - return ( - subtitleOverlayConfig.displayMode !== SubtitleDisplayMode.NONE && currentSubtitleData !== null - ) - }, [subtitleOverlayConfig.displayMode, currentSubtitleData]) + // 基础条件:显示模式不为 NONE 且有字幕数据 + if (subtitleOverlayConfig.displayMode === SubtitleDisplayMode.NONE || !currentSubtitleData) { + return false + } + + // 时间边界检查:确保当前播放时间在字幕的时间范围内 + const isInTimeRange = + currentTime >= currentSubtitleData.startTime && currentTime <= currentSubtitleData.endTime + + return isInTimeRange + }, [subtitleOverlayConfig.displayMode, currentSubtitleData, currentTime]) // === 计算显示文本 === const displayText = useMemo(() => {