feat(project): 初始化项目结构和配置
@@ -0,0 +1,3 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
||||
@@ -0,0 +1,6 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
@@ -0,0 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,3 @@
|
||||
# d.ts files Generated by unplugin-auto unplugin-vue-components
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is 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. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
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.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
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 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. Use with the GNU Affero General Public License.
|
||||
|
||||
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 Affero 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 special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU 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 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 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 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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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 General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
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 GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,19 @@
|
||||
# 阅读web端
|
||||
使用vue3 web书架和web源编辑
|
||||
## 路由
|
||||
* http://localhost:8080/ 书架
|
||||
* http://localhost:8080/#/bookSource 书源编辑
|
||||
* http://localhost:8080/#/rssSource 订阅源编辑
|
||||
|
||||
## 兼容性
|
||||
|
||||
|  |  |  |  |
|
||||
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
|
||||
| Edge ≥ 85 | Firefox ≥ 79 | Chrome ≥ 85 | Safari ≥ 14.1 |
|
||||
|
||||
## 开发
|
||||
> 需要阅读app提供后端服务
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "vue3-virtual-scroll-list";
|
||||
@@ -0,0 +1,19 @@
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import vueTsEslintConfig from '@vue/eslint-config-typescript'
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
export default [
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||
},
|
||||
|
||||
{
|
||||
name: 'app/files-to-ignore',
|
||||
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**', "src/plugins/jump.js"],
|
||||
},
|
||||
|
||||
...pluginVue.configs['flat/essential'],
|
||||
...vueTsEslintConfig(),
|
||||
skipFormatting,
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh" class="">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "legado-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20",
|
||||
"pnpm": ">=9"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" -- && node ./scripts/sync.js",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build --force",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-svg": "^2.3.1",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"@vueuse/shared": "^11.1.0",
|
||||
"axios": "^1.7.7",
|
||||
"element-plus": "2.8.5",
|
||||
"hotkeys-js": "^3.13.7",
|
||||
"pinia": "^2.2.4",
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "^4.4.5",
|
||||
"vue3-virtual-scroll-list": "^0.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sass-embedded": "^1.83.1",
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"@types/node": "^20.16.11",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@vue/eslint-config-prettier": "^10.0.0",
|
||||
"@vue/eslint-config-typescript": "^14.0.1",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"eslint": "^9.12.0",
|
||||
"eslint-plugin-vue": "^9.29.0",
|
||||
"npm-run-all2": "^6.2.3",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "~5.5.4",
|
||||
"unplugin-auto-import": "^0.18.3",
|
||||
"unplugin-icons": "^0.19.3",
|
||||
"unplugin-vue-components": "^0.27.4",
|
||||
"vite": "^5.4.8",
|
||||
"vue-tsc": "^2.1.6"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,45 @@
|
||||
import { URL } from "node:url";
|
||||
import fs from "node:fs";
|
||||
import process from "node:process";
|
||||
|
||||
if (!process.env.GITHUB_ENV) {
|
||||
console.log("非Github WorkFlows环境,取消文件复制");
|
||||
process.exit();
|
||||
}
|
||||
const LEGADO_ASSETS_WEB_VUE_DIR = new URL(
|
||||
"../../../app/src/main/assets/web/vue",
|
||||
import.meta.url,
|
||||
);
|
||||
const VUE_DIST_DIR = new URL("../dist", import.meta.url);
|
||||
|
||||
console.log("> delete", LEGADO_ASSETS_WEB_VUE_DIR.pathname);
|
||||
// 删除
|
||||
fs.rm(
|
||||
LEGADO_ASSETS_WEB_VUE_DIR,
|
||||
{
|
||||
force: true,
|
||||
recursive: true,
|
||||
},
|
||||
(error) => {
|
||||
if (error) console.log(error);
|
||||
console.log("> mkdir", LEGADO_ASSETS_WEB_VUE_DIR.pathname);
|
||||
fs.mkdir(LEGADO_ASSETS_WEB_VUE_DIR, (error) => {
|
||||
if (error) return console.error(error);
|
||||
console.log("> cp dist files");
|
||||
fs.cp(
|
||||
VUE_DIST_DIR,
|
||||
LEGADO_ASSETS_WEB_VUE_DIR,
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
(error) => {
|
||||
if (error) {
|
||||
console.warn("> cp error, you may copy files yourshelf");
|
||||
throw error;
|
||||
}
|
||||
console.log("> cp success");
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
@@ -0,0 +1,226 @@
|
||||
/** https://github.com/gedoor/legado/tree/master/app/src/main/java/io/legado/app/api */
|
||||
/** https://github.com/gedoor/legado/tree/master/app/src/main/java/io/legado/app/web */
|
||||
|
||||
import type { webReadConfig } from '@/web'
|
||||
import ajax from './axios'
|
||||
import type {
|
||||
BaseBook,
|
||||
Book,
|
||||
BookChapter,
|
||||
BookProgress,
|
||||
SeachBook,
|
||||
} from '@/book'
|
||||
import type { Source } from '@/source'
|
||||
|
||||
export type LeagdoApiResponse<T> = {
|
||||
isSuccess: boolean
|
||||
errorMsg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export let legado_http_entry_point = ''
|
||||
export let legado_webSocket_entry_point = ''
|
||||
|
||||
let wsOnError: typeof WebSocket.prototype.onerror = () => {}
|
||||
let wsOnMessage: typeof WebSocket.prototype.onmessage = () => {}
|
||||
export const setWebsocketOnMessage = (callback: typeof wsOnMessage) =>
|
||||
(wsOnMessage = callback)
|
||||
export const setWebsocketOnError = (callback: typeof wsOnError) => {
|
||||
//WebSocket.prototype.onerror = callback
|
||||
wsOnError = callback
|
||||
}
|
||||
|
||||
export const setApiEntryPoint = (
|
||||
http_entry_point: string,
|
||||
webSocket_entry_point: string,
|
||||
) => {
|
||||
legado_http_entry_point = new URL(http_entry_point).toString()
|
||||
legado_webSocket_entry_point = new URL(webSocket_entry_point).toString()
|
||||
ajax.defaults.baseURL = legado_http_entry_point
|
||||
}
|
||||
|
||||
// 书架API
|
||||
// Http
|
||||
const getReadConfig = async (http_url = legado_http_entry_point) => {
|
||||
const { data } = await ajax.get<LeagdoApiResponse<string>>('getReadConfig', {
|
||||
baseURL: http_url.toString(),
|
||||
timeout: 3000,
|
||||
})
|
||||
if (data.isSuccess) {
|
||||
try {
|
||||
return JSON.parse(data.data) as webReadConfig
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
const saveReadConfig = (config: webReadConfig) =>
|
||||
ajax.post<LeagdoApiResponse<string>>('saveReadConfig', config)
|
||||
|
||||
/** @deprecated: 使用`API.saveBookProgressWithBeacon`以确保在页面或者直接关闭的情况下保存进度 */
|
||||
const saveBookProgress = (bookProgress: BookProgress) =>
|
||||
ajax.post('saveBookProgress', bookProgress)
|
||||
|
||||
/**主要在直接关闭浏览器情况下可靠发送书籍进度 */
|
||||
const saveBookProgressWithBeacon = (bookProgress: BookProgress) => {
|
||||
if (!bookProgress) return
|
||||
// 常规请求可能会被取消 使用Fetch keep-alive 或者 navigator.sendBeacon
|
||||
navigator.sendBeacon(
|
||||
new URL('saveBookProgress', legado_http_entry_point),
|
||||
JSON.stringify(bookProgress),
|
||||
)
|
||||
}
|
||||
|
||||
const getBookShelf = () => ajax.get<LeagdoApiResponse<Book[]>>('getBookshelf')
|
||||
|
||||
const getChapterList = (/** @type {string} */ bookUrl: string) =>
|
||||
ajax.get<LeagdoApiResponse<BookChapter[]>>(
|
||||
'getChapterList?url=' + encodeURIComponent(bookUrl),
|
||||
)
|
||||
|
||||
const getBookContent = (
|
||||
/** @type {string} */ bookUrl: string,
|
||||
/** @type {number} */ chapterIndex: number,
|
||||
) =>
|
||||
ajax.get<LeagdoApiResponse<string>>(
|
||||
'getBookContent?url=' +
|
||||
encodeURIComponent(bookUrl) +
|
||||
'&index=' +
|
||||
chapterIndex,
|
||||
)
|
||||
|
||||
// webSocket
|
||||
const search = (
|
||||
searchKey: string,
|
||||
onReceive: (data: SeachBook[]) => void,
|
||||
onFinish: () => void,
|
||||
) => {
|
||||
const socket = new WebSocket(
|
||||
new URL('searchBook', legado_webSocket_entry_point),
|
||||
)
|
||||
socket.onerror = wsOnError
|
||||
|
||||
socket.onopen = () => {
|
||||
socket.send(`{"key":"${searchKey}"}`)
|
||||
}
|
||||
socket.onmessage = event => {
|
||||
try {
|
||||
onReceive(JSON.parse(event.data))
|
||||
wsOnMessage?.call(socket, event)
|
||||
} catch {
|
||||
onFinish()
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
onFinish()
|
||||
}
|
||||
}
|
||||
|
||||
const saveBook = (book: BaseBook) =>
|
||||
ajax.post<LeagdoApiResponse<string>>('saveBook', book)
|
||||
const deleteBook = (book: BaseBook) =>
|
||||
ajax.post<LeagdoApiResponse<string>>('deleteBook', book)
|
||||
|
||||
const isBookSource = /bookSource/i.test(location.href)
|
||||
|
||||
// 源编辑API
|
||||
// Http
|
||||
const getSources = () =>
|
||||
isBookSource ? ajax.get('getBookSources') : ajax.get('getRssSources')
|
||||
|
||||
const saveSource = (data: Source) =>
|
||||
isBookSource
|
||||
? ajax.post<LeagdoApiResponse<string>>('saveBookSource', data)
|
||||
: ajax.post<LeagdoApiResponse<string>>('saveRssSource', data)
|
||||
|
||||
const saveSources = (data: Source[]) =>
|
||||
isBookSource
|
||||
? ajax.post<LeagdoApiResponse<Source[]>>('saveBookSources', data)
|
||||
: ajax.post<LeagdoApiResponse<Source[]>>('saveRssSources', data)
|
||||
|
||||
const deleteSource = (data: Source[]) =>
|
||||
isBookSource
|
||||
? ajax.post<LeagdoApiResponse<string>>('deleteBookSources', data)
|
||||
: ajax.post<LeagdoApiResponse<string>>('deleteRssSources', data)
|
||||
|
||||
// webSocket
|
||||
const debug = (
|
||||
/** @type {string} */ sourceUrl: string,
|
||||
/** @type {string} */ searchKey: string,
|
||||
/** @type {(data: string) => void} */ onReceive: (data: string) => void,
|
||||
/** @type {() => void} */ onFinish: () => void,
|
||||
) => {
|
||||
const url = new URL(
|
||||
`${isBookSource ? 'bookSource' : 'rssSource'}Debug`,
|
||||
legado_webSocket_entry_point,
|
||||
)
|
||||
|
||||
const socket = new WebSocket(url)
|
||||
socket.onerror = wsOnError
|
||||
socket.onopen = () => {
|
||||
socket.send(JSON.stringify({ tag: sourceUrl, key: searchKey }))
|
||||
}
|
||||
socket.onmessage = event => {
|
||||
onReceive(event.data)
|
||||
wsOnMessage?.call(socket, event)
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
onFinish()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从阅读获取需要特定处理的书籍封面
|
||||
* @param {string} coverUrl
|
||||
*/
|
||||
const getProxyCoverUrl = (coverUrl: string) => {
|
||||
if (coverUrl.startsWith(legado_http_entry_point)) return coverUrl
|
||||
return new URL(
|
||||
'cover?path=' + encodeURIComponent(coverUrl),
|
||||
legado_http_entry_point,
|
||||
).toString()
|
||||
}
|
||||
/**
|
||||
* 从阅读获取需要特定处理的图片
|
||||
* @param {string} bookUrl
|
||||
* @param {string} src
|
||||
* @param {number|`${number}`} width
|
||||
*/
|
||||
const getProxyImageUrl = (
|
||||
bookUrl: string,
|
||||
src: string,
|
||||
width: number | `${number}`,
|
||||
) => {
|
||||
if (src.startsWith(legado_http_entry_point)) return src
|
||||
return new URL(
|
||||
'image?path=' +
|
||||
encodeURIComponent(src) +
|
||||
'&url=' +
|
||||
encodeURIComponent(bookUrl) +
|
||||
'&width=' +
|
||||
width,
|
||||
legado_http_entry_point,
|
||||
).toString()
|
||||
}
|
||||
|
||||
export default {
|
||||
getReadConfig,
|
||||
saveReadConfig,
|
||||
saveBookProgress,
|
||||
saveBookProgressWithBeacon,
|
||||
getBookShelf,
|
||||
getChapterList,
|
||||
getBookContent,
|
||||
search,
|
||||
saveBook,
|
||||
deleteBook,
|
||||
|
||||
getSources,
|
||||
saveSources,
|
||||
saveSource,
|
||||
deleteSource,
|
||||
debug,
|
||||
|
||||
getProxyCoverUrl,
|
||||
getProxyImageUrl,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import axios from 'axios'
|
||||
|
||||
/** @type {string} localStorage保存自定义阅读http服务接口的键值 */
|
||||
export const baseURL_localStorage_key = 'remoteUrl'
|
||||
const SECOND = 1000
|
||||
|
||||
const ajax = axios.create({
|
||||
baseURL:
|
||||
import.meta.env.VITE_API ||
|
||||
localStorage.getItem(baseURL_localStorage_key) ||
|
||||
location.origin,
|
||||
timeout: 120 * SECOND,
|
||||
})
|
||||
|
||||
export default ajax
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import type { LeagdoApiResponse } from './api'
|
||||
import API, {
|
||||
setWebsocketOnError,
|
||||
setApiEntryPoint,
|
||||
legado_http_entry_point,
|
||||
setWebsocketOnMessage,
|
||||
} from './api'
|
||||
import ajax from './axios'
|
||||
import { validatorHttpUrl } from '@/utils/utils'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import App from '@/App.vue'
|
||||
import store, { useConnectionStore } from '@/store'
|
||||
|
||||
createApp(App).use(store)
|
||||
const connectionStore = useConnectionStore()
|
||||
|
||||
const LeagdoApiResponseKeys: string[] = Array.of('isSuccess', 'errorMsg')
|
||||
|
||||
const notification = ElMessage
|
||||
/** Axios.Interceptor: check if resp is LeagaoLeagdoApiResponse*/
|
||||
const responseCheckInterceptor = (resp: AxiosResponse) => {
|
||||
let isLeagdoApiResponse = true
|
||||
try {
|
||||
const data = resp.data
|
||||
|
||||
for (const key of LeagdoApiResponseKeys) {
|
||||
if (!(key in data)) {
|
||||
isLeagdoApiResponse = false
|
||||
LeagdoApiResponseKeys.length = 0
|
||||
}
|
||||
}
|
||||
if ((data as LeagdoApiResponse<unknown>).isSuccess === true) {
|
||||
if (!('data' in data)) {
|
||||
isLeagdoApiResponse = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
isLeagdoApiResponse = false
|
||||
}
|
||||
if (isLeagdoApiResponse === false) {
|
||||
notification.warning({ message: '后端返回内容格式错误', grouping: true })
|
||||
throw new Error()
|
||||
}
|
||||
connectionStore.setConnectType('primary')
|
||||
connectionStore.setConnectStatus('已连接 ' + legado_http_entry_point)
|
||||
return resp
|
||||
}
|
||||
|
||||
const axiosErrorInterceptor = (err: unknown) => {
|
||||
notification.error({
|
||||
message: '后端连接失败,请检查阅读WEB服务或者设置其它可用链接',
|
||||
grouping: true,
|
||||
})
|
||||
connectionStore.setConnectType('danger')
|
||||
connectionStore.setConnectStatus('连接异常')
|
||||
throw err
|
||||
}
|
||||
// http全局
|
||||
ajax.interceptors.response.use(responseCheckInterceptor, axiosErrorInterceptor)
|
||||
// websocket
|
||||
setWebsocketOnError(axiosErrorInterceptor)
|
||||
setWebsocketOnMessage(() => {
|
||||
connectionStore.setConnectType('primary')
|
||||
connectionStore.setConnectStatus('已连接 ' + legado_http_entry_point)
|
||||
})
|
||||
/**
|
||||
* 按照阅读的默认规则 解析阅读HTTP WebSocket API入口地址
|
||||
* @returns [http_url, webSocekt_url]
|
||||
*/
|
||||
export const parseLeagdoHttpUrlWithDefault = (
|
||||
http_url: string | URL,
|
||||
): [string, string] => {
|
||||
let url = new URL(location.origin) //默认当前网址的origin部分
|
||||
if (validatorHttpUrl(http_url)) {
|
||||
url = new URL(http_url)
|
||||
}
|
||||
const { protocol, port } = url
|
||||
// websocket服务端口 为http服务端口 + 1
|
||||
let legado_webSocket_port
|
||||
if (port !== '') {
|
||||
legado_webSocket_port = String(Number(port) + 1)
|
||||
} else {
|
||||
legado_webSocket_port = protocol.startsWith('https:') ? '444' : '81'
|
||||
}
|
||||
// websocket协议是否为加密版本
|
||||
const legado_webSocket_protocol = protocol.startsWith('https:')
|
||||
? 'wss://'
|
||||
: 'ws://'
|
||||
|
||||
const http_entry_point = url.toString()
|
||||
|
||||
url.protocol = legado_webSocket_protocol
|
||||
url.port = legado_webSocket_port
|
||||
const webSocket_entry_point = url.toString()
|
||||
|
||||
console.info('legado_api_config:')
|
||||
console.table({
|
||||
'http API入口': http_entry_point,
|
||||
'webSocket API入口': webSocket_entry_point,
|
||||
})
|
||||
return [http_entry_point, webSocket_entry_point]
|
||||
}
|
||||
|
||||
//export const useLeagdoRemoteUrlDialog = () => { }
|
||||
|
||||
setApiEntryPoint(
|
||||
...parseLeagdoHttpUrlWithDefault(ajax.defaults.baseURL as string),
|
||||
)
|
||||
|
||||
export default API
|
||||
export * from './api'
|
||||
@@ -0,0 +1,14 @@
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
font-family: 'Avenir', Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: #2c3e50;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
code {
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background-color: var(--el-fill-color-light);
|
||||
transition:
|
||||
color 0.25s,
|
||||
background-color 0.5s;
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@charset "UTF-8";
|
||||
@font-face {
|
||||
font-family: 'iconfont';
|
||||
src: url('./iconfont.woff') format('woff');
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@charset "UTF-8";
|
||||
@font-face {
|
||||
font-family: 'FZZCYSK';
|
||||
src: local('☺'), url('./popfont.ttf');
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@charset "UTF-8";
|
||||
@font-face {
|
||||
font-family: 'FZZCYSK';
|
||||
src: local('☺'), url('./shelffont.ttf');
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 749 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 677 B |
|
After Width: | Height: | Size: 710 B |
|
After Width: | Height: | Size: 709 B |
|
After Width: | Height: | Size: 744 B |
|
After Width: | Height: | Size: 559 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 653 B |
|
After Width: | Height: | Size: 693 B |
|
After Width: | Height: | Size: 755 B |
|
After Width: | Height: | Size: 565 B |
|
After Width: | Height: | Size: 713 B |
|
After Width: | Height: | Size: 617 B |
|
After Width: | Height: | Size: 825 B |
|
After Width: | Height: | Size: 653 B |
|
After Width: | Height: | Size: 770 B |
|
After Width: | Height: | Size: 565 B |
@@ -0,0 +1,12 @@
|
||||
kbd {
|
||||
align-items: center;
|
||||
background: rgba(125, 125, 125, 0.1);
|
||||
border-radius: 3px;
|
||||
border: 0;
|
||||
padding: 4px 5px;
|
||||
font-weight: bold;
|
||||
box-shadow:
|
||||
inset 0 -2px 0 0 #cdcde6,
|
||||
inset 0 0 1px 1px #fff,
|
||||
0 1px 2px 1px rgba(30, 35, 90, 0.4);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
@import './kbd.css';
|
||||
@import './code.css';
|
||||
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.el-tabs__header {
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
z-index: 2;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const ElMessage: typeof import('element-plus/es')['ElMessage']
|
||||
const ElMessageBox: typeof import('element-plus/es')['ElMessageBox']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const createPinia: typeof import('pinia')['createPinia']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
const mapState: typeof import('pinia')['mapState']
|
||||
const mapStores: typeof import('pinia')['mapStores']
|
||||
const mapWritableState: typeof import('pinia')['mapWritableState']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const store: typeof import('./store/index')['default']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useBookStore: typeof import('./store/bookStore')['useBookStore']
|
||||
const useConnectionStore: typeof import('./store/connectionStore')['useConnectionStore']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useLink: typeof import('vue-router')['useLink']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useSourceStore: typeof import('./store/sourceStore')['useSourceStore']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/** https://github.com/gedoor/legado/tree/master/app/src/main/java/io/legado/app/data/entities */
|
||||
export type BaseBook = {
|
||||
name: string
|
||||
author: string
|
||||
bookUrl: string
|
||||
kind?: string
|
||||
wordCount?: string
|
||||
variable?: string
|
||||
/** 忽略序列化
|
||||
infoHtml?: string
|
||||
tocHtml?: string
|
||||
*/
|
||||
}
|
||||
export type Book = BaseBook & {
|
||||
// 目录页Url (toc=table of Contents)
|
||||
tocUrl: string
|
||||
// 书源URL(默认BookType.local)
|
||||
origin: string
|
||||
//书源名称 or 本地书籍文件名
|
||||
originName: string
|
||||
// 分类信息(用户修改)
|
||||
customTag?: string
|
||||
// 封面Url(书源获取)
|
||||
coverUrl?: string
|
||||
// 封面Url(用户修改)
|
||||
customCoverUrl?: string
|
||||
// 简介内容(书源获取)
|
||||
intro?: string
|
||||
// 简介内容(用户修改)
|
||||
customnumberro?: string
|
||||
// 自定义字符集名称(仅适用于本地书籍)
|
||||
charset?: string
|
||||
// 类型详见BookType
|
||||
type: number
|
||||
// 自定义分组索引号
|
||||
group: number
|
||||
// 最新章节标题
|
||||
latestChapterTitle?: string
|
||||
// 最新章节标题更新时间
|
||||
latestChapterTime: number
|
||||
// 最近一次更新书籍信息的时间
|
||||
lastCheckTime: number
|
||||
// 最近一次发现新章节的数量
|
||||
lastCheckCount: number
|
||||
// 书籍目录总数
|
||||
totalChapterNum: number
|
||||
// 当前章节名称
|
||||
durChapterTitle?: string
|
||||
// 当前章节索引
|
||||
durChapterIndex: number
|
||||
// 当前阅读的进度(首行字符的索引位置)
|
||||
durChapterPos: number
|
||||
// 最近一次阅读书籍的时间(打开正文的时间)
|
||||
durChapterTime: number
|
||||
// 刷新书架时更新书籍信息
|
||||
canUpdate: boolean
|
||||
// 手动排序
|
||||
order: number
|
||||
//书源排序
|
||||
originOrder: number
|
||||
//阅读设置
|
||||
readConfig?: ReadConfig
|
||||
//同步时间
|
||||
syncTime: number
|
||||
}
|
||||
export type SeachBook = BaseBook & {
|
||||
/** 书源 */
|
||||
origin: string
|
||||
originName: string
|
||||
/** BookType */
|
||||
type: number
|
||||
coverUrl?: string
|
||||
intro?: string
|
||||
latestChapterTitle?: string
|
||||
/** 目录页Url (toc=table of Contents) */
|
||||
tocUrl: string
|
||||
time: number
|
||||
originOrder: number
|
||||
chapterWordCountText?: string
|
||||
chapterWordCount: number0
|
||||
respondTime: number
|
||||
}
|
||||
export type BookProgress = Pick<
|
||||
Book,
|
||||
| 'name'
|
||||
| 'author'
|
||||
| 'durChapterIndex'
|
||||
| 'durChapterPos'
|
||||
| 'durChapterTime'
|
||||
| 'durChapterTitle'
|
||||
>
|
||||
|
||||
export type BookChapter = {
|
||||
url: string // 章节地址
|
||||
title: string // 章节标题
|
||||
isVolume: boolean // 是否是卷名
|
||||
baseUrl: string // 用来拼接相对url
|
||||
bookUrl: string // 书籍地址
|
||||
index: number // 章节序号
|
||||
isVip: boolean // 是否VIP
|
||||
isPay: boolean // 是否已购买
|
||||
resourceUrl?: string // 音频真实URL
|
||||
tag?: string // 更新时间或其他章节附加信息
|
||||
start?: number // 章节起始位置
|
||||
end?: number // 章节终止位置
|
||||
startFragmentId?: string //EPUB书籍当前章节的fragmentId
|
||||
endFragmentId?: string //EPUB书籍下一章节的fragmentId
|
||||
variable?: string //变量
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
BookItems: typeof import('./components/BookItems.vue')['default']
|
||||
CatalogItem: typeof import('./components/CatalogItem.vue')['default']
|
||||
ChapterContent: typeof import('./components/ChapterContent.vue')['default']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElLink: typeof import('element-plus/es')['ElLink']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElText: typeof import('element-plus/es')['ElText']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
PopCatalog: typeof import('./components/PopCatalog.vue')['default']
|
||||
ReadSettings: typeof import('./components/ReadSettings.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SourceDebug: typeof import('./components/SourceDebug.vue')['default']
|
||||
SourceHelp: typeof import('./components/SourceHelp.vue')['default']
|
||||
SourceItem: typeof import('./components/SourceItem.vue')['default']
|
||||
SourceJson: typeof import('./components/SourceJson.vue')['default']
|
||||
SourceList: typeof import('./components/SourceList.vue')['default']
|
||||
SourceTabForm: typeof import('./components/SourceTabForm.vue')['default']
|
||||
SourceTabTools: typeof import('./components/SourceTabTools.vue')['default']
|
||||
ToolBar: typeof import('./components/ToolBar.vue')['default']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div class="books-wrapper">
|
||||
<div class="wrapper">
|
||||
<div
|
||||
class="book"
|
||||
v-for="book in books"
|
||||
:key="book.bookUrl"
|
||||
@click="handleClick(book)"
|
||||
>
|
||||
<div class="cover-img">
|
||||
<img
|
||||
class="cover"
|
||||
:src="getCover(book)"
|
||||
:key="book.coverUrl"
|
||||
@error.once="proxyImage"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div class="info">
|
||||
<div class="name">{{ book.name }}</div>
|
||||
<div class="sub">
|
||||
<div class="author">
|
||||
{{ book.author }}
|
||||
</div>
|
||||
<div class="tags" v-if="isSearch">
|
||||
<el-tag
|
||||
v-for="tag in book.kind?.split(',').slice(0, 2)"
|
||||
:key="tag"
|
||||
>
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="update-info" v-if="!isSearch">
|
||||
<div class="dot">•</div>
|
||||
<div class="size">共{{ (book as Book).totalChapterNum }}章</div>
|
||||
<div class="dot">•</div>
|
||||
<div class="date">
|
||||
{{ dateFormat((book as Book).lastCheckTime) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="intro" v-if="isSearch">{{ book.intro }}</div>
|
||||
|
||||
<div class="dur-chapter" v-if="!isSearch">
|
||||
已读:{{ (book as Book).durChapterTitle }}
|
||||
</div>
|
||||
<div class="last-chapter">最新:{{ book.latestChapterTitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Book, SeachBook } from '@/book'
|
||||
import { dateFormat, isLegadoUrl } from '../utils/utils'
|
||||
import API from '@api'
|
||||
const props = defineProps<{
|
||||
books: Array<Book | SeachBook>
|
||||
isSearch: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['bookClick'])
|
||||
const handleClick = (book: Book | SeachBook) => emit('bookClick', book)
|
||||
const getCover = ({ bookUrl, coverUrl }: Book | SeachBook) => {
|
||||
if (coverUrl === undefined) return API.getProxyCoverUrl(bookUrl)
|
||||
return isLegadoUrl(coverUrl) ? API.getProxyCoverUrl(coverUrl) : coverUrl
|
||||
}
|
||||
const proxyImage = (evt: Event) => {
|
||||
const target = evt.target as HTMLImageElement
|
||||
target.src = API.getProxyCoverUrl(target.src)
|
||||
}
|
||||
|
||||
const subJustify = computed(() =>
|
||||
props.isSearch ? 'space-between' : 'flex-start',
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.books-wrapper {
|
||||
overflow: auto;
|
||||
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 380px);
|
||||
justify-content: space-around;
|
||||
grid-gap: 10px;
|
||||
|
||||
.book {
|
||||
user-select: none;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
margin-bottom: 18px;
|
||||
padding: 24px 24px;
|
||||
width: 360px;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
|
||||
.cover-img {
|
||||
width: 84px;
|
||||
height: 112px;
|
||||
|
||||
.cover {
|
||||
width: 84px;
|
||||
height: 112px;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
align-items: left;
|
||||
height: 112px;
|
||||
margin-left: 20px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.name {
|
||||
width: fit-content;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #33373d;
|
||||
}
|
||||
|
||||
.sub {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
justify-content: v-bind('subJustify');
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #6b6b6b;
|
||||
.tags {
|
||||
:deep(.el-tag) {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
.update-info {
|
||||
display: flex;
|
||||
.dot {
|
||||
margin: 0 7px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.intro,
|
||||
.dur-chapter,
|
||||
.last-chapter {
|
||||
color: #969ba3;
|
||||
font-size: 13px;
|
||||
margin-top: 3px;
|
||||
font-weight: 500;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.book:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
transition-duration: 0.5s;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper:last-child {
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.books-wrapper::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
.books-wrapper {
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.book {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="wrapper">
|
||||
<div
|
||||
v-for="cata in catas"
|
||||
class="cata-text"
|
||||
:key="cata.url"
|
||||
:class="{ selected: isSelected(cata.index) }"
|
||||
@click="gotoChapter(cata)"
|
||||
>
|
||||
{{ cata.title }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { BookChapter } from '@/book'
|
||||
|
||||
const props = defineProps<{
|
||||
index: number
|
||||
source: BookChapter | { index: number; catas: BookChapter[] }
|
||||
gotoChapter: (chapter: BookChapter) => void
|
||||
currentChapterIndex: number
|
||||
}>()
|
||||
|
||||
const isSelected = (idx: number) => {
|
||||
return idx == props.currentChapterIndex
|
||||
}
|
||||
|
||||
// PC端 一个虚拟列表中有两个章节
|
||||
const catas = computed(() => {
|
||||
const source = props.source
|
||||
if ('catas' in source) return source.catas
|
||||
return [props.source as BookChapter]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.selected {
|
||||
color: #eb4259;
|
||||
}
|
||||
.wrapper {
|
||||
display: flex;
|
||||
|
||||
.cata-text {
|
||||
width: 100%;
|
||||
margin-right: 26px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div class="title" data-chapterpos="0" ref="titleRef">{{ title }}</div>
|
||||
<div
|
||||
v-for="(para, index) in contents"
|
||||
:key="index"
|
||||
ref="paragraphRef"
|
||||
:data-chapterpos="chapterPos[index]"
|
||||
>
|
||||
<img
|
||||
class="full"
|
||||
v-if="/^\s*<img[^>]*src[^>]+>$/.test(String(para))"
|
||||
:src="getImageSrc(para)"
|
||||
@error.once="proxyImage"
|
||||
loading="lazy"
|
||||
/>
|
||||
<p v-else :style="{ fontFamily, fontSize }" v-html="replaceImage(para)" @error.capture="handleImgLoadError" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isLegadoUrl } from '@/utils/utils'
|
||||
import API from '@api'
|
||||
import jump from '@/plugins/jump'
|
||||
import type { webReadConfig } from '@/web'
|
||||
|
||||
const store = useBookStore()
|
||||
const readWidth = computed(() => store.config.readWidth)
|
||||
const fontSize = computed(() => store.config.fontSize)
|
||||
const bookUrl = computed(() => store.readingBook.bookUrl)
|
||||
|
||||
const props = defineProps<{
|
||||
chapterIndex: number
|
||||
contents: Array<string>
|
||||
title: string
|
||||
spacing: webReadConfig['spacing']
|
||||
fontFamily: string
|
||||
fontSize: string
|
||||
}>()
|
||||
|
||||
const imgPattern = /<img[^>]*src=['"]([^'"]*(?:['"][^>]+\})?)['"][^>]*>/g
|
||||
|
||||
const replaceImage = (content: string) => {
|
||||
return content.replace(imgPattern, (match, src) => {
|
||||
if (isLegadoUrl(src)) {
|
||||
const proxySrc = API.getProxyImageUrl(
|
||||
bookUrl.value,
|
||||
src,
|
||||
fontSize.value * 2,
|
||||
)
|
||||
return match.replace(src, proxySrc)
|
||||
}
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
const getImageSrc = (content: string) => {
|
||||
const imgPattern = /<img[^>]*src=['"]([^'"]*(?:['"][^>]+\})?)['"][^>]*>/
|
||||
const src = content.match(imgPattern)![1] //reg tested in template
|
||||
if (isLegadoUrl(src))
|
||||
return API.getProxyImageUrl(
|
||||
bookUrl.value,
|
||||
src,
|
||||
readWidth.value,
|
||||
)
|
||||
return src
|
||||
}
|
||||
const proxyImage = (event: Event) => {
|
||||
/* 获取IMG标签原始的src
|
||||
<img src="/test" />
|
||||
假设location.href = http://example.com
|
||||
event.target.src 返回 http://example.com/test
|
||||
(event.target as HTMLImageElement)?.getAttribute("src") 返回/test
|
||||
*/
|
||||
const src = (event.target as HTMLImageElement)?.getAttribute("src")
|
||||
if (src != null && src.length > 0) {
|
||||
(event.target as HTMLImageElement).src = API.getProxyImageUrl(
|
||||
bookUrl.value,
|
||||
src,
|
||||
readWidth.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理传入的IMG标签错误事件,自动替换图片的代理链接
|
||||
*/
|
||||
const handleImgLoadError = (event: Event) => {
|
||||
if ((event.target as HTMLElement)?.tagName === "IMG") {
|
||||
console.log("[ChapterContent]: IMG Load Error, replace src:",
|
||||
(event.target as HTMLImageElement)?.getAttribute("src"), "=>",
|
||||
API.getProxyImageUrl(
|
||||
bookUrl.value,
|
||||
(event.target as HTMLImageElement)?.getAttribute("src") ?? "",
|
||||
readWidth.value,
|
||||
)
|
||||
)
|
||||
proxyImage(event)
|
||||
}
|
||||
}
|
||||
|
||||
const calculateWordCount = (paragraph: string) => {
|
||||
//内嵌图片文字为1
|
||||
const imagePlaceHolder = ' '
|
||||
return paragraph.replace(imgPattern, imagePlaceHolder).length
|
||||
}
|
||||
const chapterPos = computed(() => {
|
||||
let pos = -1
|
||||
return Array.from(props.contents, content => {
|
||||
pos += calculateWordCount(content) + 1 //计算上一段的换行符
|
||||
return pos
|
||||
})
|
||||
})
|
||||
|
||||
const titleRef = ref<HTMLElement>()
|
||||
const paragraphRef = ref<HTMLParagraphElement[]>()
|
||||
const scrollToReadedLength = (length: number) => {
|
||||
if (length === 0) return
|
||||
const paragraphIndex = chapterPos.value.findIndex(
|
||||
wordCount => wordCount >= length,
|
||||
)
|
||||
if (paragraphIndex === -1) return
|
||||
nextTick(() => {
|
||||
jump(paragraphRef.value![paragraphIndex], {
|
||||
duration: 0,
|
||||
})
|
||||
})
|
||||
}
|
||||
defineExpose({
|
||||
scrollToReadedLength,
|
||||
})
|
||||
let intersectionObserver: IntersectionObserver | null = null
|
||||
const emit = defineEmits(['readedLengthChange'])
|
||||
onMounted(() => {
|
||||
intersectionObserver = new IntersectionObserver(
|
||||
entries => {
|
||||
for (const { target, isIntersecting } of entries) {
|
||||
if (isIntersecting) {
|
||||
emit(
|
||||
'readedLengthChange',
|
||||
props.chapterIndex,
|
||||
parseInt((target as HTMLElement).dataset.chapterpos as string),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: `0px 0px -${window.innerHeight - 24}px 0px`,
|
||||
},
|
||||
)
|
||||
intersectionObserver.observe(titleRef.value!)
|
||||
paragraphRef.value!.forEach(element => {
|
||||
intersectionObserver!.observe(element)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
intersectionObserver?.disconnect()
|
||||
intersectionObserver = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.title {
|
||||
margin-bottom: 57px;
|
||||
font:
|
||||
24px / 32px PingFangSC-Regular,
|
||||
HelveticaNeue-Light,
|
||||
'Helvetica Neue Light',
|
||||
'Microsoft YaHei',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
word-wrap: break-word;
|
||||
/* word-break: break-all; */
|
||||
letter-spacing: calc(v-bind('props.spacing.letter') * 1em);
|
||||
line-height: calc(1 + v-bind('props.spacing.line'));
|
||||
margin: calc(v-bind('props.spacing.paragraph') * 1em) 0;
|
||||
|
||||
:deep(img) {
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.full {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{ 'cata-wrapper': true, visible: popCataVisible }"
|
||||
:style="popupTheme"
|
||||
>
|
||||
<div class="title">目录</div>
|
||||
<virtual-list
|
||||
style="height: 300px; overflow: auto"
|
||||
:class="{ night: isNight, day: !isNight }"
|
||||
ref="virtualListRef"
|
||||
data-key="index"
|
||||
wrap-class="data-wrapper"
|
||||
item-class="cata"
|
||||
:data-sources="virtualListdata"
|
||||
:data-component="CatalogItem"
|
||||
:estimate-size="40"
|
||||
:extra-props="{ gotoChapter, currentChapterIndex }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import VirtualList from 'vue3-virtual-scroll-list'
|
||||
import settings from '../config/themeConfig'
|
||||
import '../assets/fonts/popfont.css'
|
||||
import CatalogItem from './CatalogItem.vue'
|
||||
import type { BookChapter } from '@/book'
|
||||
|
||||
const store = useBookStore()
|
||||
|
||||
const { catalog, popCataVisible, miniInterface } = storeToRefs(store)
|
||||
|
||||
//主题
|
||||
const isNight = computed(() => store.theme)
|
||||
const theme = computed(() => store.theme)
|
||||
const popupTheme = computed(() => {
|
||||
return {
|
||||
background: settings.themes[theme.value].popup,
|
||||
}
|
||||
})
|
||||
|
||||
//虚拟列表 数据源
|
||||
const virtualListdata = computed(() => {
|
||||
const catalogValue = catalog.value
|
||||
if (miniInterface.value) return catalogValue
|
||||
|
||||
// pc端 virtualListIitem有2个章节
|
||||
const length = Math.ceil(catalogValue.length / 2)
|
||||
const virtualListDataSource = new Array<{
|
||||
index: number
|
||||
catas: BookChapter[]
|
||||
}>(length)
|
||||
|
||||
let i = 0
|
||||
while (i < length) {
|
||||
virtualListDataSource[i] = {
|
||||
index: i,
|
||||
catas: catalogValue.slice(2 * i, 2 * i + 2),
|
||||
}
|
||||
i++
|
||||
}
|
||||
return virtualListDataSource
|
||||
})
|
||||
|
||||
//打开目录 计算当前章节对应的虚拟列表位置
|
||||
const virtualListRef = ref()
|
||||
const currentChapterIndex = computed({
|
||||
get: () => store.readingBook.chapterIndex,
|
||||
set: value => (store.readingBook.chapterIndex = value),
|
||||
})
|
||||
const virtualListIndex = computed(() => {
|
||||
const index = currentChapterIndex.value
|
||||
if (miniInterface.value) return index
|
||||
// pc端 virtualListIitem有2个章节
|
||||
return Math.floor(index / 2)
|
||||
})
|
||||
onUpdated(() => {
|
||||
// dom更新触发ResizeObserver,更新虚拟列表内部的sizes Map
|
||||
if (!popCataVisible.value) return
|
||||
virtualListRef.value.scrollToIndex(virtualListIndex.value)
|
||||
})
|
||||
|
||||
// 点击加载对应章节内容
|
||||
const emit = defineEmits(['getContent'])
|
||||
const gotoChapter = (chapter: BookChapter) => {
|
||||
const chapterIndex = catalog.value.indexOf(chapter)
|
||||
currentChapterIndex.value = chapterIndex
|
||||
store.setPopCataVisible(false)
|
||||
store.setContentLoading(true)
|
||||
store.saveBookProgress()
|
||||
emit('getContent', chapterIndex)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.cata-wrapper {
|
||||
margin: -16px;
|
||||
padding: 18px 0 24px 25px;
|
||||
|
||||
/* background: #ede7da url('../assets/imgs/themes/popup_1.png') repeat; */
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
font-family: FZZCYSK;
|
||||
margin: 0 0 20px 0;
|
||||
color: #ed4259;
|
||||
width: fit-content;
|
||||
border-bottom: 1px solid #ed4259;
|
||||
}
|
||||
:deep(.data-wrapper) {
|
||||
.cata {
|
||||
/*width: 50%;*/
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
font:
|
||||
16px / 40px PingFangSC-Regular,
|
||||
HelveticaNeue-Light,
|
||||
'Helvetica Neue Light',
|
||||
'Microsoft YaHei',
|
||||
sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.night {
|
||||
:deep(.cata) {
|
||||
border-bottom: 1px solid #666;
|
||||
}
|
||||
}
|
||||
|
||||
.day {
|
||||
:deep(.cata) {
|
||||
border-bottom: 1px solid #f2f2f2;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,596 @@
|
||||
<template>
|
||||
<div
|
||||
class="settings-wrapper"
|
||||
:style="popupTheme"
|
||||
:class="{ night: isNight, day: !isNight }"
|
||||
>
|
||||
<div class="settings-title">设置</div>
|
||||
<div class="setting-list">
|
||||
<ul>
|
||||
<li class="theme-list">
|
||||
<i>阅读主题</i>
|
||||
<span
|
||||
class="theme-item"
|
||||
v-for="(themeColor, index) in themeColors"
|
||||
:key="index"
|
||||
:style="themeColor"
|
||||
ref="themes"
|
||||
@click="setTheme(index)"
|
||||
:class="{ selected: theme == index }"
|
||||
><em v-if="index < 6" class="iconfont"></em
|
||||
><em v-else class="moon-icon">{{ moonIcon }}</em></span
|
||||
>
|
||||
</li>
|
||||
<li class="font-list">
|
||||
<i>正文字体</i>
|
||||
<span
|
||||
class="font-item"
|
||||
v-for="(font, index) in fonts"
|
||||
:key="index"
|
||||
:class="{ selected: selectedFont == index }"
|
||||
@click="setFont(index)"
|
||||
>{{ font }}</span
|
||||
>
|
||||
</li>
|
||||
<li class="font-list">
|
||||
<i>自定字体</i>
|
||||
<el-tooltip effect="dark" content="自定义的字体名称" placement="top">
|
||||
<input
|
||||
type="text"
|
||||
class="font-item font-item-input"
|
||||
v-model="customFontName"
|
||||
placeholder="请输入自定义的字体名称"
|
||||
/>
|
||||
</el-tooltip>
|
||||
|
||||
<el-popover
|
||||
placement="top"
|
||||
width="270"
|
||||
trigger="click"
|
||||
v-model:visible="customFontSavePopVisible"
|
||||
>
|
||||
<p>
|
||||
已经安装在您的设备上的字体请确认输入的字体名称完整无误,或者从网络下载字体。
|
||||
</p>
|
||||
<div style="text-align: right; margin: 0">
|
||||
<el-button
|
||||
size="small"
|
||||
plain
|
||||
@click="customFontSavePopVisible = false"
|
||||
>取消</el-button
|
||||
>
|
||||
<el-button type="primary" size="small" @click="setCustomFont()"
|
||||
>确定</el-button
|
||||
>
|
||||
<el-button type="primary" size="small" @click="loadFontFromURL()"
|
||||
>网络下载</el-button
|
||||
>
|
||||
</div>
|
||||
<template #reference>
|
||||
<span type="text" class="font-item">保存</span>
|
||||
</template>
|
||||
</el-popover>
|
||||
</li>
|
||||
<li class="font-size">
|
||||
<i>字体大小</i>
|
||||
<div class="resize">
|
||||
<span class="less" @click="lessFontSize"
|
||||
><em class="iconfont"></em></span
|
||||
><b></b> <span class="lang">{{ fontSize }}</span
|
||||
><b></b>
|
||||
<span class="more" @click="moreFontSize"
|
||||
><em class="iconfont"></em></span
|
||||
>
|
||||
</div>
|
||||
</li>
|
||||
<li class="letter-spacing">
|
||||
<i>字距</i>
|
||||
<div class="resize">
|
||||
<span class="less" @click="lessLetterSpacing"
|
||||
><em class="iconfont"></em></span
|
||||
><b></b> <span class="lang">{{ spacing.letter.toFixed(2) }}</span
|
||||
><b></b>
|
||||
<span class="more" @click="moreLetterSpacing"
|
||||
><em class="iconfont"></em></span
|
||||
>
|
||||
</div>
|
||||
</li>
|
||||
<li class="line-spacing">
|
||||
<i>行距</i>
|
||||
<div class="resize">
|
||||
<span class="less" @click="lessLineSpacing"
|
||||
><em class="iconfont"></em></span
|
||||
><b></b> <span class="lang">{{ spacing.line.toFixed(1) }}</span
|
||||
><b></b>
|
||||
<span class="more" @click="moreLineSpacing"
|
||||
><em class="iconfont"></em></span
|
||||
>
|
||||
</div>
|
||||
</li>
|
||||
<li class="paragraph-spacing">
|
||||
<i>段距</i>
|
||||
<div class="resize">
|
||||
<div class="resize">
|
||||
<span class="less" @click="lessParagraphSpacing"
|
||||
><em class="iconfont"></em></span
|
||||
><b></b>
|
||||
<span class="lang">{{ spacing.paragraph.toFixed(1) }}</span
|
||||
><b></b>
|
||||
<span class="more" @click="moreParagraphSpacing"
|
||||
><em class="iconfont"></em></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="read-width" v-if="!store.miniInterface">
|
||||
<i>页面宽度</i>
|
||||
<div class="resize">
|
||||
<span class="less" @click="lessReadWidth"
|
||||
><em class="iconfont"></em></span
|
||||
><b></b> <span class="lang">{{ readWidth }}</span
|
||||
><b></b>
|
||||
<span class="more" @click="moreReadWidth"
|
||||
><em class="iconfont"></em></span
|
||||
>
|
||||
</div>
|
||||
</li>
|
||||
<li class="paragraph-spacing">
|
||||
<i>翻页速度</i>
|
||||
<div class="resize">
|
||||
<div class="resize">
|
||||
<span class="less" @click="lessJumpDuration">
|
||||
<em class="iconfont"></em>
|
||||
</span>
|
||||
<b></b> <span class="lang">{{ jumpDuration }}</span
|
||||
><b></b>
|
||||
<span class="more" @click="moreJumpDuration"
|
||||
><em class="iconfont"></em></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="infinite-loading">
|
||||
<i>无限加载</i>
|
||||
<span
|
||||
class="infinite-loading-item"
|
||||
:key="0"
|
||||
:class="{ selected: infiniteLoading == false }"
|
||||
@click="setInfiniteLoading(false)"
|
||||
>关闭</span
|
||||
>
|
||||
<span
|
||||
class="infinite-loading-item"
|
||||
:key="1"
|
||||
:class="{ selected: infiniteLoading == true }"
|
||||
@click="setInfiniteLoading(true)"
|
||||
>开启</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import '../assets/fonts/popfont.css'
|
||||
import '../assets/fonts/iconfont.css'
|
||||
import settings from '../config/themeConfig'
|
||||
import API from '@api'
|
||||
import { useDebounceFn } from '@vueuse/shared'
|
||||
|
||||
const store = useBookStore()
|
||||
const saveConfigDebounce = useDebounceFn(
|
||||
() => API.saveReadConfig(store.config),
|
||||
500,
|
||||
)
|
||||
//阅读界面设置改变时保存同步配置
|
||||
watch(
|
||||
() => store.config,
|
||||
() => {
|
||||
saveConfigDebounce()
|
||||
},
|
||||
{
|
||||
deep: 2, //深度为2
|
||||
},
|
||||
)
|
||||
|
||||
//主题颜色
|
||||
const theme = computed(() => store.theme)
|
||||
const isNight = computed(() => store.isNight)
|
||||
const moonIcon = computed(() => (theme.value == 6 ? '' : ''))
|
||||
const themeColors = [
|
||||
{
|
||||
background: 'rgba(250, 245, 235, 0.8)',
|
||||
},
|
||||
{
|
||||
background: 'rgba(245, 234, 204, 0.8)',
|
||||
},
|
||||
{
|
||||
background: 'rgba(230, 242, 230, 0.8)',
|
||||
},
|
||||
{
|
||||
background: 'rgba(228, 241, 245, 0.8)',
|
||||
},
|
||||
{
|
||||
background: 'rgba(245, 228, 228, 0.8)',
|
||||
},
|
||||
{
|
||||
background: 'rgba(224, 224, 224, 0.8)',
|
||||
},
|
||||
{
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
]
|
||||
const popupTheme = computed(() => {
|
||||
return {
|
||||
background: settings.themes[theme.value].popup,
|
||||
}
|
||||
})
|
||||
const setTheme = (theme: number) => {
|
||||
store.config.theme = theme
|
||||
}
|
||||
|
||||
//预置字体
|
||||
const fonts = ref(['雅黑', '宋体', '楷书'])
|
||||
const setFont = (font: number) => {
|
||||
store.config.font = font
|
||||
}
|
||||
const selectedFont = computed(() => {
|
||||
return store.config.font
|
||||
})
|
||||
//自定义字体
|
||||
const customFontName = ref(store.config.customFontName)
|
||||
const customFontSavePopVisible = ref(false)
|
||||
const setCustomFont = () => {
|
||||
customFontSavePopVisible.value = false
|
||||
store.config.font = -1
|
||||
store.config.customFontName = customFontName.value
|
||||
}
|
||||
// 加载网络字体
|
||||
const loadFontFromURL = () => {
|
||||
customFontSavePopVisible.value = false
|
||||
ElMessageBox.prompt('请输入 字体网络链接', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
inputPattern: /^https?:.+$/,
|
||||
inputErrorMessage: 'url 形式不正确',
|
||||
beforeClose: (action, instance, done) => {
|
||||
if (action === 'confirm') {
|
||||
instance.confirmButtonLoading = true
|
||||
instance.confirmButtonText = '下载中……'
|
||||
// instance.inputValue
|
||||
const url = instance.inputValue
|
||||
if (typeof FontFace !== 'function') {
|
||||
ElMessage.error('浏览器不支持FontFace')
|
||||
return done()
|
||||
}
|
||||
const fontface = new FontFace(customFontName.value, `url("${url}")`)
|
||||
document.fonts.add(fontface)
|
||||
fontface
|
||||
.load()
|
||||
//API.getBookShelf()
|
||||
.then(function () {
|
||||
instance.confirmButtonLoading = false
|
||||
ElMessage.info('字体加载成功!')
|
||||
setCustomFont()
|
||||
done()
|
||||
})
|
||||
.catch(function (error) {
|
||||
instance.confirmButtonLoading = false
|
||||
instance.confirmButtonText = '确定'
|
||||
ElMessage.error('下载失败,请检查您输入的 url')
|
||||
throw error
|
||||
})
|
||||
} else {
|
||||
done()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
//字体大小
|
||||
const fontSize = computed(() => {
|
||||
return store.config.fontSize
|
||||
})
|
||||
const moreFontSize = () => {
|
||||
if (store.config.fontSize < 48) store.config.fontSize += 2
|
||||
}
|
||||
const lessFontSize = () => {
|
||||
if (store.config.fontSize > 12) store.config.fontSize -= 2
|
||||
}
|
||||
|
||||
//字 行 段落间距
|
||||
const spacing = computed(() => {
|
||||
return store.config.spacing
|
||||
})
|
||||
const lessLetterSpacing = () => {
|
||||
store.config.spacing.letter -= 0.01
|
||||
}
|
||||
const moreLetterSpacing = () => {
|
||||
store.config.spacing.letter += 0.01
|
||||
}
|
||||
const lessLineSpacing = () => {
|
||||
store.config.spacing.line -= 0.1
|
||||
}
|
||||
const moreLineSpacing = () => {
|
||||
store.config.spacing.line += 0.1
|
||||
}
|
||||
const lessParagraphSpacing = () => {
|
||||
store.config.spacing.paragraph -= 0.1
|
||||
}
|
||||
const moreParagraphSpacing = () => {
|
||||
store.config.spacing.paragraph += 0.1
|
||||
}
|
||||
|
||||
//页面宽度
|
||||
const readWidth = computed(() => {
|
||||
return store.config.readWidth
|
||||
})
|
||||
const moreReadWidth = () => {
|
||||
// 此时会截断页面
|
||||
if (store.config.readWidth + 160 + 2 * 68 > window.innerWidth) return
|
||||
store.config.readWidth += 160
|
||||
}
|
||||
const lessReadWidth = () => {
|
||||
if (store.config.readWidth > 640) store.config.readWidth -= 160
|
||||
}
|
||||
|
||||
//翻页速度
|
||||
const jumpDuration = computed(() => {
|
||||
return store.config.jumpDuration
|
||||
})
|
||||
const moreJumpDuration = () => {
|
||||
store.config.jumpDuration += 100
|
||||
}
|
||||
const lessJumpDuration = () => {
|
||||
if (store.config.jumpDuration === 0) return
|
||||
store.config.jumpDuration -= 100
|
||||
}
|
||||
|
||||
//无限加载
|
||||
const infiniteLoading = computed(() => {
|
||||
return store.config.infiniteLoading
|
||||
})
|
||||
const setInfiniteLoading = (loading: boolean) => {
|
||||
store.config.infiniteLoading = loading
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.iconfont) {
|
||||
font-family: iconfont;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
:deep(.moon-icon) {
|
||||
font-family: iconfont;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.settings-wrapper {
|
||||
user-select: none;
|
||||
margin: -13px;
|
||||
/* width: 478px;
|
||||
height: 350px; */
|
||||
text-align: left;
|
||||
padding: 40px 0 40px 24px;
|
||||
background: #ede7da url('../assets/imgs/themes/popup_1.png') repeat;
|
||||
|
||||
.settings-title {
|
||||
font-size: 18px;
|
||||
line-height: 22px;
|
||||
margin-bottom: 28px;
|
||||
font-family: FZZCYSK;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.setting-list {
|
||||
max-height: calc(70vh - 50px);
|
||||
overflow: auto;
|
||||
|
||||
ul {
|
||||
list-style: none outside none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
list-style: none outside none;
|
||||
|
||||
i {
|
||||
font:
|
||||
12px / 16px PingFangSC-Regular,
|
||||
'-apple-system',
|
||||
Simsun;
|
||||
display: inline-block;
|
||||
min-width: 48px;
|
||||
margin-right: 16px;
|
||||
vertical-align: middle;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.theme-item {
|
||||
line-height: 32px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
margin-right: 16px;
|
||||
margin-top: 5px;
|
||||
border-radius: 100%;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
|
||||
.iconfont {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
color: #ed4259;
|
||||
|
||||
.iconfont {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.font-list,
|
||||
.infinite-loading {
|
||||
margin-top: 28px;
|
||||
|
||||
.font-item,
|
||||
.infinite-loading-item {
|
||||
width: 78px;
|
||||
height: 34px;
|
||||
cursor: pointer;
|
||||
margin-right: 16px;
|
||||
border-radius: 2px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
font:
|
||||
14px / 34px PingFangSC-Regular,
|
||||
HelveticaNeue-Light,
|
||||
'Helvetica Neue Light',
|
||||
'Microsoft YaHei',
|
||||
sans-serif;
|
||||
}
|
||||
.font-item-input {
|
||||
width: 168px;
|
||||
color: #000000;
|
||||
}
|
||||
.selected {
|
||||
color: #ed4259;
|
||||
border: 1px solid #ed4259;
|
||||
}
|
||||
|
||||
.font-item:hover,
|
||||
.infinite-loading-item:hover {
|
||||
border: 1px solid #ed4259;
|
||||
color: #ed4259;
|
||||
}
|
||||
}
|
||||
|
||||
.font-size,
|
||||
.read-width,
|
||||
.letter-spacing,
|
||||
.line-spacing,
|
||||
.paragraph-spacing {
|
||||
margin-top: 28px;
|
||||
|
||||
.resize {
|
||||
display: inline-block;
|
||||
width: 274px;
|
||||
height: 34px;
|
||||
vertical-align: middle;
|
||||
border-radius: 2px;
|
||||
|
||||
span {
|
||||
width: 89px;
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.less:hover,
|
||||
.more:hover {
|
||||
color: #ed4259;
|
||||
}
|
||||
|
||||
.lang {
|
||||
color: #a6a6a6;
|
||||
font-weight: 400;
|
||||
font-family: FZZCYSK;
|
||||
}
|
||||
|
||||
b {
|
||||
display: inline-block;
|
||||
height: 20px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.night {
|
||||
:deep(.theme-item) {
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
:deep(.selected) {
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
:deep(.moon-icon) {
|
||||
color: #ed4259;
|
||||
}
|
||||
|
||||
:deep(.font-list),
|
||||
.infinite-loading {
|
||||
.font-item,
|
||||
.infinite-loading-item {
|
||||
border: 1px solid #666;
|
||||
background: rgba(45, 45, 45, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.resize) {
|
||||
border: 1px solid #666;
|
||||
background: rgba(45, 45, 45, 0.5);
|
||||
|
||||
b {
|
||||
border-right: 1px solid #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.day {
|
||||
:deep(.theme-item) {
|
||||
border: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
:deep(.selected) {
|
||||
border: 1px solid #ed4259;
|
||||
}
|
||||
|
||||
:deep(.moon-icon) {
|
||||
display: inline;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
:deep(.font-list),
|
||||
.infinite-loading {
|
||||
.font-item,
|
||||
.infinite-loading-item {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.resize) {
|
||||
border: 1px solid #e5e5e5;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
|
||||
b {
|
||||
border-right: 1px solid #e5e5e5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.settings-wrapper i {
|
||||
display: flex !important;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 5px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<el-input
|
||||
v-if="isBookSource"
|
||||
id="debug-key"
|
||||
v-model="searchKey"
|
||||
placeholder="搜索书名、作者"
|
||||
:prefix-icon="Search"
|
||||
style="padding-bottom: 4px"
|
||||
@keydown.enter="startDebug"
|
||||
/>
|
||||
<el-input
|
||||
id="debug-text"
|
||||
v-model="printDebug"
|
||||
type="textarea"
|
||||
readonly
|
||||
:rows="29"
|
||||
placeholder="这里用于输出调试信息"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import API from '@api'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
|
||||
const store = useSourceStore()
|
||||
|
||||
const printDebug = ref('')
|
||||
const searchKey = ref('')
|
||||
|
||||
watch(
|
||||
() => store.isDebuging,
|
||||
() => {
|
||||
if (store.isDebuging) startDebug()
|
||||
},
|
||||
)
|
||||
|
||||
const appendDebugMsg = (msg: string) => {
|
||||
const debugDom = document.querySelector('#debug-text')
|
||||
debugDom!.scrollTop = debugDom!.scrollHeight
|
||||
printDebug.value += msg + '\n'
|
||||
}
|
||||
const startDebug = async () => {
|
||||
printDebug.value = ''
|
||||
try {
|
||||
await API.saveSource(store.currentSource)
|
||||
} catch (e) {
|
||||
store.debugFinish()
|
||||
throw e
|
||||
}
|
||||
API.debug(
|
||||
store.currentSourceUrl,
|
||||
searchKey.value || store.searchKey,
|
||||
appendDebugMsg,
|
||||
store.debugFinish,
|
||||
)
|
||||
}
|
||||
|
||||
const isBookSource = computed(() => {
|
||||
return /bookSource/i.test(window.location.href)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(#debug-text) {
|
||||
height: calc(100vh - 45px - 36px - 5px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@element-plus/icons-vue'
|
||||
</script>
|
||||
<template>
|
||||
<el-link :icon="Link" href="/help/#appHelp" target="_blank"
|
||||
>APP帮助文档</el-link
|
||||
><br />
|
||||
<el-link :icon="Link" href="/help/#ruleHelp" target="_blank"
|
||||
>书源制作教程</el-link
|
||||
><br />
|
||||
<el-link :icon="Link" href="/help/#jsHelp" target="_blank"
|
||||
>js变量和函数</el-link
|
||||
><br />
|
||||
<el-link :icon="Link" href="/help/#xpathHelp" target="_blank"
|
||||
>xpath语法教程</el-link
|
||||
><br />
|
||||
<el-link :icon="Link" href="/help/#regexHelp" target="_blank"
|
||||
>正则表达式教程</el-link
|
||||
><br />
|
||||
<el-link :icon="Link" href="/help/#txtTocRuleHelp" target="_blank"
|
||||
>txt目录正则说明</el-link
|
||||
><br />
|
||||
<el-link :icon="Link" href="/help/#debugHelp" target="_blank"
|
||||
>书源调试说明</el-link
|
||||
><br />
|
||||
<el-link :icon="Link" href="/help/#httpTTSHelp" target="_blank"
|
||||
>在线朗读规则</el-link
|
||||
><br />
|
||||
<el-link :icon="Link" href="/help/#webDavBookHelp" target="_blank">
|
||||
WebDav书籍简明使用教程</el-link
|
||||
><br />
|
||||
<el-link :icon="Link" href="/help/#webDavHelp" target="_blank">
|
||||
WebDav备份教程</el-link
|
||||
><br />
|
||||
<el-link :icon="Link" href="https://regexr-cn.com/" target="_blank"
|
||||
>正则表达式在线验证工具</el-link
|
||||
><br />
|
||||
<div style="margin-top: 20px">
|
||||
<span
|
||||
><el-text
|
||||
><code>^$()[]{}.?+*|</code> 这些是Java正则特殊符号,匹配需转义</el-text
|
||||
></span
|
||||
><br />
|
||||
<span
|
||||
><el-text><code>(?s)</code> 前缀表示跨行解析</el-text></span
|
||||
><br />
|
||||
<span
|
||||
><el-text><code>(?m)</code> 前缀表示逐行匹配</el-text></span
|
||||
><br />
|
||||
<span
|
||||
><el-text><code>(?i)</code> 前缀表示忽略大小写</el-text></span
|
||||
><br />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-link {
|
||||
padding: 4px;
|
||||
}
|
||||
.el-text {
|
||||
padding-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<el-checkbox
|
||||
size="large"
|
||||
border
|
||||
:value="sourceUrl"
|
||||
:class="{
|
||||
error: isSaveError,
|
||||
edit: sourceUrl == currentSourceUrl,
|
||||
}"
|
||||
>
|
||||
{{ getSourceName(source) }}
|
||||
<el-button text :icon="Edit" @click="handleSourceClick(source)" />
|
||||
</el-checkbox>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Edit } from '@element-plus/icons-vue'
|
||||
import { getSourceUniqueKey, getSourceName } from '@/utils/souce'
|
||||
import type { Source } from '@/source'
|
||||
|
||||
const props = defineProps<{
|
||||
source: Source
|
||||
}>()
|
||||
|
||||
const store = useSourceStore()
|
||||
|
||||
const currentSourceUrl = computed(() => store.currentSourceUrl)
|
||||
const sourceUrl = computed(() => getSourceUniqueKey(props.source))
|
||||
|
||||
const handleSourceClick = (source: Source) => {
|
||||
store.changeCurrentSource(source)
|
||||
}
|
||||
const isSaveError = computed(() => {
|
||||
const map = store.savedSourcesMap
|
||||
if (map.size == 0) return false
|
||||
return !map.has(sourceUrl.value)
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-checkbox__label) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.error {
|
||||
border-color: var(--el-color-error) !important;
|
||||
color: var(--el-color-error) !important;
|
||||
--el-checkbox-checked-text-color: var(--el-color-error);
|
||||
--el-checkbox-checked-bg-color: var(--el-color-error);
|
||||
--el-checkbox-checked-input-border-color: var(--el-color-error);
|
||||
}
|
||||
.edit {
|
||||
border-color: var(--el-color-dark) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<el-input
|
||||
id="source-json"
|
||||
v-model="sourceString"
|
||||
type="textarea"
|
||||
placeholder="这里输出序列化的JSON数据,可直接导入'阅读'APP"
|
||||
:rows="30"
|
||||
@change="update"
|
||||
style="margin-bottom: 4px"
|
||||
></el-input>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useSourceStore } from '@/store'
|
||||
|
||||
const store = useSourceStore()
|
||||
const sourceString = ref('')
|
||||
const update = async (string: string) => {
|
||||
try {
|
||||
store.changeEditTabSource(JSON.parse(string))
|
||||
} catch {
|
||||
ElMessage({
|
||||
message: '粘贴的源格式错误',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(async () => {
|
||||
const source = store.editTabSource
|
||||
if (Object.keys(source).length > 0) {
|
||||
sourceString.value = JSON.stringify(source, null, 4)
|
||||
} else {
|
||||
sourceString.value = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-input) {
|
||||
width: 100%;
|
||||
}
|
||||
:deep(#source-json) {
|
||||
height: calc(100vh - 50px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<el-input
|
||||
v-model="searchKey"
|
||||
class="search"
|
||||
:prefix-icon="Search"
|
||||
placeholder="筛选源"
|
||||
/>
|
||||
<div class="tool">
|
||||
<el-button @click="importSourceFile" :icon="Folder">打开</el-button>
|
||||
<el-button
|
||||
:disabled="sourcesFiltered.length === 0"
|
||||
@click="outExport"
|
||||
:icon="Download"
|
||||
>
|
||||
导出</el-button
|
||||
>
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
@click="deleteSelectSources"
|
||||
:disabled="sourceSelect.length === 0"
|
||||
>删除</el-button
|
||||
>
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
@click="clearAllSources"
|
||||
:disabled="sources.length === 0"
|
||||
>清空</el-button
|
||||
>
|
||||
</div>
|
||||
<el-checkbox-group id="source-list" v-model="sourceUrlSelect">
|
||||
<virtual-list
|
||||
style="height: 100%; overflow-y: auto; overflow-x: hidden"
|
||||
:data-key="(source: Source) => getSourceName(source)"
|
||||
:data-sources="sourcesFiltered"
|
||||
:data-component="SourceItem"
|
||||
:estimate-size="45"
|
||||
/>
|
||||
</el-checkbox-group>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import API from '@api'
|
||||
import { Folder, Delete, Download, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
isSourceMatches,
|
||||
getSourceUniqueKey,
|
||||
getSourceName,
|
||||
convertSourcesToMap,
|
||||
} from '@utils/souce'
|
||||
import VirtualList from 'vue3-virtual-scroll-list'
|
||||
import SourceItem from './SourceItem.vue'
|
||||
import type { Source } from '@/source'
|
||||
|
||||
const store = useSourceStore()
|
||||
const sourceUrlSelect = ref<string[]>([])
|
||||
const searchKey = ref('')
|
||||
const sources = computed(() => store.sources)
|
||||
|
||||
/* 筛选源 */
|
||||
const sourcesFiltered = computed<Source[]>(() => {
|
||||
const key = searchKey.value
|
||||
if (key === '') return sources.value
|
||||
return sources.value.filter(source => isSourceMatches(source, key))
|
||||
})
|
||||
// 计算当前筛选关键词下的选中源
|
||||
const sourceSelect = computed<Source[]>(() => {
|
||||
const urls = sourceUrlSelect.value
|
||||
if (urls.length == 0) return []
|
||||
const sourcesFilteredMap =
|
||||
searchKey.value == ''
|
||||
? store.sourcesMap
|
||||
: convertSourcesToMap(sourcesFiltered.value)
|
||||
return urls.reduce((sources, sourceUrl) => {
|
||||
const source = sourcesFilteredMap.get(sourceUrl)
|
||||
if (source) sources.push(source)
|
||||
return sources
|
||||
}, [] as Source[])
|
||||
})
|
||||
|
||||
const deleteSelectSources = () => {
|
||||
const sourceSelectValue = sourceSelect.value
|
||||
API.deleteSource(sourceSelectValue).then(({ data }) => {
|
||||
if (!data.isSuccess) return ElMessage.error(data.errorMsg)
|
||||
store.deleteSources(sourceSelectValue)
|
||||
const sourceUrlSelectRawValue = toRaw(sourceUrlSelect.value)
|
||||
sourceSelectValue.forEach(source => {
|
||||
const index = sourceUrlSelectRawValue.indexOf(getSourceUniqueKey(source))
|
||||
if (index > -1) sourceUrlSelectRawValue.splice(index, 1)
|
||||
})
|
||||
sourceUrlSelect.value = sourceUrlSelectRawValue
|
||||
})
|
||||
}
|
||||
const clearAllSources = () => {
|
||||
store.clearAllSource()
|
||||
sourceUrlSelect.value = []
|
||||
}
|
||||
|
||||
//导入本地文件
|
||||
const importSourceFile = () => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.json,.txt'
|
||||
input.addEventListener('change', () => {
|
||||
const files = input.files
|
||||
if (files === null) {
|
||||
return ElMessage.info('未选择文件')
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.readAsText(files[0])
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const jsonData = JSON.parse(reader.result as string)
|
||||
store.saveSources(jsonData)
|
||||
} catch (e: unknown) {
|
||||
ElMessage.error('上传的源格式错误: ' + (e as Error).message)
|
||||
}
|
||||
}
|
||||
})
|
||||
input.click()
|
||||
}
|
||||
|
||||
const isBookSource = /bookSource/i.test(window.location.href)
|
||||
const outExport = () => {
|
||||
const exportFile = document.createElement('a')
|
||||
const sources =
|
||||
sourceUrlSelect.value.length === 0
|
||||
? sourcesFiltered.value
|
||||
: sourceSelect.value,
|
||||
sourceType = isBookSource ? 'BookSource' : 'RssSource'
|
||||
|
||||
exportFile.download = `${sourceType}_${Date()
|
||||
.replace(/.*?\s(\d+)\s(\d+)\s(\d+:\d+:\d+).*/, '$2$1$3')
|
||||
.replace(/:/g, '')}.json`
|
||||
|
||||
const myBlob = new Blob([JSON.stringify(sources, null, 4)], {
|
||||
type: 'application/json',
|
||||
})
|
||||
exportFile.href = window.URL.createObjectURL(myBlob)
|
||||
exportFile.click()
|
||||
window.URL.revokeObjectURL(exportFile.href) //avoid memory leak
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tool {
|
||||
display: flex;
|
||||
margin: 4px 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#source-list {
|
||||
margin-top: 6px;
|
||||
height: calc(100vh - 112px - 7px);
|
||||
:deep(.el-checkbox) {
|
||||
margin-bottom: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<el-tabs id="source-edit">
|
||||
<el-tab-pane
|
||||
v-for="{ name, children } in Object.values(config)"
|
||||
:label="name"
|
||||
:key="name"
|
||||
>
|
||||
<el-form label-position="right" label-width="auto">
|
||||
<el-form-item
|
||||
v-for="{
|
||||
type,
|
||||
title,
|
||||
namespace,
|
||||
id,
|
||||
array,
|
||||
hint,
|
||||
required = false,
|
||||
} in children"
|
||||
:label="title"
|
||||
:key="title"
|
||||
:required="required"
|
||||
>
|
||||
<el-input
|
||||
v-if="type == 'String' && typeof namespace == 'undefined'"
|
||||
type="textarea"
|
||||
v-model="currentSource[id]"
|
||||
:placeholder="hint"
|
||||
autosize
|
||||
/>
|
||||
<el-input
|
||||
v-if="type == 'String' && typeof namespace != 'undefined'"
|
||||
type="textarea"
|
||||
v-model="currentSource[namespace][id]"
|
||||
:placeholder="hint"
|
||||
autosize
|
||||
/>
|
||||
|
||||
<el-switch
|
||||
v-if="(type as string) === 'Boolean'"
|
||||
v-model="currentSource[id]"
|
||||
/>
|
||||
|
||||
<el-input-number
|
||||
v-if="(type as string) === 'Number'"
|
||||
v-model="currentSource[id]"
|
||||
:min="0"
|
||||
/>
|
||||
|
||||
<el-select
|
||||
v-if="(type as string) === 'Array'"
|
||||
v-model="currentSource[id]"
|
||||
>
|
||||
<el-option
|
||||
v-for="(optionName, index) in array"
|
||||
:value="index"
|
||||
:key="optionName"
|
||||
:label="optionName"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SourceConfig } from '@/config/sourceConfig'
|
||||
|
||||
const store = useSourceStore()
|
||||
defineProps<{ config: SourceConfig }>()
|
||||
|
||||
const currentSource = computed(() => store.currentSource)
|
||||
/*
|
||||
修改currentSource的属性 没有直接修改本身
|
||||
const { currentSource } = storeToRefs(store);
|
||||
*/
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-tab-pane) {
|
||||
height: calc(100vh - 55px);
|
||||
padding-top: 15px;
|
||||
padding-right: 5px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
:deep(.el-tabs__header) {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<el-tabs v-model="current_tab">
|
||||
<el-tab-pane
|
||||
v-for="(tab, index) in tabData"
|
||||
:key="tab[0]"
|
||||
:name="tab[0]"
|
||||
:label="tab[1]"
|
||||
>
|
||||
<source-json v-if="index == 0" />
|
||||
<source-debug v-if="index == 1" />
|
||||
<source-list v-if="index == 2" />
|
||||
<source-help v-if="index == 3" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSourceStore } from '@/store'
|
||||
|
||||
const store = useSourceStore()
|
||||
|
||||
const current_tab = computed({
|
||||
get: () => store.currentTab,
|
||||
set: val => (store.currentTab = val),
|
||||
})
|
||||
|
||||
const tabData = ref([
|
||||
['editTab', '编辑源'],
|
||||
['editDebug', '调试源'],
|
||||
['editList', '源列表'],
|
||||
['editHelp', '帮助信息'],
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-tabs__header) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<div class="menu flex-column-center">
|
||||
<el-button
|
||||
v-for="button in buttons"
|
||||
size="large"
|
||||
:key="button.name"
|
||||
@click="button.action"
|
||||
>
|
||||
{{ button.name }}
|
||||
</el-button>
|
||||
<el-button size="large" @click="() => (hotkeysDialogVisible = true)"
|
||||
>快捷键</el-button
|
||||
>
|
||||
</div>
|
||||
<el-dialog
|
||||
v-model="hotkeysDialogVisible"
|
||||
:show-close="false"
|
||||
:before-close="stopRecordKeyDown"
|
||||
>
|
||||
<template #header="{ titleClass, titleId }">
|
||||
<div class="hotkeys-header flex-space-between">
|
||||
<div :id="titleId" :class="titleClass">
|
||||
快捷键设置
|
||||
<span v-if="recordKeyDowning">
|
||||
<el-text> / 录入中 </el-text>
|
||||
</span>
|
||||
</div>
|
||||
<el-button
|
||||
:disabled="recordKeyDowning"
|
||||
@click="saveHotKeys"
|
||||
:icon="CircleCheckFilled"
|
||||
>保存</el-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="hotkeys-settings flex-column-center">
|
||||
<div
|
||||
v-for="(button, buttonIndex) in buttons"
|
||||
:key="button.name"
|
||||
class="hotkeys-item flex-space-between"
|
||||
>
|
||||
<span class="title"
|
||||
><el-text>{{ button.name }}</el-text></span
|
||||
>
|
||||
<div class="hotkeys-item__content">
|
||||
<div v-for="(key, hotKeysIndex) in button.hotKeys" :key="key">
|
||||
<kbd>{{ key }}</kbd>
|
||||
<span v-if="hotKeysIndex + 1 < button.hotKeys.length">
|
||||
<el-text>+</el-text>
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="button.hotKeys.length == 0">未设置</span>
|
||||
</div>
|
||||
<el-button
|
||||
:disabled="recordKeyDowning"
|
||||
text
|
||||
:icon="Edit"
|
||||
@click="recordKeyDown(buttonIndex)"
|
||||
>编辑</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import API from '@api'
|
||||
import { CircleCheckFilled, Edit } from '@element-plus/icons-vue'
|
||||
import hotkeys from 'hotkeys-js'
|
||||
import { getSourceName, isInvaildSource, normalizeSource } from '../utils/souce'
|
||||
|
||||
const store = useSourceStore()
|
||||
const pull = () => {
|
||||
const loadingMsg = ElMessage({
|
||||
message: '加载中……',
|
||||
showClose: true,
|
||||
duration: 0,
|
||||
})
|
||||
API.getSources()
|
||||
.then(({ data }) => {
|
||||
if (data.isSuccess) {
|
||||
store.changeTabName('editList')
|
||||
store.saveSources(data.data)
|
||||
ElMessage({
|
||||
message: `成功拉取${data.data.length}条源`,
|
||||
type: 'success',
|
||||
})
|
||||
} else {
|
||||
ElMessage({
|
||||
message: data.errorMsg ?? '后端错误',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
})
|
||||
.finally(() => loadingMsg.close())
|
||||
}
|
||||
|
||||
const push = () => {
|
||||
const sources = store.sources
|
||||
store.changeTabName('editList')
|
||||
if (sources.length === 0) {
|
||||
return ElMessage({
|
||||
message: '空空如也',
|
||||
type: 'info',
|
||||
})
|
||||
}
|
||||
ElMessage({
|
||||
message: '正在推送中',
|
||||
type: 'info',
|
||||
})
|
||||
API.saveSources(sources).then(({ data }) => {
|
||||
if (data.isSuccess) {
|
||||
const okData = data.data
|
||||
if (Array.isArray(okData)) {
|
||||
let failMsg = ``
|
||||
if (sources.length > okData.length) {
|
||||
failMsg = '\n推送失败的源将用红色字体标注!'
|
||||
store.setPushReturnSources(okData)
|
||||
}
|
||||
ElMessage({
|
||||
message: `批量推送源到「阅读3.0APP」\n共计: ${
|
||||
sources.length
|
||||
} 条\n成功: ${okData.length} 条\n失败: ${
|
||||
sources.length - okData.length
|
||||
} 条${failMsg}`,
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
ElMessage({
|
||||
message: `批量推送源失败!\nErrorMsg: ${data.errorMsg}`,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const conver2Tab = () => {
|
||||
store.changeTabName('editTab')
|
||||
store.changeEditTabSource(store.currentSource)
|
||||
}
|
||||
const conver2Source = () => {
|
||||
store.changeCurrentSource(store.editTabSource)
|
||||
}
|
||||
|
||||
const undo = () => {
|
||||
store.editHistoryUndo()
|
||||
}
|
||||
|
||||
const clearEdit = () => {
|
||||
store.clearEdit()
|
||||
ElMessage({
|
||||
message: '已清除',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
|
||||
const redo = () => {
|
||||
store.clearEdit()
|
||||
store.clearAllHistory()
|
||||
ElMessage({
|
||||
message: '已清除所有历史记录',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
|
||||
const saveSource = () => {
|
||||
const source = store.currentSource
|
||||
if (isInvaildSource(source)) {
|
||||
normalizeSource(source)
|
||||
API.saveSource(source).then(({ data }) => {
|
||||
const sourceName = getSourceName(source)
|
||||
if (data.isSuccess) {
|
||||
ElMessage({
|
||||
message: `源《${sourceName}》已成功保存到「阅读3.0APP」`,
|
||||
type: 'success',
|
||||
})
|
||||
//save to store
|
||||
store.saveCurrentSource()
|
||||
} else {
|
||||
ElMessage({
|
||||
message: `源《${sourceName}》保存失败!\nErrorMsg: ${data.errorMsg}`,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
ElMessage({
|
||||
message: `请检查<必填>项是否全部填写`,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const debug = () => {
|
||||
store.startDebug()
|
||||
}
|
||||
|
||||
const buttons = ref<{ name: string; hotKeys: string[]; action: () => void }[]>(
|
||||
Array.of(
|
||||
{ name: '⇈推送源', hotKeys: [], action: push },
|
||||
{ name: '⇊拉取源', hotKeys: [], action: pull },
|
||||
{ name: '⋙生成源', hotKeys: [], action: conver2Tab },
|
||||
{ name: '⋘编辑源', hotKeys: [], action: conver2Source },
|
||||
{ name: '✗清空表单', hotKeys: [], action: clearEdit },
|
||||
{ name: '↶撤销操作', hotKeys: [], action: undo },
|
||||
{ name: '↷重做操作', hotKeys: [], action: redo },
|
||||
{ name: '⇏调试源', hotKeys: [], action: debug },
|
||||
{ name: '✓保存源', hotKeys: [], action: saveSource },
|
||||
),
|
||||
)
|
||||
const hotkeysDialogVisible = ref(true)
|
||||
|
||||
const recordKeyDowning = ref(false)
|
||||
|
||||
const recordKeyDownIndex = ref(-1)
|
||||
|
||||
const stopRecordKeyDown = () => {
|
||||
if (!recordKeyDowning.value) {
|
||||
hotkeysDialogVisible.value = false
|
||||
}
|
||||
recordKeyDowning.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
hotkeysDialogVisible,
|
||||
visibale => {
|
||||
if (!visibale) {
|
||||
hotkeys.unbind('*')
|
||||
readHotkeysConfig()
|
||||
bindHotKeys()
|
||||
return
|
||||
}
|
||||
readHotkeysConfig()
|
||||
hotkeys.unbind()
|
||||
/**监听按键 */
|
||||
hotkeys('*', event => {
|
||||
event.preventDefault()
|
||||
const pressedKeys = hotkeys.getPressedKeyString()
|
||||
if (pressedKeys.length == 1 && pressedKeys[0] == 'esc') {
|
||||
//单独按下esc 不录入
|
||||
return
|
||||
}
|
||||
if (recordKeyDowning.value && recordKeyDownIndex.value > -1)
|
||||
buttons.value[recordKeyDownIndex.value].hotKeys = pressedKeys
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const recordKeyDown = (index: number) => {
|
||||
recordKeyDowning.value = true
|
||||
ElMessage({
|
||||
message: '按ESC键或者点击空白处结束录入',
|
||||
type: 'info',
|
||||
})
|
||||
buttons.value[index].hotKeys = []
|
||||
recordKeyDownIndex.value = index
|
||||
}
|
||||
|
||||
const saveHotKeys = () => {
|
||||
const hotKeysConfig: string[][] = []
|
||||
buttons.value.forEach(({ hotKeys }) => {
|
||||
hotKeysConfig.push(hotKeys)
|
||||
})
|
||||
saveHotkeysConfig(hotKeysConfig)
|
||||
hotkeysDialogVisible.value = false
|
||||
}
|
||||
|
||||
const bindHotKeys = () => {
|
||||
// hotkeys默认过滤INPUT SELECT TEXTAREA
|
||||
hotkeys.filter = () => true
|
||||
buttons.value.forEach(({ hotKeys, action }) => {
|
||||
if (hotKeys.length == 0) return
|
||||
hotkeys(hotKeys.join('+'), event => {
|
||||
event.preventDefault()
|
||||
action.call(null)
|
||||
})
|
||||
})
|
||||
}
|
||||
const saveHotkeysConfig = (config: string[][]) => {
|
||||
localStorage.setItem('legado_web_hotkeys', JSON.stringify(config))
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取快捷键配置
|
||||
* @return 是否成功读取配置
|
||||
*/
|
||||
function readHotkeysConfig() {
|
||||
try {
|
||||
const localStorageConfig = localStorage.getItem('legado_web_hotkeys')
|
||||
if (localStorageConfig === null) return false
|
||||
const config = JSON.parse(localStorageConfig)
|
||||
if (!Array.isArray(config) || config.length == 0) return false
|
||||
buttons.value.forEach((button, index) => (button.hotKeys = config[index]))
|
||||
return true
|
||||
} catch {
|
||||
ElMessage({ message: '快捷键配置错误', type: 'error' })
|
||||
localStorage.removeItem('legado_web_hotkeys')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
/**读取热键配置 */
|
||||
if (readHotkeysConfig()) {
|
||||
hotkeysDialogVisible.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flex-space-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
.flex-column-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menu > .el-button {
|
||||
margin: 4px;
|
||||
padding: 1em;
|
||||
width: 6em;
|
||||
}
|
||||
|
||||
.hotkeys-item {
|
||||
.title {
|
||||
width: 5em;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-right: 1em;
|
||||
}
|
||||
.hotkeys-item__content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
div {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
span {
|
||||
margin: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,585 @@
|
||||
export default {
|
||||
base: {
|
||||
name: '基础',
|
||||
children: [
|
||||
{
|
||||
title: '源类型',
|
||||
id: 'bookSourceType',
|
||||
type: 'Array',
|
||||
array: ['文本', '音频', '图片', '文件'],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
title: '源域名',
|
||||
id: 'bookSourceUrl',
|
||||
type: 'String',
|
||||
hint: '通常填写网站主页,例: https://www.qidian.com',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
title: '源名称',
|
||||
id: 'bookSourceName',
|
||||
type: 'String',
|
||||
hint: '会显示在源列表',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
title: '源分组',
|
||||
id: 'bookSourceGroup',
|
||||
type: 'String',
|
||||
hint: '描述源的特征信息',
|
||||
},
|
||||
{
|
||||
title: '源注释',
|
||||
id: 'bookSourceComment',
|
||||
type: 'String',
|
||||
hint: '描述源作者和状态',
|
||||
},
|
||||
{
|
||||
title: '登录地址',
|
||||
id: 'loginUrl',
|
||||
type: 'String',
|
||||
hint: '填写网站登录网址,仅在需要登录的源有用',
|
||||
},
|
||||
{
|
||||
title: '登录界面',
|
||||
id: 'loginUi',
|
||||
type: 'String',
|
||||
hint: '自定义登录界面',
|
||||
},
|
||||
{
|
||||
title: '登录检测',
|
||||
id: 'loginCheckJs',
|
||||
type: 'String',
|
||||
hint: '登录检测js',
|
||||
},
|
||||
{
|
||||
title: '封面解密',
|
||||
id: 'coverDecodeJs',
|
||||
type: 'String',
|
||||
hint: '封面解密js',
|
||||
},
|
||||
{
|
||||
title: '链接验证',
|
||||
id: 'bookUrlPattern',
|
||||
type: 'String',
|
||||
hint: '书籍URL正则,当详情页URL与源URL的域名不一致时有效,用于添加网址',
|
||||
},
|
||||
{
|
||||
title: '请求头',
|
||||
id: 'header',
|
||||
type: 'String',
|
||||
hint: '客户端标识',
|
||||
},
|
||||
{
|
||||
title: '变量说明',
|
||||
id: 'variableComment',
|
||||
type: 'String',
|
||||
hint: '书源变量说明',
|
||||
},
|
||||
{
|
||||
title: '并发率',
|
||||
id: 'concurrentRate',
|
||||
type: 'String',
|
||||
hint: '并发率,如1000(访问间隔1000ms)或者1/1000(1000ms内访问1次)',
|
||||
},
|
||||
{
|
||||
title: 'js库',
|
||||
id: 'jsLib',
|
||||
type: 'String',
|
||||
hint: 'js库, 可填写js或者key-value object获取在线js文件',
|
||||
},
|
||||
],
|
||||
},
|
||||
search: {
|
||||
name: '搜索',
|
||||
children: [
|
||||
{
|
||||
title: '搜索地址',
|
||||
id: 'searchUrl',
|
||||
type: 'String',
|
||||
hint: '[域名可省略]/search.php@kw={{key}}',
|
||||
},
|
||||
{
|
||||
title: '校验文字',
|
||||
namespace: 'ruleSearch',
|
||||
id: 'checkKeyWord',
|
||||
type: 'String',
|
||||
hint: '校验关键字,强烈建议填写',
|
||||
},
|
||||
{
|
||||
title: '列表规则',
|
||||
namespace: 'ruleSearch',
|
||||
id: 'bookList',
|
||||
type: 'String',
|
||||
hint: '选择书籍节点 (规则结果为List<Element>)',
|
||||
},
|
||||
{
|
||||
title: '书名规则',
|
||||
namespace: 'ruleSearch',
|
||||
id: 'name',
|
||||
type: 'String',
|
||||
hint: '选择节点书名 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '作者规则',
|
||||
namespace: 'ruleSearch',
|
||||
id: 'author',
|
||||
type: 'String',
|
||||
hint: '选择节点作者 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '分类规则',
|
||||
namespace: 'ruleSearch',
|
||||
id: 'kind',
|
||||
type: 'String',
|
||||
hint: '选择节点分类信息 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '字数规则',
|
||||
namespace: 'ruleSearch',
|
||||
id: 'wordCount',
|
||||
type: 'String',
|
||||
hint: '选择节点字数信息 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '最新章节',
|
||||
namespace: 'ruleSearch',
|
||||
id: 'lastChapter',
|
||||
type: 'String',
|
||||
hint: '选择节点最新章节 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '简介规则',
|
||||
namespace: 'ruleSearch',
|
||||
id: 'intro',
|
||||
type: 'String',
|
||||
hint: '选择节点书籍简介 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '封面规则',
|
||||
namespace: 'ruleSearch',
|
||||
id: 'coverUrl',
|
||||
type: 'String',
|
||||
hint: '选择节点书籍封面 (规则结果为String类型的url)',
|
||||
},
|
||||
{
|
||||
title: '详情地址',
|
||||
namespace: 'ruleSearch',
|
||||
id: 'bookUrl',
|
||||
type: 'String',
|
||||
hint: '选择书籍详情页网址 (规则结果为String类型的url)',
|
||||
},
|
||||
],
|
||||
},
|
||||
find: {
|
||||
name: '发现',
|
||||
children: [
|
||||
{
|
||||
title: '发现地址',
|
||||
id: 'exploreUrl',
|
||||
type: 'String',
|
||||
hint: '单个发现格式<name>::<url>或者{url:<url>,title:<name>,style:...};前者用换行符或者&&连接,后者放在数组内;可用js动态生成',
|
||||
},
|
||||
{
|
||||
title: '列表规则',
|
||||
namespace: 'ruleExplore',
|
||||
id: 'bookList',
|
||||
type: 'String',
|
||||
hint: '选择书籍节点 (规则结果为List<Element>)',
|
||||
},
|
||||
{
|
||||
title: '书名规则',
|
||||
namespace: 'ruleExplore',
|
||||
id: 'name',
|
||||
type: 'String',
|
||||
hint: '选择节点书名 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '作者规则',
|
||||
namespace: 'ruleExplore',
|
||||
id: 'author',
|
||||
type: 'String',
|
||||
hint: '选择节点作者 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '分类规则',
|
||||
namespace: 'ruleExplore',
|
||||
id: 'kind',
|
||||
type: 'String',
|
||||
hint: '选择节点分类信息 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '字数规则',
|
||||
namespace: 'ruleExplore',
|
||||
id: 'wordCount',
|
||||
type: 'String',
|
||||
hint: '选择节点字数信息 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '最新章节',
|
||||
namespace: 'ruleExplore',
|
||||
id: 'lastChapter',
|
||||
type: 'String',
|
||||
hint: '选择节点最新章节 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '简介规则',
|
||||
namespace: 'ruleExplore',
|
||||
id: 'intro',
|
||||
type: 'String',
|
||||
hint: '选择节点书籍简介 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '封面规则',
|
||||
namespace: 'ruleExplore',
|
||||
id: 'coverUrl',
|
||||
type: 'String',
|
||||
hint: '选择节点书籍封面 (规则结果为String类型的url)',
|
||||
},
|
||||
{
|
||||
title: '详情地址',
|
||||
namespace: 'ruleExplore',
|
||||
id: 'bookUrl',
|
||||
type: 'String',
|
||||
hint: '选择书籍详情页网址 (规则结果为String类型的url)',
|
||||
},
|
||||
],
|
||||
},
|
||||
detail: {
|
||||
name: '详情',
|
||||
children: [
|
||||
{
|
||||
title: '预处理',
|
||||
namespace: 'ruleBookInfo',
|
||||
id: 'init',
|
||||
type: 'String',
|
||||
hint: '用于加速详情信息检索,只支持AllInOne规则',
|
||||
},
|
||||
{
|
||||
title: '书名规则',
|
||||
namespace: 'ruleBookInfo',
|
||||
id: 'name',
|
||||
type: 'String',
|
||||
hint: '选择节点书名 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '作者规则',
|
||||
namespace: 'ruleBookInfo',
|
||||
id: 'author',
|
||||
type: 'String',
|
||||
hint: '选择节点作者 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '分类规则',
|
||||
namespace: 'ruleBookInfo',
|
||||
id: 'kind',
|
||||
type: 'String',
|
||||
hint: '选择节点分类信息 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '字数规则',
|
||||
namespace: 'ruleBookInfo',
|
||||
id: 'wordCount',
|
||||
type: 'String',
|
||||
hint: '选择节点字数信息 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '最新章节',
|
||||
namespace: 'ruleBookInfo',
|
||||
id: 'lastChapter',
|
||||
type: 'String',
|
||||
hint: '选择节点最新章节 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '简介规则',
|
||||
namespace: 'ruleBookInfo',
|
||||
id: 'intro',
|
||||
type: 'String',
|
||||
hint: '选择节点书籍简介 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '封面规则',
|
||||
namespace: 'ruleBookInfo',
|
||||
id: 'coverUrl',
|
||||
type: 'String',
|
||||
hint: '选择节点书籍封面 (规则结果为String类型的url)',
|
||||
},
|
||||
{
|
||||
title: '目录地址',
|
||||
namespace: 'ruleBookInfo',
|
||||
id: 'tocUrl',
|
||||
type: 'String',
|
||||
hint: '选择书籍详情页网址 (规则结果为String类型的url, 与详情页相同时可省略)',
|
||||
},
|
||||
{
|
||||
title: '修改书籍',
|
||||
namespace: 'ruleBookInfo',
|
||||
id: 'canReName',
|
||||
type: 'String',
|
||||
hint: '允许修改书名作者(规则结果为String类型, 默认不允许)',
|
||||
},
|
||||
{
|
||||
title: '下载URL',
|
||||
namespace: 'ruleBookInfo',
|
||||
id: 'downloadUrls',
|
||||
type: 'String',
|
||||
hint: '文件类书源下载地址 (规则结果为String类型的url, 多个链接返回数组)',
|
||||
},
|
||||
],
|
||||
},
|
||||
directory: {
|
||||
name: '目录',
|
||||
children: [
|
||||
{
|
||||
title: '更新前JS',
|
||||
namespace: 'ruleToc',
|
||||
id: 'preUpdateJs',
|
||||
type: 'String',
|
||||
hint: '更新目录前调用JS 动态更新目录链接',
|
||||
},
|
||||
{
|
||||
title: '列表规则',
|
||||
namespace: 'ruleToc',
|
||||
id: 'chapterList',
|
||||
type: 'String',
|
||||
hint: '选择目录列表的章节节点 (规则结果为List<Element>)',
|
||||
},
|
||||
{
|
||||
title: '章节名称',
|
||||
namespace: 'ruleToc',
|
||||
id: 'chapterName',
|
||||
type: 'String',
|
||||
hint: '选择章节名称 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '章节地址',
|
||||
namespace: 'ruleToc',
|
||||
id: 'chapterUrl',
|
||||
type: 'String',
|
||||
hint: '选择章节链接 (规则结果为String类型的Url)',
|
||||
},
|
||||
{
|
||||
title: '标题处理',
|
||||
namespace: 'ruleToc',
|
||||
id: 'formatJs',
|
||||
type: 'String',
|
||||
hint: '遍历去重后的章节列表的回调,提供index(章节序号从1开始)、title(章节标题)变量,额外提供gInt(初始值0),返回值作为新的标题',
|
||||
},
|
||||
{
|
||||
title: '卷名标识',
|
||||
namespace: 'ruleToc',
|
||||
id: 'isVolume',
|
||||
type: 'String',
|
||||
hint: '章节名称是否是卷名 (规则结果为Bool)',
|
||||
},
|
||||
{
|
||||
title: '章节信息',
|
||||
namespace: 'ruleToc',
|
||||
id: 'updateTime',
|
||||
type: 'String',
|
||||
hint: '选择章节信息(如更新时间) (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '收费标识',
|
||||
namespace: 'ruleToc',
|
||||
id: 'isVip',
|
||||
type: 'String',
|
||||
hint: '章节是否为VIP章节 (规则结果为Bool)',
|
||||
},
|
||||
{
|
||||
title: '购买标识',
|
||||
namespace: 'ruleToc',
|
||||
id: 'isPay',
|
||||
type: 'String',
|
||||
hint: '章节是否为已购买 (规则结果为Bool)',
|
||||
},
|
||||
{
|
||||
title: '翻页规则',
|
||||
namespace: 'ruleToc',
|
||||
id: 'nextTocUrl',
|
||||
type: 'String',
|
||||
hint: '选择目录下一页链接 (规则结果为List<String>或String)',
|
||||
},
|
||||
],
|
||||
},
|
||||
content: {
|
||||
name: '正文',
|
||||
children: [
|
||||
{
|
||||
title: '正文规则',
|
||||
namespace: 'ruleContent',
|
||||
id: 'content',
|
||||
type: 'String',
|
||||
hint: '选择正文内容 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '标题规则',
|
||||
namespace: 'ruleContent',
|
||||
id: 'title',
|
||||
type: 'String',
|
||||
hint: '获取结果将会覆盖章节标题 (规则结果为String)',
|
||||
},
|
||||
{
|
||||
title: '翻页规则',
|
||||
namespace: 'ruleContent',
|
||||
id: 'nextContentUrl',
|
||||
type: 'String',
|
||||
hint: '选择下一分页(不是下一章)链接 (规则结果为String类型的Url)',
|
||||
},
|
||||
{
|
||||
title: '脚本注入',
|
||||
namespace: 'ruleContent',
|
||||
id: 'webJs',
|
||||
type: 'String',
|
||||
hint: '注入javascript,用于模拟鼠标点击等,必须有返回值,一般为String类型',
|
||||
},
|
||||
{
|
||||
title: '资源正则',
|
||||
namespace: 'ruleContent',
|
||||
id: 'sourceRegex',
|
||||
type: 'String',
|
||||
hint: '匹配资源的url特征,用于嗅探',
|
||||
},
|
||||
{
|
||||
title: '替换规则',
|
||||
namespace: 'ruleContent',
|
||||
id: 'replaceRegex',
|
||||
type: 'String',
|
||||
hint: '多页内容合并后替换,用于正文净化',
|
||||
},
|
||||
{
|
||||
title: '图片样式',
|
||||
namespace: 'ruleContent',
|
||||
id: 'imageStyle',
|
||||
type: 'String',
|
||||
hint: 'FULL:铺满 不填:默认样式',
|
||||
},
|
||||
{
|
||||
title: '图片解密',
|
||||
namespace: 'ruleContent',
|
||||
id: 'imageDecode',
|
||||
type: 'String',
|
||||
hint: '填写JavaScript 返回解密图片的bytes ',
|
||||
},
|
||||
{
|
||||
title: '购买操作',
|
||||
namespace: 'ruleContent',
|
||||
id: 'payAction',
|
||||
type: 'String',
|
||||
hint: '填写JavaScript 返回购买链接或者调用购买接口',
|
||||
},
|
||||
],
|
||||
},
|
||||
/*
|
||||
review: {
|
||||
name: "段评",
|
||||
children: [
|
||||
{
|
||||
title: "段评URL",
|
||||
namespace: "ruleReview",
|
||||
id: "reviewUrl",
|
||||
type: "String",
|
||||
hint: "段评URL",
|
||||
},
|
||||
{
|
||||
title: "发布头像",
|
||||
namespace: "ruleReview",
|
||||
id: "avatarRule",
|
||||
type: "String",
|
||||
hint: "段评发布者头像",
|
||||
},
|
||||
{
|
||||
title: "段评内容",
|
||||
namespace: "ruleReview",
|
||||
id: "contentRule",
|
||||
type: "String",
|
||||
hint: "段评内容",
|
||||
},
|
||||
{
|
||||
title: "发布时间",
|
||||
namespace: "ruleReview",
|
||||
id: "postTimeRule",
|
||||
type: "String",
|
||||
hint: "段评发布时间",
|
||||
},
|
||||
{
|
||||
title: "回复URL",
|
||||
namespace: "ruleReview",
|
||||
id: "reviewQuoteUrl",
|
||||
type: "String",
|
||||
hint: "获取段评回复URL",
|
||||
},
|
||||
{
|
||||
title: "点赞URL",
|
||||
namespace: "ruleReview",
|
||||
id: "voteUpUrl",
|
||||
type: "String",
|
||||
hint: "点赞URL",
|
||||
},
|
||||
{
|
||||
title: "点踩URL",
|
||||
namespace: "ruleReview",
|
||||
id: "voteDownUrl",
|
||||
type: "String",
|
||||
hint: "点踩URL",
|
||||
},
|
||||
{
|
||||
title: "发送回复",
|
||||
namespace: "ruleReview",
|
||||
id: "postReviewUrl",
|
||||
type: "String",
|
||||
hint: "发送回复URL",
|
||||
},
|
||||
{
|
||||
title: "回复段评",
|
||||
namespace: "ruleReview",
|
||||
id: "postQuoteUrl",
|
||||
type: "String",
|
||||
hint: "发送回复段评URL",
|
||||
},
|
||||
{
|
||||
title: "删除段评",
|
||||
namespace: "ruleReview",
|
||||
id: "deleteUrl",
|
||||
type: "String",
|
||||
hint: "删除段评URL",
|
||||
},
|
||||
],
|
||||
},*/
|
||||
other: {
|
||||
name: '其他',
|
||||
children: [
|
||||
{
|
||||
title: '启用搜索',
|
||||
id: 'enabled',
|
||||
type: 'Boolean',
|
||||
},
|
||||
{
|
||||
title: '启用发现',
|
||||
id: 'enabledExplore',
|
||||
type: 'Boolean',
|
||||
},
|
||||
// {
|
||||
// title: "启用段评",
|
||||
// id: "enabledReview",
|
||||
// type: "Boolean",
|
||||
// },
|
||||
{
|
||||
title: 'CookieJar',
|
||||
id: 'enabledCookieJar',
|
||||
type: 'Boolean',
|
||||
},
|
||||
{
|
||||
title: '搜索权重',
|
||||
id: 'weight',
|
||||
type: 'Number',
|
||||
},
|
||||
{
|
||||
title: '排序编号',
|
||||
id: 'customOrder',
|
||||
type: 'Number',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
export default {
|
||||
base: {
|
||||
name: '基础',
|
||||
children: [
|
||||
{
|
||||
title: '源域名',
|
||||
id: 'sourceUrl',
|
||||
type: 'String',
|
||||
hint: '通常填写网站主页,例: https://www.qidian.com',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
title: '图标',
|
||||
id: 'sourceIcon',
|
||||
type: 'String',
|
||||
hint: '填写图片网络链接',
|
||||
},
|
||||
{
|
||||
title: '源名称',
|
||||
id: 'sourceName',
|
||||
type: 'String',
|
||||
hint: '会显示在源列表',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
title: '源分组',
|
||||
id: 'sourceGroup',
|
||||
type: 'String',
|
||||
hint: '描述源的特征信息',
|
||||
},
|
||||
{
|
||||
title: '源注释',
|
||||
id: 'sourceComment',
|
||||
type: 'String',
|
||||
hint: '描述源作者和状态',
|
||||
},
|
||||
{
|
||||
title: '分类地址',
|
||||
id: 'sortUrl',
|
||||
type: 'String',
|
||||
hint: '名称1::链接1\n名称2::链接2',
|
||||
},
|
||||
{
|
||||
title: '登录地址',
|
||||
id: 'loginUrl',
|
||||
type: 'String',
|
||||
hint: '填写网站登录网址,仅在需要登录的源有用',
|
||||
},
|
||||
{
|
||||
title: '登录界面',
|
||||
id: 'loginUi',
|
||||
type: 'String',
|
||||
hint: '自定义登录界面',
|
||||
},
|
||||
{
|
||||
title: '登录检测',
|
||||
id: 'loginCheckJs',
|
||||
type: 'String',
|
||||
hint: '登录检测js',
|
||||
},
|
||||
{
|
||||
title: '封面解密',
|
||||
id: 'coverDecodeJs',
|
||||
type: 'String',
|
||||
hint: '封面解密js',
|
||||
},
|
||||
{
|
||||
title: '请求头',
|
||||
id: 'header',
|
||||
type: 'String',
|
||||
hint: '客户端标识',
|
||||
},
|
||||
{
|
||||
title: '变量说明',
|
||||
id: 'variableComment',
|
||||
type: 'String',
|
||||
hint: '源变量说明',
|
||||
},
|
||||
{
|
||||
title: '并发率',
|
||||
id: 'concurrentRate',
|
||||
type: 'String',
|
||||
hint: '并发率',
|
||||
},
|
||||
{
|
||||
title: 'js库',
|
||||
id: 'jsLib',
|
||||
type: 'String',
|
||||
hint: 'js库, 可填写js或者key-value object获取在线js文件',
|
||||
},
|
||||
],
|
||||
},
|
||||
list: {
|
||||
name: '列表',
|
||||
children: [
|
||||
{
|
||||
title: '列表规则',
|
||||
id: 'ruleArticles',
|
||||
type: 'String',
|
||||
hint: '规则结果为List<Element>',
|
||||
},
|
||||
{
|
||||
title: '翻页规则',
|
||||
id: 'ruleNextPage',
|
||||
type: 'String',
|
||||
hint: '下一页链接 规则结果为List<String>或String',
|
||||
},
|
||||
{
|
||||
title: '标题规则',
|
||||
id: 'ruleTitle',
|
||||
type: 'String',
|
||||
hint: '文章标题 规则结果为String',
|
||||
},
|
||||
{
|
||||
title: '时间规则',
|
||||
id: 'rulePubDate',
|
||||
type: 'String',
|
||||
hint: '文章发布时间 规则结果为String',
|
||||
},
|
||||
{
|
||||
title: '描述规则',
|
||||
id: 'ruleDescription',
|
||||
type: 'String',
|
||||
hint: '文章简要描述 规则结果为String',
|
||||
},
|
||||
{
|
||||
title: '图片规则',
|
||||
id: 'ruleImage',
|
||||
type: 'String',
|
||||
hint: '文章图片链接 规则结果为String',
|
||||
},
|
||||
{
|
||||
title: '链接规则',
|
||||
id: 'ruleLink',
|
||||
type: 'String',
|
||||
hint: '文章链接 规则结果为String',
|
||||
},
|
||||
],
|
||||
},
|
||||
webView: {
|
||||
name: 'WebView',
|
||||
children: [
|
||||
{
|
||||
title: '内容规则',
|
||||
id: 'ruleContent',
|
||||
type: 'String',
|
||||
hint: '文章正文',
|
||||
},
|
||||
{
|
||||
title: '样式规则',
|
||||
id: 'style',
|
||||
type: 'String',
|
||||
hint: '文章正文样式 填写css',
|
||||
},
|
||||
{
|
||||
title: '注入规则',
|
||||
id: 'injectJs',
|
||||
type: 'String',
|
||||
hint: '注入网页的JavaScript',
|
||||
},
|
||||
{
|
||||
title: '黑名单',
|
||||
id: 'contentBlacklist',
|
||||
type: 'String',
|
||||
hint: 'webView链接加载黑名单,英文逗号隔开',
|
||||
},
|
||||
{
|
||||
title: '白名单',
|
||||
id: 'contentWhitelist',
|
||||
type: 'String',
|
||||
hint: 'webView链接加载白名单,英文逗号隔开',
|
||||
},
|
||||
{
|
||||
title: '链接拦截',
|
||||
id: 'shouldOverrideUrlLoading',
|
||||
type: 'String',
|
||||
hint: '填写js,变量url为当前资源链接,返回true拦截',
|
||||
},
|
||||
],
|
||||
},
|
||||
other: {
|
||||
name: '其他',
|
||||
children: [
|
||||
{
|
||||
title: '列表样式',
|
||||
id: 'articleStyle',
|
||||
type: 'Array',
|
||||
array: ['默认', '大图', '双列'],
|
||||
},
|
||||
{
|
||||
title: '加载地址',
|
||||
id: 'loadWithBaseUrl',
|
||||
type: 'Boolean',
|
||||
},
|
||||
{
|
||||
title: '启用JS',
|
||||
id: 'enableJs',
|
||||
type: 'Boolean',
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
id: 'enabled',
|
||||
type: 'Boolean',
|
||||
},
|
||||
{
|
||||
title: 'Cookie',
|
||||
id: 'enabledCookieJar',
|
||||
type: 'Boolean',
|
||||
},
|
||||
{
|
||||
title: '单URL',
|
||||
id: 'singleUrl',
|
||||
type: 'Boolean',
|
||||
},
|
||||
{
|
||||
title: '排序编号',
|
||||
id: 'customOrder',
|
||||
type: 'Number',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Source } from '@/source'
|
||||
import bookSourceEditConfig from './bookSourceEditConfig'
|
||||
import rssSourceEditConfig from './rssSourceEditConfig'
|
||||
|
||||
type SourceConfigKey =
|
||||
| keyof typeof bookSourceEditConfig
|
||||
| keyof typeof rssSourceEditConfig
|
||||
type SourceConfigRecord = {
|
||||
title: string
|
||||
type: string //"array" | "String" | "Boolean"
|
||||
array?: string[]
|
||||
hint?: string
|
||||
required?: boolean
|
||||
namespace?: Partial<keyof Source>
|
||||
id: Partial<keyof Source>
|
||||
}
|
||||
type SourceConfigValue = { name: string; children: SourceConfigRecord[] }
|
||||
export type SourceConfig = Partial<Record<SourceConfigKey, SourceConfigValue>>
|
||||
@@ -0,0 +1,65 @@
|
||||
import body_0 from '../assets/imgs/themes/body_0.png'
|
||||
import content_0 from '../assets/imgs/themes/content_0.png'
|
||||
import popup_0 from '../assets/imgs/themes/popup_0.png'
|
||||
import body_1 from '../assets/imgs/themes/body_1.png'
|
||||
import content_1 from '../assets/imgs/themes/content_1.png'
|
||||
import popup_1 from '../assets/imgs/themes/popup_1.png'
|
||||
import body_2 from '../assets/imgs/themes/body_2.png'
|
||||
import content_2 from '../assets/imgs/themes/content_2.png'
|
||||
import popup_2 from '../assets/imgs/themes/popup_2.png'
|
||||
import body_3 from '../assets/imgs/themes/body_3.png'
|
||||
import content_3 from '../assets/imgs/themes/content_3.png'
|
||||
import popup_3 from '../assets/imgs/themes/popup_3.png'
|
||||
import body_5 from '../assets/imgs/themes/body_5.png'
|
||||
import content_5 from '../assets/imgs/themes/content_5.png'
|
||||
import popup_5 from '../assets/imgs/themes/popup_5.png'
|
||||
import body_6 from '../assets/imgs/themes/body_6.png'
|
||||
import content_6 from '../assets/imgs/themes/content_6.png'
|
||||
import popup_6 from '../assets/imgs/themes/popup_6.png'
|
||||
const settings = {
|
||||
themes: [
|
||||
{
|
||||
body: '#ede7da url(' + body_0 + ') repeat',
|
||||
content: '#ede7da url(' + content_0 + ') repeat',
|
||||
popup: '#ede7da url(' + popup_0 + ') repeat',
|
||||
},
|
||||
{
|
||||
body: '#ede7da url(' + body_1 + ') repeat',
|
||||
content: '#ede7da url(' + content_1 + ') repeat',
|
||||
popup: '#ede7da url(' + popup_1 + ') repeat',
|
||||
},
|
||||
{
|
||||
body: '#ede7da url(' + body_2 + ') repeat',
|
||||
content: '#ede7da url(' + content_2 + ') repeat',
|
||||
popup: '#ede7da url(' + popup_2 + ') repeat',
|
||||
},
|
||||
{
|
||||
body: '#ede7da url(' + body_3 + ') repeat',
|
||||
content: '#ede7da url(' + content_3 + ') repeat',
|
||||
popup: '#ede7da url(' + popup_3 + ') repeat',
|
||||
},
|
||||
{
|
||||
body: '#ebcece repeat',
|
||||
content: '#f5e4e4 repeat',
|
||||
popup: '#faeceb repeat',
|
||||
},
|
||||
{
|
||||
body: '#ede7da url(' + body_5 + ') repeat',
|
||||
content: '#ede7da url(' + content_5 + ') repeat',
|
||||
popup: '#ede7da url(' + popup_5 + ') repeat',
|
||||
},
|
||||
{
|
||||
body: '#ede7da url(' + body_6 + ') repeat',
|
||||
content: '#ede7da url(' + content_6 + ') repeat',
|
||||
popup: '#ede7da url(' + popup_6 + ') repeat',
|
||||
},
|
||||
],
|
||||
fonts: [
|
||||
'Microsoft YaHei, PingFangSC-Regular, HelveticaNeue-Light, Helvetica Neue Light, sans-serif',
|
||||
|
||||
'PingFangSC-Regular, -apple-system, Simsun',
|
||||
|
||||
'Kaiti',
|
||||
],
|
||||
}
|
||||
export default settings
|
||||
@@ -0,0 +1,8 @@
|
||||
.el-loading-spinner {
|
||||
font-size: 36px;
|
||||
color: #b5b5b5;
|
||||
}
|
||||
.el-loading-text {
|
||||
font-weight: 500;
|
||||
color: #b5b5b5 !important;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { watch, unref, onUnmounted } from 'vue'
|
||||
import { ElLoading } from 'element-plus'
|
||||
import loadingSvg from '@element-plus/icons-svg/loading.svg?raw'
|
||||
import 'element-plus/theme-chalk/el-loading.css'
|
||||
import './loading.css'
|
||||
|
||||
export const useLoading = (
|
||||
target: MaybeRef<string | HTMLElement | undefined>,
|
||||
text: string,
|
||||
spinner = loadingSvg,
|
||||
) => {
|
||||
// loading spinner
|
||||
const isLoading = ref(false)
|
||||
let loadingInstance: ReturnType<typeof ElLoading.service> | null = null
|
||||
const closeLoading = () => (isLoading.value = false)
|
||||
const showLoading = () => (isLoading.value = true)
|
||||
watch(isLoading, loading => {
|
||||
if (!loading) return loadingInstance?.close()
|
||||
loadingInstance = ElLoading.service({
|
||||
target: unref(target),
|
||||
spinner: spinner,
|
||||
text: text,
|
||||
lock: true,
|
||||
background: 'rgba(0, 0, 0, 0)',
|
||||
})
|
||||
})
|
||||
|
||||
const loadingWrapper = (promise: Promise<unknown>) => {
|
||||
if (!(promise instanceof Promise))
|
||||
throw TypeError('loadingWrapper argument must be Promise')
|
||||
showLoading()
|
||||
return promise.finally(closeLoading)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
closeLoading()
|
||||
})
|
||||
|
||||
return { isLoading, showLoading, closeLoading, loadingWrapper }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from '@/router'
|
||||
import store from '@/store'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
|
||||
createApp(App).use(store).use(router).mount('#app')
|
||||
// 书架 同步Element PLUS 夜间模式
|
||||
watch(
|
||||
() => useBookStore().isNight,
|
||||
isNight => {
|
||||
if (isNight) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
window.addEventListener('vite:preloadError', event => {
|
||||
event.preventDefault()
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
# 「阅读3.0」 web 端(已打包进阅读3.0,不能设置IP)
|
||||
|
||||
本程序为「阅读3.0」的配套 web 端,需要保证手机和电脑在同一局域网内,然后手机端打开 web 服务。
|
||||
|
||||
~~在线地址 http://alanskycn.gitee.io/vip/reader/~~
|
||||
|
||||
## 具体实现
|
||||
|
||||
使用 Vue3 开发
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 本地存储阅读记录与设置
|
||||
- 阅读主题切换
|
||||
- 夜间模式
|
||||
- 字号调节
|
||||
- 字体调节
|
||||
- 阅读宽度调节
|
||||
|
||||
## 使用方法
|
||||
|
||||
```shell
|
||||
pnpm install
|
||||
#安装项目
|
||||
pnpm dev
|
||||
#开发模式
|
||||
pnpm build
|
||||
#打包
|
||||
pnpm lint:fix
|
||||
#格式化代码
|
||||
```
|
||||
|
||||
- 调试的时候可以修改.env.development里面的地址连接手机端调试
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh" class="">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from '@/App.vue'
|
||||
import bookRouter from '@/router/bookRouter'
|
||||
import store from '@/store'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
|
||||
createApp(App).use(store).use(bookRouter).mount('#app')
|
||||
|
||||
// 同步Element PLUS 夜间模式
|
||||
watch(
|
||||
() => useBookStore().isNight,
|
||||
isNight => {
|
||||
if (isNight) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
# legado_web_editor
|
||||
|
||||
## 🚧开发注意
|
||||
|
||||
如果你想要调试项目 请修改文件`.env.development`里`VITE_API`为阅读web服务ip
|
||||
|
||||
## 路由
|
||||
|
||||
/rssSource 订阅源编辑
|
||||
/rssSource 书源编辑
|
||||
|
||||
## 🎨Project setup
|
||||
|
||||
```
|
||||
pnpm i
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
|
||||
```
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
|
||||
```
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
|
||||
```
|
||||
pnpm lint:fix
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh" class="">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from '@/App.vue'
|
||||
import sourceRouter from '@/router/sourceRouter'
|
||||
import store from '@/store'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
|
||||
createApp(App).use(store).use(sourceRouter).mount('#app')
|
||||
@@ -0,0 +1,18 @@
|
||||
export {}
|
||||
export type Options = {
|
||||
duration?: number | [(distance: number) => number]
|
||||
offset?: number
|
||||
callback?: () => void // "undefined" is a suitable default, and won't be called
|
||||
easing?: (
|
||||
timeElapsed: number,
|
||||
start: number,
|
||||
distance: number,
|
||||
duration: number,
|
||||
) => number
|
||||
a11y?: boolean
|
||||
container?: HTMLElement | string
|
||||
}
|
||||
export default function (
|
||||
target: number | string | HTMLElement,
|
||||
options: Options = {},
|
||||
): void
|
||||
@@ -0,0 +1,186 @@
|
||||
const easeInOutQuad = (t, b, c, d) => {
|
||||
t /= d / 2
|
||||
if (t < 1) return (c / 2) * t * t + b
|
||||
t--
|
||||
return (-c / 2) * (t * (t - 2) - 1) + b
|
||||
}
|
||||
|
||||
const jumper = () => {
|
||||
// private variable cache
|
||||
// no variables are created during a jump, preventing memory leaks
|
||||
|
||||
let container // container element to be scrolled (node)
|
||||
let element // element to scroll to (node)
|
||||
|
||||
let start // where scroll starts (px)
|
||||
let stop // where scroll stops (px)
|
||||
|
||||
let offset // adjustment from the stop position (px)
|
||||
let easing // easing function (function)
|
||||
let a11y // accessibility support flag (boolean)
|
||||
|
||||
let distance // distance of scroll (px)
|
||||
let duration // scroll duration (ms)
|
||||
|
||||
let timeStart // time scroll started (ms)
|
||||
let timeElapsed // time spent scrolling thus far (ms)
|
||||
|
||||
let next // next scroll position (px)
|
||||
|
||||
let callback // to call when done scrolling (function)
|
||||
|
||||
// scroll position helper
|
||||
|
||||
function location() {
|
||||
let top = container.scrollTop || container.scrollY || container.pageYOffset
|
||||
top = typeof top === 'undefined' ? 0 : top
|
||||
return top
|
||||
}
|
||||
|
||||
// element offset helper
|
||||
|
||||
function top(element) {
|
||||
const elementTop = element.getBoundingClientRect().top
|
||||
const containerTop = container.getBoundingClientRect
|
||||
? container.getBoundingClientRect().top
|
||||
: 0
|
||||
|
||||
return elementTop - containerTop + start
|
||||
}
|
||||
|
||||
// scrollTo helper
|
||||
|
||||
function scrollTo(top) {
|
||||
container.scrollTo
|
||||
? container.scrollTo(0, top) // window
|
||||
: (container.scrollTop = top) // custom container
|
||||
}
|
||||
|
||||
// rAF loop helper
|
||||
|
||||
function loop(timeCurrent) {
|
||||
// store time scroll started, if not started already
|
||||
if (!timeStart) {
|
||||
timeStart = timeCurrent
|
||||
}
|
||||
|
||||
// determine time spent scrolling so far
|
||||
timeElapsed = timeCurrent - timeStart
|
||||
|
||||
// calculate next scroll position
|
||||
next = easing(timeElapsed, start, distance, duration)
|
||||
|
||||
// scroll to it
|
||||
scrollTo(next)
|
||||
|
||||
// check progress
|
||||
timeElapsed < duration
|
||||
? requestAnimationFrame(loop) // continue scroll loop
|
||||
: done() // scrolling is done
|
||||
}
|
||||
|
||||
// scroll finished helper
|
||||
|
||||
function done() {
|
||||
// account for rAF time rounding inaccuracies
|
||||
scrollTo(start + distance)
|
||||
|
||||
// if scrolling to an element, and accessibility is enabled
|
||||
if (element && a11y) {
|
||||
// add tabindex indicating programmatic focus
|
||||
element.setAttribute('tabindex', '-1')
|
||||
|
||||
// focus the element
|
||||
element.focus()
|
||||
}
|
||||
|
||||
// if it exists, fire the callback
|
||||
if (typeof callback === 'function') {
|
||||
callback()
|
||||
}
|
||||
|
||||
// reset time for next jump
|
||||
timeStart = false
|
||||
}
|
||||
|
||||
// API
|
||||
|
||||
function jump(target, options = {}) {
|
||||
// resolve options, or use defaults
|
||||
duration = options.duration || 1000
|
||||
offset = options.offset || 0
|
||||
callback = options.callback // "undefined" is a suitable default, and won't be called
|
||||
easing = options.easing || easeInOutQuad
|
||||
a11y = options.a11y || false
|
||||
|
||||
// resolve container
|
||||
switch (typeof options.container) {
|
||||
case 'object':
|
||||
// we assume container is an HTML element (Node)
|
||||
container = options.container
|
||||
break
|
||||
|
||||
case 'string':
|
||||
container = document.querySelector(options.container)
|
||||
break
|
||||
|
||||
default:
|
||||
container = window
|
||||
}
|
||||
|
||||
// cache starting position
|
||||
start = location()
|
||||
|
||||
// resolve target
|
||||
switch (typeof target) {
|
||||
// scroll from current position
|
||||
case 'number':
|
||||
element = undefined // no element to scroll to
|
||||
a11y = false // make sure accessibility is off
|
||||
stop = start + target
|
||||
break
|
||||
|
||||
// scroll to element (node)
|
||||
// bounding rect is relative to the viewport
|
||||
case 'object':
|
||||
element = target
|
||||
stop = top(element)
|
||||
break
|
||||
|
||||
// scroll to element (selector)
|
||||
// bounding rect is relative to the viewport
|
||||
case 'string':
|
||||
element = document.querySelector(target)
|
||||
stop = top(element)
|
||||
break
|
||||
}
|
||||
|
||||
// resolve scroll distance, accounting for offset
|
||||
distance = stop - start + offset
|
||||
|
||||
// resolve duration
|
||||
switch (typeof options.duration) {
|
||||
// number in ms
|
||||
case 'number':
|
||||
duration = options.duration
|
||||
break
|
||||
|
||||
// function passed the distance of the scroll
|
||||
case 'function':
|
||||
duration = options.duration(distance)
|
||||
break
|
||||
}
|
||||
|
||||
// start the loop
|
||||
requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
// expose only the jump method
|
||||
return jump
|
||||
}
|
||||
|
||||
// export singleton
|
||||
|
||||
const singleton = jumper()
|
||||
|
||||
export default singleton
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createWebHashHistory, createRouter } from 'vue-router'
|
||||
|
||||
export const bookRoutes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'shelf',
|
||||
component: () => import('../views/BookShelf.vue'),
|
||||
},
|
||||
{
|
||||
path: '/chapter',
|
||||
name: 'chapter',
|
||||
component: () => import('../views/BookChapter.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
// mode: "history",
|
||||
history: createWebHashHistory(),
|
||||
routes: bookRoutes,
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createWebHashHistory, createRouter } from 'vue-router'
|
||||
import { bookRoutes } from './bookRouter'
|
||||
import { sourceRoutes } from './sourceRouter'
|
||||
|
||||
const router = createRouter({
|
||||
// history: createWebHistory(process.env.BASE_URL),
|
||||
history: createWebHashHistory(),
|
||||
routes: [bookRoutes, sourceRoutes].flat(),
|
||||
})
|
||||
|
||||
router.afterEach(to => {
|
||||
if (to.name == 'shelf') document.title = '书架'
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,23 @@
|
||||
import sourceEditor from '../views/SourceEditor.vue'
|
||||
import { createWebHashHistory, createRouter } from 'vue-router'
|
||||
|
||||
export const sourceRoutes = [
|
||||
{
|
||||
path: '/bookSource',
|
||||
name: 'book-home',
|
||||
component: sourceEditor,
|
||||
},
|
||||
{
|
||||
path: '/rssSource',
|
||||
name: 'rss-home',
|
||||
component: sourceEditor,
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
// history: createWebHistory(process.env.BASE_URL),
|
||||
history: createWebHashHistory(),
|
||||
routes: sourceRoutes,
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,165 @@
|
||||
/** https://github.com/gedoor/legado/tree/master/app/src/main/java/io/legado/app/data/entities */
|
||||
type BaseSource = {
|
||||
/**
|
||||
* 并发率
|
||||
*/
|
||||
concurrentRate?: string
|
||||
/**
|
||||
* 登录地址
|
||||
*/
|
||||
loginUrl?: string
|
||||
|
||||
/**
|
||||
* 登录UI
|
||||
*/
|
||||
loginUi?: string
|
||||
|
||||
/**
|
||||
* 请求头
|
||||
*/
|
||||
header?: string
|
||||
|
||||
/**
|
||||
* 启用cookieJar
|
||||
*/
|
||||
enabledCookieJar?: boolean
|
||||
|
||||
/**
|
||||
* js库
|
||||
*/
|
||||
jsLib?: string
|
||||
}
|
||||
type BookSoure = BaseSource & {
|
||||
// 地址,包括 http/https
|
||||
bookSourceUrl: string
|
||||
// 名称
|
||||
bookSourceName: string
|
||||
// 分组
|
||||
bookSourceGroup?: string
|
||||
// 类型,0 文本,1 音频, 2 图片, 3 文件(指的是类似知轩藏书只提供下载的网站)
|
||||
bookSourceType: number
|
||||
// 详情页url正则
|
||||
bookUrlPattern?: string
|
||||
// 手动排序编号
|
||||
customOrder: number
|
||||
// 是否启用
|
||||
enabled: boolean
|
||||
// 启用发现
|
||||
enabledExplore: boolean
|
||||
// 登录检测js
|
||||
loginCheckJs?: string
|
||||
// 封面解密js
|
||||
coverDecodeJs?: string
|
||||
// 注释
|
||||
bookSourceComment?: string
|
||||
// 自定义变量说明
|
||||
variableComment?: string
|
||||
// 最后更新时间,用于排序
|
||||
lastUpdateTime: number
|
||||
// 响应时间,用于排序
|
||||
respondTime: number
|
||||
// 智能排序的权重
|
||||
weight: number
|
||||
// 发现url
|
||||
exploreUrl?: string
|
||||
// 发现筛选规则
|
||||
exploreScreen?: string
|
||||
// 发现规则
|
||||
ruleExplore?: ExploreRule
|
||||
// 搜索url
|
||||
searchUrl?: string
|
||||
// 搜索规则
|
||||
ruleSearch?: SearchRule
|
||||
// 书籍信息页规则
|
||||
ruleBookInfo?: BookInfoRule
|
||||
// 目录页规则
|
||||
ruleToc?: TocRule
|
||||
// 正文页规则
|
||||
ruleContent?: ContentRule
|
||||
// 段评规则
|
||||
ruleReview?: ReviewRule
|
||||
}
|
||||
type RuleSearch = {
|
||||
checkKeyWord?: string
|
||||
[prop: string]: string
|
||||
}
|
||||
/* type ExploreRule = {
|
||||
[prop:string]: string
|
||||
}
|
||||
type BookInfoRule = {
|
||||
[prop:string]: string
|
||||
}
|
||||
type TocRule = {
|
||||
[prop:string]: string
|
||||
}
|
||||
type ContentRule = {
|
||||
[prop:string]: string
|
||||
}
|
||||
type ReviewRule = {
|
||||
[prop:string]: string
|
||||
} */
|
||||
type RssSource = BaseSource & {
|
||||
sourceUrl: string
|
||||
// 名称
|
||||
sourceName: string
|
||||
// 图标
|
||||
sourceIcon: string
|
||||
// 分组
|
||||
sourceGroup?: string
|
||||
// 注释
|
||||
sourceComment?: string
|
||||
// 是否启用
|
||||
enabled: boolean
|
||||
// 自定义变量说明
|
||||
variableComment?: string
|
||||
/**登录检测js**/
|
||||
loginCheckJs?: string
|
||||
/**封面解密js**/
|
||||
coverDecodeJs?: string
|
||||
/**分类Url**/
|
||||
sortUrl?: string
|
||||
/**是否单url源**/
|
||||
singleUrl: boolean
|
||||
/*列表规则*/
|
||||
/**列表样式,0,1,2**/
|
||||
articleStyle: number
|
||||
/**列表规则**/
|
||||
ruleArticles?: string
|
||||
/**下一页规则**/
|
||||
ruleNextPage?: string
|
||||
/**标题规则**/
|
||||
ruleTitle?: string
|
||||
/**发布日期规则**/
|
||||
rulePubDate?: string
|
||||
/*webView规则*/
|
||||
/**描述规则**/
|
||||
ruleDescription?: string
|
||||
/**图片规则**/
|
||||
ruleImage?: string
|
||||
/**链接规则**/
|
||||
ruleLink?: string
|
||||
/**正文规则**/
|
||||
ruleContent?: string
|
||||
/**正文url白名单**/
|
||||
contentWhitelist?: string
|
||||
/**正文url黑名单**/
|
||||
contentBlacklist?: string
|
||||
/**
|
||||
* 跳转url拦截,
|
||||
* js, 返回true拦截,js变量url,可以通过js打开url,比如调用阅读搜索,添加书架等,简化规则写法,不用webView js注入
|
||||
* **/
|
||||
shouldOverrideUrlLoading?: string
|
||||
/**webView样式**/
|
||||
style?: string
|
||||
enableJs: boolean
|
||||
loadWithBaseUrl: boolean
|
||||
/**注入js**/
|
||||
injectJs?: string
|
||||
/*其它规则*/
|
||||
/**最后更新时间,用于排序**/
|
||||
lastUpdateTime: number
|
||||
customOrder: number
|
||||
}
|
||||
type Source = BookSoure | RssSource
|
||||
|
||||
export { Source, BookSoure, RssSource }
|
||||
@@ -0,0 +1,210 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import API from '@api'
|
||||
import type {
|
||||
BaseBook,
|
||||
Book,
|
||||
BookChapter,
|
||||
BookProgress,
|
||||
SeachBook,
|
||||
} from '@/book'
|
||||
import type { webReadConfig } from '@/web'
|
||||
import { ElMessage } from 'element-plus/es'
|
||||
|
||||
const default_config: webReadConfig = {
|
||||
theme: 0,
|
||||
font: 0,
|
||||
fontSize: 18,
|
||||
readWidth: 800,
|
||||
infiniteLoading: false,
|
||||
customFontName: '',
|
||||
jumpDuration: 1000,
|
||||
spacing: {
|
||||
paragraph: 1,
|
||||
line: 0.8,
|
||||
letter: 0,
|
||||
},
|
||||
}
|
||||
let webReadConfigLoadedDate: Date | undefined
|
||||
|
||||
export const useBookStore = defineStore('book', {
|
||||
state: () => {
|
||||
return {
|
||||
searchBooks: [] as SeachBook[],
|
||||
shelf: [] as Book[],
|
||||
catalog: [] as BookChapter[],
|
||||
readingBook: { chapterPos: 0, chapterIndex: 0 } as BaseBook & {
|
||||
chapterPos: number
|
||||
chapterIndex: number
|
||||
isSeachBook?: boolean
|
||||
},
|
||||
popCataVisible: false,
|
||||
contentLoading: true,
|
||||
showContent: false,
|
||||
config: default_config,
|
||||
miniInterface: false,
|
||||
readSettingsVisible: false,
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
bookProgress: (state): BookProgress | undefined => {
|
||||
if (state.catalog.length == 0) return
|
||||
const { chapterIndex, chapterPos, name, author } = state.readingBook
|
||||
const title = state.catalog[chapterIndex]?.title
|
||||
if (!title) return
|
||||
return {
|
||||
name,
|
||||
author,
|
||||
durChapterIndex: chapterIndex,
|
||||
durChapterPos: chapterPos,
|
||||
durChapterTime: new Date().getTime(),
|
||||
durChapterTitle: title,
|
||||
}
|
||||
},
|
||||
theme: state => {
|
||||
return state.config.theme
|
||||
},
|
||||
isNight: state => state.config.theme == 6,
|
||||
},
|
||||
actions: {
|
||||
/** 从后端加载书架书籍,优先返回内存缓存 */
|
||||
async loadBookShelf(): Promise<Book[]> {
|
||||
const fetchBookshellf_promise = API.getBookShelf().then(resp => {
|
||||
console.log('API.getBookShelf数据返回')
|
||||
const { isSuccess, data, errorMsg } = resp.data
|
||||
if (isSuccess === true) {
|
||||
if (
|
||||
this.shelf.length !== data.length &&
|
||||
this.shelf.length > 0 &&
|
||||
data.length > 0
|
||||
) {
|
||||
ElMessage.info(`书架数据已更新`)
|
||||
}
|
||||
this.shelf = data.sort((a, b) => {
|
||||
const x = a['durChapterTime'] || 0
|
||||
const y = b['durChapterTime'] || 0
|
||||
return y - x
|
||||
})
|
||||
} else {
|
||||
if (errorMsg.includes('还没有添加小说') && this.shelf.length > 0) {
|
||||
ElMessage.info('当前书架上的书籍已经被删除')
|
||||
return (this.shelf = [])
|
||||
}
|
||||
ElMessage.error(errorMsg ?? '后端返回格式错误!')
|
||||
}
|
||||
console.log('书架数据已更新')
|
||||
return this.shelf
|
||||
})
|
||||
|
||||
if (this.shelf.length > 0) {
|
||||
// bookshelf data fetched before:do not await
|
||||
console.log('返回缓存书架数据')
|
||||
return this.shelf
|
||||
} else {
|
||||
console.log('从阅读后端获取书架数据...')
|
||||
return await fetchBookshellf_promise
|
||||
}
|
||||
},
|
||||
/** 从后端加载书籍目录,优先返回内存缓存 */
|
||||
async loadWebCatalog(
|
||||
book: typeof this.readingBook,
|
||||
): Promise<BookChapter[]> {
|
||||
const { bookUrl, name, chapterIndex } = book
|
||||
const fetchChapterList_promise = API.getChapterList(
|
||||
bookUrl as string,
|
||||
).then(res => {
|
||||
const { isSuccess, data, errorMsg } = res.data
|
||||
if (isSuccess === false) {
|
||||
ElMessage.error(errorMsg)
|
||||
throw new Error()
|
||||
}
|
||||
if (
|
||||
bookUrl === this.readingBook.bookUrl &&
|
||||
data.length !== this.catalog.length &&
|
||||
data.length > 0 &&
|
||||
this.catalog.length > 0
|
||||
) {
|
||||
ElMessage.info(`书籍${name}: 章节目录已更新`)
|
||||
}
|
||||
this.catalog = data
|
||||
console.log(`书籍${name}: 章节目录已更新`)
|
||||
return this.catalog
|
||||
})
|
||||
if (
|
||||
bookUrl === this.readingBook.bookUrl &&
|
||||
this.catalog.length > 0 &&
|
||||
this.catalog.length - 1 >= chapterIndex
|
||||
) {
|
||||
console.log(`返回书籍《${name}》 缓存的章节目录`)
|
||||
return this.catalog
|
||||
} else {
|
||||
console.log(`从阅读后端获取书籍《${name}》 章节目录数据...`)
|
||||
return await fetchChapterList_promise
|
||||
}
|
||||
},
|
||||
setPopCataVisible(visible: boolean) {
|
||||
this.popCataVisible = visible
|
||||
},
|
||||
setContentLoading(loading: boolean) {
|
||||
this.contentLoading = loading
|
||||
},
|
||||
setReadingBook(readingBook: typeof this.readingBook) {
|
||||
this.readingBook = readingBook
|
||||
},
|
||||
/** 只从从后端加载一次web阅读配置 */
|
||||
async loadWebConfig() {
|
||||
if (webReadConfigLoadedDate === undefined) {
|
||||
const _config = await API.getReadConfig()
|
||||
webReadConfigLoadedDate = new Date()
|
||||
console.log(
|
||||
`${this.$id}.loadWebConfig: ${webReadConfigLoadedDate.toLocaleString()}成功加载阅读配置`,
|
||||
)
|
||||
return this.setConfig(_config)
|
||||
}
|
||||
console.log(
|
||||
`${this.$id}.loadWebConfig: 已于${webReadConfigLoadedDate.toLocaleString()}成功加载`,
|
||||
)
|
||||
},
|
||||
setConfig(config?: webReadConfig) {
|
||||
this.config = Object.assign({}, this.config, config)
|
||||
},
|
||||
setReadSettingsVisible(visible: boolean) {
|
||||
this.readSettingsVisible = visible
|
||||
},
|
||||
setShowContent(visible: boolean) {
|
||||
this.showContent = visible
|
||||
},
|
||||
setMiniInterface(mini: boolean) {
|
||||
this.miniInterface = mini
|
||||
},
|
||||
async setSearchBooks(books: SeachBook[]) {
|
||||
books.forEach(book => {
|
||||
const isSeachBook = this.shelf.every(
|
||||
item => item.bookUrl !== book.bookUrl,
|
||||
)
|
||||
if (isSeachBook === true) {
|
||||
this.searchBooks.push(book)
|
||||
}
|
||||
})
|
||||
},
|
||||
clearSearchBooks() {
|
||||
this.searchBooks = []
|
||||
},
|
||||
/** 1.保存进度到app 2.修改内存中的数据*/
|
||||
async saveBookProgress() {
|
||||
if (!this.bookProgress) return Promise.resolve()
|
||||
const { bookUrl } = this.readingBook
|
||||
const shelfRaw = toRaw(this.shelf)
|
||||
const findIndex = shelfRaw.findIndex(book => book.bookUrl === bookUrl)
|
||||
if (findIndex > -1) {
|
||||
this.shelf[findIndex] = Object.assign(
|
||||
{},
|
||||
shelfRaw[findIndex],
|
||||
this.bookProgress,
|
||||
)
|
||||
}
|
||||
// 直接关闭浏览器时 http请求可能被取消
|
||||
// return API.saveBookProgress(this.bookProgress)
|
||||
return API.saveBookProgressWithBeacon(this.bookProgress)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useConnectionStore = defineStore('connection', {
|
||||
state: () => {
|
||||
return {
|
||||
connectStatus: '正在连接后端服务器……',
|
||||
connectType: 'primary' as 'primary' | 'success' | 'danger',
|
||||
newConnect: false,
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
setConnectStatus(connectStatus: string) {
|
||||
if (this.newConnect === true) return
|
||||
this.connectStatus = connectStatus
|
||||
},
|
||||
setConnectType(connectType: 'primary' | 'success' | 'danger') {
|
||||
if (this.newConnect === true) return
|
||||
this.connectType = connectType
|
||||
},
|
||||
setNewConnect(newConnect: boolean) {
|
||||
this.newConnect = newConnect
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export * from './bookStore'
|
||||
export * from './sourceStore'
|
||||
export * from './connectionStore'
|
||||
export default createPinia()
|
||||
@@ -0,0 +1,134 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import {
|
||||
emptyBookSource,
|
||||
emptyRssSource,
|
||||
getSourceUniqueKey,
|
||||
convertSourcesToMap,
|
||||
} from '@utils/souce'
|
||||
import type { BookSoure, RssSource, Source } from '@/source'
|
||||
|
||||
const isBookSource = /bookSource/i.test(location.href)
|
||||
const emptySource = isBookSource ? emptyBookSource : emptyRssSource
|
||||
|
||||
export const useSourceStore = defineStore('source', {
|
||||
state: () => {
|
||||
return {
|
||||
bookSources: shallowRef([] as BookSoure[]), // 临时存放所有书源,
|
||||
rssSources: shallowRef([] as RssSource[]), // 临时存放所有订阅源
|
||||
savedSources: [] as Source[], // 批量保存到阅读app成功的源
|
||||
currentSource: JSON.parse(JSON.stringify(emptySource)) as Source, // 当前编辑的源
|
||||
currentTab: localStorage.getItem('tabName') || 'editTab',
|
||||
editTabSource: {} as Source, // 生成序列化的json数据
|
||||
isDebuging: false,
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
sources: (state): Source[] =>
|
||||
isBookSource ? state.bookSources : state.rssSources,
|
||||
sourcesMap: function (): Map<string, Source> {
|
||||
return convertSourcesToMap(this.sources)
|
||||
},
|
||||
savedSourcesMap: (state): Map<string, Source> =>
|
||||
convertSourcesToMap(state.savedSources),
|
||||
currentSourceUrl: state =>
|
||||
isBookSource
|
||||
? (state.currentSource as BookSoure).bookSourceUrl
|
||||
: (state.currentSource as RssSource).sourceUrl,
|
||||
searchKey: (state): string =>
|
||||
isBookSource
|
||||
? (state.currentSource as BookSoure)?.ruleSearch?.checkKeyWord || '我的'
|
||||
: '',
|
||||
},
|
||||
actions: {
|
||||
startDebug() {
|
||||
this.currentTab = 'editDebug'
|
||||
this.isDebuging = true
|
||||
},
|
||||
debugFinish() {
|
||||
this.isDebuging = false
|
||||
},
|
||||
|
||||
//拉取源后保存
|
||||
saveSources(data: Source[]) {
|
||||
if (isBookSource) {
|
||||
this.bookSources = markRaw(data) as BookSoure[]
|
||||
} else {
|
||||
this.rssSources = markRaw(data) as RssSource[]
|
||||
}
|
||||
},
|
||||
//批量推送
|
||||
setPushReturnSources(returnSoures: Source[]) {
|
||||
this.savedSources = returnSoures
|
||||
},
|
||||
//删除源
|
||||
deleteSources(data: Source[]) {
|
||||
const sources: Source[] = isBookSource
|
||||
? this.bookSources
|
||||
: this.rssSources
|
||||
data.forEach(source => {
|
||||
const index = sources.indexOf(source)
|
||||
if (index > -1) sources.splice(index, 1)
|
||||
})
|
||||
},
|
||||
//保存当前编辑源
|
||||
saveCurrentSource() {
|
||||
const source = this.currentSource,
|
||||
map = this.sourcesMap
|
||||
map.set(getSourceUniqueKey(source), JSON.parse(JSON.stringify(source)))
|
||||
this.saveSources(Array.from(map.values()))
|
||||
},
|
||||
// 更改当前编辑的源qq
|
||||
changeCurrentSource(source: Source) {
|
||||
this.currentSource = JSON.parse(JSON.stringify(source))
|
||||
},
|
||||
// update editTab tabName and editTab info
|
||||
changeTabName(tabName: string) {
|
||||
this.currentTab = tabName
|
||||
localStorage.setItem('tabName', tabName)
|
||||
},
|
||||
changeEditTabSource(source: Source) {
|
||||
this.editTabSource = JSON.parse(JSON.stringify(source))
|
||||
},
|
||||
editHistory(history: Source) {
|
||||
let historyObj
|
||||
if (localStorage.getItem('history')) {
|
||||
historyObj = JSON.parse(localStorage.getItem('history')!)
|
||||
historyObj.new.push(history)
|
||||
if (historyObj.new.length > 50) {
|
||||
historyObj.new.shift()
|
||||
}
|
||||
if (historyObj.old.length > 50) {
|
||||
historyObj.old.shift()
|
||||
}
|
||||
localStorage.setItem('history', JSON.stringify(historyObj))
|
||||
} else {
|
||||
const arr = { new: [history], old: [] }
|
||||
localStorage.setItem('history', JSON.stringify(arr))
|
||||
}
|
||||
},
|
||||
editHistoryUndo() {
|
||||
if (localStorage.getItem('history')) {
|
||||
const historyObj = JSON.parse(localStorage.getItem('history')!)
|
||||
historyObj.old.push(this.currentSource)
|
||||
if (historyObj.new.length) {
|
||||
this.currentSource = historyObj.new.pop()
|
||||
}
|
||||
localStorage.setItem('history', JSON.stringify(historyObj))
|
||||
}
|
||||
},
|
||||
clearAllHistory() {
|
||||
localStorage.setItem('history', JSON.stringify({ new: [], old: [] }))
|
||||
},
|
||||
clearEdit() {
|
||||
this.editTabSource = {} as Source
|
||||
this.currentSource = JSON.parse(JSON.stringify(emptySource)) //复制一份新对象
|
||||
},
|
||||
|
||||
// clear all source
|
||||
clearAllSource() {
|
||||
this.bookSources = []
|
||||
this.rssSources = []
|
||||
this.savedSources = []
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { BookSoure, RssSource, Source } from '../source'
|
||||
import { isNullOrBlank } from './utils'
|
||||
|
||||
const isBookSource = (source: Source): source is BookSoure =>
|
||||
'bookSourceName' in source
|
||||
|
||||
export const isInvaildSource: (source: Source) => boolean = source => {
|
||||
if (isBookSource(source)) {
|
||||
return (
|
||||
!isNullOrBlank(source.bookSourceName) &&
|
||||
!isNullOrBlank(source.bookSourceUrl) &&
|
||||
!isNullOrBlank(source.bookSourceType)
|
||||
)
|
||||
}
|
||||
return !isNullOrBlank(source.sourceName) && !isNullOrBlank(source.sourceUrl)
|
||||
}
|
||||
|
||||
export const getSourceUniqueKey = (source: Source) =>
|
||||
isBookSource(source) ? source.bookSourceUrl : source.sourceUrl
|
||||
export const getSourceName = (source: Source) =>
|
||||
isBookSource(source) ? source.bookSourceName : source.sourceName
|
||||
|
||||
export const isSourceMatches: (source: Source, searchKey: string) => boolean = (
|
||||
source,
|
||||
searchKey,
|
||||
) => {
|
||||
// TODO: 正则和普通字符串识别 识别 * . \ [ ] <= <! != = ?: () \d\w\s\...
|
||||
if (isBookSource(source)) {
|
||||
return (
|
||||
(source.bookSourceName.includes(searchKey) ||
|
||||
source.bookSourceUrl.includes(searchKey) ||
|
||||
source.bookSourceGroup?.includes(searchKey) ||
|
||||
source.bookSourceComment?.includes(searchKey)) ??
|
||||
false
|
||||
)
|
||||
}
|
||||
return (
|
||||
(source.sourceName.includes(searchKey) ||
|
||||
source.sourceUrl.includes(searchKey) ||
|
||||
source.sourceGroup?.includes(searchKey) ||
|
||||
source.sourceComment?.includes(searchKey)) ??
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
export const convertSourcesToMap = (sources: Source[]): Map<string, Source> => {
|
||||
const map = new Map()
|
||||
sources.forEach(source => map.set(getSourceUniqueKey(source), source))
|
||||
return map
|
||||
}
|
||||
|
||||
export const normalizeSource = (source: any) => {
|
||||
for (const key in source) {
|
||||
const value = source[key]
|
||||
if (
|
||||
value === '' ||
|
||||
value === null ||
|
||||
(typeof value === 'string' && !value.trim())
|
||||
) {
|
||||
delete source[key]
|
||||
} else if (value instanceof Object) {
|
||||
normalizeSource(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const emptyBookSource = {
|
||||
ruleSearch: {},
|
||||
ruleBookInfo: {},
|
||||
ruleToc: {},
|
||||
ruleContent: {},
|
||||
// ruleReview: {},
|
||||
ruleExplore: {},
|
||||
} as BookSoure
|
||||
export const emptyRssSource = {} as RssSource
|
||||
@@ -0,0 +1,55 @@
|
||||
import { formatDate } from '@vueuse/shared'
|
||||
export const isNullOrBlank = (string: string | null | undefined | number) =>
|
||||
string == null ||
|
||||
(string as string).length === 0 ||
|
||||
/^\s+$/.test(string as string)
|
||||
|
||||
export const isLegadoUrl = (/** @type {string} */ url: string) =>
|
||||
/,\s*\{/.test(url) ||
|
||||
!(
|
||||
url.startsWith('http') ||
|
||||
url.startsWith('data:') ||
|
||||
url.startsWith('blob:')
|
||||
)
|
||||
|
||||
/**
|
||||
* 验证输入的URL是否符合阅读后端地址规则
|
||||
* @param allowedProtocols 允许的协议,默认`["https:", "http:"]`
|
||||
*/
|
||||
export const validatorHttpUrl = (
|
||||
http_url: string | URL,
|
||||
allowedProtocols: string[] = ['https:', 'http:'],
|
||||
) => {
|
||||
try {
|
||||
const url = new URL(http_url)
|
||||
const { protocol } = url
|
||||
if (!allowedProtocols.includes(protocol))
|
||||
throw new Error(
|
||||
`Expected protocol ${allowedProtocols.join('/')}, but ${protocol}`,
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const dateFormat = (/** @type {number} */ t: number) => {
|
||||
const time = new Date().getTime()
|
||||
const offset = Math.floor((time - t) / 1000)
|
||||
let str = ''
|
||||
|
||||
if (offset <= 30) {
|
||||
str = '刚刚'
|
||||
} else if (offset < 60) {
|
||||
str = offset + '秒前'
|
||||
} else if (offset < 3600) {
|
||||
str = Math.floor(offset / 60) + '分钟前'
|
||||
} else if (offset < 86400) {
|
||||
str = Math.floor(offset / 3600) + '小时前'
|
||||
} else if (offset < 2592000) {
|
||||
str = Math.floor(offset / 86400) + '天前'
|
||||
} else {
|
||||
str = formatDate(new Date(t), 'YYYY-MM-DD')
|
||||
}
|
||||
return str
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
<template>
|
||||
<div
|
||||
class="chapter-wrapper"
|
||||
:style="bodyTheme"
|
||||
:class="{ night: isNight, day: !isNight }"
|
||||
@click="showToolBar = !showToolBar"
|
||||
>
|
||||
<div class="tool-bar" :style="leftBarTheme">
|
||||
<div class="tools">
|
||||
<el-popover
|
||||
placement="right"
|
||||
:width="popupWidth"
|
||||
trigger="click"
|
||||
:show-arrow="false"
|
||||
v-model:visible="popCataVisible"
|
||||
popper-class="pop-cata"
|
||||
>
|
||||
<PopCatalog @getContent="getContent" class="popup" />
|
||||
<template #reference>
|
||||
<div class="tool-icon" :class="{ 'no-point': false }">
|
||||
<div class="iconfont"></div>
|
||||
<div class="icon-text">目录</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-popover>
|
||||
<el-popover
|
||||
placement="right"
|
||||
:width="popupWidth"
|
||||
trigger="click"
|
||||
:show-arrow="false"
|
||||
v-model:visible="readSettingsVisible"
|
||||
popper-class="pop-setting"
|
||||
>
|
||||
<read-settings class="popup" />
|
||||
<template #reference>
|
||||
<div class="tool-icon" :class="{ 'no-point': noPoint }">
|
||||
<div class="iconfont"></div>
|
||||
<div class="icon-text">设置</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-popover>
|
||||
<div class="tool-icon" @click="toShelf">
|
||||
<div class="iconfont"></div>
|
||||
<div class="icon-text">书架</div>
|
||||
</div>
|
||||
<div class="tool-icon" :class="{ 'no-point': noPoint }" @click="toTop">
|
||||
<div class="iconfont"></div>
|
||||
<div class="icon-text">顶部</div>
|
||||
</div>
|
||||
<div
|
||||
class="tool-icon"
|
||||
:class="{ 'no-point': noPoint }"
|
||||
@click="toBottom"
|
||||
>
|
||||
<div class="iconfont"></div>
|
||||
<div class="icon-text">底部</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="read-bar" :style="rightBarTheme">
|
||||
<div class="tools">
|
||||
<div
|
||||
class="tool-icon"
|
||||
:class="{ 'no-point': noPoint }"
|
||||
@click="toPreChapter"
|
||||
>
|
||||
<div class="iconfont"></div>
|
||||
<span v-if="miniInterface">上一章</span>
|
||||
</div>
|
||||
<div
|
||||
class="tool-icon"
|
||||
:class="{ 'no-point': noPoint }"
|
||||
@click="toNextChapter"
|
||||
>
|
||||
<span v-if="miniInterface">下一章</span>
|
||||
<div class="iconfont"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chapter-bar"></div>
|
||||
<div class="chapter" ref="content" :style="chapterTheme">
|
||||
<div class="content">
|
||||
<div class="top-bar" ref="top"></div>
|
||||
<div
|
||||
v-for="data in chapterData"
|
||||
:key="data.index"
|
||||
:chapterIndex="data.index"
|
||||
ref="chapter"
|
||||
>
|
||||
<chapter-content
|
||||
ref="chapterRef"
|
||||
:chapterIndex="data.index"
|
||||
:contents="data.content"
|
||||
:title="data.title"
|
||||
:spacing="store.config.spacing"
|
||||
:fontSize="fontSize"
|
||||
:fontFamily="fontFamily"
|
||||
@readedLengthChange="onReadedLengthChange"
|
||||
v-if="showContent"
|
||||
/>
|
||||
</div>
|
||||
<div class="loading" ref="loading"></div>
|
||||
<div class="bottom-bar" ref="bottom"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import jump from '@/plugins/jump'
|
||||
import settings from '@/config/themeConfig'
|
||||
import API from '@api'
|
||||
import { useLoading } from '@/hooks/loading'
|
||||
import { useThrottleFn } from '@vueuse/shared'
|
||||
import { isNullOrBlank } from '@/utils/utils'
|
||||
|
||||
const content = ref()
|
||||
// loading spinner
|
||||
const { isLoading, loadingWrapper } = useLoading(content, '正在获取信息')
|
||||
const store = useBookStore()
|
||||
|
||||
const {
|
||||
catalog,
|
||||
popCataVisible,
|
||||
readSettingsVisible,
|
||||
miniInterface,
|
||||
showContent,
|
||||
bookProgress,
|
||||
theme,
|
||||
isNight,
|
||||
} = storeToRefs(store)
|
||||
|
||||
const chapterPos = computed({
|
||||
get: () => store.readingBook.chapterPos,
|
||||
set: value => (store.readingBook.chapterPos = value),
|
||||
})
|
||||
const chapterIndex = computed({
|
||||
get: () => store.readingBook.chapterIndex,
|
||||
set: value => (store.readingBook.chapterIndex = value),
|
||||
})
|
||||
const isSeachBook = computed({
|
||||
get: () => store.readingBook.isSeachBook,
|
||||
set: value => (store.readingBook.isSeachBook = value),
|
||||
})
|
||||
|
||||
// 当前阅读书籍readingBook持久化
|
||||
watch(
|
||||
() => store.readingBook,
|
||||
book => {
|
||||
// 保存localStorage
|
||||
// localStorage.setItem(book.bookUrl, JSON.stringify(book));
|
||||
// 最近阅读
|
||||
localStorage.setItem('readingRecent', JSON.stringify(book))
|
||||
//保存 sessionStorage
|
||||
sessionStorage.setItem('chapterIndex', book.chapterIndex.toString())
|
||||
sessionStorage.setItem('chapterPos', book.chapterPos.toString())
|
||||
},
|
||||
{ deep: 1 },
|
||||
)
|
||||
|
||||
// 无限滚动
|
||||
const infiniteLoading = computed(() => store.config.infiniteLoading)
|
||||
let scrollObserver: IntersectionObserver | null
|
||||
const loading = ref()
|
||||
watchEffect(() => {
|
||||
if (!infiniteLoading.value) {
|
||||
scrollObserver?.disconnect()
|
||||
} else {
|
||||
scrollObserver?.observe(loading.value)
|
||||
}
|
||||
})
|
||||
const loadMore = () => {
|
||||
const index = chapterData.value.slice(-1)[0].index
|
||||
if (catalog.value.length - 1 > index) {
|
||||
getContent(index + 1, false)
|
||||
store.saveBookProgress() // 保存的是上一章的进度,不是预载的本章进度
|
||||
}
|
||||
}
|
||||
// IntersectionObserver回调 底部加载
|
||||
const onReachBottom = (entries: IntersectionObserverEntry[]) => {
|
||||
if (isLoading.value) return
|
||||
for (const { isIntersecting } of entries) {
|
||||
if (!isIntersecting) return
|
||||
loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
// 字体
|
||||
const fontFamily = computed(() => {
|
||||
if (store.config.font >= 0) {
|
||||
return settings.fonts[store.config.font]
|
||||
}
|
||||
return store.config.customFontName
|
||||
})
|
||||
const fontSize = computed(() => {
|
||||
return store.config.fontSize + 'px'
|
||||
})
|
||||
|
||||
// 主题部分
|
||||
const bodyColor = computed(() => settings.themes[theme.value].body)
|
||||
const chapterColor = computed(() => settings.themes[theme.value].content)
|
||||
const popupColor = computed(() => settings.themes[theme.value].popup)
|
||||
|
||||
const readWidth = computed(() => {
|
||||
if (!miniInterface.value) {
|
||||
return store.config.readWidth - 130 + 'px'
|
||||
} else {
|
||||
return window.innerWidth + 'px'
|
||||
}
|
||||
})
|
||||
const popupWidth = computed(() => {
|
||||
if (!miniInterface.value) {
|
||||
return store.config.readWidth - 33
|
||||
} else {
|
||||
return window.innerWidth - 33
|
||||
}
|
||||
})
|
||||
const bodyTheme = computed(() => {
|
||||
return {
|
||||
background: bodyColor.value,
|
||||
}
|
||||
})
|
||||
const chapterTheme = computed(() => {
|
||||
return {
|
||||
background: chapterColor.value,
|
||||
width: readWidth.value,
|
||||
}
|
||||
})
|
||||
const showToolBar = ref(false)
|
||||
const leftBarTheme = computed(() => {
|
||||
return {
|
||||
background: popupColor.value,
|
||||
marginLeft: miniInterface.value
|
||||
? 0
|
||||
: -(store.config.readWidth / 2 + 68) + 'px',
|
||||
display: miniInterface.value && !showToolBar.value ? 'none' : 'block',
|
||||
}
|
||||
})
|
||||
const rightBarTheme = computed(() => {
|
||||
return {
|
||||
background: popupColor.value,
|
||||
marginRight: miniInterface.value
|
||||
? 0
|
||||
: -(store.config.readWidth / 2 + 52) + 'px',
|
||||
display: miniInterface.value && !showToolBar.value ? 'none' : 'block',
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* pc移动端判断 最大阅读宽度修正
|
||||
* 阅读宽度最小为640px 加上工具栏 68px 52px 取较大值 为 776px
|
||||
*/
|
||||
const onResize = () => {
|
||||
store.setMiniInterface(window.innerWidth < 776)
|
||||
const width = store.config.readWidth /**包含padding */
|
||||
checkPageWidth(width)
|
||||
}
|
||||
/** 判断阅读宽度是否超出页面或者低于默认值640 */
|
||||
const checkPageWidth = (readWidth: number) => {
|
||||
if (store.miniInterface) return
|
||||
if (readWidth < 640) store.config.readWidth = 640
|
||||
if (readWidth + 2 * 68 > window.innerWidth) store.config.readWidth -= 160
|
||||
}
|
||||
watch(
|
||||
() => store.config.readWidth,
|
||||
width => checkPageWidth(width),
|
||||
)
|
||||
// 顶部底部跳转
|
||||
const top = ref()
|
||||
const bottom = ref()
|
||||
const toTop = () => {
|
||||
jump(top.value)
|
||||
}
|
||||
const toBottom = () => {
|
||||
jump(bottom.value)
|
||||
}
|
||||
|
||||
// 书架路由切换
|
||||
const router = useRouter()
|
||||
const toShelf = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
// 获取章节内容
|
||||
const chapterData = ref<{ index: number; content: string[]; title: string }[]>(
|
||||
[],
|
||||
)
|
||||
const noPoint = ref(true)
|
||||
const getContent = (index: number, reloadChapter = true, chapterPos = 0) => {
|
||||
if (reloadChapter) {
|
||||
//展示进度条
|
||||
store.setShowContent(false)
|
||||
//强制滚回顶层
|
||||
jump(top.value, { duration: 0 })
|
||||
//从目录,按钮切换章节时保存进度 预加载时不保存
|
||||
saveReadingBookProgressToBrowser(index, chapterPos)
|
||||
chapterData.value = []
|
||||
}
|
||||
const bookUrl = store.readingBook.bookUrl
|
||||
const { title, index: chapterIndex } = catalog.value[index]
|
||||
|
||||
loadingWrapper(
|
||||
API.getBookContent(bookUrl, chapterIndex).then(
|
||||
res => {
|
||||
if (res.data.isSuccess) {
|
||||
const data = res.data.data
|
||||
const content = data.split(/\n+/)
|
||||
chapterData.value.push({ index, content, title })
|
||||
if (reloadChapter) toChapterPos(chapterPos)
|
||||
} else {
|
||||
ElMessage({ message: res.data.errorMsg, type: 'error' })
|
||||
const content = [res.data.errorMsg]
|
||||
chapterData.value.push({ index, content, title })
|
||||
}
|
||||
store.setContentLoading(true)
|
||||
noPoint.value = false
|
||||
store.setShowContent(true)
|
||||
if (!res.data.isSuccess) {
|
||||
throw res.data
|
||||
}
|
||||
},
|
||||
err => {
|
||||
const content = ['获取章节内容失败!']
|
||||
chapterData.value.push({ index, content, title })
|
||||
store.setShowContent(true)
|
||||
throw err
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 章节进度跳转和计算
|
||||
const chapter = ref()
|
||||
const chapterRef = ref()
|
||||
const toChapterPos = (pos: number) => {
|
||||
nextTick(() => {
|
||||
if (chapterRef.value.length === 1)
|
||||
chapterRef.value[0].scrollToReadedLength(pos)
|
||||
})
|
||||
}
|
||||
|
||||
// 60秒保存一次进度
|
||||
const saveBookProgressThrottle = useThrottleFn(
|
||||
() => store.saveBookProgress(),
|
||||
60000,
|
||||
)
|
||||
|
||||
const onReadedLengthChange = (index: number, pos: number) => {
|
||||
saveReadingBookProgressToBrowser(index, pos)
|
||||
saveBookProgressThrottle()
|
||||
}
|
||||
|
||||
// 文档标题
|
||||
watchEffect(() => {
|
||||
document.title = catalog.value[chapterIndex.value]?.title || document.title
|
||||
})
|
||||
|
||||
// 阅读记录保存浏览器
|
||||
const saveReadingBookProgressToBrowser = (index: number, pos: number) => {
|
||||
// 保存pinia
|
||||
chapterIndex.value = index
|
||||
chapterPos.value = pos
|
||||
}
|
||||
|
||||
// 进度同步
|
||||
// 返回导航变化 同步请求会在获取书架前完成
|
||||
|
||||
/**
|
||||
* VisibilityChange https://developer.mozilla.org/zh-CN/docs/Web/API/Document/visibilitychange_event
|
||||
* 监听关闭页面 切换tab 返回桌面 等操作
|
||||
* 注意不用监听点击链接导航变化 不对Safari<14.5兼容处理
|
||||
**/
|
||||
const onVisibilityChange = () => {
|
||||
const _bookProgress = bookProgress.value
|
||||
if (document.visibilityState == 'hidden' && _bookProgress) {
|
||||
store.saveBookProgress()
|
||||
}
|
||||
}
|
||||
// 定时同步
|
||||
|
||||
// 章节切换
|
||||
const toNextChapter = () => {
|
||||
store.setContentLoading(true)
|
||||
const index = chapterIndex.value + 1
|
||||
if (typeof catalog.value[index] !== 'undefined') {
|
||||
ElMessage({
|
||||
message: '下一章',
|
||||
type: 'info',
|
||||
})
|
||||
getContent(index)
|
||||
store.saveBookProgress()
|
||||
} else {
|
||||
ElMessage({
|
||||
message: '本章是最后一章',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
const toPreChapter = () => {
|
||||
store.setContentLoading(true)
|
||||
const index = chapterIndex.value - 1
|
||||
if (typeof catalog.value[index] !== 'undefined') {
|
||||
ElMessage({
|
||||
message: '上一章',
|
||||
type: 'info',
|
||||
})
|
||||
getContent(index)
|
||||
store.saveBookProgress()
|
||||
} else {
|
||||
ElMessage({
|
||||
message: '本章是第一章',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let canJump = true
|
||||
// 监听方向键
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
if (!canJump) return
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
toPreChapter()
|
||||
break
|
||||
case 'ArrowRight':
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
toNextChapter()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
if (document.documentElement.scrollTop === 0) {
|
||||
ElMessage.warning('已到达页面顶部')
|
||||
} else {
|
||||
canJump = false
|
||||
jump(0 - document.documentElement.clientHeight + 100, {
|
||||
duration: store.config.jumpDuration,
|
||||
callback: () => (canJump = true),
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'ArrowDown':
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
if (
|
||||
document.documentElement.clientHeight +
|
||||
document.documentElement.scrollTop ===
|
||||
document.documentElement.scrollHeight
|
||||
) {
|
||||
ElMessage.warning('已到达页面底部')
|
||||
} else {
|
||||
canJump = false
|
||||
jump(document.documentElement.clientHeight - 100, {
|
||||
duration: store.config.jumpDuration,
|
||||
callback: () => (canJump = true),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 阻止默认滚动事件
|
||||
const ignoreKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await store.loadWebConfig()
|
||||
//获取书籍数据
|
||||
const bookUrl = sessionStorage.getItem('bookUrl')
|
||||
const name = sessionStorage.getItem('bookName')
|
||||
const author = sessionStorage.getItem('bookAuthor')
|
||||
const chapterIndex = Number(sessionStorage.getItem('chapterIndex') || 0)
|
||||
const chapterPos = Number(sessionStorage.getItem('chapterPos') || 0)
|
||||
const isSeachBook = sessionStorage.getItem('isSeachBook') === 'true'
|
||||
if (isNullOrBlank(bookUrl) || isNullOrBlank(name) || author === null) {
|
||||
ElMessage.warning('书籍信息为空,即将自动返回书架页面...')
|
||||
return setTimeout(toShelf, 500)
|
||||
}
|
||||
const book: typeof store.readingBook = {
|
||||
// @ts-expect-error: bookUrl name author is NON_Blank string here
|
||||
bookUrl,
|
||||
// @ts-expect-error: bookUrl name author is NON_Blank string here
|
||||
name,
|
||||
author,
|
||||
chapterIndex,
|
||||
chapterPos,
|
||||
isSeachBook,
|
||||
}
|
||||
onResize()
|
||||
window.addEventListener('resize', onResize)
|
||||
loadingWrapper(
|
||||
store.loadWebCatalog(book).then(chapters => {
|
||||
store.setReadingBook(book)
|
||||
getContent(chapterIndex, true, chapterPos)
|
||||
window.addEventListener('keyup', handleKeyPress)
|
||||
window.addEventListener('keydown', ignoreKeyPress)
|
||||
// 兼容Safari < 14
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
//监听底部加载
|
||||
scrollObserver = new IntersectionObserver(onReachBottom, {
|
||||
rootMargin: '-100% 0% 20% 0%',
|
||||
})
|
||||
if (infiniteLoading.value === true) scrollObserver.observe(loading.value)
|
||||
//第二次点击同一本书 页面标题不会变化
|
||||
document.title = '...'
|
||||
document.title = (name as string) + ' | ' + chapters[chapterIndex].title
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keyup', handleKeyPress)
|
||||
window.removeEventListener('keydown', ignoreKeyPress)
|
||||
window.removeEventListener('resize', onResize)
|
||||
// 兼容Safari < 14
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
readSettingsVisible.value = false
|
||||
popCataVisible.value = false
|
||||
scrollObserver?.disconnect()
|
||||
scrollObserver = null
|
||||
})
|
||||
|
||||
const addToBookShelfConfirm = async () => {
|
||||
const book = store.readingBook
|
||||
// 阅读的是搜索的书籍 并未在书架
|
||||
if (book.isSeachBook === true) {
|
||||
await ElMessageBox.confirm(`是否将《${book.name}》放入书架?`, '放入书架', {
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '否',
|
||||
type: 'info',
|
||||
/*
|
||||
ElMessageBox.confirm默认在触发hashChange事件时自动关闭
|
||||
按下物理返回键时触发hashChange事件
|
||||
使用router.push("/")则不会触发hashChange事件
|
||||
*/
|
||||
closeOnHashChange: false,
|
||||
})
|
||||
.then(() => {
|
||||
//选择是,无动作
|
||||
isSeachBook.value = false
|
||||
})
|
||||
.catch(async () => {
|
||||
//选择否,删除书籍
|
||||
await API.deleteBook(book)
|
||||
})
|
||||
.finally(() => sessionStorage.removeItem('isSeachBook'))
|
||||
}
|
||||
}
|
||||
onBeforeRouteLeave(async (to, from, next) => {
|
||||
console.log('onBeforeRouteLeave')
|
||||
// 弹窗时停止响应按键翻页
|
||||
window.removeEventListener('keyup', handleKeyPress)
|
||||
await addToBookShelfConfirm()
|
||||
next()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.pop-setting) {
|
||||
margin-left: 68px;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
:deep(.pop-cata) {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.chapter-wrapper {
|
||||
padding: 0 4%;
|
||||
|
||||
overflow-x: hidden;
|
||||
|
||||
:deep(.no-point) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tool-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
z-index: 100;
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.tool-icon {
|
||||
font-size: 18px;
|
||||
width: 58px;
|
||||
height: 48px;
|
||||
text-align: center;
|
||||
padding-top: 12px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
|
||||
.iconfont {
|
||||
font-family: iconfont;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
margin: 0 auto 6px;
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.read-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 50%;
|
||||
z-index: 100;
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.tool-icon {
|
||||
font-size: 18px;
|
||||
width: 42px;
|
||||
height: 31px;
|
||||
padding-top: 12px;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
margin-top: -1px;
|
||||
|
||||
.iconfont {
|
||||
font-family: iconfont;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
margin: 0 auto 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chapter {
|
||||
font-family: 'Microsoft YaHei', PingFangSC-Regular, HelveticaNeue-Light,
|
||||
'Helvetica Neue Light', sans-serif;
|
||||
text-align: left;
|
||||
padding: 0 65px;
|
||||
min-height: 100vh;
|
||||
width: 670px;
|
||||
margin: 0 auto;
|
||||
|
||||
.content {
|
||||
font-size: 18px;
|
||||
line-height: 1.8;
|
||||
font-family: 'Microsoft YaHei', PingFangSC-Regular, HelveticaNeue-Light,
|
||||
'Helvetica Neue Light', sans-serif;
|
||||
|
||||
.bottom-bar,
|
||||
.top-bar {
|
||||
height: 64px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.day {
|
||||
:deep(.popup) {
|
||||
box-shadow:
|
||||
0 2px 4px rgba(0, 0, 0, 0.12),
|
||||
0 0 6px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
:deep(.tool-icon) {
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
margin-top: -1px;
|
||||
color: #000;
|
||||
|
||||
.icon-text {
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.chapter) {
|
||||
border: 1px solid #d8d8d8;
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
.night {
|
||||
:deep(.popup) {
|
||||
box-shadow:
|
||||
0 2px 4px rgba(0, 0, 0, 0.48),
|
||||
0 0 6px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
:deep(.tool-icon) {
|
||||
border: 1px solid #444;
|
||||
margin-top: -1px;
|
||||
color: #666;
|
||||
|
||||
.icon-text {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.chapter) {
|
||||
border: 1px solid #444;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
:deep(.popper__arrow) {
|
||||
background: #666;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 776px) {
|
||||
.chapter-wrapper {
|
||||
padding: 0;
|
||||
|
||||
.tool-bar {
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
margin-left: 0 !important;
|
||||
|
||||
.tools {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
.tool-icon {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.read-bar {
|
||||
right: 0;
|
||||
width: 100vw;
|
||||
margin-right: 0 !important;
|
||||
|
||||
.tools {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 0 15px;
|
||||
|
||||
.tool-icon {
|
||||
border: none;
|
||||
width: auto;
|
||||
|
||||
.iconfont {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chapter {
|
||||
width: 100vw !important;
|
||||
padding: 0 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,488 @@
|
||||
<template>
|
||||
<div :class="{ 'index-wrapper': true, night: isNight, day: !isNight }">
|
||||
<div class="navigation-wrapper">
|
||||
<div class="navigation-title-wrapper">
|
||||
<div class="navigation-title">阅读</div>
|
||||
<div class="navigation-sub-title">清风不识字,何故乱翻书</div>
|
||||
</div>
|
||||
<div class="search-wrapper">
|
||||
<el-input
|
||||
placeholder="搜索书籍,在线书籍自动加入书架"
|
||||
v-model="searchWord"
|
||||
class="search-input"
|
||||
:prefix-icon="SearchIcon"
|
||||
@keyup.enter="searchBook"
|
||||
>
|
||||
</el-input>
|
||||
</div>
|
||||
<div class="bottom-wrapper">
|
||||
<div class="recent-wrapper">
|
||||
<div class="recent-title">最近阅读</div>
|
||||
<div class="reading-recent">
|
||||
<el-tag
|
||||
:type="
|
||||
readingRecent.name == '尚无阅读记录' ? 'warning' : 'primary'
|
||||
"
|
||||
class="recent-book"
|
||||
size="large"
|
||||
@click="
|
||||
toDetail(
|
||||
readingRecent.bookUrl,
|
||||
readingRecent.name,
|
||||
readingRecent.author,
|
||||
readingRecent.chapterIndex,
|
||||
readingRecent.chapterPos,
|
||||
readingRecent.isSeachBook,
|
||||
true,
|
||||
)
|
||||
"
|
||||
:class="{ 'no-point': readingRecent.bookUrl == '' }"
|
||||
>
|
||||
{{ readingRecent.name }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-wrapper">
|
||||
<div class="setting-title">基本设定</div>
|
||||
<div class="setting-item">
|
||||
<el-tag
|
||||
:type="connectType"
|
||||
size="large"
|
||||
class="setting-connect"
|
||||
:class="{ 'no-point': newConnect }"
|
||||
@click="setLegadoRetmoteUrl"
|
||||
>
|
||||
{{ connectStatus }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bottom-icons">
|
||||
<a
|
||||
href="https://github.com/gedoor/legado_web_bookshelf"
|
||||
target="_blank"
|
||||
>
|
||||
<div class="bottom-icon">
|
||||
<img :src="githubUrl" alt="" />
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shelf-wrapper" ref="shelfWrapper">
|
||||
<book-items
|
||||
:books="books"
|
||||
@bookClick="handleBookClick"
|
||||
:isSearch="isSearching"
|
||||
></book-items>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import '@/assets/bookshelf.css'
|
||||
import '@/assets/fonts/shelffont.css'
|
||||
import { useBookStore } from '@/store'
|
||||
import githubUrl from '@/assets/imgs/github.png'
|
||||
import { useLoading } from '@/hooks/loading'
|
||||
import { Search as SearchIcon } from '@element-plus/icons-vue'
|
||||
import { baseURL_localStorage_key } from '@/api/axios'
|
||||
import API, {
|
||||
legado_http_entry_point,
|
||||
parseLeagdoHttpUrlWithDefault,
|
||||
setApiEntryPoint,
|
||||
} from '@api'
|
||||
import { validatorHttpUrl } from '@/utils/utils'
|
||||
import type { Book, SeachBook } from '@/book'
|
||||
import type { webReadConfig } from '@/web'
|
||||
|
||||
const store = useBookStore()
|
||||
const isNight = computed(() => store.isNight)
|
||||
|
||||
/** shortcuts of `store.setConfig` */
|
||||
const applyReadConfig = (config?: webReadConfig) => {
|
||||
try {
|
||||
if (config !== undefined) store.setConfig(config)
|
||||
} catch {
|
||||
ElMessage.info('阅读界面配置解析错误')
|
||||
}
|
||||
}
|
||||
|
||||
const readingRecent = ref<typeof store.readingBook>({
|
||||
name: '尚无阅读记录',
|
||||
author: '',
|
||||
bookUrl: '',
|
||||
chapterIndex: 0,
|
||||
chapterPos: 0,
|
||||
isSeachBook: false,
|
||||
})
|
||||
|
||||
const shelfWrapper = ref<HTMLElement>()
|
||||
//const shelfWrapper = useTemplateRef<HTMLElement>("shelfWrapper")
|
||||
const { showLoading, closeLoading, loadingWrapper, isLoading } = useLoading(
|
||||
shelfWrapper,
|
||||
'正在获取书籍信息',
|
||||
)
|
||||
|
||||
// 书架书籍和在线书籍搜索
|
||||
const books = shallowRef<Book[] | SeachBook[]>([])
|
||||
const shelf = computed(() => store.shelf)
|
||||
const searchWord = ref('')
|
||||
const isSearching = ref(false)
|
||||
watchEffect(() => {
|
||||
if (isSearching.value && searchWord.value != '') return
|
||||
isSearching.value = false
|
||||
books.value = []
|
||||
if (searchWord.value == '') {
|
||||
books.value = shelf.value
|
||||
return
|
||||
}
|
||||
books.value = shelf.value.filter(book => {
|
||||
return (
|
||||
book.name.includes(searchWord.value) ||
|
||||
book.author.includes(searchWord.value)
|
||||
)
|
||||
})
|
||||
})
|
||||
//搜索在线书籍
|
||||
const searchBook = () => {
|
||||
if (searchWord.value == '') return
|
||||
books.value = []
|
||||
store.clearSearchBooks()
|
||||
showLoading()
|
||||
isSearching.value = true
|
||||
API.search(
|
||||
searchWord.value,
|
||||
searcBooks => {
|
||||
if (isLoading) {
|
||||
closeLoading()
|
||||
}
|
||||
try {
|
||||
store.setSearchBooks(searcBooks)
|
||||
books.value = store.searchBooks
|
||||
//store.searchBooks.forEach((item) => books.value.push(item));
|
||||
} catch (e) {
|
||||
ElMessage.error('后端数据错误')
|
||||
throw e
|
||||
}
|
||||
},
|
||||
() => {
|
||||
closeLoading()
|
||||
if (books.value.length == 0) {
|
||||
ElMessage.info('搜索结果为空')
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
//连接状态
|
||||
const connectionStore = useConnectionStore()
|
||||
const { connectStatus, connectType, newConnect } = storeToRefs(connectionStore)
|
||||
|
||||
const setLegadoRetmoteUrl = () => {
|
||||
ElMessageBox.prompt(
|
||||
'请输入 后端地址 ( 如:http://127.0.0.1:9527 或者通过内网穿透的地址)',
|
||||
'提示',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
inputPlaceholder: legado_http_entry_point,
|
||||
inputValidator: value => validatorHttpUrl(value),
|
||||
inputErrorMessage: '输入的格式不对',
|
||||
beforeClose: (action, instance, done) => {
|
||||
if (action === 'confirm') {
|
||||
connectionStore.setNewConnect(true)
|
||||
instance.confirmButtonLoading = true
|
||||
instance.confirmButtonText = '校验中……'
|
||||
// instance.inputValue
|
||||
const url = new URL(instance.inputValue).toString()
|
||||
API.getReadConfig(url)
|
||||
.then(function (config) {
|
||||
connectionStore.setNewConnect(false)
|
||||
applyReadConfig(config)
|
||||
instance.confirmButtonLoading = false
|
||||
store.clearSearchBooks()
|
||||
setApiEntryPoint(...parseLeagdoHttpUrlWithDefault(url))
|
||||
if (url === location.origin) {
|
||||
localStorage.removeItem(baseURL_localStorage_key)
|
||||
} else {
|
||||
localStorage.setItem(baseURL_localStorage_key, url)
|
||||
}
|
||||
store.loadBookShelf()
|
||||
done()
|
||||
})
|
||||
.catch(function (error) {
|
||||
connectionStore.setNewConnect(false)
|
||||
instance.confirmButtonLoading = false
|
||||
instance.confirmButtonText = '确定'
|
||||
throw error
|
||||
})
|
||||
} else {
|
||||
done()
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const handleBookClick = async (book: SeachBook | Book) => {
|
||||
// 判断是否为 searchBook
|
||||
const isSeachBook = 'respondTime' in book
|
||||
if (isSeachBook) {
|
||||
await API.saveBook(book)
|
||||
}
|
||||
const {
|
||||
bookUrl,
|
||||
name,
|
||||
author,
|
||||
// @ts-expect-error: descruct with default value
|
||||
durChapterIndex = 0,
|
||||
// @ts-expect-error: descruct with default value
|
||||
durChapterPos = 0,
|
||||
} = book
|
||||
|
||||
toDetail(bookUrl, name, author, durChapterIndex, durChapterPos, isSeachBook)
|
||||
}
|
||||
const toDetail = (
|
||||
bookUrl: string,
|
||||
bookName: string,
|
||||
bookAuthor: string,
|
||||
chapterIndex: number,
|
||||
chapterPos: number,
|
||||
isSeachBook: boolean | undefined = false,
|
||||
fromReadRecentClick = false,
|
||||
) => {
|
||||
if (bookName === '尚无阅读记录') return
|
||||
// 最近书籍不再书架上 自动搜索
|
||||
if (
|
||||
fromReadRecentClick &&
|
||||
shelf.value.every(book => book.bookUrl !== bookUrl)
|
||||
) {
|
||||
searchWord.value = bookName
|
||||
searchBook()
|
||||
return
|
||||
}
|
||||
sessionStorage.setItem('bookUrl', bookUrl)
|
||||
sessionStorage.setItem('bookName', bookName)
|
||||
sessionStorage.setItem('bookAuthor', bookAuthor)
|
||||
sessionStorage.setItem('chapterIndex', String(chapterIndex))
|
||||
sessionStorage.setItem('chapterPos', String(chapterPos))
|
||||
sessionStorage.setItem('isSeachBook', String(isSeachBook))
|
||||
readingRecent.value = {
|
||||
name: bookName,
|
||||
author: bookAuthor,
|
||||
bookUrl,
|
||||
chapterIndex,
|
||||
chapterPos,
|
||||
isSeachBook,
|
||||
}
|
||||
localStorage.setItem('readingRecent', JSON.stringify(readingRecent.value))
|
||||
router.push({
|
||||
path: '/chapter',
|
||||
})
|
||||
}
|
||||
|
||||
const loadShelf = async () => {
|
||||
await store.loadWebConfig()
|
||||
await store.saveBookProgress()
|
||||
//确保各种网络情况下同步请求先完成
|
||||
await store.loadBookShelf()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
//获取最近阅读书籍
|
||||
const readingRecentStr = localStorage.getItem('readingRecent')
|
||||
if (readingRecentStr != null) {
|
||||
readingRecent.value = JSON.parse(readingRecentStr)
|
||||
if (typeof readingRecent.value.chapterIndex == 'undefined') {
|
||||
readingRecent.value.chapterIndex = 0
|
||||
}
|
||||
}
|
||||
console.log('bookshelf mounted')
|
||||
loadingWrapper(loadShelf())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.index-wrapper {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.navigation-wrapper {
|
||||
width: 260px;
|
||||
min-width: 260px;
|
||||
padding: 48px 36px;
|
||||
background-color: #f7f7f7;
|
||||
|
||||
.navigation-title {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
font-family: FZZCYSK;
|
||||
}
|
||||
|
||||
.navigation-sub-title {
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
font-family: FZZCYSK;
|
||||
margin-top: 16px;
|
||||
color: #b1b1b1;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
.search-input {
|
||||
border-radius: 50%;
|
||||
margin-top: 24px;
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 50px;
|
||||
border-color: #e3e3e3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.recent-wrapper {
|
||||
margin-top: 36px;
|
||||
|
||||
.recent-title {
|
||||
font-size: 14px;
|
||||
color: #b1b1b1;
|
||||
font-family: FZZCYSK;
|
||||
}
|
||||
|
||||
.reading-recent {
|
||||
margin: 18px 0;
|
||||
|
||||
.recent-book {
|
||||
font-size: 10px;
|
||||
/* // font-weight: 400;
|
||||
// margin: 12px 0;
|
||||
// font-weight: 500;
|
||||
// color: #6B7C87; */
|
||||
cursor: pointer;
|
||||
/* // padding: 6px 18px; */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.setting-wrapper {
|
||||
margin-top: 36px;
|
||||
|
||||
.setting-title {
|
||||
font-size: 14px;
|
||||
color: #b1b1b1;
|
||||
font-family: FZZCYSK;
|
||||
}
|
||||
|
||||
.no-point {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.setting-connect {
|
||||
font-size: 8px;
|
||||
margin-top: 16px;
|
||||
/* // color: #6B7C87; */
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-icons {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: 120px;
|
||||
width: 260px;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.shelf-wrapper {
|
||||
padding: 48px 48px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
.index-wrapper {
|
||||
overflow-x: hidden;
|
||||
flex-direction: column;
|
||||
|
||||
.navigation-wrapper {
|
||||
padding: 20px 24px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
|
||||
.navigation-title-wrapper {
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.bottom-wrapper {
|
||||
flex-direction: row;
|
||||
|
||||
> * {
|
||||
flex-grow: 1;
|
||||
margin-top: 18px;
|
||||
|
||||
.reading-recent,
|
||||
.setting-item {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-icons {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.shelf-wrapper {
|
||||
padding: 0;
|
||||
flex-grow: 1;
|
||||
|
||||
:deep(.el-loading-spinner) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.night {
|
||||
.navigation-wrapper {
|
||||
background-color: #454545;
|
||||
|
||||
.navigation-title {
|
||||
color: #aeaeae;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
.search-input {
|
||||
.el-input__wrapper {
|
||||
background-color: #454545;
|
||||
}
|
||||
|
||||
.el-input__inner {
|
||||
color: #b1b1b1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.shelf-wrapper) {
|
||||
background-color: #161819;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="editor">
|
||||
<source-tab-form class="left" :config="config" />
|
||||
<tool-bar />
|
||||
<source-tab-tools class="right" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import bookSourceConfig from '@/config/bookSourceEditConfig'
|
||||
import rssSourceConfig from '@/config/rssSourceEditConfig'
|
||||
import '@/assets/sourceeditor.css'
|
||||
import { useDark } from '@vueuse/core'
|
||||
import type { SourceConfig } from '@/config/sourceConfig'
|
||||
|
||||
useDark()
|
||||
|
||||
let config: SourceConfig
|
||||
|
||||
if (/bookSource/i.test(location.href)) {
|
||||
config = bookSourceConfig as SourceConfig
|
||||
document.title = '书源管理'
|
||||
} else {
|
||||
config = rssSourceConfig as SourceConfig
|
||||
document.title = '订阅源管理'
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.editor {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
.left {
|
||||
flex: 1;
|
||||
margin-left: 20px;
|
||||
}
|
||||
.right {
|
||||
flex: 1;
|
||||
width: 360px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
export type webReadConfig = {
|
||||
theme: number
|
||||
font: number
|
||||
fontSize: number
|
||||
readWidth: number
|
||||
infiniteLoading: boolean
|
||||
customFontName: string
|
||||
jumpDuration: number
|
||||
spacing: {
|
||||
paragraph: number
|
||||
line: number
|
||||
letter: number
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@api": ["./src/api"],
|
||||
"@utils/*": ["./src/utils/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@tsconfig/node20/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import Icons from "unplugin-icons/vite";
|
||||
import IconsResolver from "unplugin-icons/resolver";
|
||||
import AutoImport from "unplugin-auto-import/vite";
|
||||
import Components from "unplugin-vue-components/vite";
|
||||
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
return {
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
imports: ["vue", "vue-router", "pinia"],
|
||||
include: [/\.[tj]sx?$/, /\.vue$/, /\.vue\?vue/],
|
||||
dirs: ["src/components", "src/store", "*.d.ts"],
|
||||
eslintrc: {
|
||||
//enabled: true,
|
||||
},
|
||||
resolvers: [
|
||||
ElementPlusResolver(),
|
||||
IconsResolver({
|
||||
prefix: "Icon",
|
||||
}),
|
||||
],
|
||||
dts: "./src/auto-imports.d.ts",
|
||||
}),
|
||||
Components({
|
||||
resolvers: [
|
||||
ElementPlusResolver(),
|
||||
IconsResolver({
|
||||
enabledCollections: ["ep"],
|
||||
}),
|
||||
],
|
||||
dts: "./src/components.d.ts",
|
||||
}),
|
||||
Icons({
|
||||
autoInstall: true,
|
||||
}),
|
||||
],
|
||||
base: mode === "development" ? "/" : "./",
|
||||
server: {
|
||||
port: 8080,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
"@api": fileURLToPath(new URL("./src/api", import.meta.url)),
|
||||
"@utils": fileURLToPath(new URL("./src/utils/", import.meta.url)),
|
||||
},
|
||||
},
|
||||
esbuild: {
|
||||
drop: mode === "development" ? undefined : ["console", "debugger"],
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: (id) => {
|
||||
if (id.includes("node_modules")) {
|
||||
return "vendor";
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
api: 'modern-compiler', // or 'modern'
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
});
|
||||